diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index 735f6b93..08af99e0 100644 --- a/app/Http/Controllers/Admin/ResourceController.php +++ b/app/Http/Controllers/Admin/ResourceController.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; +use Inertia\Inertia; class ResourceController extends Controller { @@ -27,9 +28,21 @@ public function store(StoreResourceRequest $request) $validated['file_path'] = $path; } + $user = Auth::user(); + $isDirect = $user->can('approve resources') || $user->is_verified; + $validated['status'] = $isDirect ? 'approved' : 'pending'; + if ($isDirect) { + $validated['reviewed_by'] = $user->id; + $validated['reviewed_at'] = now(); + } + Resource::create($validated); - return back()->with('success', 'Resource created successfully.'); + $message = $isDirect + ? 'Resource created successfully.' + : 'Resource submitted and is pending review.'; + + return back()->with('success', $message); } public function update(UpdateResourceRequest $request, Resource $resource) @@ -37,7 +50,6 @@ public function update(UpdateResourceRequest $request, Resource $resource) $validated = $request->validated(); if ($request->hasFile('file')) { - if ($resource->file_path) { Storage::delete($resource->file_path); } @@ -67,20 +79,32 @@ public function destroy(Resource $resource) public function storeBulkImages(BulkImageStoreRequest $request) { $validated = $request->validated(); + $user = Auth::user(); + $isDirect = $user->can('approve resources') || $user->is_verified; + $status = $isDirect ? 'approved' : 'pending'; + $reviewedBy = $isDirect ? $user->id : null; + $reviewedAt = $isDirect ? now() : null; - DB::transaction(function () use ($request, $validated) { + DB::transaction(function () use ($request, $validated, $status, $reviewedBy, $reviewedAt) { foreach ($request->file('files') as $index => $file) { $validated['title'] = $validated['custom_titles'][$index]; $validated['file_path'] = $file->store('resources/images'); $validated['user_id'] = Auth::id(); $validated['resource_type'] = 'image'; + $validated['status'] = $status; + $validated['reviewed_by'] = $reviewedBy; + $validated['reviewed_at'] = $reviewedAt; Resource::create($validated); } }); - return back()->with('success', 'Images uploaded successfully.'); + $message = $isDirect + ? 'Images uploaded successfully.' + : 'Images submitted and are pending review.'; + + return back()->with('success', $message); } public function storeBulkVideos(BulkVideoStoreRequest $request) @@ -149,8 +173,13 @@ public function storeBulkVideos(BulkVideoStoreRequest $request) } $userId = Auth::id(); + $user = Auth::user(); + $isDirect = $user->can('approve resources') || $user->is_verified; + $status = $isDirect ? 'approved' : 'pending'; + $reviewedBy = $isDirect ? $user->id : null; + $reviewedAt = $isDirect ? now() : null; - DB::transaction(function () use ($videos, $validated, $userId) { + DB::transaction(function () use ($videos, $validated, $userId, $status, $reviewedBy, $reviewedAt) { foreach ($videos as $video) { $finalUrl = "https://www.youtube.com/watch?v={$video['video_id']}"; @@ -160,11 +189,18 @@ public function storeBulkVideos(BulkVideoStoreRequest $request) 'title' => $video['title'], 'resource_type' => 'video', 'external_url' => $finalUrl, + 'status' => $status, + 'reviewed_by' => $reviewedBy, + 'reviewed_at' => $reviewedAt, ]); } }); - return back()->with('success', 'YouTube playlist imported successfully.'); + $message = $isDirect + ? 'YouTube playlist imported successfully.' + : 'YouTube playlist imported and is pending review.'; + + return back()->with('success', $message); } public function bulkRename(Request $request, Node $node) @@ -194,4 +230,66 @@ public function bulkRename(Request $request, Node $node) return back()->with('success', "Renamed {$resources->count()} resources successfully."); } + + public function pending(Request $request) + { + $resources = Resource::where('status', 'pending') + ->with([ + 'user:id,name,username,image_path,is_verified', + 'node.subject', + ]) + ->latest('updated_at') + ->paginate(20) + ->withQueryString(); + + return Inertia::render('admin/resources/Pending', [ + 'resources' => $resources, + ]); + } + + public function approve(Request $request) + { + $validated = $request->validate([ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer', 'exists:resources,id'], + ]); + + $resources = Resource::whereIn('id', $validated['ids']) + ->where('status', 'pending') + ->get(); + + $userId = Auth::id(); + $now = now(); + + foreach ($resources as $resource) { + $resource->update([ + 'status' => 'approved', + 'reviewed_by' => $userId, + 'reviewed_at' => $now, + ]); + } + + $count = $resources->count(); + + return back()->with('success', "{$count} resource(s) approved successfully."); + } + + public function reject(Request $request, Resource $resource) + { + $request->validate([ + 'rejection_reason' => ['nullable', 'string', 'max:1000'], + ]); + + $userId = Auth::id(); + $now = now(); + + $resource->update([ + 'status' => 'rejected', + 'rejection_reason' => $request->input('rejection_reason'), + 'reviewed_by' => $userId, + 'reviewed_at' => $now, + ]); + + return back()->with('success', 'Resource rejected.'); + } } diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 70db7bb2..b87beed7 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -34,14 +34,19 @@ public function show(Subject $subject, $path) $nodes = Cache::remember("node_children_{$node->id}", now()->addDay(), function () use ($node) { return Node::where('parent_id', $node->id) - ->withCount(['children', 'resources', 'upvotes', 'downvotes']) + ->withCount([ + 'children', + 'resources' => fn ($query) => $query->where('status', 'approved'), + 'upvotes', + 'downvotes', + ]) ->orderByRaw('(upvotes_count - downvotes_count) DESC') ->orderBy('sort_order') ->get(['id', 'name', 'slug'])->toArray(); }); $resources = Cache::remember("node_resources_{$node->id}", now()->addDay(), function () use ($node) { - return $node->resources()->get()->toArray(); + return $node->resources()->where('status', 'approved')->get()->toArray(); }); $upvotesCount = $node->upvotes()->count(); diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index e4649c31..be474753 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -12,15 +12,24 @@ class ResourceController extends Controller { public function show($id) { - $data = Cache::rememberForever("resource_{$id}", function () use ($id) { - $resource = Resource::with(['user', 'node.subject'])->findOrFail($id); + $resource = Resource::with(['user', 'node.subject'])->findOrFail($id); + if ($resource->status !== 'approved') { + $user = auth()->user(); + if (! $user || ($user->id !== $resource->user_id && ! $user->can('approve resources'))) { + abort(404); + } + } + + $loadData = function () use ($resource) { $previousResourceId = Resource::where('node_id', $resource->node_id) + ->where('status', 'approved') ->where('id', '<', $resource->id) ->orderByDesc('id') ->value('id'); $nextResourceId = Resource::where('node_id', $resource->node_id) + ->where('status', 'approved') ->where('id', '>', $resource->id) ->orderBy('id') ->value('id'); @@ -31,7 +40,11 @@ public function show($id) 'previousResourceId' => $previousResourceId, 'nextResourceId' => $nextResourceId, ]; - }); + }; + + $data = $resource->status === 'approved' + ? Cache::rememberForever("resource_{$id}", $loadData) + : $loadData(); $isCompleted = auth()->check() ? ResourceCompletion::where('resource_id', $id)->where('user_id', auth()->id())->exists() diff --git a/app/Models/Resource.php b/app/Models/Resource.php index fd5d1b6c..5bcde0cf 100644 --- a/app/Models/Resource.php +++ b/app/Models/Resource.php @@ -18,6 +18,14 @@ class Resource extends Model 'file_path', 'external_url', 'user_id', + 'status', + 'reviewed_by', + 'reviewed_at', + 'rejection_reason', + ]; + + protected $casts = [ + 'reviewed_at' => 'datetime', ]; protected $appends = [ @@ -33,6 +41,16 @@ protected static function booted(): void }); } + public function scopeApproved($query) + { + return $query->where('status', 'approved'); + } + + public function scopePending($query) + { + return $query->where('status', 'pending'); + } + public function getFileUrlAttribute(): ?string { if ($this->external_url) { @@ -57,6 +75,11 @@ public function user() return $this->belongsTo(User::class); } + public function reviewer() + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + public function completions() { return $this->hasMany(ResourceCompletion::class); diff --git a/app/Observers/ResourceObserver.php b/app/Observers/ResourceObserver.php index 88937b07..ad29dd56 100644 --- a/app/Observers/ResourceObserver.php +++ b/app/Observers/ResourceObserver.php @@ -10,7 +10,7 @@ class ResourceObserver { public function creating(Resource $resource): void { - if ($resource->node_id && $resource->user_id) { + if ($resource->node_id && $resource->user_id && $resource->status === 'approved') { $otherResourcesCount = Resource::where('node_id', $resource->node_id)->count(); if ($otherResourcesCount === 0) { diff --git a/database/migrations/2026_10_08_230000_add_moderation_to_resources_table.php b/database/migrations/2026_10_08_230000_add_moderation_to_resources_table.php new file mode 100644 index 00000000..7e194fea --- /dev/null +++ b/database/migrations/2026_10_08_230000_add_moderation_to_resources_table.php @@ -0,0 +1,36 @@ +string('status', 20)->default('approved')->index()->after('user_id'); + $table->foreignId('reviewed_by')->nullable()->after('status')->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable()->after('reviewed_by'); + $table->text('rejection_reason')->nullable()->after('reviewed_at'); + + $table->index(['node_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('resources', function (Blueprint $table) { + $table->dropIndex(['node_id', 'status']); + $table->dropIndex(['status']); + $table->dropForeign(['reviewed_by']); + $table->dropColumn(['status', 'reviewed_by', 'reviewed_at', 'rejection_reason']); + }); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 9eea7db3..67646d45 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('approve resources'); /* * Blog management diff --git a/resources/js/components/admin/ResourceRow.vue b/resources/js/components/admin/ResourceRow.vue index 22de4382..c4180d5f 100644 --- a/resources/js/components/admin/ResourceRow.vue +++ b/resources/js/components/admin/ResourceRow.vue @@ -84,6 +84,20 @@ const handleDelete = () => { > {{ resource?.resource_type }} + + + + Pending Review + + + Rejected + diff --git a/resources/js/layouts/AdminLayout.vue b/resources/js/layouts/AdminLayout.vue index 996596a7..afddfb49 100644 --- a/resources/js/layouts/AdminLayout.vue +++ b/resources/js/layouts/AdminLayout.vue @@ -44,6 +44,12 @@ watch(adminCollapsed, (v) => { const allNavigation: AdminNavItem[] = [ { name: 'Dashboard', to: '/admin', icon: 'dashboard' }, { name: 'Manage Contents', to: '/admin/subjects', icon: 'menu_book' }, + { + name: 'Pending Resources', + to: '/admin/resources/pending', + icon: 'rule', + permission: 'approve resources', + }, { name: 'Manage Blogs', to: '/admin/blogs', icon: 'book' }, { name: 'Manage Products', diff --git a/resources/js/pages/Resource.vue b/resources/js/pages/Resource.vue index cb19f36d..57ec6839 100644 --- a/resources/js/pages/Resource.vue +++ b/resources/js/pages/Resource.vue @@ -10,6 +10,7 @@ import { Image as ImageIcon, ExternalLink, CheckCircle2, + Clock, } from 'lucide-vue-next'; import { ref, watch, computed } from 'vue'; import AuthModal from '@/components/AuthModal.vue'; @@ -67,7 +68,7 @@ const backUrl = computed(() => { return path ? `/${subjectSlug}/${path}` : `/${subjectSlug}`; }); -const { user, requireAuth, showAuthModal, authModalMessage } = useAuth(); +const { user, can, requireAuth, showAuthModal, authModalMessage } = useAuth(); const showCompletersModal = ref(false); const { isMobile, isHydrated: breakpointReady } = useBreakpoint(1024); @@ -222,6 +223,61 @@ const toggleFullscreen = () => {
+ +
+
+
+ +
+
+
+ + {{ + resource.status === 'rejected' + ? 'Submission Rejected' + : 'Under Review' + }} + + + Pending Approval + +
+

+ + +

+
+
+ + + Review Queue + +
+
diff --git a/resources/js/pages/admin/resources/Pending.vue b/resources/js/pages/admin/resources/Pending.vue new file mode 100644 index 00000000..cf535495 --- /dev/null +++ b/resources/js/pages/admin/resources/Pending.vue @@ -0,0 +1,531 @@ + + + diff --git a/routes/admin.php b/routes/admin.php index e38476ad..752711f2 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -78,6 +78,12 @@ Route::delete('/resources/{resource}', [AdminResourceController::class, 'destroy'])->middleware('permission:delete resources'); +Route::middleware('permission:approve resources')->group(function () { + Route::get('/resources/pending', [AdminResourceController::class, 'pending'])->name('resources.pending'); + Route::post('/resources/approve', [AdminResourceController::class, 'approve'])->name('resources.approve'); + Route::post('/resources/{resource}/reject', [AdminResourceController::class, 'reject'])->name('resources.reject'); +}); + // Notice Route::middleware('permission:edit notice')->group(function () { Route::get('/notice', [AdminNoticeController::class, 'edit'])->name('notice.edit');