diff --git a/GEMINI.md b/GEMINI.md index d4630fd4..a1a1ff18 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,8 +1,9 @@ # Project Guidelines & Automated Checks -## Formatting and Linting +## Formatting, Linting & Builds - **Strict Trigger**: Do NOT run formatting, linting, or fix commands (`npm run format`, `composer lint`, `npm run lint`) during intermediate edits or regular conversational turns. -- Only run the automated check commands when: +- **Build Command**: Do NOT run `npm run build` during intermediate edits or conversational turns unless explicitly instructed by the user or strictly necessary for final pre-push verification. +- Only run automated check commands when: 1. The user explicitly instructs to `"push"` or `"commit"`. 2. The user explicitly asks to check or fix formatting/linting issues. diff --git a/app/Console/Commands/DeleteUnusedImages.php b/app/Console/Commands/DeleteUnusedImages.php index 8fd9b29d..754958f7 100644 --- a/app/Console/Commands/DeleteUnusedImages.php +++ b/app/Console/Commands/DeleteUnusedImages.php @@ -7,6 +7,7 @@ use App\Models\ForumPost; use App\Models\Notice; use App\Models\Resource; +use App\Models\ResourceChangeRequest; use App\Models\SupportTicket; use App\Models\User; use Illuminate\Console\Command; @@ -46,9 +47,19 @@ public function handle(): void ); // Resource files (notes, images, videos — all stored under resources/) + // Also protects pending change requests awaiting moderation review + $activeResourceFiles = Resource::whereNotNull('file_path')->pluck('file_path')->toArray(); + $pendingChangeRequestFiles = ResourceChangeRequest::where('status', 'pending') + ->whereNotNull('payload') + ->get() + ->pluck('payload.file_path') + ->filter() + ->values() + ->toArray(); + $this->cleanDirectory( 'resources', - Resource::whereNotNull('file_path')->pluck('file_path')->toArray() + array_values(array_unique(array_merge($activeResourceFiles, $pendingChangeRequestFiles))) ); // Forum post images diff --git a/app/Http/Controllers/Admin/NodeController.php b/app/Http/Controllers/Admin/NodeController.php index e7b775ca..5ee69755 100644 --- a/app/Http/Controllers/Admin/NodeController.php +++ b/app/Http/Controllers/Admin/NodeController.php @@ -6,6 +6,7 @@ use App\Http\Requests\Node\StoreNodeRequest; use App\Http\Requests\Node\UpdateNodeRequest; use App\Models\Node; +use App\Models\ResourceChangeRequest; use App\Models\Subject; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -25,7 +26,7 @@ public function show(Subject $subject, $path = null) 'subject' => $subject, 'nodes' => $nodes, 'resources' => [], - + 'breadcrumb' => [], ]); } @@ -42,13 +43,26 @@ public function show(Subject $subject, $path = null) foreach (array_slice($slugs, 1) as $slug) { $node = $node->children()->where('slug', $slug)->first(); + if (! $node) { + abort(404); + } } + $pendingCreates = ResourceChangeRequest::where('node_id', $node->id) + ->where('action_type', 'create') + ->where('status', 'pending') + ->with('user:id,name,username') + ->get(); + return Inertia::render('admin/Node', [ 'subject' => $subject, 'nodes' => $node->children, - 'resources' => $node->resources ?? [], - 'parent' => $node ? $node->append('is_effectively_frozen') : null, + 'resources' => $node->resources()->with([ + 'pendingChangeRequest' => fn ($q) => $q->with('user:id,name,username'), + ])->get(), + 'pending_creates' => $pendingCreates, + 'parent' => $node->append('is_effectively_frozen'), + 'breadcrumb' => $node->breadcrumb(), ]); } diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index 735f6b93..872746c5 100644 --- a/app/Http/Controllers/Admin/ResourceController.php +++ b/app/Http/Controllers/Admin/ResourceController.php @@ -9,78 +9,120 @@ use App\Http\Requests\Resource\UpdateResourceRequest; use App\Models\Node; use App\Models\Resource; +use App\Models\ResourceChangeRequest; 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 { + /** + * 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 + { + $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) { + $this->ensureUnderPendingLimit(1); + $validated = $request->validated(); - $validated['user_id'] = Auth::id(); if ($request->hasFile('file')) { - $path = $request->file('file')->store("resources/{$validated['resource_type']}s"); - $validated['file_path'] = $path; + $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); } - Resource::create($validated); + ResourceChangeRequest::recordCreate( + Auth::id(), + (int) $validated['node_id'], + $validated + ); - return back()->with('success', 'Resource created successfully.'); + return back()->with('success', 'Resource submitted for moderation.'); } public function update(UpdateResourceRequest $request, Resource $resource) { - $validated = $request->validated(); - - if ($request->hasFile('file')) { + if ($resource->pendingChangeRequest()->exists()) { + return back()->with('error', 'This resource already has a pending change request under review.'); + } - if ($resource->file_path) { - Storage::delete($resource->file_path); - } + $this->ensureUnderPendingLimit(1); - $path = $request->file('file') - ->store("resources/{$validated['resource_type']}s"); + $validated = $request->validated(); - $validated['file_path'] = $path; + if ($request->hasFile('file')) { + $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); } - $resource->update($validated); + ResourceChangeRequest::recordUpdate( + Auth::id(), + $resource, + $validated + ); - return back()->with('success', 'Resource updated successfully.'); + return back()->with('success', 'Resource update submitted for moderation.'); } public function destroy(Resource $resource) { - if ($resource->file_path) { - Storage::delete($resource->file_path); + if ($resource->pendingChangeRequest()->exists()) { + return back()->with('error', 'This resource already has a pending change request under review.'); } - $resource->delete(); + $this->ensureUnderPendingLimit(1); - return redirect()->back()->with('success', 'Resource deleted successfully.'); + 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') ?? []); - DB::transaction(function () use ($request, $validated) { - foreach ($request->file('files') as $index => $file) { + $this->ensureUnderPendingLimit($filesCount); - $validated['title'] = $validated['custom_titles'][$index]; - $validated['file_path'] = $file->store('resources/images'); - $validated['user_id'] = Auth::id(); - $validated['resource_type'] = 'image'; + $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; - Resource::create($validated); + DB::transaction(function () use ($request, $validated, $userId, $nodeId) { + foreach ($request->file('files') as $index => $file) { + ResourceChangeRequest::recordCreate($userId, $nodeId, [ + 'title' => $validated['custom_titles'][$index], + 'resource_type' => 'image', + 'file_path' => $file->store('resources/images'), + ]); } }); - return back()->with('success', 'Images uploaded successfully.'); + return back()->with('success', 'Images submitted for moderation.'); } public function storeBulkVideos(BulkVideoStoreRequest $request) @@ -149,14 +191,16 @@ public function storeBulkVideos(BulkVideoStoreRequest $request) } $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; + $videosCount = count($videos); + + $this->ensureUnderPendingLimit($videosCount); - DB::transaction(function () use ($videos, $validated, $userId) { + DB::transaction(function () use ($videos, $userId, $nodeId) { foreach ($videos as $video) { $finalUrl = "https://www.youtube.com/watch?v={$video['video_id']}"; - Resource::create([ - 'user_id' => $userId, - 'node_id' => $validated['node_id'], + ResourceChangeRequest::recordCreate($userId, $nodeId, [ 'title' => $video['title'], 'resource_type' => 'video', 'external_url' => $finalUrl, @@ -164,7 +208,7 @@ public function storeBulkVideos(BulkVideoStoreRequest $request) } }); - return back()->with('success', 'YouTube playlist imported successfully.'); + return back()->with('success', 'YouTube playlist imported and submitted for moderation.'); } public function bulkRename(Request $request, Node $node) diff --git a/app/Http/Controllers/Admin/ResourceModerationController.php b/app/Http/Controllers/Admin/ResourceModerationController.php new file mode 100644 index 00000000..7eb0d03e --- /dev/null +++ b/app/Http/Controllers/Admin/ResourceModerationController.php @@ -0,0 +1,170 @@ +query('status', 'pending'); + + $query = ResourceChangeRequest::with([ + 'user:id,name,username,image_path', + 'resource', + 'node.subject', + 'reviewer:id,name,username', + ]); + + if (in_array($status, ['pending', 'approved', 'rejected'])) { + $query->where('status', $status); + } + + $requests = $query->latest() + ->simplePaginate(15) + ->withQueryString(); + + $requests->getCollection()->each(function ($req) { + $req->node?->append('breadcrumb'); + }); + + $counts = [ + 'pending' => ResourceChangeRequest::where('status', 'pending')->count(), + 'approved' => ResourceChangeRequest::where('status', 'approved')->count(), + 'rejected' => ResourceChangeRequest::where('status', 'rejected')->count(), + ]; + + return Inertia::render('admin/moderation/Resources', [ + 'requests' => $requests, + 'counts' => $counts, + 'filters' => [ + 'status' => $status, + ], + ]); + } + + public function approve(Request $request) + { + $validated = $request->validate([ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer', 'exists:resource_change_requests,id'], + ]); + + $changeRequests = ResourceChangeRequest::whereIn('id', $validated['ids']) + ->where('status', 'pending') + ->with('resource') + ->get(); + + if ($changeRequests->isEmpty()) { + return back()->with('error', 'Selected requests have already been reviewed.'); + } + + DB::transaction(function () use ($changeRequests) { + $reviewerId = Auth::id(); + $now = now(); + + foreach ($changeRequests as $changeRequest) { + if ($changeRequest->action_type === 'create') { + $payload = $changeRequest->payload ?? []; + $payload['node_id'] = $changeRequest->node_id; + $payload['user_id'] = $changeRequest->user_id; + + $resource = Resource::create($payload); + $changeRequest->resource_id = $resource->id; + } elseif ($changeRequest->action_type === 'update') { + $resource = $changeRequest->resource; + + if ($resource) { + $newFilePath = $changeRequest->payload['file_path'] ?? null; + if ($newFilePath && $resource->file_path && $newFilePath !== $resource->file_path) { + Storage::delete($resource->file_path); + } + + $resource->update($changeRequest->payload); + } + } elseif ($changeRequest->action_type === 'delete') { + $resource = $changeRequest->resource; + + if ($resource) { + if ($resource->file_path) { + Storage::delete($resource->file_path); + } + $resource->delete(); + } + } + + $changeRequest->update([ + 'status' => 'approved', + 'reviewed_by' => $reviewerId, + 'reviewed_at' => $now, + ]); + } + }); + + $count = $changeRequests->count(); + $message = $count === 1 + ? 'Resource request approved successfully.' + : "{$count} resource requests approved successfully."; + + return back()->with('success', $message); + } + + public function reject(Request $request) + { + $validated = $request->validate([ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer', 'exists:resource_change_requests,id'], + 'rejection_reason' => ['nullable', 'string', 'max:500'], + ]); + + $changeRequests = ResourceChangeRequest::whereIn('id', $validated['ids']) + ->where('status', 'pending') + ->with('resource') + ->get(); + + if ($changeRequests->isEmpty()) { + return back()->with('error', 'Selected requests have already been reviewed.'); + } + + DB::transaction(function () use ($changeRequests, $validated) { + $reviewerId = Auth::id(); + $now = now(); + $reason = $validated['rejection_reason'] ?? null; + + foreach ($changeRequests as $item) { + $stagedFile = $item->payload['file_path'] ?? null; + + if ($stagedFile) { + $isNewFile = $item->action_type === 'create' + || ($item->action_type === 'update' && $stagedFile !== $item->resource?->file_path); + + if ($isNewFile) { + Storage::delete($stagedFile); + } + } + + $item->update([ + 'status' => 'rejected', + 'rejection_reason' => $reason, + 'reviewed_by' => $reviewerId, + 'reviewed_at' => $now, + ]); + } + }); + + $count = $changeRequests->count(); + $message = $count === 1 + ? 'Resource change request rejected.' + : "{$count} resource requests rejected."; + + return back()->with('success', $message); + } +} diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 70db7bb2..9ac54df7 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -67,9 +67,7 @@ public function show(Subject $subject, $path) 'slug' => $node->slug, ], 'nodes' => $nodes, - 'breadcrumb' => Cache::remember("node_breadcrumb_{$node->id}", now()->addDay(), function () use ($node) { - return $node->breadcrumb(); - }), + 'breadcrumb' => $node->breadcrumb(), 'resources' => $resources, 'upvotesCount' => $upvotesCount, 'downvotesCount' => $downvotesCount, diff --git a/app/Models/Node.php b/app/Models/Node.php index 53708041..d4f23e01 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; /** * @property-read Node|null $parent @@ -59,19 +60,26 @@ public function getIsEffectivelyFrozenAttribute(): bool public function breadcrumb(): array { - $breadcrumb = []; - $node = $this; + return Cache::remember("node_breadcrumb_{$this->id}", now()->addDays(7), function () { + $breadcrumb = []; + $node = $this; - while ($node) { - array_unshift($breadcrumb, [ - 'name' => $node->name, - 'slug' => $node->slug, - ]); + while ($node) { + array_unshift($breadcrumb, [ + 'name' => $node->name, + 'slug' => $node->slug, + ]); - $node = $node->parent; - } + $node = $node->parent; + } + + return $breadcrumb; + }); + } - return $breadcrumb; + public function getBreadcrumbAttribute(): array + { + return $this->breadcrumb(); } public function user() diff --git a/app/Models/Resource.php b/app/Models/Resource.php index fd5d1b6c..2f644a51 100644 --- a/app/Models/Resource.php +++ b/app/Models/Resource.php @@ -61,4 +61,14 @@ public function completions() { return $this->hasMany(ResourceCompletion::class); } + + public function changeRequests() + { + return $this->hasMany(ResourceChangeRequest::class); + } + + public function pendingChangeRequest() + { + return $this->hasOne(ResourceChangeRequest::class)->where('status', 'pending'); + } } diff --git a/app/Models/ResourceChangeRequest.php b/app/Models/ResourceChangeRequest.php new file mode 100644 index 00000000..44ee8f18 --- /dev/null +++ b/app/Models/ResourceChangeRequest.php @@ -0,0 +1,138 @@ +where('reviewed_at', '<=', now()->subDays(30)); + } + + protected $casts = [ + 'payload' => 'array', + 'reviewed_at' => 'datetime', + ]; + + protected $appends = [ + 'staged_file_url', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function resource(): BelongsTo + { + return $this->belongsTo(Resource::class); + } + + public function node(): BelongsTo + { + return $this->belongsTo(Node::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + public function getStagedFileUrlAttribute(): ?string + { + $filePath = $this->payload['file_path'] ?? null; + + if ($filePath) { + return Storage::url($filePath); + } + + return null; + } + + public function scopePending($query) + { + return $query->where('status', 'pending'); + } + + /** + * Sanitize and whitelist only valid resource attributes. + */ + public static function sanitizePayload(array $data): array + { + return [ + 'node_id' => $data['node_id'] ?? null, + 'resource_type' => $data['resource_type'] ?? null, + 'title' => $data['title'] ?? null, + 'content' => $data['content'] ?? null, + 'external_url' => $data['external_url'] ?? null, + 'file_path' => $data['file_path'] ?? null, + ]; + } + + public static function recordCreate(int $userId, int $nodeId, array $data): self + { + $data['node_id'] = $nodeId; + + return self::create([ + 'user_id' => $userId, + 'node_id' => $nodeId, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => self::sanitizePayload($data), + ]); + } + + public static function recordUpdate(int $userId, Resource $resource, array $data): self + { + if (! isset($data['file_path']) && $resource->file_path) { + $data['file_path'] = $resource->file_path; + } + + $payload = self::sanitizePayload($data); + + return self::create([ + 'user_id' => $userId, + 'resource_id' => $resource->id, + 'node_id' => $payload['node_id'] ?? $resource->node_id, + 'action_type' => 'update', + 'status' => 'pending', + 'payload' => $payload, + ]); + } + + public static function recordDelete(int $userId, Resource $resource): self + { + return self::create([ + 'user_id' => $userId, + 'resource_id' => $resource->id, + 'node_id' => $resource->node_id, + 'action_type' => 'delete', + 'status' => 'pending', + 'payload' => null, + ]); + } +} diff --git a/app/Notifications/NodeVoteNotification.php b/app/Notifications/NodeVoteNotification.php index e192d0b9..22d5404b 100644 --- a/app/Notifications/NodeVoteNotification.php +++ b/app/Notifications/NodeVoteNotification.php @@ -25,7 +25,12 @@ public function via(object $notifiable): array public function toArray(object $notifiable): array { $subject = $this->node->subject; - $url = $subject ? url("/{$subject->slug}/{$this->node->slug}") : url('/'); + if ($subject) { + $path = implode('/', array_column($this->node->breadcrumb(), 'slug')); + $url = url("/{$subject->slug}/{$path}"); + } else { + $url = url('/'); + } return [ 'type' => 'node_vote', diff --git a/app/Policies/ResourcePolicy.php b/app/Policies/ResourcePolicy.php index 173019a5..4f728ec1 100644 --- a/app/Policies/ResourcePolicy.php +++ b/app/Policies/ResourcePolicy.php @@ -18,4 +18,16 @@ public function update(User $user, Resource $resource): bool return $user->id === $resource->user_id || $user->can('edit resources'); } + + /** + * Determine whether the user can delete the resource. + */ + public function delete(User $user, Resource $resource): bool + { + if ($resource->node?->isEffectivelyFrozen()) { + return false; + } + + return $user->id === $resource->user_id || $user->can('delete resources'); + } } diff --git a/database/migrations/2026_10_09_145000_create_resource_change_requests_table.php b/database/migrations/2026_10_09_145000_create_resource_change_requests_table.php new file mode 100644 index 00000000..b1556609 --- /dev/null +++ b/database/migrations/2026_10_09_145000_create_resource_change_requests_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('resource_id')->nullable()->constrained('resources')->cascadeOnDelete(); + $table->foreignId('node_id')->constrained('nodes')->cascadeOnDelete(); + + $table->enum('action_type', ['create', 'update', 'delete']); + $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending'); + + $table->json('payload')->nullable(); + + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->text('rejection_reason')->nullable(); + + $table->timestamps(); + + $table->index('status'); + $table->index(['resource_id', 'status']); + $table->index(['user_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('resource_change_requests'); + } +}; diff --git a/database/migrations/2026_10_09_154900_add_moderate_resources_permission.php b/database/migrations/2026_10_09_154900_add_moderate_resources_permission.php new file mode 100644 index 00000000..1d17d7b3 --- /dev/null +++ b/database/migrations/2026_10_09_154900_add_moderate_resources_permission.php @@ -0,0 +1,43 @@ +forgetCachedPermissions(); + + $permission = Permission::findOrCreate('moderate resources', 'web'); + + $admin = Role::where('name', 'admin')->where('guard_name', 'web')->first(); + if ($admin) { + $admin->givePermissionTo($permission); + } + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + Cache::flush(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + Cache::flush(); + + $permission = Permission::where('name', 'moderate resources')->where('guard_name', 'web')->first(); + $permission?->delete(); + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + Cache::flush(); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 24e3a8bc..bb549735 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -30,6 +30,7 @@ public function run(): void ReportSeeder::class, UserAppreciationSeeder::class, ChatSeeder::class, + ResourceChangeRequestSeeder::class, ]); Blog::factory()->count(10)->create(); } diff --git a/database/seeders/ResourceChangeRequestSeeder.php b/database/seeders/ResourceChangeRequestSeeder.php new file mode 100644 index 00000000..dda33ca3 --- /dev/null +++ b/database/seeders/ResourceChangeRequestSeeder.php @@ -0,0 +1,225 @@ +first() ?? User::first(); + $contributor = User::where('id', '!=', $admin?->id)->first() ?? User::factory()->create(); + + $node = Node::has('subject')->first() ?? Node::first(); + if (! $node) { + $this->command->warn('No nodes available to seed change requests.'); + + return; + } + + // Fetch or create sample resources for update and delete requests + $noteResource = Resource::where('resource_type', 'note')->first() ?? Resource::create([ + 'node_id' => $node->id, + 'user_id' => $contributor->id, + 'resource_type' => 'note', + 'title' => 'Original Summary of Newton Mechanics', + 'content' => 'Newton second law states that F = dp/dt. When mass is constant, F = ma.', + ]); + + $pdfResource = Resource::where('resource_type', 'pdf')->first() ?? Resource::create([ + 'node_id' => $node->id, + 'user_id' => $contributor->id, + 'resource_type' => 'pdf', + 'title' => 'Calculus Formula Sheet 2024', + 'external_url' => 'https://example.com/calculus-formula-sheet.pdf', + ]); + + $videoResource = Resource::where('resource_type', 'video')->first() ?? Resource::create([ + 'node_id' => $node->id, + 'user_id' => $contributor->id, + 'resource_type' => 'video', + 'title' => 'Introduction to Organic Reactions', + 'external_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ]); + + $deleteCandidateResource = Resource::whereNotIn('id', [$noteResource->id, $pdfResource->id, $videoResource->id])->first() ?? Resource::create([ + 'node_id' => $node->id, + 'user_id' => $contributor->id, + 'resource_type' => 'note', + 'title' => 'Outdated Exam Routine 2021', + 'content' => 'Routine for 2021 HSC batch. No longer relevant.', + ]); + + // 1. Pending CREATE Requests + // Note + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => [ + 'node_id' => $node->id, + 'resource_type' => 'note', + 'title' => 'Photosynthesis Light & Dark Reaction Summary Notes', + 'content' => "### Photosynthesis Key Points\n\n- **Light Dependent Phase**: Occurs in thylakoid membranes.\n- **Light Independent Phase (Calvin Cycle)**: Occurs in the stroma.\n- Key enzyme: RuBisCO.", + 'external_url' => null, + 'file_path' => null, + ], + ]); + + // PDF + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => [ + 'node_id' => $node->id, + 'resource_type' => 'pdf', + 'title' => 'Thermodynamics Formulas & Chapter Practice PDF', + 'content' => null, + 'external_url' => 'https://drive.google.com/file/d/1sample_pdf_drive_link/view', + 'file_path' => null, + ], + ]); + + // Video + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => [ + 'node_id' => $node->id, + 'resource_type' => 'video', + 'title' => 'Complete Matrices and Determinants Masterclass (Bangla)', + 'content' => null, + 'external_url' => 'https://www.youtube.com/watch?v=ScMzIvxBSi4', + 'file_path' => null, + ], + ]); + + // Image + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => [ + 'node_id' => $node->id, + 'resource_type' => 'image', + 'title' => 'Conic Sections - Hyperbola and Ellipse Geometric Diagram', + 'content' => null, + 'external_url' => null, + 'file_path' => 'resources/images/sample-conic-diagram.png', + ], + ]); + + // 2. Pending UPDATE Requests + // Note update + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'resource_id' => $noteResource->id, + 'node_id' => $noteResource->node_id, + 'action_type' => 'update', + 'status' => 'pending', + 'payload' => [ + 'node_id' => $noteResource->node_id, + 'resource_type' => 'note', + 'title' => 'Newtonian Mechanics - Comprehensive Derivations & Solved Problems', + 'content' => "### Comprehensive Newtonian Mechanics\n\n1. Momentum conservation: m1*u1 + m2*u2 = m1*v1 + m2*v2\n2. Friction: f_k = mu_k * N\n3. Centripetal Force: F_c = m * v^2 / r", + 'external_url' => null, + 'file_path' => null, + ], + ]); + + // PDF update + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'resource_id' => $pdfResource->id, + 'node_id' => $pdfResource->node_id, + 'action_type' => 'update', + 'status' => 'pending', + 'payload' => [ + 'node_id' => $pdfResource->node_id, + 'resource_type' => 'pdf', + 'title' => 'Calculus Formula Sheet 2026 (Updated with Integration Rules)', + 'content' => null, + 'external_url' => 'https://example.com/calculus-formula-sheet-2026-revised.pdf', + 'file_path' => null, + ], + ]); + + // Video update + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'resource_id' => $videoResource->id, + 'node_id' => $videoResource->node_id, + 'action_type' => 'update', + 'status' => 'pending', + 'payload' => [ + 'node_id' => $videoResource->node_id, + 'resource_type' => 'video', + 'title' => 'Organic Chemistry - Electrophilic Aromatic Substitution (HD Remastered)', + 'content' => null, + 'external_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'file_path' => null, + ], + ]); + + // 3. Pending DELETE Request + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'resource_id' => $deleteCandidateResource->id, + 'node_id' => $deleteCandidateResource->node_id, + 'action_type' => 'delete', + 'status' => 'pending', + 'payload' => null, + ]); + + // 4. APPROVED Request (history check) + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'approved', + 'reviewed_by' => $admin?->id, + 'reviewed_at' => now()->subDay(), + 'payload' => [ + 'node_id' => $node->id, + 'resource_type' => 'pdf', + 'title' => 'HSC English 1st Paper Flowchart and Summary Guidelines', + 'content' => null, + 'external_url' => 'https://example.com/english-1st-paper-guide.pdf', + 'file_path' => null, + ], + ]); + + // 5. REJECTED Request (history check) + ResourceChangeRequest::create([ + 'user_id' => $contributor->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'rejected', + 'reviewed_by' => $admin?->id, + 'reviewed_at' => now()->subHours(6), + 'rejection_reason' => 'The provided Google Drive link requires permission to view. Please set link sharing to "Anyone with the link can view" and resubmit.', + 'payload' => [ + 'node_id' => $node->id, + 'resource_type' => 'pdf', + 'title' => 'Inaccessible Question Bank PDF', + 'content' => null, + 'external_url' => 'https://drive.google.com/file/d/private-file-id/view', + 'file_path' => null, + ], + ]); + } +} diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 9eea7db3..eff1c2c8 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -49,6 +49,7 @@ public function run(): void Permission::findOrCreate('create resources'); Permission::findOrCreate('edit resources'); Permission::findOrCreate('delete resources'); + Permission::findOrCreate('moderate resources'); /* * Blog management diff --git a/docs/storage-cleanup.md b/docs/storage-cleanup.md index 6ec6e105..c0d9c9f2 100644 --- a/docs/storage-cleanup.md +++ b/docs/storage-cleanup.md @@ -6,12 +6,12 @@ The `resources:clean-unused-images` Artisan command removes orphaned files from It scans four storage directories and cross-references them against the database: -| Storage Directory | Database Check | -| ----------------- | ------------------------------------- | -| `resources/` | `resources.file_path` | -| `users/` | `users.image_path` | -| `blogs/` | `blogs.featured_image_path` | -| `notices/` | `notices.image` (non-HTTP paths only) | +| Storage Directory | Database Check | +| ----------------- | ------------------------------------------------------------------------------ | +| `resources/` | `resources.file_path` & pending `resource_change_requests.payload['file_path']` | +| `users/` | `users.image_path` | +| `blogs/` | `blogs.featured_image_path` | +| `notices/` | `notices.image` (non-HTTP paths only) | A file is considered unused when it exists in storage but its path is not referenced by any database record. diff --git a/resources/js/components/admin/BulkImageModal.vue b/resources/js/components/admin/BulkImageModal.vue index be2c40ac..d9eddb39 100644 --- a/resources/js/components/admin/BulkImageModal.vue +++ b/resources/js/components/admin/BulkImageModal.vue @@ -244,6 +244,7 @@ const submitForm = async () => { }, onError: (errors) => { errorMessage.value = + (errors.pending_limit as string) || Object.values(errors).flat().join(', ') || 'Failed to upload images.'; }, @@ -272,14 +273,20 @@ const submitForm = async () => {
+ {{ errorMessage }} +
+ {{ errorMessage }} +
+ {{ errorMessage }} +