From 7d64a52af440fd5ce9ea01f5ff9555ed93916273 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Mon, 12 Oct 2026 23:39:40 +0600 Subject: [PATCH 1/2] feat(resources): enable public note and bulk upload with quota checks, drop create resources permission --- .../Controllers/Admin/ResourceController.php | 212 +---------------- app/Http/Controllers/ResourceController.php | 219 ++++++++++++++++++ ...253_remove_create_resources_permission.php | 23 ++ database/seeders/RolePermissionSeeder.php | 1 - resources/js/components/EmptyState.vue | 2 +- .../js/components/admin/BulkImageModal.vue | 10 +- .../js/components/admin/BulkVideoModal.vue | 25 +- .../components/admin/CreateResourceModal.vue | 14 +- resources/js/components/admin/NodeHeader.vue | 2 +- resources/js/pages/Node.vue | 133 ++++++++++- routes/admin.php | 6 - routes/web.php | 3 + 12 files changed, 425 insertions(+), 225 deletions(-) create mode 100644 database/migrations/2026_10_12_171253_remove_create_resources_permission.php diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index 0355185d..145f2e43 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(); @@ -124,150 +60,6 @@ public function destroy(Resource $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/ResourceController.php b/app/Http/Controllers/ResourceController.php index e4649c31..5352a5f0 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): void + { + if (Auth::user()?->can('moderate resources')) { + return; + } + + $user = Auth::user(); + + if ($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/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..382c4a65 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,6 +290,14 @@ const submitForm = async () => { + +
+ নোট সাবমিশন: সকল আপলোডকৃত ইমেজ বা + নোট মডারেটর কর্তৃক পর্যালোচনার পর ওয়েবসাইটে প্রকাশিত হবে। +
+
diff --git a/resources/js/components/admin/BulkVideoModal.vue b/resources/js/components/admin/BulkVideoModal.vue index e2ddc95b..884a7151 100644 --- a/resources/js/components/admin/BulkVideoModal.vue +++ b/resources/js/components/admin/BulkVideoModal.vue @@ -79,7 +79,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 +131,29 @@ const submitForm = () => {
+ +
+
প্লেলিস্ট সাবমিশন নির্দেশিকা:
+ +
+
diff --git a/resources/js/components/admin/CreateResourceModal.vue b/resources/js/components/admin/CreateResourceModal.vue index ccd79ff3..1cefdc3e 100644 --- a/resources/js/components/admin/CreateResourceModal.vue +++ b/resources/js/components/admin/CreateResourceModal.vue @@ -211,7 +211,7 @@ const submitForm = () => { }, }); } else { - router.post('/admin/resources', payload, { + router.post('/resources', payload, { forceFormData: true, preserveScroll: true, onSuccess: () => { @@ -267,6 +267,18 @@ const submitForm = () => {
+ +
+ সাবমিশন নির্দেশিকা: সকল + আপলোডকৃত নোট এবং রিসোর্স মডারেটর কর্তৃক পর্যালোচনার পর + ওয়েবসাইটে প্রকাশিত হবে। কোনো রকম পাইরেটেড কনটেন্ট গ্রহণযোগ্য + নয়; ভিডিওর ক্ষেত্রে সংশ্লিষ্ট শিক্ষকের অফিশিয়াল চ্যানেলের + ভিডিও যুক্ত করুন। +
+