From 6e374fc5823a8f9254fcab79f37bf6f342a39cd3 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 16:22:52 +0600 Subject: [PATCH 01/23] feat(resources): open uploads to authenticated users with quota and moderation --- .../Controllers/Admin/ResourceController.php | 216 +---------------- app/Http/Controllers/NodeController.php | 1 + app/Http/Controllers/ResourceController.php | 217 ++++++++++++++++++ app/Http/Controllers/SubmissionController.php | 77 +++++++ .../Resource/BulkImageStoreRequest.php | 12 +- .../Resource/BulkVideoStoreRequest.php | 12 +- .../Resource/StoreResourceRequest.php | 12 +- .../ResourceModerationNotification.php | 9 +- ...000_remove_create_resources_permission.php | 23 ++ database/seeders/RolePermissionSeeder.php | 1 - routes/admin.php | 7 - routes/web.php | 10 + 12 files changed, 370 insertions(+), 227 deletions(-) create mode 100644 app/Http/Controllers/SubmissionController.php create mode 100644 database/migrations/2026_10_13_160000_remove_create_resources_permission.php 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..410c5610 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -2,10 +2,18 @@ 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 @@ -71,4 +79,213 @@ public function toggleComplete(Resource $resource) return back(); } + + /** + * Determine maximum allowed pending submissions for the user. + * Verified users: 100, Unverified users: 30. + */ + public function getMaxPendingSubmissions(): int + { + return Auth::user()?->is_verified ? 100 : 30; + } + + /** + * Check if user would exceed their pending change requests quota or is blocked by age limit. + */ + public function ensureUnderPendingLimit(int $incomingCount = 1, bool $checkAge = true): void + { + $user = Auth::user(); + if ($user?->can('moderate resources')) { + return; + } + + if ($checkAge && $user?->created_at && $user->created_at->diffInHours(now()) < 24) { + throw ValidationException::withMessages([ + 'account_age' => 'আপনার অ্যাকাউন্টটি নতুন। স্প্যাম রোধে অ্যাকাউন্ট তৈরির ২৪ ঘণ্টা পর রিসোর্স আপলোড করা যাবে।', + ]); + } + + $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, true); + + 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, true); + } + + $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, true); + } + + 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); + } } diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php new file mode 100644 index 00000000..744c75ce --- /dev/null +++ b/app/Http/Controllers/SubmissionController.php @@ -0,0 +1,77 @@ +is_verified ? 100 : 30; + $pendingCount = ResourceChangeRequest::where('user_id', $user->id) + ->where('status', 'pending') + ->count(); + + $totalCount = ResourceChangeRequest::where('user_id', $user->id)->count(); + $approvedCount = ResourceChangeRequest::where('user_id', $user->id) + ->where('status', 'approved') + ->count(); + $rejectedCount = ResourceChangeRequest::where('user_id', $user->id) + ->where('status', 'rejected') + ->count(); + + $status = $request->query('status'); + $query = ResourceChangeRequest::where('user_id', $user->id) + ->with(['node.subject', 'reviewer:id,name,username']) + ->latest(); + + if ($status && in_array($status, ['pending', 'approved', 'rejected'])) { + $query->where('status', $status); + } + + $submissions = $query->paginate(15)->withQueryString(); + + return Inertia::render('Submissions/Index', [ + 'submissions' => $submissions, + 'quota' => [ + 'used' => $pendingCount, + 'max' => $maxLimit, + 'is_verified' => (bool) $user->is_verified, + ], + 'counts' => [ + 'all' => $totalCount, + 'pending' => $pendingCount, + 'approved' => $approvedCount, + 'rejected' => $rejectedCount, + ], + 'currentStatus' => $status ?? 'all', + ]); + } + + public function destroy(ResourceChangeRequest $changeRequest) + { + if ($changeRequest->user_id !== Auth::id()) { + abort(403); + } + + if ($changeRequest->status !== 'pending') { + return back()->with('error', 'Only pending submissions can be cancelled.'); + } + + $filePath = $changeRequest->payload['file_path'] ?? null; + if ($filePath) { + Storage::delete($filePath); + } + + $changeRequest->delete(); + + return back()->with('success', 'Submission cancelled successfully.'); + } +} diff --git a/app/Http/Requests/Resource/BulkImageStoreRequest.php b/app/Http/Requests/Resource/BulkImageStoreRequest.php index 08c2357a..efea724d 100644 --- a/app/Http/Requests/Resource/BulkImageStoreRequest.php +++ b/app/Http/Requests/Resource/BulkImageStoreRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Resource; use App\Models\Node; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -15,7 +16,16 @@ 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; + } + + protected function failedAuthorization() + { + throw new AuthorizationException('এই ফোল্ডারটি লক করা রয়েছে। অ্যাডমিন কর্তৃক কন্টেন্ট আপলোড সাময়িকভাবে বন্ধ আছে।'); } /** diff --git a/app/Http/Requests/Resource/BulkVideoStoreRequest.php b/app/Http/Requests/Resource/BulkVideoStoreRequest.php index dac36bdf..917346e7 100644 --- a/app/Http/Requests/Resource/BulkVideoStoreRequest.php +++ b/app/Http/Requests/Resource/BulkVideoStoreRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Resource; use App\Models\Node; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -15,7 +16,16 @@ 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; + } + + protected function failedAuthorization() + { + throw new AuthorizationException('এই ফোল্ডারটি লক করা রয়েছে। অ্যাডমিন কর্তৃক কন্টেন্ট আপলোড সাময়িকভাবে বন্ধ আছে।'); } /** diff --git a/app/Http/Requests/Resource/StoreResourceRequest.php b/app/Http/Requests/Resource/StoreResourceRequest.php index d35c4705..6e2462ad 100644 --- a/app/Http/Requests/Resource/StoreResourceRequest.php +++ b/app/Http/Requests/Resource/StoreResourceRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Resource; use App\Models\Node; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -16,7 +17,16 @@ 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; + } + + protected function failedAuthorization() + { + throw new AuthorizationException('এই ফোল্ডারটি লক করা রয়েছে। অ্যাডমিন কর্তৃক কন্টেন্ট আপলোড সাময়িকভাবে বন্ধ আছে।'); } /** diff --git a/app/Notifications/ResourceModerationNotification.php b/app/Notifications/ResourceModerationNotification.php index 4266c5d6..3961be83 100644 --- a/app/Notifications/ResourceModerationNotification.php +++ b/app/Notifications/ResourceModerationNotification.php @@ -36,12 +36,13 @@ 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 ($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 = "/subjects/{$node->subject->slug}/nodes/{$path}"; } else { - $url = '/admin'; + $url = '/my-submissions'; } if ($this->totalCount > 1) { diff --git a/database/migrations/2026_10_13_160000_remove_create_resources_permission.php b/database/migrations/2026_10_13_160000_remove_create_resources_permission.php new file mode 100644 index 00000000..e69193f1 --- /dev/null +++ b/database/migrations/2026_10_13_160000_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/routes/admin.php b/routes/admin.php index 61b41c95..e0cee254 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -47,13 +47,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..c85160e3 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,15 @@ 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'); + + // Resource Uploads (Public Authenticated) + 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'); + + // My Submissions + 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'); From bbb04c4fef1a871df003abb5cb414d3a4a78e468 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 16:22:59 +0600 Subject: [PATCH 02/23] feat(ui): add submissions page, public upload triggers, and navigation links --- resources/js/components/ToastNotification.vue | 21 +- .../js/components/admin/BulkImageModal.vue | 32 +- .../js/components/admin/BulkVideoModal.vue | 36 +- .../components/admin/CreateResourceModal.vue | 36 +- resources/js/components/admin/NodeHeader.vue | 4 +- .../js/components/navigation/Navigation.tsx | 104 +++++ resources/js/lib/toast.ts | 10 + resources/js/pages/Node.vue | 160 ++++++- resources/js/pages/Submissions/Index.vue | 389 ++++++++++++++++++ 9 files changed, 774 insertions(+), 18 deletions(-) create mode 100644 resources/js/lib/toast.ts create mode 100644 resources/js/pages/Submissions/Index.vue diff --git a/resources/js/components/ToastNotification.vue b/resources/js/components/ToastNotification.vue index fbfa8918..b5198294 100644 --- a/resources/js/components/ToastNotification.vue +++ b/resources/js/components/ToastNotification.vue @@ -1,7 +1,7 @@ diff --git a/resources/js/pages/Submissions/Index.vue b/resources/js/pages/Submissions/Index.vue new file mode 100644 index 00000000..e94d076e --- /dev/null +++ b/resources/js/pages/Submissions/Index.vue @@ -0,0 +1,389 @@ + + + From a6f32feba1fec35ab7e4c6779c3718e8c34341d3 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 16:23:07 +0600 Subject: [PATCH 03/23] test(resources): add test suite for public upload and submissions --- tests/Feature/PublicResourceUploadTest.php | 319 +++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 tests/Feature/PublicResourceUploadTest.php diff --git a/tests/Feature/PublicResourceUploadTest.php b/tests/Feature/PublicResourceUploadTest.php new file mode 100644 index 00000000..4cbaeec3 --- /dev/null +++ b/tests/Feature/PublicResourceUploadTest.php @@ -0,0 +1,319 @@ + 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + 'is_frozen' => $isFrozen, + ]); + + return [$subject, $node]; +} + +test('guest cannot upload resources', function () { + [, $node] = createTestSubjectAndNode(); + + $this->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => 'Test Note', + 'content' => 'Note content', + ])->assertRedirect('/login'); +}); + +test('public authenticated user older than 24h stages resource into change request', function () { + [, $node] = createTestSubjectAndNode(); + + $user = User::factory()->create([ + 'created_at' => now()->subHours(25), + ]); + + $this->actingAs($user) + ->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => 'My Community Note', + 'content' => 'Some useful content', + ]) + ->assertSessionHas('success', 'Resource submitted for moderation.'); + + expect(Resource::count())->toBe(0); + expect(ResourceChangeRequest::count())->toBe(1); + + $cr = ResourceChangeRequest::first(); + expect($cr->user_id)->toBe($user->id) + ->and($cr->node_id)->toBe($node->id) + ->and($cr->action_type)->toBe('create') + ->and($cr->status)->toBe('pending') + ->and($cr->payload['title'])->toBe('My Community Note'); +}); + +test('accounts less than 24h old are blocked with Bangla message', function () { + [, $node] = createTestSubjectAndNode(); + + $user = User::factory()->create([ + 'created_at' => now()->subHours(2), + ]); + + $response = $this->actingAs($user) + ->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => 'Spam Note', + 'content' => 'Test', + ]); + + $response->assertSessionHasErrors([ + 'account_age' => 'আপনার অ্যাকাউন্টটি নতুন। স্প্যাম রোধে অ্যাকাউন্ট তৈরির ২৪ ঘণ্টা পর রিসোর্স আপলোড করা যাবে।', + ]); + + expect(ResourceChangeRequest::count())->toBe(0); +}); + +test('moderator directly creates resource without staging and bypasses age check', function () { + [, $node] = createTestSubjectAndNode(); + + $moderator = User::factory()->create([ + 'created_at' => now()->subMinutes(10), // even if new + ]); + $moderator->givePermissionTo('moderate resources'); + + $this->actingAs($moderator) + ->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => 'Direct Moderator Resource', + 'content' => 'Immediate publish', + ]) + ->assertSessionHas('success', 'Resource created successfully.'); + + expect(Resource::count())->toBe(1); + expect(ResourceChangeRequest::count())->toBe(0); + expect(Resource::first()->title)->toBe('Direct Moderator Resource'); +}); + +test('unverified user cannot exceed quota of 30 pending requests', function () { + [, $node] = createTestSubjectAndNode(); + + $user = User::factory()->create([ + 'is_verified' => false, + 'created_at' => now()->subDays(2), + ]); + + // Create 30 existing pending change requests + for ($i = 0; $i < 30; $i++) { + ResourceChangeRequest::recordCreate($user->id, $node->id, [ + 'title' => "Resource {$i}", + 'resource_type' => 'note', + ]); + } + + $response = $this->actingAs($user) + ->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => '31st Resource', + 'content' => 'Exceed limit', + ]); + + $response->assertSessionHasErrors('pending_limit'); + expect(ResourceChangeRequest::count())->toBe(30); +}); + +test('verified user can upload beyond 30 pending requests up to 100', function () { + [, $node] = createTestSubjectAndNode(); + + $user = User::factory()->create([ + 'is_verified' => true, + 'created_at' => now()->subDays(2), + ]); + + // Create 30 existing pending change requests + for ($i = 0; $i < 30; $i++) { + ResourceChangeRequest::recordCreate($user->id, $node->id, [ + 'title' => "Resource {$i}", + 'resource_type' => 'note', + ]); + } + + $this->actingAs($user) + ->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => '31st Resource', + 'content' => 'Allowed for verified', + ]) + ->assertSessionHas('success', 'Resource submitted for moderation.'); + + expect(ResourceChangeRequest::count())->toBe(31); +}); + +test('frozen node rejects upload for non-moderators', function () { + [, $node] = createTestSubjectAndNode(isFrozen: true); + + $user = User::factory()->create([ + 'created_at' => now()->subDays(2), + ]); + + $this->actingAs($user) + ->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => 'Locked Folder Resource', + 'content' => 'Should fail', + ]) + ->assertForbidden(); + + expect(ResourceChangeRequest::count())->toBe(0); +}); + +test('moderator can upload to frozen node', function () { + [, $node] = createTestSubjectAndNode(isFrozen: true); + + $moderator = User::factory()->create(); + $moderator->givePermissionTo('moderate resources'); + + $this->actingAs($moderator) + ->post('/resources', [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => 'Moderator Bypass Frozen', + 'content' => 'Allowed', + ]) + ->assertSessionHas('success', 'Resource created successfully.'); + + expect(Resource::count())->toBe(1); +}); + +test('bulk image upload stages multiple change requests for non-moderator', function () { + [, $node] = createTestSubjectAndNode(); + + $user = User::factory()->create([ + 'created_at' => now()->subDays(2), + ]); + + $file1 = UploadedFile::fake()->create('page1.png', 100, 'image/png'); + $file2 = UploadedFile::fake()->create('page2.png', 100, 'image/png'); + + $this->actingAs($user) + ->post('/resources/bulk/images', [ + 'node_id' => $node->id, + 'files' => [$file1, $file2], + 'custom_titles' => ['Image Page 1', 'Image Page 2'], + ]) + ->assertSessionHas('success', 'Images submitted for moderation.'); + + expect(Resource::count())->toBe(0); + expect(ResourceChangeRequest::count())->toBe(2); + expect(ResourceChangeRequest::pluck('status')->all())->toBe(['pending', 'pending']); +}); + +test('my-submissions page returns list and quota', function () { + [, $node] = createTestSubjectAndNode(); + + $user = User::factory()->create(); + ResourceChangeRequest::recordCreate($user->id, $node->id, [ + 'title' => 'Pending Resource', + 'resource_type' => 'note', + ]); + + $this->actingAs($user) + ->get('/my-submissions') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Submissions/Index') + ->has('submissions.data', 1) + ->where('quota.used', 1) + ->where('quota.max', 30) + ->where('counts.pending', 1) + ); +}); + +test('user can cancel their pending submission', function () { + [, $node] = createTestSubjectAndNode(); + + $user = User::factory()->create(); + $cr = ResourceChangeRequest::recordCreate($user->id, $node->id, [ + 'title' => 'To Cancel', + 'resource_type' => 'note', + ]); + + $this->actingAs($user) + ->delete("/my-submissions/{$cr->id}") + ->assertSessionHas('success', 'Submission cancelled successfully.'); + + expect(ResourceChangeRequest::count())->toBe(0); +}); + +test('user cannot cancel approved submission or another users submission', function () { + [, $node] = createTestSubjectAndNode(); + + $userA = User::factory()->create(); + $userB = User::factory()->create(); + + $crA = ResourceChangeRequest::recordCreate($userA->id, $node->id, [ + 'title' => 'Resource A', + 'resource_type' => 'note', + ]); + + // User B tries to cancel user A's submission + $this->actingAs($userB) + ->delete("/my-submissions/{$crA->id}") + ->assertForbidden(); + + // Approved submission cannot be cancelled + $crA->update(['status' => 'approved']); + $this->actingAs($userA) + ->delete("/my-submissions/{$crA->id}") + ->assertSessionHas('error', 'Only pending submissions can be cancelled.'); +}); + +test('moderation notification generates public url on approve and my-submissions url on reject', function () { + [$subject, $node] = createTestSubjectAndNode(); + $user = User::factory()->create(); + + $cr = ResourceChangeRequest::recordCreate($user->id, $node->id, [ + 'title' => 'Physics Formula Note', + 'resource_type' => 'note', + ]); + + // Approved notification + $approvedNotif = new ResourceModerationNotification($cr, 'approved'); + $approvedData = $approvedNotif->toArray($user); + expect($approvedData['url'])->toBe("/subjects/{$subject->slug}/nodes/{$node->slug}"); + + // Rejected notification + $rejectedNotif = new ResourceModerationNotification($cr, 'rejected', feedback: 'Irrelevant topic'); + $rejectedData = $rejectedNotif->toArray($user); + expect($rejectedData['url'])->toBe('/my-submissions?status=rejected'); +}); From fbaf3846fa16fc2dbcb38673f146dd3e24e8a58b Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 16:33:31 +0600 Subject: [PATCH 04/23] refactor: simplify resource upload modals by removing dynamic moderator checks --- .../js/components/admin/BulkImageModal.vue | 18 +++------------ .../js/components/admin/BulkVideoModal.vue | 18 +++------------ .../components/admin/CreateResourceModal.vue | 23 ++++--------------- 3 files changed, 11 insertions(+), 48 deletions(-) diff --git a/resources/js/components/admin/BulkImageModal.vue b/resources/js/components/admin/BulkImageModal.vue index 47f27565..715f768a 100644 --- a/resources/js/components/admin/BulkImageModal.vue +++ b/resources/js/components/admin/BulkImageModal.vue @@ -1,5 +1,5 @@ + + diff --git a/resources/js/components/resources/UploadResourceDropdown.vue b/resources/js/components/resources/UploadResourceDropdown.vue new file mode 100644 index 00000000..b4790bd0 --- /dev/null +++ b/resources/js/components/resources/UploadResourceDropdown.vue @@ -0,0 +1,139 @@ + + + diff --git a/resources/js/pages/admin/Node.vue b/resources/js/pages/admin/Node.vue index f39aeebb..1acdaa89 100644 --- a/resources/js/pages/admin/Node.vue +++ b/resources/js/pages/admin/Node.vue @@ -1,10 +1,8 @@ From 45ea291ee818ae4826ac611551c33dd9e0c2dd51 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 17:12:27 +0600 Subject: [PATCH 08/23] refactor: move UploadResourceDropdown to table header and remove empty state cta in Node.vue --- resources/js/pages/Node.vue | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/resources/js/pages/Node.vue b/resources/js/pages/Node.vue index f0cd1066..a27adda3 100644 --- a/resources/js/pages/Node.vue +++ b/resources/js/pages/Node.vue @@ -254,11 +254,6 @@ const handleVote = (type: 'up' | 'down') => { v-if="currentNode" class="flex flex-wrap items-center gap-3" > - -
{ class="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900" >
Name - +
@@ -418,19 +417,7 @@ const handleVote = (type: 'up' | 'down') => { title="কোনো রিসোর্স পাওয়া যায়নি" description="শীঘ্রই এখানে নতুন স্টাডি ম্যাটেরিয়াল ও নোট আপলোড করা হবে।" :show-cta="false" - > - - + />
From c37305cbdb4cf9e662acec25f2de6bd555f1ecbf Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 17:18:04 +0600 Subject: [PATCH 09/23] refactor: remove unused contributor cta card and props from EmptyState --- resources/js/components/EmptyState.vue | 40 +------------------------- resources/js/pages/Node.vue | 1 - resources/js/pages/admin/Index.vue | 1 - 3 files changed, 1 insertion(+), 41 deletions(-) diff --git a/resources/js/components/EmptyState.vue b/resources/js/components/EmptyState.vue index fc37ccc8..c8027f96 100644 --- a/resources/js/components/EmptyState.vue +++ b/resources/js/components/EmptyState.vue @@ -1,19 +1,13 @@ @@ -385,5 +352,5 @@ const getResourceIcon = (type?: string) => { title="কোনো সাবমিশন পাওয়া যায়নি" description="আপনার কোনো কন্টেন্ট সাবমিশন এই তালিকায় নেই।" /> - + diff --git a/routes/web.php b/routes/web.php index c85160e3..c3b40b1a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -70,7 +70,6 @@ // My Submissions 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 index 4cbaeec3..a3e0ebf2 100644 --- a/tests/Feature/PublicResourceUploadTest.php +++ b/tests/Feature/PublicResourceUploadTest.php @@ -259,45 +259,6 @@ function createTestSubjectAndNode(bool $isFrozen = false): array ); }); -test('user can cancel their pending submission', function () { - [, $node] = createTestSubjectAndNode(); - - $user = User::factory()->create(); - $cr = ResourceChangeRequest::recordCreate($user->id, $node->id, [ - 'title' => 'To Cancel', - 'resource_type' => 'note', - ]); - - $this->actingAs($user) - ->delete("/my-submissions/{$cr->id}") - ->assertSessionHas('success', 'Submission cancelled successfully.'); - - expect(ResourceChangeRequest::count())->toBe(0); -}); - -test('user cannot cancel approved submission or another users submission', function () { - [, $node] = createTestSubjectAndNode(); - - $userA = User::factory()->create(); - $userB = User::factory()->create(); - - $crA = ResourceChangeRequest::recordCreate($userA->id, $node->id, [ - 'title' => 'Resource A', - 'resource_type' => 'note', - ]); - - // User B tries to cancel user A's submission - $this->actingAs($userB) - ->delete("/my-submissions/{$crA->id}") - ->assertForbidden(); - - // Approved submission cannot be cancelled - $crA->update(['status' => 'approved']); - $this->actingAs($userA) - ->delete("/my-submissions/{$crA->id}") - ->assertSessionHas('error', 'Only pending submissions can be cancelled.'); -}); - test('moderation notification generates public url on approve and my-submissions url on reject', function () { [$subject, $node] = createTestSubjectAndNode(); $user = User::factory()->create(); From c670eb20e81061e7683b959e6cb6fe6ba92ac7e1 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 17:38:29 +0600 Subject: [PATCH 11/23] Update web.php --- routes/web.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/web.php b/routes/web.php index c3b40b1a..a5bb1a8e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -63,7 +63,7 @@ Route::delete('/blogs/comments/{comment}', [BlogController::class, 'destroyComment'])->name('blogs.comments.destroy'); Route::post('/resources/{resource}/complete', [ResourceController::class, 'toggleComplete'])->name('resources.complete'); - // Resource Uploads (Public Authenticated) + // Resource Uploads 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'); From 858240f8cb4f780442b9bde0a6d4faabce2e9f1a Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 18:05:07 +0600 Subject: [PATCH 12/23] refactor(admin): extract ReviewResourceModal component and clean up unused code --- .../js/components/admin/BulkVideoModal.vue | 6 +- resources/js/components/admin/NodeHeader.vue | 1 - .../components/admin/ReviewResourceModal.vue | 737 ++++++++++++++++++ .../resources/FolderLockedModal.vue | 11 +- resources/js/pages/Node.vue | 6 +- resources/js/pages/Submissions/Index.vue | 7 +- resources/js/pages/admin/Node.vue | 5 - .../js/pages/admin/moderation/Resources.vue | 548 +------------ routes/web.php | 2 +- 9 files changed, 761 insertions(+), 562 deletions(-) create mode 100644 resources/js/components/admin/ReviewResourceModal.vue diff --git a/resources/js/components/admin/BulkVideoModal.vue b/resources/js/components/admin/BulkVideoModal.vue index 12be6a86..f23459d4 100644 --- a/resources/js/components/admin/BulkVideoModal.vue +++ b/resources/js/components/admin/BulkVideoModal.vue @@ -9,7 +9,7 @@ import { ChevronDown, ChevronRight, } from 'lucide-vue-next'; -import { computed, ref, watch } from 'vue'; +import { ref, watch } from 'vue'; import BaseModal from '@/components/BaseModal.vue'; const props = defineProps<{ @@ -333,9 +333,7 @@ const submitForm = () => { > {{ - isSaving - ? 'Importing Playlist...' - : 'Import Playlist' + isSaving ? 'Importing Playlist...' : 'Import Playlist' }} diff --git a/resources/js/components/admin/NodeHeader.vue b/resources/js/components/admin/NodeHeader.vue index 3df20319..67d4c991 100644 --- a/resources/js/components/admin/NodeHeader.vue +++ b/resources/js/components/admin/NodeHeader.vue @@ -6,7 +6,6 @@ import { ChevronRight, FolderPlus, Lock, - Plus, Unlock, } from 'lucide-vue-next'; import { onMounted, onUnmounted, ref } from 'vue'; diff --git a/resources/js/components/admin/ReviewResourceModal.vue b/resources/js/components/admin/ReviewResourceModal.vue new file mode 100644 index 00000000..6b4acb64 --- /dev/null +++ b/resources/js/components/admin/ReviewResourceModal.vue @@ -0,0 +1,737 @@ + + + diff --git a/resources/js/components/resources/FolderLockedModal.vue b/resources/js/components/resources/FolderLockedModal.vue index e01d9b29..a9b4ea01 100644 --- a/resources/js/components/resources/FolderLockedModal.vue +++ b/resources/js/components/resources/FolderLockedModal.vue @@ -25,14 +25,15 @@ const emit = defineEmits<{ -

+

ফোল্ডারটি লক করা রয়েছে

-

- এই ফোল্ডারটি বর্তমানে লক করা রয়েছে। তাই এখানে কোনো কিছু আপলোড করা যাবে না। +

+ এই ফোল্ডারটি বর্তমানে লক করা রয়েছে। তাই এখানে কোনো কিছু আপলোড + করা যাবে না।

diff --git a/resources/js/pages/Node.vue b/resources/js/pages/Node.vue index 6dde3a8e..8de3a297 100644 --- a/resources/js/pages/Node.vue +++ b/resources/js/pages/Node.vue @@ -1,10 +1,6 @@ - - diff --git a/resources/js/components/admin/ReviewResourceModal.vue b/resources/js/components/admin/ReviewResourceModal.vue index 6b4acb64..333a94fd 100644 --- a/resources/js/components/admin/ReviewResourceModal.vue +++ b/resources/js/components/admin/ReviewResourceModal.vue @@ -1,12 +1,10 @@
- - -
- -
- বাতিল করার কারণ: - {{ - item.rejection_reason || - 'কোনো কারণ উল্লেখ করা হয়নি।' - }} -
-
@@ -355,5 +409,11 @@ const getResourceIcon = (type?: string) => { title="কোনো সাবমিশন পাওয়া যায়নি" description="আপনার কোনো কন্টেন্ট সাবমিশন এই তালিকায় নেই।" /> + + + From c79a689d9b71efc11b86e22fbee7f9dcb0507fd4 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 19:32:47 +0600 Subject: [PATCH 15/23] refactor: update video playlist modal notice and simplify moderation notification routes --- app/Notifications/ResourceModerationNotification.php | 12 +++--------- resources/js/components/admin/BulkVideoModal.vue | 3 +-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/app/Notifications/ResourceModerationNotification.php b/app/Notifications/ResourceModerationNotification.php index 3961be83..a3e72bfa 100644 --- a/app/Notifications/ResourceModerationNotification.php +++ b/app/Notifications/ResourceModerationNotification.php @@ -35,15 +35,9 @@ public function via(object $notifiable): array */ public function toArray(object $notifiable): array { - $node = $this->changeRequest->node; - if ($this->status === 'rejected') { - $url = '/my-submissions?status=rejected'; - } elseif ($node && $node->subject) { - $path = implode('/', array_column($node->breadcrumb(), 'slug')); - $url = "/subjects/{$node->subject->slug}/nodes/{$path}"; - } else { - $url = '/my-submissions'; - } + $url = $this->status === 'rejected' + ? '/my-submissions?status=rejected' + : '/my-submissions?status=approved'; if ($this->totalCount > 1) { $title = $this->status === 'rejected' diff --git a/resources/js/components/admin/BulkVideoModal.vue b/resources/js/components/admin/BulkVideoModal.vue index f23459d4..df5fdfc9 100644 --- a/resources/js/components/admin/BulkVideoModal.vue +++ b/resources/js/components/admin/BulkVideoModal.vue @@ -120,8 +120,7 @@ const submitForm = () => { class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 dark:text-indigo-400" /> আপনার ইম্পোর্টকৃত প্লেলিস্টটি অ্যাডমিন পর্যালোচনার পর - ওয়েবসাইটে দৃশ্যমান হবে।শুধুমাত্র ভেরিফাইড শিক্ষকদের অফিশিয়াল চ্যানেলের ভিডিও যুক্ত করা যাবে। কোনো ধরনের আনলিস্টেড কিংবা পাইরেটেড ভিডিও অনুমোদিত নয়। From 9935b5038adf65a820bd947fd961514a6110a72a Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 19:45:14 +0600 Subject: [PATCH 16/23] perf: optimize submission status counts into a single grouped query --- app/Http/Controllers/SubmissionController.php | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php index d2d6ae63..59517998 100644 --- a/app/Http/Controllers/SubmissionController.php +++ b/app/Http/Controllers/SubmissionController.php @@ -14,17 +14,15 @@ public function index(Request $request) $user = Auth::user(); $maxLimit = $user->is_verified ? 100 : 30; - $pendingCount = ResourceChangeRequest::where('user_id', $user->id) - ->where('status', 'pending') - ->count(); - - $totalCount = ResourceChangeRequest::where('user_id', $user->id)->count(); - $approvedCount = ResourceChangeRequest::where('user_id', $user->id) - ->where('status', 'approved') - ->count(); - $rejectedCount = ResourceChangeRequest::where('user_id', $user->id) - ->where('status', 'rejected') - ->count(); + $counts = ResourceChangeRequest::where('user_id', $user->id) + ->selectRaw('status, count(*) as total') + ->groupBy('status') + ->pluck('total', 'status'); + + $pendingCount = (int) $counts->get('pending', 0); + $approvedCount = (int) $counts->get('approved', 0); + $rejectedCount = (int) $counts->get('rejected', 0); + $totalCount = (int) $counts->sum(); $status = $request->query('status'); $query = ResourceChangeRequest::where('user_id', $user->id) From 1d0620299c069fbd60d69754253a933ffc33350d Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 19:57:46 +0600 Subject: [PATCH 17/23] fix: enforce strict folder freeze lock without moderator bypass in store requests --- app/Http/Requests/Resource/BulkImageStoreRequest.php | 6 +----- app/Http/Requests/Resource/BulkVideoStoreRequest.php | 6 +----- app/Http/Requests/Resource/StoreResourceRequest.php | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/app/Http/Requests/Resource/BulkImageStoreRequest.php b/app/Http/Requests/Resource/BulkImageStoreRequest.php index efea724d..744614e4 100644 --- a/app/Http/Requests/Resource/BulkImageStoreRequest.php +++ b/app/Http/Requests/Resource/BulkImageStoreRequest.php @@ -16,11 +16,7 @@ public function authorize(): bool { $node = Node::find($this->input('node_id')); - if ($node?->isEffectivelyFrozen() && ! $this->user()?->can('moderate resources')) { - return false; - } - - return true; + return ! ($node?->isEffectivelyFrozen()); } protected function failedAuthorization() diff --git a/app/Http/Requests/Resource/BulkVideoStoreRequest.php b/app/Http/Requests/Resource/BulkVideoStoreRequest.php index 917346e7..135fc9bf 100644 --- a/app/Http/Requests/Resource/BulkVideoStoreRequest.php +++ b/app/Http/Requests/Resource/BulkVideoStoreRequest.php @@ -16,11 +16,7 @@ public function authorize(): bool { $node = Node::find($this->input('node_id')); - if ($node?->isEffectivelyFrozen() && ! $this->user()?->can('moderate resources')) { - return false; - } - - return true; + return ! ($node?->isEffectivelyFrozen()); } protected function failedAuthorization() diff --git a/app/Http/Requests/Resource/StoreResourceRequest.php b/app/Http/Requests/Resource/StoreResourceRequest.php index 6e2462ad..2693b2d2 100644 --- a/app/Http/Requests/Resource/StoreResourceRequest.php +++ b/app/Http/Requests/Resource/StoreResourceRequest.php @@ -17,11 +17,7 @@ public function authorize(): bool { $node = Node::find($this->input('node_id')); - if ($node?->isEffectivelyFrozen() && ! $this->user()?->can('moderate resources')) { - return false; - } - - return true; + return ! ($node?->isEffectivelyFrozen()); } protected function failedAuthorization() From 6162fea0af10dd1fb42f958c94269dde26fbab6c Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 20:00:09 +0600 Subject: [PATCH 18/23] refactor: remove redundant failedAuthorization in resource store requests --- app/Http/Requests/Resource/BulkImageStoreRequest.php | 6 ------ app/Http/Requests/Resource/BulkVideoStoreRequest.php | 6 ------ app/Http/Requests/Resource/StoreResourceRequest.php | 6 ------ 3 files changed, 18 deletions(-) diff --git a/app/Http/Requests/Resource/BulkImageStoreRequest.php b/app/Http/Requests/Resource/BulkImageStoreRequest.php index 744614e4..08c2357a 100644 --- a/app/Http/Requests/Resource/BulkImageStoreRequest.php +++ b/app/Http/Requests/Resource/BulkImageStoreRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests\Resource; use App\Models\Node; -use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -19,11 +18,6 @@ public function authorize(): bool return ! ($node?->isEffectivelyFrozen()); } - protected function failedAuthorization() - { - throw new AuthorizationException('এই ফোল্ডারটি লক করা রয়েছে। অ্যাডমিন কর্তৃক কন্টেন্ট আপলোড সাময়িকভাবে বন্ধ আছে।'); - } - /** * Get the validation rules that apply to the request. * diff --git a/app/Http/Requests/Resource/BulkVideoStoreRequest.php b/app/Http/Requests/Resource/BulkVideoStoreRequest.php index 135fc9bf..dac36bdf 100644 --- a/app/Http/Requests/Resource/BulkVideoStoreRequest.php +++ b/app/Http/Requests/Resource/BulkVideoStoreRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests\Resource; use App\Models\Node; -use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -19,11 +18,6 @@ public function authorize(): bool return ! ($node?->isEffectivelyFrozen()); } - protected function failedAuthorization() - { - throw new AuthorizationException('এই ফোল্ডারটি লক করা রয়েছে। অ্যাডমিন কর্তৃক কন্টেন্ট আপলোড সাময়িকভাবে বন্ধ আছে।'); - } - /** * Get the validation rules that apply to the request. * diff --git a/app/Http/Requests/Resource/StoreResourceRequest.php b/app/Http/Requests/Resource/StoreResourceRequest.php index 2693b2d2..d35c4705 100644 --- a/app/Http/Requests/Resource/StoreResourceRequest.php +++ b/app/Http/Requests/Resource/StoreResourceRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests\Resource; use App\Models\Node; -use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -20,11 +19,6 @@ public function authorize(): bool return ! ($node?->isEffectivelyFrozen()); } - protected function failedAuthorization() - { - throw new AuthorizationException('এই ফোল্ডারটি লক করা রয়েছে। অ্যাডমিন কর্তৃক কন্টেন্ট আপলোড সাময়িকভাবে বন্ধ আছে।'); - } - /** * Get the validation rules that apply to the request. * From 1ce4526506a4a4213d18d69ecad44dbddd4e20e1 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 20:05:10 +0600 Subject: [PATCH 19/23] refactor: rename ensureUnderPendingLimit to ensureCanSubmit and update tests --- app/Http/Controllers/Admin/ResourceController.php | 4 ++-- app/Http/Controllers/ResourceController.php | 10 +++++----- tests/Feature/PublicResourceUploadTest.php | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index d7c2c262..cfe7b021 100644 --- a/app/Http/Controllers/Admin/ResourceController.php +++ b/app/Http/Controllers/Admin/ResourceController.php @@ -31,7 +31,7 @@ public function update(UpdateResourceRequest $request, Resource $resource) return back()->with('success', 'Resource updated successfully.'); } - $this->ensureUnderPendingLimit(1, false); + $this->ensureCanSubmit(1, false); ResourceChangeRequest::recordUpdate( Auth::id(), @@ -53,7 +53,7 @@ public function destroy(Resource $resource) return redirect()->back()->with('success', 'Resource deleted successfully.'); } - $this->ensureUnderPendingLimit(1, false); + $this->ensureCanSubmit(1, false); ResourceChangeRequest::recordDelete(Auth::id(), $resource); diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index 410c5610..084aa67c 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -90,9 +90,9 @@ public function getMaxPendingSubmissions(): int } /** - * Check if user would exceed their pending change requests quota or is blocked by age limit. + * Check if user is eligible to submit (account age) and within pending change requests quota. */ - public function ensureUnderPendingLimit(int $incomingCount = 1, bool $checkAge = true): void + public function ensureCanSubmit(int $incomingCount = 1, bool $checkAge = true): void { $user = Auth::user(); if ($user?->can('moderate resources')) { @@ -134,7 +134,7 @@ public function store(StoreResourceRequest $request) return back()->with('success', 'Resource created successfully.'); } - $this->ensureUnderPendingLimit(1, true); + $this->ensureCanSubmit(1, true); ResourceChangeRequest::recordCreate( Auth::id(), @@ -152,7 +152,7 @@ public function storeBulkImages(BulkImageStoreRequest $request) $isModerator = $request->user()->can('moderate resources'); if (! $isModerator) { - $this->ensureUnderPendingLimit($filesCount, true); + $this->ensureCanSubmit($filesCount, true); } $userId = Auth::id(); @@ -257,7 +257,7 @@ public function storeBulkVideos(BulkVideoStoreRequest $request) $isModerator = $request->user()->can('moderate resources'); if (! $isModerator) { - $this->ensureUnderPendingLimit($videosCount, true); + $this->ensureCanSubmit($videosCount, true); } DB::transaction(function () use ($videos, $userId, $nodeId, $isModerator) { diff --git a/tests/Feature/PublicResourceUploadTest.php b/tests/Feature/PublicResourceUploadTest.php index a3e0ebf2..853b13e2 100644 --- a/tests/Feature/PublicResourceUploadTest.php +++ b/tests/Feature/PublicResourceUploadTest.php @@ -197,7 +197,7 @@ function createTestSubjectAndNode(bool $isFrozen = false): array expect(ResourceChangeRequest::count())->toBe(0); }); -test('moderator can upload to frozen node', function () { +test('frozen node rejects upload for moderators as well', function () { [, $node] = createTestSubjectAndNode(isFrozen: true); $moderator = User::factory()->create(); @@ -207,12 +207,12 @@ function createTestSubjectAndNode(bool $isFrozen = false): array ->post('/resources', [ 'node_id' => $node->id, 'resource_type' => 'note', - 'title' => 'Moderator Bypass Frozen', - 'content' => 'Allowed', + 'title' => 'Moderator Cannot Bypass Frozen', + 'content' => 'Should fail', ]) - ->assertSessionHas('success', 'Resource created successfully.'); + ->assertForbidden(); - expect(Resource::count())->toBe(1); + expect(Resource::count())->toBe(0); }); test('bulk image upload stages multiple change requests for non-moderator', function () { @@ -259,7 +259,7 @@ function createTestSubjectAndNode(bool $isFrozen = false): array ); }); -test('moderation notification generates public url on approve and my-submissions url on reject', function () { +test('moderation notification generates my-submissions url with status on approve and reject', function () { [$subject, $node] = createTestSubjectAndNode(); $user = User::factory()->create(); @@ -271,7 +271,7 @@ function createTestSubjectAndNode(bool $isFrozen = false): array // Approved notification $approvedNotif = new ResourceModerationNotification($cr, 'approved'); $approvedData = $approvedNotif->toArray($user); - expect($approvedData['url'])->toBe("/subjects/{$subject->slug}/nodes/{$node->slug}"); + expect($approvedData['url'])->toBe('/my-submissions?status=approved'); // Rejected notification $rejectedNotif = new ResourceModerationNotification($cr, 'rejected', feedback: 'Irrelevant topic'); From 44bae2e0afb5f9fcfaadc492bec402d6b65432d8 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Tue, 13 Oct 2026 20:10:52 +0600 Subject: [PATCH 20/23] fix(resources): validate submission eligibility before storing files and import pagination --- app/Http/Controllers/ResourceController.php | 18 +++++++++-- .../js/components/admin/BulkVideoModal.vue | 4 ++- .../components/admin/ReviewResourceModal.vue | 32 +++++++++++++++---- resources/js/pages/Submissions/Index.vue | 10 ++++-- .../js/pages/admin/moderation/Resources.vue | 1 - 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index 084aa67c..7a891ad4 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -122,20 +122,23 @@ public function ensureCanSubmit(int $incomingCount = 1, bool $checkAge = true): public function store(StoreResourceRequest $request) { $validated = $request->validated(); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureCanSubmit(1, true); + } if ($request->hasFile('file')) { $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); } - if ($request->user()->can('moderate resources')) { + if ($isModerator) { $validated['user_id'] = Auth::id(); Resource::create($validated); return back()->with('success', 'Resource created successfully.'); } - $this->ensureCanSubmit(1, true); - ResourceChangeRequest::recordCreate( Auth::id(), (int) $validated['node_id'], @@ -189,6 +192,11 @@ public function storeBulkImages(BulkImageStoreRequest $request) public function storeBulkVideos(BulkVideoStoreRequest $request) { $validated = $request->validated(); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureCanSubmit(1, true); + } $playlistUrl = $validated['playlist_url']; @@ -227,6 +235,10 @@ public function storeBulkVideos(BulkVideoStoreRequest $request) ]; } + if (! $isModerator && count($videos) >= $this->getMaxPendingSubmissions()) { + break; + } + $pageToken = $data['nextPageToken'] ?? null; } while ($pageToken); diff --git a/resources/js/components/admin/BulkVideoModal.vue b/resources/js/components/admin/BulkVideoModal.vue index df5fdfc9..cba8cd3c 100644 --- a/resources/js/components/admin/BulkVideoModal.vue +++ b/resources/js/components/admin/BulkVideoModal.vue @@ -120,7 +120,9 @@ const submitForm = () => { class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 dark:text-indigo-400" /> শুধুমাত্র ভেরিফাইড শিক্ষকদের অফিশিয়াল চ্যানেলের ভিডিও যুক্ত করা যাবে। কোনো ধরনের আনলিস্টেড কিংবা পাইরেটেড ভিডিও অনুমোদিত নয়।শুধুমাত্র ভেরিফাইড শিক্ষকদের অফিশিয়াল চ্যানেলের ভিডিও যুক্ত + করা যাবে। কোনো ধরনের আনলিস্টেড কিংবা পাইরেটেড ভিডিও অনুমোদিত + নয়। diff --git a/resources/js/components/admin/ReviewResourceModal.vue b/resources/js/components/admin/ReviewResourceModal.vue index 783a1c63..116e5fef 100644 --- a/resources/js/components/admin/ReviewResourceModal.vue +++ b/resources/js/components/admin/ReviewResourceModal.vue @@ -323,7 +323,7 @@ const getChangedFields = (req: ChangeRequest): ChangedField[] => { {

{{ request.payload.content }}

@@ -461,7 +461,12 @@ const getChangedFields = (req: ChangeRequest): ChangedField[] => { :src="change.oldVal" alt="Current file" class="max-h-48 max-w-full cursor-zoom-in rounded-lg border border-slate-200 object-contain transition hover:opacity-90 dark:border-gray-700" - @click="openImageViewer(change.oldVal, 'Current Image')" + @click=" + openImageViewer( + change.oldVal, + 'Current Image', + ) + " />
{ :src="change.newVal" alt="Proposed file" class="max-h-48 max-w-full cursor-zoom-in rounded-lg border border-slate-200 object-contain transition hover:opacity-90 dark:border-gray-700" - @click="openImageViewer(change.newVal, 'Proposed New Image')" + @click=" + openImageViewer( + change.newVal, + 'Proposed New Image', + ) + " />
{ v-if="request.status === 'approved'" class="font-semibold text-emerald-600 dark:text-emerald-400" > - ✓ Approved by {{ request.reviewer.name }} + ✓ Approved + by {{ request.reviewer.name }} on {{ formatDateTime(request.reviewed_at) }}
- ✕ Rejected by {{ request.reviewer.name }} + ✕ Rejected + by {{ request.reviewer.name }} on {{ formatDateTime(request.reviewed_at) }} @@ -662,7 +676,11 @@ const getChangedFields = (req: ChangeRequest): ChangedField[] => {