diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index 0355185d..d7c2c262 100644 --- a/app/Http/Controllers/Admin/ResourceController.php +++ b/app/Http/Controllers/Admin/ResourceController.php @@ -2,10 +2,7 @@ namespace App\Http\Controllers\Admin; -use App\Http\Controllers\Controller; -use App\Http\Requests\Resource\BulkImageStoreRequest; -use App\Http\Requests\Resource\BulkVideoStoreRequest; -use App\Http\Requests\Resource\StoreResourceRequest; +use App\Http\Controllers\ResourceController as BaseResourceController; use App\Http\Requests\Resource\UpdateResourceRequest; use App\Models\Node; use App\Models\Resource; @@ -13,71 +10,10 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; -use Illuminate\Validation\ValidationException; -class ResourceController extends Controller +class ResourceController extends BaseResourceController { - /** - * Determine maximum allowed pending submissions for the user. - * Verified users: 100, Unverified users: 30. - */ - protected function getMaxPendingSubmissions(): int - { - return Auth::user()?->is_verified ? 100 : 30; - } - - /** - * Check if user would exceed their pending change requests quota. - * Throws standard ValidationException so it returns as a typed error in Inertia errors. - */ - protected function ensureUnderPendingLimit(int $incomingCount = 1): void - { - if (Auth::user()?->can('moderate resources')) { - return; - } - - $userId = Auth::id(); - $maxLimit = $this->getMaxPendingSubmissions(); - - $currentPending = ResourceChangeRequest::where('user_id', $userId) - ->where('status', 'pending') - ->count(); - - if (($currentPending + $incomingCount) > $maxLimit) { - throw ValidationException::withMessages([ - 'pending_limit' => "আপনি সর্বোচ্চ {$maxLimit}টি কন্টেন্ট আপলোড করার অনুরোধ করতে পারেন। আপনার আপলোডকৃত {$currentPending}টি কন্টেন্ট বর্তমানে পর্যালোচনাধীন রয়েছে, তাই অনুগ্রহ করে অপেক্ষা করুন। ", - ]); - } - } - - public function store(StoreResourceRequest $request) - { - $validated = $request->validated(); - - if ($request->hasFile('file')) { - $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); - } - - if ($request->user()->can('moderate resources')) { - $validated['user_id'] = Auth::id(); - Resource::create($validated); - - return back()->with('success', 'Resource created successfully.'); - } - - $this->ensureUnderPendingLimit(1); - - ResourceChangeRequest::recordCreate( - Auth::id(), - (int) $validated['node_id'], - $validated - ); - - return back()->with('success', 'Resource submitted for moderation.'); - } - public function update(UpdateResourceRequest $request, Resource $resource) { $validated = $request->validated(); @@ -95,7 +31,7 @@ public function update(UpdateResourceRequest $request, Resource $resource) return back()->with('success', 'Resource updated successfully.'); } - $this->ensureUnderPendingLimit(1); + $this->ensureUnderPendingLimit(1, false); ResourceChangeRequest::recordUpdate( Auth::id(), @@ -117,157 +53,13 @@ public function destroy(Resource $resource) return redirect()->back()->with('success', 'Resource deleted successfully.'); } - $this->ensureUnderPendingLimit(1); + $this->ensureUnderPendingLimit(1, false); ResourceChangeRequest::recordDelete(Auth::id(), $resource); return redirect()->back()->with('success', 'Resource deletion request submitted for moderation.'); } - public function storeBulkImages(BulkImageStoreRequest $request) - { - $validated = $request->validated(); - $filesCount = count($request->file('files') ?? []); - $isModerator = $request->user()->can('moderate resources'); - - if (! $isModerator) { - $this->ensureUnderPendingLimit($filesCount); - } - - $userId = Auth::id(); - $nodeId = (int) $validated['node_id']; - - DB::transaction(function () use ($request, $validated, $userId, $nodeId, $isModerator) { - foreach ($request->file('files') as $index => $file) { - $filePath = $file->store('resources/images'); - $title = $validated['custom_titles'][$index]; - - if ($isModerator) { - Resource::create([ - 'user_id' => $userId, - 'node_id' => $nodeId, - 'title' => $title, - 'resource_type' => 'image', - 'file_path' => $filePath, - ]); - } else { - ResourceChangeRequest::recordCreate($userId, $nodeId, [ - 'title' => $title, - 'resource_type' => 'image', - 'file_path' => $filePath, - ]); - } - } - }); - - $message = $isModerator ? 'Images uploaded successfully.' : 'Images submitted for moderation.'; - - return back()->with('success', $message); - } - - public function storeBulkVideos(BulkVideoStoreRequest $request) - { - $validated = $request->validated(); - - $playlistUrl = $validated['playlist_url']; - - // Extract playlist ID - parse_str(parse_url($playlistUrl, PHP_URL_QUERY), $query); - - $playlistId = $query['list'] ?? null; - - if (! $playlistId) { - return back()->with('error', 'Invalid Youtube URL'); - } - - $videos = []; - $pageToken = null; - - do { - $response = Http::get('https://www.googleapis.com/youtube/v3/playlistItems', [ - 'part' => 'snippet', - 'playlistId' => $playlistId, - 'maxResults' => 50, - 'pageToken' => $pageToken, - 'key' => config('services.youtube.key'), - ]); - - if (! $response->successful()) { - return back()->with('error', 'Unable to fetch youtube url'); - } - - $data = $response->json(); - - foreach ($data['items'] as $item) { - $videos[] = [ - 'title' => $item['snippet']['title'], - 'video_id' => $item['snippet']['resourceId']['videoId'], - 'position' => $item['snippet']['position'], - ]; - } - - $pageToken = $data['nextPageToken'] ?? null; - } while ($pageToken); - - if (! empty($validated['is_reversed'])) { - $videos = array_reverse($videos); - } - - // Apply naming strategy - foreach ($videos as $index => &$video) { - if ($validated['naming_strategy'] === 'youtube') { - $video['title'] = $video['title']; - } else { - $number = str_pad( - ($validated['start_number'] ?? 1) + $index, - 2, - '0', - STR_PAD_LEFT - ); - - $prefix = trim($validated['naming_prefix'] ?? ''); - $video['title'] = $prefix !== '' ? "{$prefix} - {$number}" : $number; - } - } - - $userId = Auth::id(); - $nodeId = (int) $validated['node_id']; - $videosCount = count($videos); - $isModerator = $request->user()->can('moderate resources'); - - if (! $isModerator) { - $this->ensureUnderPendingLimit($videosCount); - } - - DB::transaction(function () use ($videos, $userId, $nodeId, $isModerator) { - foreach ($videos as $video) { - $finalUrl = "https://www.youtube.com/watch?v={$video['video_id']}"; - - if ($isModerator) { - Resource::create([ - 'user_id' => $userId, - 'node_id' => $nodeId, - 'title' => $video['title'], - 'resource_type' => 'video', - 'external_url' => $finalUrl, - ]); - } else { - ResourceChangeRequest::recordCreate($userId, $nodeId, [ - 'title' => $video['title'], - 'resource_type' => 'video', - 'external_url' => $finalUrl, - ]); - } - } - }); - - $message = $isModerator - ? 'YouTube playlist imported successfully.' - : 'YouTube playlist imported and submitted for moderation.'; - - return back()->with('success', $message); - } - public function bulkRename(Request $request, Node $node) { if ($node->isEffectivelyFrozen()) { diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 9ac54df7..e7199ea9 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -65,6 +65,7 @@ public function show(Subject $subject, $path) 'id' => $node->id, 'name' => $node->name, 'slug' => $node->slug, + 'is_frozen' => $node->isEffectivelyFrozen(), ], 'nodes' => $nodes, 'breadcrumb' => $node->breadcrumb(), diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index e4649c31..e9f46d94 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -2,14 +2,233 @@ namespace App\Http\Controllers; +use App\Http\Requests\Resource\BulkImageStoreRequest; +use App\Http\Requests\Resource\BulkVideoStoreRequest; +use App\Http\Requests\Resource\StoreResourceRequest; use App\Models\Node; use App\Models\Resource; +use App\Models\ResourceChangeRequest; use App\Models\ResourceCompletion; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; class ResourceController extends Controller { + /** + * Determine maximum allowed pending submissions for the user. + * Verified users: 100, Unverified users: 30. + */ + protected function getMaxPendingSubmissions(): int + { + return Auth::user()?->is_verified ? 100 : 30; + } + + /** + * Check if user would exceed their pending change requests quota or fails eligibility. + * Throws standard ValidationException so it returns as a typed error in Inertia errors. + */ + protected function ensureUnderPendingLimit(int $incomingCount = 1, bool $checkAge = true): void + { + if (Auth::user()?->can('moderate resources')) { + return; + } + + $user = Auth::user(); + + if ($checkAge && $user && $user->created_at && $user->created_at->isAfter(now()->subDay())) { + throw ValidationException::withMessages([ + 'pending_limit' => 'নোট বা রিসোর্স আপলোড করার জন্য আপনার অ্যাকাউন্টের বয়স কমপক্ষে ২৪ ঘণ্টা হতে হবে।', + ]); + } + + $userId = Auth::id(); + $maxLimit = $this->getMaxPendingSubmissions(); + + $currentPending = ResourceChangeRequest::where('user_id', $userId) + ->where('status', 'pending') + ->count(); + + if (($currentPending + $incomingCount) > $maxLimit) { + throw ValidationException::withMessages([ + 'pending_limit' => "আপনি সর্বোচ্চ {$maxLimit}টি কন্টেন্ট আপলোড করার অনুরোধ করতে পারেন। আপনার আপলোডকৃত {$currentPending}টি কন্টেন্ট বর্তমানে পর্যালোচনাধীন রয়েছে, তাই অনুগ্রহ করে অপেক্ষা করুন।", + ]); + } + } + + public function store(StoreResourceRequest $request) + { + $validated = $request->validated(); + + if ($request->hasFile('file')) { + $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); + } + + if ($request->user()->can('moderate resources')) { + $validated['user_id'] = Auth::id(); + Resource::create($validated); + + return back()->with('success', 'Resource created successfully.'); + } + + $this->ensureUnderPendingLimit(1); + + ResourceChangeRequest::recordCreate( + Auth::id(), + (int) $validated['node_id'], + $validated + ); + + return back()->with('success', 'Resource submitted for moderation.'); + } + + public function storeBulkImages(BulkImageStoreRequest $request) + { + $validated = $request->validated(); + $filesCount = count($request->file('files') ?? []); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureUnderPendingLimit($filesCount); + } + + $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; + + DB::transaction(function () use ($request, $validated, $userId, $nodeId, $isModerator) { + foreach ($request->file('files') as $index => $file) { + $filePath = $file->store('resources/images'); + $title = $validated['custom_titles'][$index]; + + if ($isModerator) { + Resource::create([ + 'user_id' => $userId, + 'node_id' => $nodeId, + 'title' => $title, + 'resource_type' => 'image', + 'file_path' => $filePath, + ]); + } else { + ResourceChangeRequest::recordCreate($userId, $nodeId, [ + 'title' => $title, + 'resource_type' => 'image', + 'file_path' => $filePath, + ]); + } + } + }); + + $message = $isModerator ? 'Images uploaded successfully.' : 'Images submitted for moderation.'; + + return back()->with('success', $message); + } + + public function storeBulkVideos(BulkVideoStoreRequest $request) + { + $validated = $request->validated(); + + $playlistUrl = $validated['playlist_url']; + + // Extract playlist ID + parse_str(parse_url($playlistUrl, PHP_URL_QUERY), $query); + + $playlistId = $query['list'] ?? null; + + if (! $playlistId) { + return back()->with('error', 'Invalid Youtube URL'); + } + + $videos = []; + $pageToken = null; + + do { + $response = Http::get('https://www.googleapis.com/youtube/v3/playlistItems', [ + 'part' => 'snippet', + 'playlistId' => $playlistId, + 'maxResults' => 50, + 'pageToken' => $pageToken, + 'key' => config('services.youtube.key'), + ]); + + if (! $response->successful()) { + return back()->with('error', 'Unable to fetch youtube url'); + } + + $data = $response->json(); + + foreach ($data['items'] as $item) { + $videos[] = [ + 'title' => $item['snippet']['title'], + 'video_id' => $item['snippet']['resourceId']['videoId'], + 'position' => $item['snippet']['position'], + ]; + } + + $pageToken = $data['nextPageToken'] ?? null; + } while ($pageToken); + + if (! empty($validated['is_reversed'])) { + $videos = array_reverse($videos); + } + + // Apply naming strategy + foreach ($videos as $index => &$video) { + if ($validated['naming_strategy'] === 'youtube') { + $video['title'] = $video['title']; + } else { + $number = str_pad( + ($validated['start_number'] ?? 1) + $index, + 2, + '0', + STR_PAD_LEFT + ); + + $prefix = trim($validated['naming_prefix'] ?? ''); + $video['title'] = $prefix !== '' ? "{$prefix} - {$number}" : $number; + } + } + + $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; + $videosCount = count($videos); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureUnderPendingLimit($videosCount); + } + + DB::transaction(function () use ($videos, $userId, $nodeId, $isModerator) { + foreach ($videos as $video) { + $finalUrl = "https://www.youtube.com/watch?v={$video['video_id']}"; + + if ($isModerator) { + Resource::create([ + 'user_id' => $userId, + 'node_id' => $nodeId, + 'title' => $video['title'], + 'resource_type' => 'video', + 'external_url' => $finalUrl, + ]); + } else { + ResourceChangeRequest::recordCreate($userId, $nodeId, [ + 'title' => $video['title'], + 'resource_type' => 'video', + 'external_url' => $finalUrl, + ]); + } + } + }); + + $message = $isModerator + ? 'YouTube playlist imported successfully.' + : 'YouTube playlist imported and submitted for moderation.'; + + return back()->with('success', $message); + } + public function show($id) { $data = Cache::rememberForever("resource_{$id}", function () use ($id) { diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php new file mode 100644 index 00000000..57b84e4b --- /dev/null +++ b/app/Http/Controllers/SubmissionController.php @@ -0,0 +1,73 @@ +query('status', 'all'); + $userId = Auth::id(); + + $query = ResourceChangeRequest::where('user_id', $userId) + ->with([ + 'resource', + 'node.subject', + 'reviewer:id,name,username', + ]); + + if (in_array($status, ['pending', 'approved', 'rejected'], true)) { + $query->where('status', $status); + } + + $submissions = $query->latest('id') + ->simplePaginate(15) + ->withQueryString(); + + $submissions->getCollection()->each(function ($req) { + $req->node?->append('breadcrumb'); + }); + + $pendingCount = ResourceChangeRequest::where('user_id', $userId)->where('status', 'pending')->count(); + $approvedCount = ResourceChangeRequest::where('user_id', $userId)->where('status', 'approved')->count(); + $rejectedCount = ResourceChangeRequest::where('user_id', $userId)->where('status', 'rejected')->count(); + $maxPending = Auth::user()?->is_verified ? 100 : 30; + + return Inertia::render('Submissions/Index', [ + 'submissions' => $submissions, + 'counts' => [ + 'all' => $pendingCount + $approvedCount + $rejectedCount, + 'pending' => $pendingCount, + 'approved' => $approvedCount, + 'rejected' => $rejectedCount, + 'max_pending' => $maxPending, + ], + 'filters' => [ + 'status' => $status, + ], + ]); + } + + public function destroy(ResourceChangeRequest $changeRequest) + { + abort_unless($changeRequest->user_id === Auth::id(), 403); + + if ($changeRequest->status !== 'pending') { + return back()->with('error', 'Only pending submissions can be cancelled.'); + } + + if (! empty($changeRequest->payload['file_path'])) { + Storage::delete($changeRequest->payload['file_path']); + } + + $changeRequest->delete(); + + return back()->with('success', 'আপনার সাবমিশন বাতিল করা হয়েছে।'); + } +} diff --git a/app/Http/Requests/Resource/BulkImageStoreRequest.php b/app/Http/Requests/Resource/BulkImageStoreRequest.php index 08c2357a..7f9f2e65 100644 --- a/app/Http/Requests/Resource/BulkImageStoreRequest.php +++ b/app/Http/Requests/Resource/BulkImageStoreRequest.php @@ -15,7 +15,11 @@ public function authorize(): bool { $node = Node::find($this->input('node_id')); - return ! ($node?->isEffectivelyFrozen()); + if ($node?->isEffectivelyFrozen() && ! $this->user()?->can('moderate resources')) { + return false; + } + + return true; } /** diff --git a/app/Http/Requests/Resource/BulkVideoStoreRequest.php b/app/Http/Requests/Resource/BulkVideoStoreRequest.php index dac36bdf..87a2a860 100644 --- a/app/Http/Requests/Resource/BulkVideoStoreRequest.php +++ b/app/Http/Requests/Resource/BulkVideoStoreRequest.php @@ -15,7 +15,11 @@ public function authorize(): bool { $node = Node::find($this->input('node_id')); - return ! ($node?->isEffectivelyFrozen()); + if ($node?->isEffectivelyFrozen() && ! $this->user()?->can('moderate resources')) { + return false; + } + + return true; } /** diff --git a/app/Http/Requests/Resource/StoreResourceRequest.php b/app/Http/Requests/Resource/StoreResourceRequest.php index d35c4705..c088a88e 100644 --- a/app/Http/Requests/Resource/StoreResourceRequest.php +++ b/app/Http/Requests/Resource/StoreResourceRequest.php @@ -16,7 +16,11 @@ public function authorize(): bool { $node = Node::find($this->input('node_id')); - return ! ($node?->isEffectivelyFrozen()); + if ($node?->isEffectivelyFrozen() && ! $this->user()?->can('moderate resources')) { + return false; + } + + return true; } /** diff --git a/app/Notifications/ResourceModerationNotification.php b/app/Notifications/ResourceModerationNotification.php index 4266c5d6..5a5079f6 100644 --- a/app/Notifications/ResourceModerationNotification.php +++ b/app/Notifications/ResourceModerationNotification.php @@ -36,12 +36,15 @@ public function via(object $notifiable): array public function toArray(object $notifiable): array { $node = $this->changeRequest->node; - if ($node && $node->subject) { + if ($this->status === 'rejected') { + $url = '/my-submissions?status=rejected'; + } elseif ($this->changeRequest->resource_id) { + $url = "/resources/{$this->changeRequest->resource_id}"; + } elseif ($node && $node->subject) { $path = implode('/', array_column($node->breadcrumb(), 'slug')); - $hash = $this->status === 'rejected' ? '#rejected' : '#live'; - $url = "/admin/subjects/{$node->subject->slug}/nodes/{$path}{$hash}"; + $url = "/{$node->subject->slug}/{$path}"; } else { - $url = '/admin'; + $url = '/my-submissions'; } if ($this->totalCount > 1) { diff --git a/database/migrations/2026_10_12_171253_remove_create_resources_permission.php b/database/migrations/2026_10_12_171253_remove_create_resources_permission.php new file mode 100644 index 00000000..e69193f1 --- /dev/null +++ b/database/migrations/2026_10_12_171253_remove_create_resources_permission.php @@ -0,0 +1,23 @@ +delete(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Permission::findOrCreate('create resources', 'web'); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 9017d5a4..85e9ad9e 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -46,7 +46,6 @@ public function run(): void /* * Resource management */ - Permission::findOrCreate('create resources'); Permission::findOrCreate('edit resources'); Permission::findOrCreate('delete resources'); Permission::findOrCreate('moderate resources'); diff --git a/resources/js/components/EmptyState.vue b/resources/js/components/EmptyState.vue index fc37ccc8..bf15204c 100644 --- a/resources/js/components/EmptyState.vue +++ b/resources/js/components/EmptyState.vue @@ -33,7 +33,7 @@ const { can } = usePermissions(); const isAlreadyContributor = computed(() => { return Boolean( (page.props.auth as any)?.can_access_admin || - can('create resources') || + can('moderate resources') || can('create nodes'), ); }); diff --git a/resources/js/components/admin/BulkImageModal.vue b/resources/js/components/admin/BulkImageModal.vue index d9eddb39..900620e0 100644 --- a/resources/js/components/admin/BulkImageModal.vue +++ b/resources/js/components/admin/BulkImageModal.vue @@ -220,7 +220,7 @@ const submitForm = async () => { custom_titles: processedTitles.value, }; - router.post('/admin/resources/bulk/images', payload, { + router.post('/resources/bulk/images', payload, { forceFormData: true, preserveScroll: true, onProgress: (progress) => { @@ -290,9 +290,26 @@ const submitForm = async () => { + +
+ নোট সাবমিশন: সকল আপলোডকৃত ইমেজ বা + নোট মডারেটর কর্তৃক পর্যালোচনার পর ওয়েবসাইটে প্রকাশিত হবে। +
+
+ +
+ সাবমিশন নির্দেশিকা: আপলোডকৃত + ছবি ও লেকচার নোট মডারেটর কর্তৃক পর্যালোচনার পর ওয়েবসাইটে + প্রকাশিত হবে। স্পষ্ট ও পাঠযোগ্য ছবি আপলোড করুন। +
+
diff --git a/resources/js/components/admin/BulkVideoModal.vue b/resources/js/components/admin/BulkVideoModal.vue index e2ddc95b..ba170161 100644 --- a/resources/js/components/admin/BulkVideoModal.vue +++ b/resources/js/components/admin/BulkVideoModal.vue @@ -9,8 +9,9 @@ import { ChevronDown, ChevronRight, } from 'lucide-vue-next'; -import { ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import BaseModal from '@/components/BaseModal.vue'; +import { usePermissions } from '@/lib/usePermissions'; const props = defineProps<{ isOpen: boolean; @@ -24,6 +25,9 @@ const emit = defineEmits<{ (e: 'close'): void; }>(); +const { can } = usePermissions(); +const isModerator = computed(() => can('moderate resources')); + const playlistUrl = ref(''); const namingStrategy = ref<'serial' | 'youtube'>('serial'); const namingPrefix = ref('video'); @@ -79,7 +83,7 @@ const submitForm = () => { is_reversed: isReversed.value, }; - router.post('/admin/resources/bulk/videos', payload, { + router.post('/resources/bulk/videos', payload, { preserveScroll: true, onSuccess: () => { resetForm(); @@ -131,6 +135,29 @@ const submitForm = () => {
+ +
+
প্লেলিস্ট সাবমিশন নির্দেশিকা:
+ +
+
@@ -319,7 +346,11 @@ const submitForm = () => { > {{ - isSaving ? 'Importing Playlist...' : 'Import Playlist' + isSaving + ? 'Importing Playlist...' + : isModerator + ? 'Import Playlist' + : 'Submit Playlist for Review' }}
diff --git a/resources/js/components/admin/CreateResourceModal.vue b/resources/js/components/admin/CreateResourceModal.vue index ccd79ff3..14eb5666 100644 --- a/resources/js/components/admin/CreateResourceModal.vue +++ b/resources/js/components/admin/CreateResourceModal.vue @@ -13,6 +13,7 @@ import { import { ref, computed, watch } from 'vue'; import BaseModal from '@/components/BaseModal.vue'; import { compressImage } from '@/lib/imageCompression'; +import { usePermissions } from '@/lib/usePermissions'; const props = defineProps<{ isOpen: boolean; @@ -35,6 +36,9 @@ const emit = defineEmits<{ (e: 'close'): void; }>(); +const { can } = usePermissions(); +const isModerator = computed(() => can('moderate resources')); + const resourceTypes = [ { id: 'image', @@ -211,7 +215,7 @@ const submitForm = () => { }, }); } else { - router.post('/admin/resources', payload, { + router.post('/resources', payload, { forceFormData: true, preserveScroll: true, onSuccess: () => { @@ -267,6 +271,18 @@ const submitForm = () => {
+ +
+ সাবমিশন নির্দেশিকা: সকল + আপলোডকৃত নোট এবং রিসোর্স মডারেটর কর্তৃক পর্যালোচনার পর + ওয়েবসাইটে প্রকাশিত হবে। কোনো রকম পাইরেটেড কনটেন্ট গ্রহণযোগ্য + নয়; ভিডিওর ক্ষেত্রে সংশ্লিষ্ট শিক্ষকের অফিশিয়াল চ্যানেলের + ভিডিও যুক্ত করুন। +
+
diff --git a/resources/js/components/admin/NodeHeader.vue b/resources/js/components/admin/NodeHeader.vue index 36f0fae4..df6e9f28 100644 --- a/resources/js/components/admin/NodeHeader.vue +++ b/resources/js/components/admin/NodeHeader.vue @@ -234,7 +234,7 @@ onUnmounted(() => document.removeEventListener('click', closeDropdowns));
diff --git a/resources/js/components/navigation/Navigation.tsx b/resources/js/components/navigation/Navigation.tsx index f58db1e6..41162b4b 100644 --- a/resources/js/components/navigation/Navigation.tsx +++ b/resources/js/components/navigation/Navigation.tsx @@ -365,6 +365,31 @@ export const SiteRail = defineComponent({ ); })} + {user.value && ( + + + + Submissions + + + )} {canAccessAdmin.value && ( ); })} + {user.value && ( + + + + My Submissions + + {isActive('/my-submissions') && ( + + )} + + )} {canAccessAdmin.value && ( My Profile - {/* 2. Account Settings */} + {/* 2. My Submissions */} + { + isProfileMenuOpen.value = false; + }} + class="flex items-center gap-2.5 rounded-xl px-2.5 py-2 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white" + > + + + My Submissions + + + + {/* 3. Account Settings */} { diff --git a/resources/js/pages/Node.vue b/resources/js/pages/Node.vue index 7524a8e9..99f1c5d7 100644 --- a/resources/js/pages/Node.vue +++ b/resources/js/pages/Node.vue @@ -1,8 +1,20 @@ diff --git a/resources/js/pages/Submissions/Index.vue b/resources/js/pages/Submissions/Index.vue new file mode 100644 index 00000000..d5f989d5 --- /dev/null +++ b/resources/js/pages/Submissions/Index.vue @@ -0,0 +1,543 @@ + + + diff --git a/routes/admin.php b/routes/admin.php index 61b41c95..c655601e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -48,12 +48,6 @@ Route::get('/subjects/{subject:slug}/nodes/{path?}', [AdminNodeController::class, 'show'])->name('nodes.index')->where('path', '.*'); // Resources -Route::middleware('permission:create resources')->group(function () { - Route::post('/resources', [AdminResourceController::class, 'store']); - Route::post('/resources/bulk/images', [AdminResourceController::class, 'storeBulkImages']); - Route::post('/resources/bulk/videos', [AdminResourceController::class, 'storeBulkVideos']); -}); - Route::post('/resources/{resource}/patch', [AdminResourceController::class, 'update'])->middleware('can:update,resource'); Route::middleware('permission:edit resources')->group(function () { diff --git a/routes/web.php b/routes/web.php index 64a233cd..3c1faf7e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Http\Controllers\ShortUrlController; use App\Http\Controllers\StudyTrackerController; use App\Http\Controllers\SubjectController; +use App\Http\Controllers\SubmissionController; use App\Http\Controllers\SupportTicketController; use App\Http\Controllers\UserProfileController; use Illuminate\Http\Request; @@ -61,6 +62,11 @@ Route::post('/blogs/{blog}/comments', [BlogController::class, 'storeComment'])->name('blogs.comments.store'); Route::delete('/blogs/comments/{comment}', [BlogController::class, 'destroyComment'])->name('blogs.comments.destroy'); Route::post('/resources/{resource}/complete', [ResourceController::class, 'toggleComplete'])->name('resources.complete'); + Route::post('/resources', [ResourceController::class, 'store'])->name('resources.store'); + Route::post('/resources/bulk/images', [ResourceController::class, 'storeBulkImages'])->name('resources.bulk.images'); + Route::post('/resources/bulk/videos', [ResourceController::class, 'storeBulkVideos'])->name('resources.bulk.videos'); + Route::get('/my-submissions', [SubmissionController::class, 'index'])->name('submissions.index'); + Route::delete('/my-submissions/{changeRequest}', [SubmissionController::class, 'destroy'])->name('submissions.destroy'); Route::post('/tracker/nodes/{node}/toggle', [StudyTrackerController::class, 'toggleNode'])->name('tracker.nodes.toggle'); Route::post('/tracker/log-time', [StudyTrackerController::class, 'logTime'])->name('tracker.log-time'); Route::post('/tracker/reset-today', [StudyTrackerController::class, 'resetToday'])->name('tracker.reset-today'); diff --git a/tests/Feature/PublicResourceUploadTest.php b/tests/Feature/PublicResourceUploadTest.php new file mode 100644 index 00000000..c0ae7a9d --- /dev/null +++ b/tests/Feature/PublicResourceUploadTest.php @@ -0,0 +1,175 @@ +subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-blue-500', + 'icon' => 'atom', + ]); + + $this->node = Node::create([ + 'subject_id' => $this->subject->id, + 'name' => 'Mechanics', + 'slug' => 'mechanics', + ]); +}); + +test('authenticated user can upload a note for moderation without create resources permission', function () { + $user = User::factory()->create([ + 'created_at' => now()->subDays(3), + ]); + + $response = $this->actingAs($user)->post('/resources', [ + 'node_id' => $this->node->id, + 'resource_type' => 'note', + 'title' => 'Newton Laws of Motion', + 'content' => 'First, second, and third laws explained.', + ]); + + $response->assertSessionHas('success', 'Resource submitted for moderation.'); + + // Resource should NOT exist in resources table yet + expect(Resource::where('title', 'Newton Laws of Motion')->exists())->toBeFalse(); + + // Change request should exist with pending status + $changeRequest = ResourceChangeRequest::where('user_id', $user->id) + ->where('action_type', 'create') + ->first(); + + expect($changeRequest)->not->toBeNull() + ->and($changeRequest->status)->toBe('pending') + ->and($changeRequest->payload['title'])->toBe('Newton Laws of Motion'); +}); + +test('user younger than 24 hours cannot upload a resource', function () { + $newbie = User::factory()->create([ + 'created_at' => now()->subHours(2), + ]); + + $response = $this->actingAs($newbie)->post('/resources', [ + 'node_id' => $this->node->id, + 'resource_type' => 'note', + 'title' => 'Too early note', + 'content' => 'Some content', + ]); + + $response->assertSessionHasErrors('pending_limit'); + expect(ResourceChangeRequest::where('user_id', $newbie->id)->exists())->toBeFalse(); +}); + +test('user cannot upload to a frozen folder', function () { + $user = User::factory()->create([ + 'created_at' => now()->subDays(3), + ]); + + $this->node->update(['is_frozen' => true]); + + $response = $this->actingAs($user)->post('/resources', [ + 'node_id' => $this->node->id, + 'resource_type' => 'note', + 'title' => 'Frozen note', + 'content' => 'Cannot upload here', + ]); + + $response->assertForbidden(); + expect(ResourceChangeRequest::where('user_id', $user->id)->exists())->toBeFalse(); +}); + +test('moderator can upload directly to a frozen folder', function () { + $moderator = User::factory()->create([ + 'created_at' => now()->subDays(3), + ]); + $moderator->givePermissionTo('moderate resources'); + + $this->node->update(['is_frozen' => true]); + + $response = $this->actingAs($moderator)->post('/resources', [ + 'node_id' => $this->node->id, + 'resource_type' => 'note', + 'title' => 'Moderator override note', + 'content' => 'Admin announcement', + ]); + + $response->assertSessionHas('success', 'Resource created successfully.'); + + expect(Resource::where('title', 'Moderator override note')->exists())->toBeTrue(); +}); + +test('user can view their own submissions on my-submissions page', function () { + $user = User::factory()->create(); + + ResourceChangeRequest::recordCreate($user->id, $this->node->id, [ + 'title' => 'User Submission 1', + 'resource_type' => 'note', + ]); + + $response = $this->actingAs($user)->get('/my-submissions'); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Submissions/Index') + ->has('submissions.data', 1) + ->where('counts.pending', 1) + ->where('counts.max_pending', 30) + ); +}); + +test('user can cancel their pending submission and delete staged file', function () { + Storage::fake('local'); + $user = User::factory()->create(); + + $file = UploadedFile::fake()->create('test_note.pdf', 100, 'application/pdf'); + $filePath = $file->store('resources/pdfs'); + + $changeRequest = ResourceChangeRequest::create([ + 'user_id' => $user->id, + 'node_id' => $this->node->id, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => [ + 'title' => 'Handwritten Notes', + 'resource_type' => 'image', + 'file_path' => $filePath, + ], + ]); + + Storage::assertExists($filePath); + + $response = $this->actingAs($user)->delete("/my-submissions/{$changeRequest->id}"); + + $response->assertSessionHas('success'); + expect(ResourceChangeRequest::find($changeRequest->id))->toBeNull(); + Storage::assertMissing($filePath); +}); + +test('user cannot cancel another users submission', function () { + $owner = User::factory()->create(); + $attacker = User::factory()->create(); + + $changeRequest = ResourceChangeRequest::recordCreate($owner->id, $this->node->id, [ + 'title' => 'Protected Note', + 'resource_type' => 'note', + ]); + + $this->actingAs($attacker) + ->delete("/my-submissions/{$changeRequest->id}") + ->assertForbidden(); + + expect(ResourceChangeRequest::find($changeRequest->id))->not->toBeNull(); +});