From bd9b7c8882a7aadb5d8a9b4c0ab6d9f0170558e8 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 14:46:24 +0600 Subject: [PATCH 01/17] feat(resources): allow authors to delete their own resources --- app/Policies/ResourcePolicy.php | 12 ++++ resources/js/components/admin/ResourceRow.vue | 6 +- routes/admin.php | 2 +- tests/Feature/AdminResourceTest.php | 72 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) 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/resources/js/components/admin/ResourceRow.vue b/resources/js/components/admin/ResourceRow.vue index 22de4382..5fab49ea 100644 --- a/resources/js/components/admin/ResourceRow.vue +++ b/resources/js/components/admin/ResourceRow.vue @@ -32,7 +32,11 @@ const canEdit = computed(() => { }); const canDelete = computed(() => { - return !props.isFrozen && can('delete resources'); + return ( + !props.isFrozen && + (can('delete resources') || + (userId.value !== null && userId.value === props.resource?.user_id)) + ); }); const handleDelete = () => { diff --git a/routes/admin.php b/routes/admin.php index e38476ad..68b93d5e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -76,7 +76,7 @@ Route::post('/nodes/{node}/resources/bulk-rename', [AdminResourceController::class, 'bulkRename'])->name('resources.bulk-rename'); }); -Route::delete('/resources/{resource}', [AdminResourceController::class, 'destroy'])->middleware('permission:delete resources'); +Route::delete('/resources/{resource}', [AdminResourceController::class, 'destroy'])->middleware('can:delete,resource'); // Notice Route::middleware('permission:edit notice')->group(function () { diff --git a/tests/Feature/AdminResourceTest.php b/tests/Feature/AdminResourceTest.php index dad817a1..cbedf421 100644 --- a/tests/Feature/AdminResourceTest.php +++ b/tests/Feature/AdminResourceTest.php @@ -131,3 +131,75 @@ expect($res1->fresh()->title)->toBe('Lecture - 05'); expect($res2->fresh()->title)->toBe('Lecture - 06'); }); + +test('resource author can delete their own resource', function () { + Permission::findOrCreate('delete resources', 'web'); + + $author = User::factory()->create(); + $author->givePermissionTo('view admin'); + + $subject = Subject::create([ + 'name' => 'Math', + 'slug' => 'math', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'calculator', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Geometry', + 'slug' => 'geometry', + ]); + + $resource = Resource::create([ + 'user_id' => $author->id, + 'node_id' => $node->id, + 'resource_type' => 'video', + 'title' => 'My Video', + 'external_url' => 'https://youtube.com/watch?v=myvideo12345', + ]); + + $this->actingAs($author) + ->delete("/admin/resources/{$resource->id}") + ->assertRedirect() + ->assertSessionHas('success'); + + expect(Resource::find($resource->id))->toBeNull(); +}); + +test('non-author without delete resources permission cannot delete another users resource', function () { + Permission::findOrCreate('delete resources', 'web'); + + $author = User::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->givePermissionTo('view admin'); + + $subject = Subject::create([ + 'name' => 'Biology', + 'slug' => 'biology', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'dna', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Genetics', + 'slug' => 'genetics', + ]); + + $resource = Resource::create([ + 'user_id' => $author->id, + 'node_id' => $node->id, + 'resource_type' => 'video', + 'title' => 'Cell Division', + 'external_url' => 'https://youtube.com/watch?v=cell12345678', + ]); + + $this->actingAs($otherUser) + ->delete("/admin/resources/{$resource->id}") + ->assertForbidden(); + + expect(Resource::find($resource->id))->not->toBeNull(); +}); From 0d8e246275b7d25c63057a7db3f52fbb2eab272d Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 14:56:04 +0600 Subject: [PATCH 02/17] feat(moderation): add resource_change_requests schema and model --- app/Models/Resource.php | 10 +++ app/Models/ResourceChangeRequest.php | 67 +++++++++++++++++++ ..._create_resource_change_requests_table.php | 44 ++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 app/Models/ResourceChangeRequest.php create mode 100644 database/migrations/2026_10_09_145000_create_resource_change_requests_table.php 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..38e6f952 --- /dev/null +++ b/app/Models/ResourceChangeRequest.php @@ -0,0 +1,67 @@ + '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 $this->payload['external_url'] ?? null; + } + + public function scopePending($query) + { + return $query->where('status', 'pending'); + } +} 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'); + } +}; From ffbbbd21c3637b0381622659d9b48d2165679e9b Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 15:14:22 +0600 Subject: [PATCH 03/17] feat(moderation): route resource actions into moderation queue with encapsulated model methods --- .../Controllers/Admin/ResourceController.php | 88 ++++++++++--------- app/Models/ResourceChangeRequest.php | 52 +++++++++++ database/seeders/RolePermissionSeeder.php | 1 + tests/Feature/AdminResourceTest.php | 63 ++++++++++++- 4 files changed, 157 insertions(+), 47 deletions(-) diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index 735f6b93..df0d29b0 100644 --- a/app/Http/Controllers/Admin/ResourceController.php +++ b/app/Http/Controllers/Admin/ResourceController.php @@ -9,78 +9,81 @@ 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; class ResourceController extends Controller { public function store(StoreResourceRequest $request) { $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; - } - - Resource::create($validated); - - return back()->with('success', 'Resource created successfully.'); + $filePath = $request->hasFile('file') + ? $request->file('file')->store("resources/{$validated['resource_type']}s") + : null; + + ResourceChangeRequest::recordCreate( + Auth::id(), + (int) $validated['node_id'], + $validated, + $filePath + ); + + return back()->with('success', 'Resource submitted for moderation.'); } public function update(UpdateResourceRequest $request, Resource $resource) { - $validated = $request->validated(); - - if ($request->hasFile('file')) { - - if ($resource->file_path) { - Storage::delete($resource->file_path); - } - - $path = $request->file('file') - ->store("resources/{$validated['resource_type']}s"); - - $validated['file_path'] = $path; + if ($resource->pendingChangeRequest()->exists()) { + return back()->with('error', 'This resource already has a pending change request under review.'); } - $resource->update($validated); - - return back()->with('success', 'Resource updated successfully.'); + $validated = $request->validated(); + $filePath = $request->hasFile('file') + ? $request->file('file')->store("resources/{$validated['resource_type']}s") + : null; + + ResourceChangeRequest::recordUpdate( + Auth::id(), + $resource, + $validated, + $filePath + ); + + 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(); + ResourceChangeRequest::recordDelete(Auth::id(), $resource); - return redirect()->back()->with('success', 'Resource deleted successfully.'); + return redirect()->back()->with('success', 'Resource deletion request submitted for moderation.'); } public function storeBulkImages(BulkImageStoreRequest $request) { $validated = $request->validated(); + $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; - DB::transaction(function () use ($request, $validated) { + DB::transaction(function () use ($request, $validated, $userId, $nodeId) { foreach ($request->file('files') as $index => $file) { + $filePath = $file->store('resources/images'); - $validated['title'] = $validated['custom_titles'][$index]; - $validated['file_path'] = $file->store('resources/images'); - $validated['user_id'] = Auth::id(); - $validated['resource_type'] = 'image'; - - Resource::create($validated); + ResourceChangeRequest::recordCreate($userId, $nodeId, [ + 'title' => $validated['custom_titles'][$index], + 'resource_type' => 'image', + ], $filePath); } }); - return back()->with('success', 'Images uploaded successfully.'); + return back()->with('success', 'Images submitted for moderation.'); } public function storeBulkVideos(BulkVideoStoreRequest $request) @@ -149,14 +152,13 @@ public function storeBulkVideos(BulkVideoStoreRequest $request) } $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; - 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 +166,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/Models/ResourceChangeRequest.php b/app/Models/ResourceChangeRequest.php index 38e6f952..97658741 100644 --- a/app/Models/ResourceChangeRequest.php +++ b/app/Models/ResourceChangeRequest.php @@ -64,4 +64,56 @@ public function scopePending($query) { return $query->where('status', 'pending'); } + + /** + * Sanitize and whitelist only valid resource attributes. + */ + public static function sanitizePayload(array $data, ?string $filePath = null): 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' => $filePath ?? ($data['file_path'] ?? null), + ]; + } + + public static function recordCreate(int $userId, int $nodeId, array $data, ?string $filePath = null): self + { + return self::create([ + 'user_id' => $userId, + 'node_id' => $nodeId, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => self::sanitizePayload($data, $filePath), + ]); + } + + public static function recordUpdate(int $userId, Resource $resource, array $data, ?string $filePath = null): self + { + $payload = self::sanitizePayload($data, $filePath ?? $resource->file_path); + + 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/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/tests/Feature/AdminResourceTest.php b/tests/Feature/AdminResourceTest.php index cbedf421..8c0b91c0 100644 --- a/tests/Feature/AdminResourceTest.php +++ b/tests/Feature/AdminResourceTest.php @@ -2,6 +2,7 @@ use App\Models\Node; use App\Models\Resource; +use App\Models\ResourceChangeRequest; use App\Models\Subject; use App\Models\User; use Spatie\Permission\Models\Permission; @@ -132,7 +133,7 @@ expect($res2->fresh()->title)->toBe('Lecture - 06'); }); -test('resource author can delete their own resource', function () { +test('resource author can submit a deletion request for moderation', function () { Permission::findOrCreate('delete resources', 'web'); $author = User::factory()->create(); @@ -165,10 +166,17 @@ ->assertRedirect() ->assertSessionHas('success'); - expect(Resource::find($resource->id))->toBeNull(); + // Live resource remains until approved + expect(Resource::find($resource->id))->not->toBeNull(); + + // Pending deletion request exists in moderation queue + $request = ResourceChangeRequest::where('resource_id', $resource->id)->first(); + expect($request)->not->toBeNull() + ->and($request->action_type)->toBe('delete') + ->and($request->status)->toBe('pending'); }); -test('non-author without delete resources permission cannot delete another users resource', function () { +test('non-author without delete resources permission cannot submit deletion request', function () { Permission::findOrCreate('delete resources', 'web'); $author = User::factory()->create(); @@ -201,5 +209,52 @@ ->delete("/admin/resources/{$resource->id}") ->assertForbidden(); - expect(Resource::find($resource->id))->not->toBeNull(); + expect(ResourceChangeRequest::where('resource_id', $resource->id)->exists())->toBeFalse(); +}); + +test('resource author can submit an update for moderation while live resource is unchanged', function () { + $author = User::factory()->create(); + $author->givePermissionTo('view admin'); + + $subject = Subject::create([ + 'name' => 'Chemistry', + 'slug' => 'chemistry', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'flask', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Acids', + 'slug' => 'acids', + ]); + + $resource = Resource::create([ + 'user_id' => $author->id, + 'node_id' => $node->id, + 'resource_type' => 'video', + 'title' => 'Old Title', + 'external_url' => 'https://youtube.com/watch?v=acid11111111', + ]); + + $this->actingAs($author) + ->post("/admin/resources/{$resource->id}/patch", [ + 'node_id' => $node->id, + 'title' => 'New Proposed Title', + 'resource_type' => 'video', + 'external_url' => 'https://youtube.com/watch?v=acid22222222', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + // Live resource is untouched + expect($resource->fresh()->title)->toBe('Old Title'); + + // Moderation queue has the pending update + $change = ResourceChangeRequest::where('resource_id', $resource->id)->first(); + expect($change)->not->toBeNull() + ->and($change->action_type)->toBe('update') + ->and($change->status)->toBe('pending') + ->and($change->payload['title'])->toBe('New Proposed Title'); }); From feea8fdcd42ccb7481b83ab4ea1f14ec9d3d4ec9 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 15:19:48 +0600 Subject: [PATCH 04/17] refactor(moderation): simplify record signatures by embedding file_path in validated array --- .../Controllers/Admin/ResourceController.php | 25 +++++++++---------- app/Models/ResourceChangeRequest.php | 16 +++++++----- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index df0d29b0..febfbded 100644 --- a/app/Http/Controllers/Admin/ResourceController.php +++ b/app/Http/Controllers/Admin/ResourceController.php @@ -20,15 +20,15 @@ class ResourceController extends Controller public function store(StoreResourceRequest $request) { $validated = $request->validated(); - $filePath = $request->hasFile('file') - ? $request->file('file')->store("resources/{$validated['resource_type']}s") - : null; + + if ($request->hasFile('file')) { + $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); + } ResourceChangeRequest::recordCreate( Auth::id(), (int) $validated['node_id'], - $validated, - $filePath + $validated ); return back()->with('success', 'Resource submitted for moderation.'); @@ -41,15 +41,15 @@ public function update(UpdateResourceRequest $request, Resource $resource) } $validated = $request->validated(); - $filePath = $request->hasFile('file') - ? $request->file('file')->store("resources/{$validated['resource_type']}s") - : null; + + if ($request->hasFile('file')) { + $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); + } ResourceChangeRequest::recordUpdate( Auth::id(), $resource, - $validated, - $filePath + $validated ); return back()->with('success', 'Resource update submitted for moderation.'); @@ -74,12 +74,11 @@ public function storeBulkImages(BulkImageStoreRequest $request) DB::transaction(function () use ($request, $validated, $userId, $nodeId) { foreach ($request->file('files') as $index => $file) { - $filePath = $file->store('resources/images'); - ResourceChangeRequest::recordCreate($userId, $nodeId, [ 'title' => $validated['custom_titles'][$index], 'resource_type' => 'image', - ], $filePath); + 'file_path' => $file->store('resources/images'), + ]); } }); diff --git a/app/Models/ResourceChangeRequest.php b/app/Models/ResourceChangeRequest.php index 97658741..adf3f9ce 100644 --- a/app/Models/ResourceChangeRequest.php +++ b/app/Models/ResourceChangeRequest.php @@ -68,7 +68,7 @@ public function scopePending($query) /** * Sanitize and whitelist only valid resource attributes. */ - public static function sanitizePayload(array $data, ?string $filePath = null): array + public static function sanitizePayload(array $data): array { return [ 'node_id' => $data['node_id'] ?? null, @@ -76,24 +76,28 @@ public static function sanitizePayload(array $data, ?string $filePath = null): a 'title' => $data['title'] ?? null, 'content' => $data['content'] ?? null, 'external_url' => $data['external_url'] ?? null, - 'file_path' => $filePath ?? ($data['file_path'] ?? null), + 'file_path' => $data['file_path'] ?? null, ]; } - public static function recordCreate(int $userId, int $nodeId, array $data, ?string $filePath = null): self + public static function recordCreate(int $userId, int $nodeId, array $data): self { return self::create([ 'user_id' => $userId, 'node_id' => $nodeId, 'action_type' => 'create', 'status' => 'pending', - 'payload' => self::sanitizePayload($data, $filePath), + 'payload' => self::sanitizePayload($data), ]); } - public static function recordUpdate(int $userId, Resource $resource, array $data, ?string $filePath = null): self + public static function recordUpdate(int $userId, Resource $resource, array $data): self { - $payload = self::sanitizePayload($data, $filePath ?? $resource->file_path); + if (! isset($data['file_path']) && $resource->file_path) { + $data['file_path'] = $resource->file_path; + } + + $payload = self::sanitizePayload($data); return self::create([ 'user_id' => $userId, From 6912169a202a807325a6cf524e988b6cdab08de6 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 16:10:53 +0600 Subject: [PATCH 05/17] feat(moderation): add moderate resources permission migration and seeder --- ...4900_add_moderate_resources_permission.php | 44 ++++ database/seeders/DatabaseSeeder.php | 1 + .../seeders/ResourceChangeRequestSeeder.php | 225 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 database/migrations/2026_10_09_154900_add_moderate_resources_permission.php create mode 100644 database/seeders/ResourceChangeRequestSeeder.php 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..8f4c9f8c --- /dev/null +++ b/database/migrations/2026_10_09_154900_add_moderate_resources_permission.php @@ -0,0 +1,44 @@ +forgetCachedPermissions(); + Cache::flush(); + + $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, + ], + ]); + } +} From 2d4e26786b844f6a7df2005929dfd2072d578d04 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 16:10:57 +0600 Subject: [PATCH 06/17] feat(moderation): add ResourceModerationController routes, nav link, and feature tests --- .../Admin/ResourceModerationController.php | 133 ++++++++++++++++++ resources/js/layouts/AdminLayout.vue | 6 + routes/admin.php | 8 ++ tests/Feature/AdminResourceTest.php | 76 ++++++++++ 4 files changed, 223 insertions(+) create mode 100644 app/Http/Controllers/Admin/ResourceModerationController.php diff --git a/app/Http/Controllers/Admin/ResourceModerationController.php b/app/Http/Controllers/Admin/ResourceModerationController.php new file mode 100644 index 00000000..f8ee9ec4 --- /dev/null +++ b/app/Http/Controllers/Admin/ResourceModerationController.php @@ -0,0 +1,133 @@ +query('status', 'pending'); + $actionType = $request->query('action_type'); + + $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); + } + + if (in_array($actionType, ['create', 'update', 'delete'])) { + $query->where('action_type', $actionType); + } + + $requests = $query->latest() + ->paginate(15) + ->withQueryString(); + + $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, + 'action_type' => $actionType, + ], + ]); + } + + public function approve(ResourceChangeRequest $changeRequest) + { + if ($changeRequest->status !== 'pending') { + return back()->with('error', 'This request has already been reviewed.'); + } + + DB::transaction(function () use ($changeRequest) { + if ($changeRequest->action_type === 'create') { + $resource = Resource::create($changeRequest->payload); + $changeRequest->resource_id = $resource->id; + } elseif ($changeRequest->action_type === 'update') { + $resource = $changeRequest->resource; + + if (! $resource) { + abort(404, 'Target resource not found.'); + } + + $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' => Auth::id(), + 'reviewed_at' => now(), + ]); + }); + + return back()->with('success', 'Resource change request approved successfully.'); + } + + public function reject(Request $request, ResourceChangeRequest $changeRequest) + { + if ($changeRequest->status !== 'pending') { + return back()->with('error', 'This request has already been reviewed.'); + } + + $validated = $request->validate([ + 'rejection_reason' => ['nullable', 'string', 'max:500'], + ]); + + DB::transaction(function () use ($changeRequest, $validated) { + $stagedFile = $changeRequest->payload['file_path'] ?? null; + + if ($stagedFile) { + // If create action, or update action where new file is different from live resource's file + $isNewFile = $changeRequest->action_type === 'create' + || ($changeRequest->action_type === 'update' && $stagedFile !== $changeRequest->resource?->file_path); + + if ($isNewFile) { + Storage::delete($stagedFile); + } + } + + $changeRequest->update([ + 'status' => 'rejected', + 'rejection_reason' => $validated['rejection_reason'] ?? null, + 'reviewed_by' => Auth::id(), + 'reviewed_at' => now(), + ]); + }); + + return back()->with('success', 'Resource change request rejected.'); + } +} diff --git a/resources/js/layouts/AdminLayout.vue b/resources/js/layouts/AdminLayout.vue index 996596a7..60ae860c 100644 --- a/resources/js/layouts/AdminLayout.vue +++ b/resources/js/layouts/AdminLayout.vue @@ -43,6 +43,12 @@ watch(adminCollapsed, (v) => { const allNavigation: AdminNavItem[] = [ { name: 'Dashboard', to: '/admin', icon: 'dashboard' }, + { + name: 'Resource Moderation', + to: '/admin/moderation/resources', + icon: 'fact_check', + permission: 'moderate resources', + }, { name: 'Manage Contents', to: '/admin/subjects', icon: 'menu_book' }, { name: 'Manage Blogs', to: '/admin/blogs', icon: 'book' }, { diff --git a/routes/admin.php b/routes/admin.php index 68b93d5e..0e451efd 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Admin\PeerSettingsController; use App\Http\Controllers\Admin\ProductController as AdminProductController; use App\Http\Controllers\Admin\ResourceController as AdminResourceController; +use App\Http\Controllers\Admin\ResourceModerationController; use App\Http\Controllers\Admin\SubjectController as AdminSubjectController; use App\Http\Controllers\Admin\SupportTicketController as AdminSupportTicketController; use App\Http\Controllers\Admin\UserController as AdminUserController; @@ -78,6 +79,13 @@ Route::delete('/resources/{resource}', [AdminResourceController::class, 'destroy'])->middleware('can:delete,resource'); +// Resource Moderation +Route::middleware('permission:moderate resources')->group(function () { + Route::get('/moderation/resources', [ResourceModerationController::class, 'index'])->name('moderation.resources.index'); + Route::post('/moderation/resources/{changeRequest}/approve', [ResourceModerationController::class, 'approve'])->name('moderation.resources.approve'); + Route::post('/moderation/resources/{changeRequest}/reject', [ResourceModerationController::class, 'reject'])->name('moderation.resources.reject'); +}); + // Notice Route::middleware('permission:edit notice')->group(function () { Route::get('/notice', [AdminNoticeController::class, 'edit'])->name('notice.edit'); diff --git a/tests/Feature/AdminResourceTest.php b/tests/Feature/AdminResourceTest.php index 8c0b91c0..93eb0f4a 100644 --- a/tests/Feature/AdminResourceTest.php +++ b/tests/Feature/AdminResourceTest.php @@ -258,3 +258,79 @@ ->and($change->status)->toBe('pending') ->and($change->payload['title'])->toBe('New Proposed Title'); }); + +test('moderator can approve a create request to bring resource live', function () { + Permission::findOrCreate('moderate resources', 'web'); + + $moderator = User::factory()->create(); + $moderator->givePermissionTo(['view admin', 'moderate resources']); + + $subject = Subject::create([ + 'name' => 'Math', + 'slug' => 'math', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'calculator', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Algebra', + 'slug' => 'algebra', + ]); + + $changeRequest = ResourceChangeRequest::recordCreate($moderator->id, $node->id, [ + 'title' => 'Equations Note', + 'resource_type' => 'note', + 'content' => 'Algebra notes content', + ]); + + expect(Resource::where('title', 'Equations Note')->exists())->toBeFalse(); + + $this->actingAs($moderator) + ->post("/admin/moderation/resources/{$changeRequest->id}/approve") + ->assertRedirect() + ->assertSessionHas('success'); + + expect($changeRequest->fresh()->status)->toBe('approved') + ->and($changeRequest->fresh()->reviewed_by)->toBe($moderator->id) + ->and(Resource::where('title', 'Equations Note')->exists())->toBeTrue(); +}); + +test('moderator can reject a request with feedback reason', function () { + Permission::findOrCreate('moderate resources', 'web'); + + $moderator = User::factory()->create(); + $moderator->givePermissionTo(['view admin', 'moderate resources']); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Optics', + 'slug' => 'optics', + ]); + + $changeRequest = ResourceChangeRequest::recordCreate($moderator->id, $node->id, [ + 'title' => 'Bad Video Link', + 'resource_type' => 'video', + 'external_url' => 'https://youtube.com/watch?v=brokenlink11', + ]); + + $this->actingAs($moderator) + ->post("/admin/moderation/resources/{$changeRequest->id}/reject", [ + 'rejection_reason' => 'The video URL is not valid.', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($changeRequest->fresh()->status)->toBe('rejected') + ->and($changeRequest->fresh()->rejection_reason)->toBe('The video URL is not valid.') + ->and(Resource::where('title', 'Bad Video Link')->exists())->toBeFalse(); +}); From 0882853b42221fdb407b525ae843344afb9a4404 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 16:11:00 +0600 Subject: [PATCH 07/17] feat(moderation): add moderation review page and pending badges in resource tree --- app/Http/Controllers/Admin/NodeController.php | 4 +- resources/js/components/admin/ResourceRow.vue | 28 +- .../js/pages/admin/moderation/Resources.vue | 664 ++++++++++++++++++ 3 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 resources/js/pages/admin/moderation/Resources.vue diff --git a/app/Http/Controllers/Admin/NodeController.php b/app/Http/Controllers/Admin/NodeController.php index e7b775ca..517d77c7 100644 --- a/app/Http/Controllers/Admin/NodeController.php +++ b/app/Http/Controllers/Admin/NodeController.php @@ -47,7 +47,9 @@ public function show(Subject $subject, $path = null) return Inertia::render('admin/Node', [ 'subject' => $subject, 'nodes' => $node->children, - 'resources' => $node->resources ?? [], + 'resources' => $node ? $node->resources()->with([ + 'pendingChangeRequest' => fn ($q) => $q->select('id', 'resource_id', 'action_type', 'status'), + ])->get() : [], 'parent' => $node ? $node->append('is_effectively_frozen') : null, ]); } diff --git a/resources/js/components/admin/ResourceRow.vue b/resources/js/components/admin/ResourceRow.vue index 5fab49ea..8819af75 100644 --- a/resources/js/components/admin/ResourceRow.vue +++ b/resources/js/components/admin/ResourceRow.vue @@ -23,9 +23,18 @@ const emit = defineEmits<{ (e: 'edit', resource: any): void; }>(); +const hasPendingChange = computed(() => { + return Boolean(props.resource?.pending_change_request); +}); + +const pendingAction = computed(() => { + return props.resource?.pending_change_request?.action_type; +}); + const canEdit = computed(() => { return ( !props.isFrozen && + !hasPendingChange.value && (can('edit resources') || (userId.value !== null && userId.value === props.resource?.user_id)) ); @@ -34,13 +43,16 @@ const canEdit = computed(() => { const canDelete = computed(() => { return ( !props.isFrozen && + !hasPendingChange.value && (can('delete resources') || (userId.value !== null && userId.value === props.resource?.user_id)) ); }); const handleDelete = () => { - if (confirm('Are you sure you want to delete this Resource?')) { + if ( + confirm('Are you sure you want to request deletion of this Resource?') + ) { router.delete(`/admin/resources/${props.resource?.id}`); } }; @@ -88,6 +100,20 @@ const handleDelete = () => { > {{ resource?.resource_type }} + + + + Edit Pending + + + Deletion Pending + diff --git a/resources/js/pages/admin/moderation/Resources.vue b/resources/js/pages/admin/moderation/Resources.vue new file mode 100644 index 00000000..6321e63a --- /dev/null +++ b/resources/js/pages/admin/moderation/Resources.vue @@ -0,0 +1,664 @@ + + + From 6b242ca74d2af4f190f2c7257902f570515b75ad Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 16:39:46 +0600 Subject: [PATCH 08/17] fix(moderation): assign node_id and user_id when approving create requests --- app/Http/Controllers/Admin/ResourceModerationController.php | 6 +++++- app/Models/ResourceChangeRequest.php | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Admin/ResourceModerationController.php b/app/Http/Controllers/Admin/ResourceModerationController.php index f8ee9ec4..5faed42a 100644 --- a/app/Http/Controllers/Admin/ResourceModerationController.php +++ b/app/Http/Controllers/Admin/ResourceModerationController.php @@ -61,7 +61,11 @@ public function approve(ResourceChangeRequest $changeRequest) DB::transaction(function () use ($changeRequest) { if ($changeRequest->action_type === 'create') { - $resource = Resource::create($changeRequest->payload); + $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; diff --git a/app/Models/ResourceChangeRequest.php b/app/Models/ResourceChangeRequest.php index adf3f9ce..eafe7ff1 100644 --- a/app/Models/ResourceChangeRequest.php +++ b/app/Models/ResourceChangeRequest.php @@ -82,6 +82,8 @@ public static function sanitizePayload(array $data): array public static function recordCreate(int $userId, int $nodeId, array $data): self { + $data['node_id'] = $nodeId; + return self::create([ 'user_id' => $userId, 'node_id' => $nodeId, From 1ec21b6dc0f8a1571a2a74962f93af05e46f8f58 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 16:43:29 +0600 Subject: [PATCH 09/17] feat(user): send goodbye email with feature highlights when admin deletes account --- app/Http/Controllers/Admin/UserController.php | 6 ++ app/Mail/AccountDeletedMail.php | 59 +++++++++++++++++++ resources/views/emails/default.blade.php | 7 ++- tests/Feature/AdminUserDeletionMailTest.php | 56 ++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 app/Mail/AccountDeletedMail.php create mode 100644 tests/Feature/AdminUserDeletionMailTest.php diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index c32679c1..d41d0804 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -5,10 +5,12 @@ use App\Http\Controllers\Controller; use App\Http\Requests\User\StoreUserRequest; use App\Http\Requests\User\UpdateUserRequest; +use App\Mail\AccountDeletedMail; use App\Models\User; use App\Notifications\WelcomeNotification; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Spatie\Permission\Models\Permission; @@ -157,6 +159,10 @@ public function update(UpdateUserRequest $request, User $user) public function destroy(User $user) { + if (! empty($user->email)) { + Mail::to($user->email)->queue(new AccountDeletedMail($user->name)); + } + $user->delete(); return redirect() diff --git a/app/Mail/AccountDeletedMail.php b/app/Mail/AccountDeletedMail.php new file mode 100644 index 00000000..5c8299e2 --- /dev/null +++ b/app/Mail/AccountDeletedMail.php @@ -0,0 +1,59 @@ +recipientName ? "প্রিয় {$this->recipientName}," : 'প্রিয় শিক্ষার্থী,'; + $privacyUrl = url('/privacy-policy'); + $termsUrl = url('/terms-service'); + $archiveUrl = url('/'); + $forumUrl = url('/forum'); + $chatUrl = url('/chat'); + $trackerUrl = url('/tracker'); + + return new Content( + view: 'emails.default', + with: [ + 'subject' => 'আপনার অ্যাকাউন্ট ডিলিট করা হয়েছে', + 'greeting' => $name, + 'lines' => [ + "আমরা আপনার অ্যাকাউন্ট ডিলিট করার অনুরোধটি পেয়েছি। আপনার অ্যাকাউন্ট এবং এর সাথে সম্পর্কিত যাবতীয় তথ্য আমাদের Privacy Policy ও Terms & Conditions অনুযায়ী স্থায়ীভাবে মুছে ফেলা হয়েছে।", + 'ভবিষ্যতে যদি আবার HSCStack ব্যবহার করতে চান, তাহলে যেকোনো সময় নতুন করে একটি অ্যাকাউন্ট তৈরি করে আমাদের প্ল্যাটফর্মে ফিরে আসতে পারেন।', + 'HSCStack-এর উল্লেখযোগ্য ফিচারসমূহ:', + "• Resource Archive — HSC ও SSC-এর Science, Arts এবং Commerce বিভাগের ক্লাস, নোট ও বিভিন্ন শিক্ষামূলক রিসোর্স।", + "• Forum — যেকোনো প্রশ্ন করতে পারবেন এবং সহপাঠীদের কাছ থেকে উত্তর ও সহযোগিতা পেতে পারবেন।", + "• Global Chat — সারাদেশের শিক্ষার্থীদের সাথে যোগাযোগ ও মতবিনিময়ের সুযোগ।", + "• Study Tracker — আপনার পড়াশোনা ও সিলেবাসের অগ্রগতি সহজেই ট্র্যাক করুন।", + '• Community Interaction — অন্যান্য শিক্ষার্থীদের সাথে যুক্ত হয়ে আলোচনা, সহযোগিতা ও জ্ঞান বিনিময় করুন।', + 'আবারও ধন্যবাদ HSCStack-এর সাথে থাকার জন্য।', + 'ভবিষ্যতে আবার দেখা হবে! 💙', + ], + 'actionText' => 'Visit HSCStack', + 'actionUrl' => config('app.url', url('/')), + ], + ); + } +} diff --git a/resources/views/emails/default.blade.php b/resources/views/emails/default.blade.php index 6e39cab2..4637a72a 100644 --- a/resources/views/emails/default.blade.php +++ b/resources/views/emails/default.blade.php @@ -70,6 +70,11 @@ .content p { margin: 0 0 16px; } + .content a { + color: #4f46e5; + text-decoration: underline; + font-weight: 600; + } .action-button-container { margin: 28px 0 16px; text-align: left; @@ -153,7 +158,7 @@ @if(!empty($lines)) @foreach($lines as $line) -

{{ $line }}

+

{!! $line !!}

@endforeach @endif diff --git a/tests/Feature/AdminUserDeletionMailTest.php b/tests/Feature/AdminUserDeletionMailTest.php new file mode 100644 index 00000000..850eec68 --- /dev/null +++ b/tests/Feature/AdminUserDeletionMailTest.php @@ -0,0 +1,56 @@ +seed(RolePermissionSeeder::class); +}); + +test('account deleted mailable renders expected subject, content and anchored links via emails.default', function () { + $mailable = new AccountDeletedMail('Rahim Ahmed'); + + $mailable->assertHasSubject('আপনার অ্যাকাউন্ট ডিলিট করা হয়েছে'); + + $html = $mailable->render(); + + expect($html) + ->toContain('আপনার অ্যাকাউন্ট ডিলিট করা হয়েছে') + ->toContain('আমরা আপনার অ্যাকাউন্ট ডিলিট করার অনুরোধটি পেয়েছি') + ->toContain('Privacy Policy') + ->toContain('Terms & Conditions') + ->toContain('/privacy-policy') + ->toContain('/terms-service') + ->toContain('Resource Archive') + ->toContain('Forum') + ->toContain('Global Chat') + ->toContain('Study Tracker') + ->toContain('Community Interaction') + ->toContain('ভবিষ্যতে আবার দেখা হবে! 💙'); +}); + +test('admin deleting a user queues account deleted mail and deletes user', function () { + Mail::fake(); + + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $userToDelete = User::factory()->create([ + 'name' => 'Karim Hasan', + 'email' => 'karim@example.com', + ]); + + $this->actingAs($admin) + ->delete("/admin/users/{$userToDelete->id}") + ->assertRedirect(route('admin.users.index')) + ->assertSessionHas('success'); + + expect(User::find($userToDelete->id))->toBeNull(); + + Mail::assertQueued(AccountDeletedMail::class, function (AccountDeletedMail $mail) use ($userToDelete) { + return $mail->hasTo($userToDelete->email) + && $mail->recipientName === 'Karim Hasan'; + }); +}); From 1cddcc873fc1174b83d49157d0442b834b56c56e Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 16:47:25 +0600 Subject: [PATCH 10/17] Revert "feat(user): send goodbye email with feature highlights when admin deletes account" This reverts commit 1ec21b6dc0f8a1571a2a74962f93af05e46f8f58. --- app/Http/Controllers/Admin/UserController.php | 6 -- app/Mail/AccountDeletedMail.php | 59 ------------------- resources/views/emails/default.blade.php | 7 +-- tests/Feature/AdminUserDeletionMailTest.php | 56 ------------------ 4 files changed, 1 insertion(+), 127 deletions(-) delete mode 100644 app/Mail/AccountDeletedMail.php delete mode 100644 tests/Feature/AdminUserDeletionMailTest.php diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index d41d0804..c32679c1 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -5,12 +5,10 @@ use App\Http\Controllers\Controller; use App\Http\Requests\User\StoreUserRequest; use App\Http\Requests\User\UpdateUserRequest; -use App\Mail\AccountDeletedMail; use App\Models\User; use App\Notifications\WelcomeNotification; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Spatie\Permission\Models\Permission; @@ -159,10 +157,6 @@ public function update(UpdateUserRequest $request, User $user) public function destroy(User $user) { - if (! empty($user->email)) { - Mail::to($user->email)->queue(new AccountDeletedMail($user->name)); - } - $user->delete(); return redirect() diff --git a/app/Mail/AccountDeletedMail.php b/app/Mail/AccountDeletedMail.php deleted file mode 100644 index 5c8299e2..00000000 --- a/app/Mail/AccountDeletedMail.php +++ /dev/null @@ -1,59 +0,0 @@ -recipientName ? "প্রিয় {$this->recipientName}," : 'প্রিয় শিক্ষার্থী,'; - $privacyUrl = url('/privacy-policy'); - $termsUrl = url('/terms-service'); - $archiveUrl = url('/'); - $forumUrl = url('/forum'); - $chatUrl = url('/chat'); - $trackerUrl = url('/tracker'); - - return new Content( - view: 'emails.default', - with: [ - 'subject' => 'আপনার অ্যাকাউন্ট ডিলিট করা হয়েছে', - 'greeting' => $name, - 'lines' => [ - "আমরা আপনার অ্যাকাউন্ট ডিলিট করার অনুরোধটি পেয়েছি। আপনার অ্যাকাউন্ট এবং এর সাথে সম্পর্কিত যাবতীয় তথ্য আমাদের Privacy Policy ও Terms & Conditions অনুযায়ী স্থায়ীভাবে মুছে ফেলা হয়েছে।", - 'ভবিষ্যতে যদি আবার HSCStack ব্যবহার করতে চান, তাহলে যেকোনো সময় নতুন করে একটি অ্যাকাউন্ট তৈরি করে আমাদের প্ল্যাটফর্মে ফিরে আসতে পারেন।', - 'HSCStack-এর উল্লেখযোগ্য ফিচারসমূহ:', - "• Resource Archive — HSC ও SSC-এর Science, Arts এবং Commerce বিভাগের ক্লাস, নোট ও বিভিন্ন শিক্ষামূলক রিসোর্স।", - "• Forum — যেকোনো প্রশ্ন করতে পারবেন এবং সহপাঠীদের কাছ থেকে উত্তর ও সহযোগিতা পেতে পারবেন।", - "• Global Chat — সারাদেশের শিক্ষার্থীদের সাথে যোগাযোগ ও মতবিনিময়ের সুযোগ।", - "• Study Tracker — আপনার পড়াশোনা ও সিলেবাসের অগ্রগতি সহজেই ট্র্যাক করুন।", - '• Community Interaction — অন্যান্য শিক্ষার্থীদের সাথে যুক্ত হয়ে আলোচনা, সহযোগিতা ও জ্ঞান বিনিময় করুন।', - 'আবারও ধন্যবাদ HSCStack-এর সাথে থাকার জন্য।', - 'ভবিষ্যতে আবার দেখা হবে! 💙', - ], - 'actionText' => 'Visit HSCStack', - 'actionUrl' => config('app.url', url('/')), - ], - ); - } -} diff --git a/resources/views/emails/default.blade.php b/resources/views/emails/default.blade.php index 4637a72a..6e39cab2 100644 --- a/resources/views/emails/default.blade.php +++ b/resources/views/emails/default.blade.php @@ -70,11 +70,6 @@ .content p { margin: 0 0 16px; } - .content a { - color: #4f46e5; - text-decoration: underline; - font-weight: 600; - } .action-button-container { margin: 28px 0 16px; text-align: left; @@ -158,7 +153,7 @@ @if(!empty($lines)) @foreach($lines as $line) -

{!! $line !!}

+

{{ $line }}

@endforeach @endif diff --git a/tests/Feature/AdminUserDeletionMailTest.php b/tests/Feature/AdminUserDeletionMailTest.php deleted file mode 100644 index 850eec68..00000000 --- a/tests/Feature/AdminUserDeletionMailTest.php +++ /dev/null @@ -1,56 +0,0 @@ -seed(RolePermissionSeeder::class); -}); - -test('account deleted mailable renders expected subject, content and anchored links via emails.default', function () { - $mailable = new AccountDeletedMail('Rahim Ahmed'); - - $mailable->assertHasSubject('আপনার অ্যাকাউন্ট ডিলিট করা হয়েছে'); - - $html = $mailable->render(); - - expect($html) - ->toContain('আপনার অ্যাকাউন্ট ডিলিট করা হয়েছে') - ->toContain('আমরা আপনার অ্যাকাউন্ট ডিলিট করার অনুরোধটি পেয়েছি') - ->toContain('Privacy Policy') - ->toContain('Terms & Conditions') - ->toContain('/privacy-policy') - ->toContain('/terms-service') - ->toContain('Resource Archive') - ->toContain('Forum') - ->toContain('Global Chat') - ->toContain('Study Tracker') - ->toContain('Community Interaction') - ->toContain('ভবিষ্যতে আবার দেখা হবে! 💙'); -}); - -test('admin deleting a user queues account deleted mail and deletes user', function () { - Mail::fake(); - - $admin = User::factory()->create(); - $admin->assignRole('admin'); - - $userToDelete = User::factory()->create([ - 'name' => 'Karim Hasan', - 'email' => 'karim@example.com', - ]); - - $this->actingAs($admin) - ->delete("/admin/users/{$userToDelete->id}") - ->assertRedirect(route('admin.users.index')) - ->assertSessionHas('success'); - - expect(User::find($userToDelete->id))->toBeNull(); - - Mail::assertQueued(AccountDeletedMail::class, function (AccountDeletedMail $mail) use ($userToDelete) { - return $mail->hasTo($userToDelete->email) - && $mail->recipientName === 'Karim Hasan'; - }); -}); From 25ed0b04b63a13ecef37b7ae1bdef03c83087560 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 19:46:29 +0600 Subject: [PATCH 11/17] feat(moderation): improve moderation image diff, bulk reject, and node breadcrumbs --- app/Http/Controllers/Admin/NodeController.php | 12 +- .../Admin/ResourceModerationController.php | 163 +- app/Http/Controllers/NodeController.php | 4 +- app/Models/Node.php | 28 +- app/Models/ResourceChangeRequest.php | 15 +- app/Notifications/NodeVoteNotification.php | 7 +- resources/js/pages/admin/Node.vue | 66 + .../js/pages/admin/moderation/Resources.vue | 1736 +++++++++++++---- routes/admin.php | 5 +- routes/console.php | 3 + tests/Feature/AdminResourceTest.php | 148 +- tests/Feature/NodeVoteNotificationTest.php | 37 + 12 files changed, 1758 insertions(+), 466 deletions(-) diff --git a/app/Http/Controllers/Admin/NodeController.php b/app/Http/Controllers/Admin/NodeController.php index 517d77c7..b932ce8a 100644 --- a/app/Http/Controllers/Admin/NodeController.php +++ b/app/Http/Controllers/Admin/NodeController.php @@ -25,7 +25,7 @@ public function show(Subject $subject, $path = null) 'subject' => $subject, 'nodes' => $nodes, 'resources' => [], - + 'breadcrumb' => [], ]); } @@ -42,15 +42,19 @@ 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); + } } return Inertia::render('admin/Node', [ 'subject' => $subject, 'nodes' => $node->children, - 'resources' => $node ? $node->resources()->with([ + 'resources' => $node->resources()->with([ 'pendingChangeRequest' => fn ($q) => $q->select('id', 'resource_id', 'action_type', 'status'), - ])->get() : [], - 'parent' => $node ? $node->append('is_effectively_frozen') : null, + ])->get(), + 'parent' => $node->append('is_effectively_frozen'), + 'breadcrumb' => $node->breadcrumb(), ]); } diff --git a/app/Http/Controllers/Admin/ResourceModerationController.php b/app/Http/Controllers/Admin/ResourceModerationController.php index 5faed42a..2137782c 100644 --- a/app/Http/Controllers/Admin/ResourceModerationController.php +++ b/app/Http/Controllers/Admin/ResourceModerationController.php @@ -16,7 +16,6 @@ class ResourceModerationController extends Controller public function index(Request $request) { $status = $request->query('status', 'pending'); - $actionType = $request->query('action_type'); $query = ResourceChangeRequest::with([ 'user:id,name,username,image_path', @@ -29,14 +28,14 @@ public function index(Request $request) $query->where('status', $status); } - if (in_array($actionType, ['create', 'update', 'delete'])) { - $query->where('action_type', $actionType); - } - $requests = $query->latest() - ->paginate(15) + ->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(), @@ -48,90 +47,130 @@ public function index(Request $request) 'counts' => $counts, 'filters' => [ 'status' => $status, - 'action_type' => $actionType, ], ]); } - public function approve(ResourceChangeRequest $changeRequest) + public function approve(Request $request) { - if ($changeRequest->status !== 'pending') { - return back()->with('error', 'This request has already been reviewed.'); - } + $validated = $request->validate([ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer', 'exists:resource_change_requests,id'], + ]); - DB::transaction(function () use ($changeRequest) { - if ($changeRequest->action_type === 'create') { - $payload = $changeRequest->payload ?? []; - $payload['node_id'] = $changeRequest->node_id; - $payload['user_id'] = $changeRequest->user_id; + $changeRequests = ResourceChangeRequest::whereIn('id', $validated['ids']) + ->where('status', 'pending') + ->with('resource') + ->get(); - $resource = Resource::create($payload); - $changeRequest->resource_id = $resource->id; - } elseif ($changeRequest->action_type === 'update') { - $resource = $changeRequest->resource; + if ($changeRequests->isEmpty()) { + return back()->with('error', 'Selected requests have already been reviewed.'); + } - if (! $resource) { - abort(404, 'Target resource not found.'); - } + DB::transaction(function () use ($changeRequests) { + $reviewerId = Auth::id(); + $now = now(); - $newFilePath = $changeRequest->payload['file_path'] ?? null; - if ($newFilePath && $resource->file_path && $newFilePath !== $resource->file_path) { - Storage::delete($resource->file_path); - } + 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; - $resource->update($changeRequest->payload); - } elseif ($changeRequest->action_type === 'delete') { - $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); + } - if ($resource) { - if ($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(); } - $resource->delete(); } - } - $changeRequest->update([ - 'status' => 'approved', - 'reviewed_by' => Auth::id(), - 'reviewed_at' => now(), - ]); + $changeRequest->update([ + 'status' => 'approved', + 'reviewed_by' => $reviewerId, + 'reviewed_at' => $now, + ]); + } }); - return back()->with('success', 'Resource change request approved successfully.'); + $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, ResourceChangeRequest $changeRequest) + public function reject(Request $request, ?ResourceChangeRequest $changeRequest = null) { - if ($changeRequest->status !== 'pending') { - return back()->with('error', 'This request has already been reviewed.'); - } - $validated = $request->validate([ + 'ids' => ['nullable', 'array', 'min:1'], + 'ids.*' => ['integer', 'exists:resource_change_requests,id'], 'rejection_reason' => ['nullable', 'string', 'max:500'], ]); - DB::transaction(function () use ($changeRequest, $validated) { - $stagedFile = $changeRequest->payload['file_path'] ?? null; + $ids = $validated['ids'] ?? ($changeRequest ? [$changeRequest->id] : []); + + if (empty($ids)) { + return back()->with('error', 'No change requests selected.'); + } + + $changeRequests = ResourceChangeRequest::whereIn('id', $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; - if ($stagedFile) { - // If create action, or update action where new file is different from live resource's file - $isNewFile = $changeRequest->action_type === 'create' - || ($changeRequest->action_type === 'update' && $stagedFile !== $changeRequest->resource?->file_path); + foreach ($changeRequests as $item) { + $stagedFile = $item->payload['file_path'] ?? null; - if ($isNewFile) { - Storage::delete($stagedFile); + if ($stagedFile) { + $isNewFile = $item->action_type === 'create' + || ($item->action_type === 'update' && $stagedFile !== $item->resource?->file_path); + + if ($isNewFile) { + Storage::delete($stagedFile); + } } - } - $changeRequest->update([ - 'status' => 'rejected', - 'rejection_reason' => $validated['rejection_reason'] ?? null, - 'reviewed_by' => Auth::id(), - 'reviewed_at' => now(), - ]); + $item->update([ + 'status' => 'rejected', + 'rejection_reason' => $reason, + 'reviewed_by' => $reviewerId, + 'reviewed_at' => $now, + ]); + } }); - return back()->with('success', 'Resource change request rejected.'); + $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/ResourceChangeRequest.php b/app/Models/ResourceChangeRequest.php index eafe7ff1..44ee8f18 100644 --- a/app/Models/ResourceChangeRequest.php +++ b/app/Models/ResourceChangeRequest.php @@ -3,11 +3,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Prunable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Storage; class ResourceChangeRequest extends Model { + use Prunable; + protected $fillable = [ 'user_id', 'resource_id', @@ -20,6 +23,16 @@ class ResourceChangeRequest extends Model 'rejection_reason', ]; + /** + * Get the prunable model query. + * Prunes approved and rejected change requests older than 30 days. + */ + public function prunable() + { + return static::whereIn('status', ['approved', 'rejected']) + ->where('reviewed_at', '<=', now()->subDays(30)); + } + protected $casts = [ 'payload' => 'array', 'reviewed_at' => 'datetime', @@ -57,7 +70,7 @@ public function getStagedFileUrlAttribute(): ?string return Storage::url($filePath); } - return $this->payload['external_url'] ?? null; + return null; } public function scopePending($query) 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/resources/js/pages/admin/Node.vue b/resources/js/pages/admin/Node.vue index fa2579a8..ef0e454a 100644 --- a/resources/js/pages/admin/Node.vue +++ b/resources/js/pages/admin/Node.vue @@ -5,6 +5,7 @@ import { FolderPlus, ArrowLeft, ChevronDown, + ChevronRight, PencilLine, Lock, Unlock, @@ -29,6 +30,7 @@ const props = defineProps({ nodes: Array, resources: Array, parent: Object, + breadcrumb: Array, }); const isDirectlyFrozen = computed(() => Boolean(props.parent?.is_frozen)); @@ -135,6 +137,38 @@ const backUrl = computed(() => { return '/' + segments.join('/'); }); +interface BreadcrumbItem { + name: string; + link: string; +} + +const adminBreadcrumbs = computed(() => { + const items: BreadcrumbItem[] = [ + { + name: 'Subjects', + link: '/admin/subjects', + }, + { + name: (props.subject as any)?.name || 'Subject', + link: `/admin/subjects/${(props.subject as any)?.slug}/nodes`, + }, + ]; + + if (props.breadcrumb && Array.isArray(props.breadcrumb)) { + let currentPath = `/admin/subjects/${(props.subject as any)?.slug}/nodes`; + + for (const crumb of props.breadcrumb as any[]) { + currentPath += `/${crumb.slug}`; + items.push({ + name: crumb.name, + link: currentPath, + }); + } + } + + return items; +}); + const closeDropdowns = (e: MouseEvent) => { const target = e.target as Node | null; @@ -163,6 +197,38 @@ onUnmounted(() => document.removeEventListener('click', closeDropdowns));
+ +
+ +
+
-import { Head, router } from '@inertiajs/vue3'; +import { Head, Link, router, usePage } from '@inertiajs/vue3'; import { Check, Clock, ExternalLink, + Eye, FileImage, FileText, FileVideo, + Loader2, Pencil, PlusCircle, Trash2, X, } from 'lucide-vue-next'; -import { ref } from 'vue'; -import Pagination from '@/components/Pagination.vue'; +import { computed, ref, watch } from 'vue'; +import BaseModal from '@/components/BaseModal.vue'; +import EmptyState from '@/components/EmptyState.vue'; +import { formatDateTime, formatTimeAgo } from '@/lib/useDate'; +import { usePermissions } from '@/lib/usePermissions'; interface User { id: number; @@ -28,10 +33,17 @@ interface Subject { slug: string; } +interface NodeBreadcrumb { + name: string; + slug: string; +} + interface Node { id: number; name: string; + slug?: string; subject?: Subject; + breadcrumb?: NodeBreadcrumb[]; } interface LiveResource { @@ -41,6 +53,7 @@ interface LiveResource { content?: string; external_url?: string; file_path?: string; + file_url?: string; } interface ChangeRequest { @@ -70,12 +83,10 @@ interface ChangeRequest { const props = defineProps<{ requests: { data: ChangeRequest[]; - links: any[]; - from: number; - to: number; - total: number; + next_page_url?: string | null; + prev_page_url?: string | null; current_page: number; - last_page: number; + per_page?: number; }; counts: { pending: number; @@ -84,38 +95,285 @@ const props = defineProps<{ }; filters: { status: string; - action_type?: string; }; }>(); -const rejectingId = ref(null); +const page = usePage(); +const { can } = usePermissions(); +const loadedRequests = ref([...(props.requests?.data || [])]); +const nextPageUrl = ref(props.requests?.next_page_url || null); +const isLoadingMore = ref(false); + +const viewingRequest = ref(null); +const rejectingRequest = ref(null); const rejectionReason = ref(''); const isProcessing = ref(false); +const processingId = ref(null); +const selectedIds = ref([]); + +watch( + () => props.filters.status, + () => { + loadedRequests.value = [...(props.requests?.data || [])]; + nextPageUrl.value = props.requests?.next_page_url || null; + selectedIds.value = []; + }, +); + +watch( + () => props.requests, + (newReqs) => { + if (!newReqs) { +return; +} + + if (loadedRequests.value.length === 0) { + loadedRequests.value = [...(newReqs.data || [])]; + nextPageUrl.value = newReqs.next_page_url || null; + } + }, + { deep: true }, +); + +const pendingRequests = computed(() => + loadedRequests.value.filter((r) => r.status === 'pending'), +); + +const allPendingSelected = computed( + () => + pendingRequests.value.length > 0 && + pendingRequests.value.every((r) => selectedIds.value.includes(r.id)), +); + +const isSomePendingSelected = computed( + () => selectedIds.value.length > 0 && !allPendingSelected.value, +); + +const toggleSelectAllPending = () => { + if (allPendingSelected.value) { + selectedIds.value = []; + } else { + selectedIds.value = pendingRequests.value.map((r) => r.id); + } +}; + +const toggleSelect = (id: number) => { + const idx = selectedIds.value.indexOf(id); + + if (idx > -1) { + selectedIds.value.splice(idx, 1); + } else { + selectedIds.value.push(id); + } +}; + +const clearSelection = () => { + selectedIds.value = []; +}; const setStatusFilter = (status: string) => { + selectedIds.value = []; router.get( '/admin/moderation/resources', - { status, action_type: props.filters.action_type }, - { preserveState: true, preserveScroll: true }, + { status }, + { preserveState: false, preserveScroll: true }, ); }; -const setActionFilter = (actionType?: string) => { - router.get( - '/admin/moderation/resources', - { status: props.filters.status, action_type: actionType }, - { preserveState: true, preserveScroll: true }, - ); +interface ChangedField { + key: string; + label: string; + oldVal: any; + newVal: any; + isMedia?: boolean; +} + +const getChangedFields = (req: ChangeRequest): ChangedField[] => { + if (req.action_type !== 'update' || !req.resource || !req.payload) { + return []; + } + + const changes: ChangedField[] = []; + const live = req.resource; + const proposed = req.payload; + + if (proposed.title !== undefined && proposed.title !== live.title) { + changes.push({ + key: 'title', + label: 'Title', + oldVal: live.title, + newVal: proposed.title, + }); + } + + if ( + proposed.resource_type !== undefined && + proposed.resource_type !== live.resource_type + ) { + changes.push({ + key: 'resource_type', + label: 'Resource Type', + oldVal: live.resource_type, + newVal: proposed.resource_type, + }); + } + + // Description / Content + const liveContent = (live.content || '').trim(); + const proposedContent = (proposed.content || '').trim(); + + if (proposed.content !== undefined && liveContent !== proposedContent) { + changes.push({ + key: 'content', + label: 'Description / Content', + oldVal: liveContent || '(empty)', + newVal: proposedContent || '(empty)', + }); + } + + // External URL + const liveUrl = (live.external_url || '').trim(); + const proposedUrl = (proposed.external_url || '').trim(); + + if (proposed.external_url !== undefined && liveUrl !== proposedUrl) { + changes.push({ + key: 'external_url', + label: 'External URL', + oldVal: liveUrl || '(none)', + newVal: proposedUrl || '(none)', + }); + } + + if (proposed.file_path && proposed.file_path !== (live.file_path || '')) { + changes.push({ + key: 'file', + label: 'Uploaded File', + oldVal: + live.file_url || + (live.file_path ? 'Existing file on storage' : null), + newVal: req.staged_file_url || proposed.file_path, + isMedia: true, + }); + } + + return changes; +}; + +interface BreadcrumbItem { + name: string; + url: string; +} + +const getNodeBreadcrumbs = (node?: Node): BreadcrumbItem[] => { + if (!node) { + return []; + } + + const items: BreadcrumbItem[] = []; + + if (node.subject) { + items.push({ + name: node.subject.name, + url: `/${node.subject.slug}`, + }); + } + + if (node.breadcrumb && node.breadcrumb.length > 0) { + let currentPath = node.subject ? `/${node.subject.slug}` : ''; + + for (const crumb of node.breadcrumb) { + currentPath += `/${crumb.slug}`; + items.push({ + name: crumb.name, + url: currentPath, + }); + } + } else if (node.name) { + const url = node.subject + ? `/${node.subject.slug}/${node.slug || ''}` + : '#'; + items.push({ + name: node.name, + url, + }); + } + + return items; }; const handleApprove = (req: ChangeRequest) => { - if (confirm(`Approve this ${req.action_type} request?`)) { + const actionLabel = + req.action_type === 'create' + ? 'new upload' + : req.action_type === 'update' + ? 'edited resource' + : 'resource deletion'; + + if (confirm(`Approve this ${actionLabel}?`)) { + isProcessing.value = true; + processingId.value = req.id; + router.post( + '/admin/moderation/resources/approve', + { ids: [req.id] }, + { + preserveScroll: true, + onSuccess: () => { + if (props.filters.status === 'pending') { + loadedRequests.value = loadedRequests.value.filter( + (r) => r.id !== req.id, + ); + } + + selectedIds.value = selectedIds.value.filter( + (id) => id !== req.id, + ); + + if (viewingRequest.value?.id === req.id) { + viewingRequest.value = null; + } + }, + onFinish: () => { + isProcessing.value = false; + processingId.value = null; + }, + }, + ); + } +}; + +const handleBulkApprove = () => { + if (selectedIds.value.length === 0) { + return; + } + + const count = selectedIds.value.length; + + if (confirm(`Approve ${count} selected request${count > 1 ? 's' : ''}?`)) { isProcessing.value = true; + const toApprove = [...selectedIds.value]; router.post( - `/admin/moderation/resources/${req.id}/approve`, - {}, + '/admin/moderation/resources/approve', + { ids: toApprove }, { preserveScroll: true, + onSuccess: () => { + const approvedSet = new Set(toApprove); + + if (props.filters.status === 'pending') { + loadedRequests.value = loadedRequests.value.filter( + (r) => !approvedSet.has(r.id), + ); + } + + if ( + viewingRequest.value && + approvedSet.has(viewingRequest.value.id) + ) { + viewingRequest.value = null; + } + + selectedIds.value = []; + }, onFinish: () => { isProcessing.value = false; }, @@ -124,26 +382,157 @@ const handleApprove = (req: ChangeRequest) => { } }; +const loadMore = async () => { + if (!nextPageUrl.value || isLoadingMore.value) { + return; + } + + isLoadingMore.value = true; + + try { + const headers: Record = { + 'X-Inertia': 'true', + 'X-Inertia-Partial-Component': 'admin/moderation/Resources', + 'X-Inertia-Partial-Data': 'requests', + 'X-Requested-With': 'XMLHttpRequest', + }; + + if (page.version) { + headers['X-Inertia-Version'] = String(page.version); + } + + const res = await fetch(nextPageUrl.value, { headers }); + + if (res.status === 409) { + const location = + res.headers.get('X-Inertia-Location') || nextPageUrl.value; + window.location.href = location; + + return; + } + + if (res.ok) { + const data = await res.json(); + const newRequests = data?.props?.requests?.data || []; + const existingIds = new Set(loadedRequests.value.map((r) => r.id)); + const uniqueNew = newRequests.filter( + (r: ChangeRequest) => !existingIds.has(r.id), + ); + loadedRequests.value = [...loadedRequests.value, ...uniqueNew]; + nextPageUrl.value = data?.props?.requests?.next_page_url || null; + } + } catch (e) { + console.error('Failed to load more requests:', e); + } finally { + isLoadingMore.value = false; + } +}; + +const isBulkReject = ref(false); + const openRejectModal = (req: ChangeRequest) => { - rejectingId.value = req.id; + isBulkReject.value = false; + rejectingRequest.value = req; + rejectionReason.value = ''; +}; + +const openBulkRejectModal = () => { + if (selectedIds.value.length === 0) { + return; + } + + isBulkReject.value = true; + rejectingRequest.value = null; + rejectionReason.value = ''; +}; + +const closeRejectModal = () => { + isBulkReject.value = false; + rejectingRequest.value = null; rejectionReason.value = ''; }; const handleReject = () => { - if (!rejectingId.value) { + if (isBulkReject.value) { + if (selectedIds.value.length === 0) { + return; + } + + const toReject = [...selectedIds.value]; + isProcessing.value = true; + processingId.value = null; + + router.post( + '/admin/moderation/resources/reject', + { + ids: toReject, + rejection_reason: rejectionReason.value || null, + }, + { + preserveScroll: true, + onSuccess: () => { + const rejectedSet = new Set(toReject); + + if (props.filters.status === 'pending') { + loadedRequests.value = loadedRequests.value.filter( + (r) => !rejectedSet.has(r.id), + ); + } + + if ( + viewingRequest.value && + rejectedSet.has(viewingRequest.value.id) + ) { + viewingRequest.value = null; + } + + selectedIds.value = []; + closeRejectModal(); + }, + onFinish: () => { + isProcessing.value = false; + }, + }, + ); + return; } + if (!rejectingRequest.value) { + return; + } + + const reqId = rejectingRequest.value.id; isProcessing.value = true; + processingId.value = reqId; + router.post( - `/admin/moderation/resources/${rejectingId.value}/reject`, - { rejection_reason: rejectionReason.value }, + '/admin/moderation/resources/reject', + { + ids: [reqId], + rejection_reason: rejectionReason.value || null, + }, { preserveScroll: true, + onSuccess: () => { + if (props.filters.status === 'pending') { + loadedRequests.value = loadedRequests.value.filter( + (r) => r.id !== reqId, + ); + } + + selectedIds.value = selectedIds.value.filter( + (id) => id !== reqId, + ); + closeRejectModal(); + + if (viewingRequest.value?.id === reqId) { + viewingRequest.value = null; + } + }, onFinish: () => { isProcessing.value = false; - rejectingId.value = null; - rejectionReason.value = ''; + processingId.value = null; }, }, ); @@ -151,44 +540,49 @@ const handleReject = () => { + +
+ + - -
- -
-

- Target: "{{ req.resource?.title }}" -

-

- Author requested complete deletion of this - resource and its associated files. -

-
+ +
+
+ +
+

+ Target: "{{ + viewingRequest.resource?.title || 'Resource' + }}" +

+

+ The author has requested complete and permanent + removal of this resource and all attached files. +

+
- + +
- -
- - Approved by {{ req.reviewer?.name }} on - {{ - req.reviewed_at - ? new Date( - req.reviewed_at, - ).toLocaleDateString() - : '' - }} - - - Rejected by {{ req.reviewer?.name }}: - {{ req.rejection_reason || 'No reason specified' }} + ✓ Approved by {{ viewingRequest.reviewer?.name || 'Admin' }} + + on {{ formatDateTime(viewingRequest.reviewed_at) }} + +
+
+
+ ✕ Rejected by + {{ viewingRequest.reviewer?.name || 'Admin' }} + + on {{ formatDateTime(viewingRequest.reviewed_at) }}
-
- -
- - - + Reason: {{ viewingRequest.rejection_reason }}
- - -
+ + + + + -
-

- Reject Change Request -

-

- Provide optional feedback to the contributor explaining why this - request was declined. -

- -
+ + +
+
+ Batch Target: + Rejecting {{ selectedIds.length }} change request{{ + selectedIds.length > 1 ? 's' : '' + }} + at once. +
+
+ + Target: + + {{ + rejectingRequest.payload?.title || + rejectingRequest.resource?.title || + 'Resource' + }} + + ({{ rejectingRequest.action_type }}) + +
+ +
+
+
-
+ + diff --git a/routes/admin.php b/routes/admin.php index 0e451efd..602b2613 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -82,8 +82,9 @@ // Resource Moderation Route::middleware('permission:moderate resources')->group(function () { Route::get('/moderation/resources', [ResourceModerationController::class, 'index'])->name('moderation.resources.index'); - Route::post('/moderation/resources/{changeRequest}/approve', [ResourceModerationController::class, 'approve'])->name('moderation.resources.approve'); - Route::post('/moderation/resources/{changeRequest}/reject', [ResourceModerationController::class, 'reject'])->name('moderation.resources.reject'); + Route::post('/moderation/resources/approve', [ResourceModerationController::class, 'approve'])->name('moderation.resources.approve'); + Route::post('/moderation/resources/reject', [ResourceModerationController::class, 'reject'])->name('moderation.resources.reject'); + Route::post('/moderation/resources/{changeRequest}/reject', [ResourceModerationController::class, 'reject'])->name('moderation.resources.reject.single'); }); // Notice diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..789f66e1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('model:prune')->daily(); diff --git a/tests/Feature/AdminResourceTest.php b/tests/Feature/AdminResourceTest.php index 93eb0f4a..e33dbfdd 100644 --- a/tests/Feature/AdminResourceTest.php +++ b/tests/Feature/AdminResourceTest.php @@ -288,7 +288,9 @@ expect(Resource::where('title', 'Equations Note')->exists())->toBeFalse(); $this->actingAs($moderator) - ->post("/admin/moderation/resources/{$changeRequest->id}/approve") + ->post('/admin/moderation/resources/approve', [ + 'ids' => [$changeRequest->id], + ]) ->assertRedirect() ->assertSessionHas('success'); @@ -297,6 +299,51 @@ ->and(Resource::where('title', 'Equations Note')->exists())->toBeTrue(); }); +test('moderator can bulk approve multiple requests at once', function () { + Permission::findOrCreate('moderate resources', 'web'); + + $moderator = User::factory()->create(); + $moderator->givePermissionTo(['view admin', 'moderate resources']); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics-bulk', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Mechanics', + 'slug' => 'mechanics', + ]); + + $req1 = ResourceChangeRequest::recordCreate($moderator->id, $node->id, [ + 'title' => 'Bulk Note 1', + 'resource_type' => 'note', + 'content' => 'Content 1', + ]); + + $req2 = ResourceChangeRequest::recordCreate($moderator->id, $node->id, [ + 'title' => 'Bulk Note 2', + 'resource_type' => 'note', + 'content' => 'Content 2', + ]); + + $this->actingAs($moderator) + ->post('/admin/moderation/resources/approve', [ + 'ids' => [$req1->id, $req2->id], + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($req1->fresh()->status)->toBe('approved') + ->and($req2->fresh()->status)->toBe('approved') + ->and(Resource::where('title', 'Bulk Note 1')->exists())->toBeTrue() + ->and(Resource::where('title', 'Bulk Note 2')->exists())->toBeTrue(); +}); + test('moderator can reject a request with feedback reason', function () { Permission::findOrCreate('moderate resources', 'web'); @@ -334,3 +381,102 @@ ->and($changeRequest->fresh()->rejection_reason)->toBe('The video URL is not valid.') ->and(Resource::where('title', 'Bad Video Link')->exists())->toBeFalse(); }); + +test('moderator can bulk reject multiple requests with shared feedback', function () { + Permission::findOrCreate('moderate resources', 'web'); + + $moderator = User::factory()->create(); + $moderator->givePermissionTo(['view admin', 'moderate resources']); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics-bulk-reject', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Thermodynamics', + 'slug' => 'thermodynamics', + ]); + + $req1 = ResourceChangeRequest::recordCreate($moderator->id, $node->id, [ + 'title' => 'Spam 1', + 'resource_type' => 'note', + ]); + + $req2 = ResourceChangeRequest::recordCreate($moderator->id, $node->id, [ + 'title' => 'Spam 2', + 'resource_type' => 'note', + ]); + + $this->actingAs($moderator) + ->post('/admin/moderation/resources/reject', [ + 'ids' => [$req1->id, $req2->id], + 'rejection_reason' => 'Duplicate spam uploads.', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($req1->fresh()->status)->toBe('rejected') + ->and($req1->fresh()->rejection_reason)->toBe('Duplicate spam uploads.') + ->and($req2->fresh()->status)->toBe('rejected') + ->and($req2->fresh()->rejection_reason)->toBe('Duplicate spam uploads.'); +}); + +test('old reviewed change requests are pruned after 30 days', function () { + $user = User::factory()->create(); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics-prune', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Waves', + 'slug' => 'waves', + ]); + + // Old approved request (> 30 days) + $oldApproved = ResourceChangeRequest::create([ + 'user_id' => $user->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'approved', + 'reviewed_by' => $user->id, + 'reviewed_at' => now()->subDays(31), + 'payload' => ['title' => 'Old Approved'], + ]); + + // Recent approved request (<= 30 days) + $recentApproved = ResourceChangeRequest::create([ + 'user_id' => $user->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'approved', + 'reviewed_by' => $user->id, + 'reviewed_at' => now()->subDays(10), + 'payload' => ['title' => 'Recent Approved'], + ]); + + // Pending request (> 30 days old created_at, but status pending) + $pendingReq = ResourceChangeRequest::create([ + 'user_id' => $user->id, + 'node_id' => $node->id, + 'action_type' => 'create', + 'status' => 'pending', + 'payload' => ['title' => 'Pending Req'], + ]); + + $this->artisan('model:prune', ['--model' => [ResourceChangeRequest::class]]); + + expect(ResourceChangeRequest::where('id', $oldApproved->id)->exists())->toBeFalse() + ->and(ResourceChangeRequest::where('id', $recentApproved->id)->exists())->toBeTrue() + ->and(ResourceChangeRequest::where('id', $pendingReq->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/NodeVoteNotificationTest.php b/tests/Feature/NodeVoteNotificationTest.php index 1507d549..3d1a892f 100644 --- a/tests/Feature/NodeVoteNotificationTest.php +++ b/tests/Feature/NodeVoteNotificationTest.php @@ -158,3 +158,40 @@ $notif = new NodeVoteNotification($node, $voter); expect($notif->via($author))->toBe(['database']); }); + +test('NodeVoteNotification constructs full nested path for child folders', function () { + $author = User::factory()->create(); + $voter = User::factory()->create(['name' => 'Diligent Student']); + $subject = Subject::create([ + 'name' => 'Higher Math', + 'course' => 'hsc', + 'tailwind_format' => 'bg-emerald-500', + 'slug' => 'hsc-hmath-1st', + 'icon' => 'calculator', + 'sort_order' => 1, + ]); + + $parent = Node::create([ + 'user_id' => $author->id, + 'subject_id' => $subject->id, + 'name' => 'Circles', + 'slug' => 'circles', + 'sort_order' => 1, + ]); + + $child = Node::create([ + 'user_id' => $author->id, + 'subject_id' => $subject->id, + 'parent_id' => $parent->id, + 'name' => 'Class', + 'slug' => 'class', + 'sort_order' => 1, + ]); + + $notif = new NodeVoteNotification($child, $voter); + $data = $notif->toArray($author); + + expect($data['url'])->toBe(url('/hsc-hmath-1st/circles/class')) + ->and($data['title'])->toBe('Diligent Student upvoted your folder') + ->and($data['message'])->toBe('"Class"'); +}); From 8f8e153d95250963aea6d2f0207fe6ca0320f6ac Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 22:52:59 +0600 Subject: [PATCH 12/17] feat(admin): display pending resource uploads in node view with preview modal --- GEMINI.md | 5 +- app/Http/Controllers/Admin/NodeController.php | 8 + resources/js/pages/admin/Node.vue | 311 +++++++++++++++++- .../js/pages/admin/moderation/Resources.vue | 8 +- 4 files changed, 323 insertions(+), 9 deletions(-) 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/Http/Controllers/Admin/NodeController.php b/app/Http/Controllers/Admin/NodeController.php index b932ce8a..e2e02a32 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; @@ -47,12 +48,19 @@ public function show(Subject $subject, $path = null) } } + $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()->with([ 'pendingChangeRequest' => fn ($q) => $q->select('id', 'resource_id', 'action_type', 'status'), ])->get(), + 'pending_creates' => $pendingCreates, 'parent' => $node->append('is_effectively_frozen'), 'breadcrumb' => $node->breadcrumb(), ]); diff --git a/resources/js/pages/admin/Node.vue b/resources/js/pages/admin/Node.vue index ef0e454a..87297b07 100644 --- a/resources/js/pages/admin/Node.vue +++ b/resources/js/pages/admin/Node.vue @@ -9,6 +9,12 @@ import { PencilLine, Lock, Unlock, + Clock, + Eye, + ExternalLink, + User, + FileText, + FileArchive, } from 'lucide-vue-next'; import { computed, ref, onMounted, onUnmounted } from 'vue'; import BulkImageModal from '@/components/admin/BulkImageModal.vue'; @@ -19,6 +25,7 @@ import CreateNodeModal from '@/components/admin/CreateNodeModal.vue'; import CreateResourceModal from '@/components/admin/CreateResourceModal.vue'; import NodeRow from '@/components/admin/NodeRow.vue'; import ResourceRow from '@/components/admin/ResourceRow.vue'; +import BaseModal from '@/components/BaseModal.vue'; import EmptyState from '@/components/EmptyState.vue'; import { usePermissions } from '@/lib/usePermissions'; @@ -29,6 +36,7 @@ const props = defineProps({ subject: Object, nodes: Array, resources: Array, + pending_creates: Array, parent: Object, breadcrumb: Array, }); @@ -85,6 +93,7 @@ const isSingleModalOpen = ref(false); const editingNode = ref(null); const isSingleResourceModalOpen = ref(false); const editingResource = ref(null); +const viewingPendingModal = ref(null); const openCreateNodeModal = () => { editingNode.value = null; @@ -117,7 +126,10 @@ const handleResourceModalClose = () => { }; const totalItemsCount = computed( - () => (props.nodes?.length ?? 0) + (props.resources?.length ?? 0), + () => + (props.nodes?.length ?? 0) + + (props.resources?.length ?? 0) + + (props.pending_creates?.length ?? 0), ); const backUrl = computed(() => { @@ -366,7 +378,7 @@ onUnmounted(() => document.removeEventListener('click', closeDropdowns));
@@ -475,6 +487,243 @@ onUnmounted(() => document.removeEventListener('click', closeDropdowns)); @close="isBulkRenameModalOpen = false" /> + + + + +
+ +
+
+ + Pending Review + + + Action: Create Resource + +
+ +
+ + + Submitted by + + {{ viewingPendingModal.user.name }} + + + {{ viewingPendingModal.user.name }} + + +
+
+ + +
+
+
+ Resource Title +

+ {{ + viewingPendingModal.payload?.title || + '(Untitled)' + }} +

+
+ + {{ + viewingPendingModal.payload?.resource_type || + 'Resource' + }} + +
+ + +
+ Description / Content +

+ {{ viewingPendingModal.payload.content }} +

+
+ + + +
+ + +
+
+ + + Attached Media / File + + + Open file in new tab + + +
+ +
+
+ Staged Preview +
+ +
+
+
+ +
+
+
+ Uploaded Attachment +
+
+ Click to download or view file content +
+
+
+ + + Open File + +
+
+
+
+ + +
+
From 328f83a53abf5b3b309872eb91bb10b29e8b6772 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 23:56:06 +0600 Subject: [PATCH 16/17] feat(schedule, moderation): consolidate daily schedules in console.php and clean up reject route --- .../Admin/ResourceModerationController.php | 12 +++--------- routes/admin.php | 1 - routes/console.php | 19 ++++++++++++++++++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Admin/ResourceModerationController.php b/app/Http/Controllers/Admin/ResourceModerationController.php index 2137782c..7eb0d03e 100644 --- a/app/Http/Controllers/Admin/ResourceModerationController.php +++ b/app/Http/Controllers/Admin/ResourceModerationController.php @@ -117,21 +117,15 @@ public function approve(Request $request) return back()->with('success', $message); } - public function reject(Request $request, ?ResourceChangeRequest $changeRequest = null) + public function reject(Request $request) { $validated = $request->validate([ - 'ids' => ['nullable', 'array', 'min:1'], + 'ids' => ['required', 'array', 'min:1'], 'ids.*' => ['integer', 'exists:resource_change_requests,id'], 'rejection_reason' => ['nullable', 'string', 'max:500'], ]); - $ids = $validated['ids'] ?? ($changeRequest ? [$changeRequest->id] : []); - - if (empty($ids)) { - return back()->with('error', 'No change requests selected.'); - } - - $changeRequests = ResourceChangeRequest::whereIn('id', $ids) + $changeRequests = ResourceChangeRequest::whereIn('id', $validated['ids']) ->where('status', 'pending') ->with('resource') ->get(); diff --git a/routes/admin.php b/routes/admin.php index 602b2613..4cb53095 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -84,7 +84,6 @@ Route::get('/moderation/resources', [ResourceModerationController::class, 'index'])->name('moderation.resources.index'); Route::post('/moderation/resources/approve', [ResourceModerationController::class, 'approve'])->name('moderation.resources.approve'); Route::post('/moderation/resources/reject', [ResourceModerationController::class, 'reject'])->name('moderation.resources.reject'); - Route::post('/moderation/resources/{changeRequest}/reject', [ResourceModerationController::class, 'reject'])->name('moderation.resources.reject.single'); }); // Notice diff --git a/routes/console.php b/routes/console.php index 789f66e1..cf593eed 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,4 +8,21 @@ $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); -Schedule::command('model:prune')->daily(); +// 1. Clean up stale/unused resources and models first at 03:00 +Schedule::command('resources:clean-unused-images') + ->dailyAt('03:00') + ->withoutOverlapping(); + +Schedule::command('model:prune') + ->dailyAt('03:00') + ->withoutOverlapping(); + +// 2. Run backup 30 minutes later at 03:30 after deletions are complete +Schedule::command('backup:drive') + ->dailyAt('03:30') + ->withoutOverlapping(); + +// 3. Refresh sitemap at 04:00 +Schedule::command('seo:sitemap') + ->dailyAt('04:00') + ->withoutOverlapping(); From 0259d73df50a7ae691ed612cff63d937b8c2b1ce Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Fri, 9 Oct 2026 23:57:42 +0600 Subject: [PATCH 17/17] Update 2026_10_09_154900_add_moderate_resources_permission.php --- .../2026_10_09_154900_add_moderate_resources_permission.php | 1 - 1 file changed, 1 deletion(-) 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 index 8f4c9f8c..1d17d7b3 100644 --- a/database/migrations/2026_10_09_154900_add_moderate_resources_permission.php +++ b/database/migrations/2026_10_09_154900_add_moderate_resources_permission.php @@ -14,7 +14,6 @@ public function up(): void { app()[PermissionRegistrar::class]->forgetCachedPermissions(); - Cache::flush(); $permission = Permission::findOrCreate('moderate resources', 'web');