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 () => { + +
+ আপনার আপলোডকৃত নোট, পিডিএফ ও কন্টেন্টের পর্যালোচনার বর্তমান + অবস্থা +
++ একবারে সর্বোচ্চ {{ counts.max_pending }}টি কন্টেন্ট + পর্যালোচনাধীন রাখতে পারবেন। মডারেটর অনুমোদন করলে কোটা + আবার খালি হবে। +
++ {{ + getBreadcrumbText(sub) + }} +
+ + ++ জমা দেওয়া হয়েছে: + {{ formatDateTime(sub.created_at) }} +
+