From 3c90ceaa52ff3f82526625a612e0ca78d7a4a826 Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Sat, 17 Oct 2026 19:48:32 +0600 Subject: [PATCH 1/7] feat(subject): add disable subject option and admin controls --- app/Console/Commands/GenerateSitemap.php | 2 +- app/Http/Controllers/SubjectController.php | 7 +- .../Requests/Subject/StoreSubjectRequest.php | 1 + .../Requests/Subject/UpdateSubjectRequest.php | 1 + app/Models/Subject.php | 1 + ...2746_add_is_disabled_to_subjects_table.php | 32 +++++++ .../components/admin/CreateSubjectModal.vue | 31 +++++++ resources/js/components/admin/SubjectCard.vue | 7 ++ tests/Feature/SubjectManagementTest.php | 91 +++++++++++++++++++ 9 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_10_17_132746_add_is_disabled_to_subjects_table.php diff --git a/app/Console/Commands/GenerateSitemap.php b/app/Console/Commands/GenerateSitemap.php index a9d6549d..068be6ae 100644 --- a/app/Console/Commands/GenerateSitemap.php +++ b/app/Console/Commands/GenerateSitemap.php @@ -48,7 +48,7 @@ public function handle(): int } // 2. Subjects - Subject::orderBy('name')->get()->each(function ($subject) use ($sitemap) { + Subject::where('is_disabled', false)->orderBy('name')->get()->each(function ($subject) use ($sitemap) { $sitemap->add( Url::create(url($subject->slug)) ->setLastModificationDate($subject->updated_at) diff --git a/app/Http/Controllers/SubjectController.php b/app/Http/Controllers/SubjectController.php index a3164b75..978a8173 100644 --- a/app/Http/Controllers/SubjectController.php +++ b/app/Http/Controllers/SubjectController.php @@ -27,7 +27,8 @@ public function index(Request $request, $course) } $subjects = Cache::rememberForever("home_page_subjects_{$course}_{$group}", function () use ($course, $group) { - return Subject::orderBy('sort_order', 'asc') + return Subject::where('is_disabled', false) + ->orderBy('sort_order', 'asc') ->where('course', $course) ->whereIn('group', [$group, 'common']) ->withCount([ @@ -96,6 +97,10 @@ public function index(Request $request, $course) public function show(Subject $subject) { + if ($subject->is_disabled && ! auth()->user()?->can('edit subjects')) { + abort(404); + } + $nodes = Cache::rememberForever("subject_page_{$subject->id}", function () use ($subject) { return Node::where('subject_id', $subject->id) ->whereNull('parent_id') diff --git a/app/Http/Requests/Subject/StoreSubjectRequest.php b/app/Http/Requests/Subject/StoreSubjectRequest.php index 320d15e4..090d4daf 100644 --- a/app/Http/Requests/Subject/StoreSubjectRequest.php +++ b/app/Http/Requests/Subject/StoreSubjectRequest.php @@ -44,6 +44,7 @@ public function rules(): array 'course' => ['required', 'string', 'in:ssc,hsc'], 'group' => ['sometimes', 'string', 'in:science,humanities,commerce,common'], 'is_trackable' => ['sometimes', 'boolean'], + 'is_disabled' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Http/Requests/Subject/UpdateSubjectRequest.php b/app/Http/Requests/Subject/UpdateSubjectRequest.php index bd7b6afc..f7476e4a 100644 --- a/app/Http/Requests/Subject/UpdateSubjectRequest.php +++ b/app/Http/Requests/Subject/UpdateSubjectRequest.php @@ -44,6 +44,7 @@ public function rules(): array 'course' => ['sometimes', 'string', 'in:ssc,hsc'], 'group' => ['sometimes', 'string', 'in:science,humanities,commerce,common'], 'is_trackable' => ['sometimes', 'boolean'], + 'is_disabled' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Models/Subject.php b/app/Models/Subject.php index 41691ec6..3858704a 100644 --- a/app/Models/Subject.php +++ b/app/Models/Subject.php @@ -13,6 +13,7 @@ protected function casts(): array return [ 'is_trackable' => 'boolean', 'is_optional' => 'boolean', + 'is_disabled' => 'boolean', ]; } diff --git a/database/migrations/2026_10_17_132746_add_is_disabled_to_subjects_table.php b/database/migrations/2026_10_17_132746_add_is_disabled_to_subjects_table.php new file mode 100644 index 00000000..e31bf6e7 --- /dev/null +++ b/database/migrations/2026_10_17_132746_add_is_disabled_to_subjects_table.php @@ -0,0 +1,32 @@ +boolean('is_disabled')->default(false)->after('is_trackable'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subjects', function (Blueprint $table) { + if (Schema::hasColumn('subjects', 'is_disabled')) { + $table->dropColumn('is_disabled'); + } + }); + } +}; diff --git a/resources/js/components/admin/CreateSubjectModal.vue b/resources/js/components/admin/CreateSubjectModal.vue index b9484aa7..0211c706 100644 --- a/resources/js/components/admin/CreateSubjectModal.vue +++ b/resources/js/components/admin/CreateSubjectModal.vue @@ -24,6 +24,7 @@ const props = defineProps<{ icon: string; sort_order: number; is_trackable?: boolean; + is_disabled?: boolean; } | null; }>(); @@ -58,6 +59,7 @@ const tailwindFormat = ref('bg-indigo-50 text-indigo-600'); const icon = ref('BookOpen'); const sortOrder = ref(0); const isTrackable = ref(false); +const isDisabled = ref(false); const initForm = () => { if (props.subject) { @@ -70,6 +72,7 @@ const initForm = () => { icon.value = props.subject.icon || 'BookOpen'; sortOrder.value = props.subject.sort_order ?? 0; isTrackable.value = Boolean(props.subject.is_trackable); + isDisabled.value = Boolean(props.subject.is_disabled); } else { name.value = ''; slug.value = ''; @@ -79,6 +82,7 @@ const initForm = () => { icon.value = 'BookOpen'; sortOrder.value = 0; isTrackable.value = false; + isDisabled.value = false; } errorMessage.value = ''; @@ -119,6 +123,7 @@ const submitForm = () => { icon: icon.value, sort_order: sortOrder.value, is_trackable: isTrackable.value, + is_disabled: isDisabled.value, }; if (props.subject) { @@ -433,6 +438,32 @@ const submitForm = () => { + + +
+ +
diff --git a/resources/js/components/admin/SubjectCard.vue b/resources/js/components/admin/SubjectCard.vue index 11d90a11..552a8cca 100644 --- a/resources/js/components/admin/SubjectCard.vue +++ b/resources/js/components/admin/SubjectCard.vue @@ -54,6 +54,13 @@ const handleDelete = () => { > Trackable + + + Disabled + diff --git a/tests/Feature/SubjectManagementTest.php b/tests/Feature/SubjectManagementTest.php index f8edc63a..0e7494a7 100644 --- a/tests/Feature/SubjectManagementTest.php +++ b/tests/Feature/SubjectManagementTest.php @@ -188,3 +188,94 @@ 'group' => 'science', ]); }); + +test('admin can toggle disable on a subject', function () { + $admin = adminUserWithPermissions(['view admin', 'create subjects', 'edit subjects']); + + $response = $this->actingAs($admin)->post('/admin/subjects', [ + 'name' => 'Disabled Subject', + 'course' => 'hsc', + 'group' => 'science', + 'tailwind_format' => 'bg-slate-500', + 'icon' => 'book-open', + 'sort_order' => 1, + 'is_disabled' => true, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('subjects', [ + 'name' => 'Disabled Subject', + 'is_disabled' => 1, + ]); + + $subject = Subject::where('name', 'Disabled Subject')->first(); + + $response = $this->actingAs($admin)->patch("/admin/subjects/edit/{$subject->id}", [ + 'is_disabled' => false, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('subjects', [ + 'id' => $subject->id, + 'is_disabled' => 0, + ]); +}); + +test('disabled subjects are hidden from home page', function () { + $this->withoutVite(); + + $enabledSubject = Subject::create([ + 'name' => 'Enabled Subject', + 'slug' => 'enabled-subject', + 'course' => 'hsc', + 'group' => 'science', + 'tailwind_format' => 'bg-slate-500', + 'icon' => 'book-open', + 'sort_order' => 1, + 'is_disabled' => false, + ]); + + $disabledSubject = Subject::create([ + 'name' => 'Disabled Subject', + 'slug' => 'disabled-subject', + 'course' => 'hsc', + 'group' => 'science', + 'tailwind_format' => 'bg-slate-500', + 'icon' => 'book-open', + 'sort_order' => 2, + 'is_disabled' => true, + ]); + + $response = $this->get('/?group=science'); + $response->assertOk(); + + $response->assertInertia(fn ($page) => $page + ->component('Home') + ->where('subjects', fn ($subjects) => collect($subjects)->pluck('id')->contains($enabledSubject->id) + && ! collect($subjects)->pluck('id')->contains($disabledSubject->id) + ) + ); +}); + +test('disabled subjects return 404 for regular users but accessible to admins with permission', function () { + $this->withoutVite(); + + $disabledSubject = Subject::create([ + 'name' => 'Hidden Subject', + 'slug' => 'hidden-subject', + 'course' => 'hsc', + 'group' => 'science', + 'tailwind_format' => 'bg-slate-500', + 'icon' => 'book-open', + 'sort_order' => 1, + 'is_disabled' => true, + ]); + + $this->get('/hidden-subject') + ->assertInertia(fn ($page) => $page->component('errors/404')); + + $admin = adminUserWithPermissions(['edit subjects']); + $this->actingAs($admin) + ->get('/hidden-subject') + ->assertInertia(fn ($page) => $page->component('Node')); +}); From b3f689473336e96f017b1159025f7a0f1c26cc6e Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Sat, 17 Oct 2026 19:48:36 +0600 Subject: [PATCH 2/7] fix(leaderboard): start weekly timeframe Saturday morning after Friday ends --- .../Controllers/LeaderboardController.php | 18 ++- tests/Feature/LeaderboardTest.php | 111 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/LeaderboardTest.php diff --git a/app/Http/Controllers/LeaderboardController.php b/app/Http/Controllers/LeaderboardController.php index 8ae7c2a2..6b111b26 100644 --- a/app/Http/Controllers/LeaderboardController.php +++ b/app/Http/Controllers/LeaderboardController.php @@ -20,14 +20,26 @@ public function index(Request $request): Response ? $request->input('timeframe') : 'this_month'; + $tz = 'Asia/Dhaka'; + $now = Carbon::now($tz); + + $startOfWeek = $now->copy()->startOfWeek(Carbon::SATURDAY)->startOfDay(); + $startOfMonth = $now->copy()->startOfMonth()->startOfDay(); + $dateFilter = match ($timeframe) { - 'this_week' => Carbon::now()->startOfWeek(Carbon::SATURDAY)->toDateString(), - 'this_month' => Carbon::now()->startOfMonth()->toDateString(), + 'this_week' => $startOfWeek->toDateString(), + 'this_month' => $startOfMonth->toDateString(), + 'all_time' => null, + }; + + $utcDateFilter = match ($timeframe) { + 'this_week' => $startOfWeek->copy()->setTimezone('UTC')->toDateTimeString(), + 'this_month' => $startOfMonth->copy()->setTimezone('UTC')->toDateTimeString(), 'all_time' => null, }; [$leaderboard, $currentUserRank] = $tab === 'contributors' - ? $this->getContributors($request->user(), $dateFilter) + ? $this->getContributors($request->user(), $utcDateFilter) : $this->getStudents($request->user(), $dateFilter); return Inertia::render('Leaderboard/Index', [ diff --git a/tests/Feature/LeaderboardTest.php b/tests/Feature/LeaderboardTest.php new file mode 100644 index 00000000..ce323321 --- /dev/null +++ b/tests/Feature/LeaderboardTest.php @@ -0,0 +1,111 @@ +withoutVite(); + + // Freeze time to a Saturday at 10:00 AM in Asia/Dhaka + // Saturday morning 2026-10-17 10:00:00 in Asia/Dhaka is 2026-10-17 04:00:00 UTC + $satDhaka = Carbon::parse('2026-10-17 10:00:00', 'Asia/Dhaka'); + Carbon::setTestNow($satDhaka); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics-lb', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1-lb', + ]); + + $studentOld = User::factory()->create(['name' => 'Old Week Student']); + $studentNew = User::factory()->create(['name' => 'Saturday Student']); + + // Old log from Friday + DailyStudyLog::create([ + 'user_id' => $studentOld->id, + 'study_date' => '2026-10-16', + 'total_seconds' => 7200, + ]); + + // New log on Saturday morning (Asia/Dhaka date: 2026-10-17) + DailyStudyLog::create([ + 'user_id' => $studentNew->id, + 'study_date' => '2026-10-17', + 'total_seconds' => 3600, + ]); + + $contributorOld = User::factory()->create(['name' => 'Friday Contributor']); + $contributorNew = User::factory()->create(['name' => 'Saturday Contributor']); + + // Resource created on Friday 23:30 Dhaka time (Friday 17:30 UTC) + $resOld = new Resource([ + 'node_id' => $node->id, + 'user_id' => $contributorOld->id, + 'title' => 'Friday Resource', + 'resource_type' => 'note', + 'content' => 'Old note', + ]); + $resOld->forceFill(['created_at' => Carbon::parse('2026-10-16 23:30:00', 'Asia/Dhaka')->setTimezone('UTC')])->save(); + + // Resource created on Saturday 00:30 Dhaka time (Friday 18:30 UTC) - after Friday ends + $resNew = new Resource([ + 'node_id' => $node->id, + 'user_id' => $contributorNew->id, + 'title' => 'Saturday Morning Resource', + 'resource_type' => 'note', + 'content' => 'New note', + ]); + $resNew->forceFill(['created_at' => Carbon::parse('2026-10-17 00:30:00', 'Asia/Dhaka')->setTimezone('UTC')])->save(); + + // Test students tab for this_week + $responseStudents = $this->get('/leaderboard?tab=students&timeframe=this_week'); + $responseStudents->assertInertia(fn (Assert $page) => $page + ->component('Leaderboard/Index') + ->where('timeframe', 'this_week') + ->where('leaderboard', fn ($lb) => collect($lb)->pluck('id')->contains($studentNew->id) + && ! collect($lb)->pluck('id')->contains($studentOld->id) + ) + ); + + // Test contributors tab for this_week + $responseContributors = $this->get('/leaderboard?tab=contributors&timeframe=this_week'); + $responseContributors->assertInertia(fn (Assert $page) => $page + ->component('Leaderboard/Index') + ->where('timeframe', 'this_week') + ->where('leaderboard', fn ($lb) => collect($lb)->pluck('id')->contains($contributorNew->id) + && ! collect($lb)->pluck('id')->contains($contributorOld->id) + ) + ); + + Carbon::setTestNow(); // Reset test clock +}); + +test('leaderboard transitions to new week precisely at Saturday 00:00:00 Asia/Dhaka', function () { + $this->withoutVite(); + + $tz = 'Asia/Dhaka'; + + // 1 second before Friday ends: 2026-10-16 23:59:59 + Carbon::setTestNow(Carbon::parse('2026-10-16 23:59:59', $tz)); + $respFriday = $this->get('/leaderboard?tab=students&timeframe=this_week'); + $respFriday->assertOk(); + + // Exactly when Friday ends and Saturday begins: 2026-10-17 00:00:00 + Carbon::setTestNow(Carbon::parse('2026-10-17 00:00:00', $tz)); + $respSaturday = $this->get('/leaderboard?tab=students&timeframe=this_week'); + $respSaturday->assertOk(); + + Carbon::setTestNow(); +}); From c75438aada33d7b9aa81ce54634f47be3729a06f Mon Sep 17 00:00:00 2001 From: TR Tajim Date: Sat, 17 Oct 2026 19:48:41 +0600 Subject: [PATCH 3/7] feat(resource): show content for all resource types and support note in bulk image upload --- app/Http/Controllers/ResourceController.php | 4 ++ .../Resource/BulkImageStoreRequest.php | 1 + .../js/components/admin/BulkImageModal.vue | 44 +++++++++++++++++++ resources/js/pages/Resource.vue | 15 +++++++ tests/Feature/PublicResourceUploadTest.php | 27 ++++++++++++ 5 files changed, 91 insertions(+) diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index 0eea9a30..98f0f460 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -169,6 +169,8 @@ public function storeBulkImages(BulkImageStoreRequest $request) $nodeId = (int) $validated['node_id']; DB::transaction(function () use ($request, $validated, $userId, $nodeId, $isModerator) { + $content = $validated['content'] ?? null; + foreach ($request->file('files') as $index => $file) { $filePath = $file->store('resources/images'); $title = $validated['custom_titles'][$index]; @@ -178,12 +180,14 @@ public function storeBulkImages(BulkImageStoreRequest $request) 'user_id' => $userId, 'node_id' => $nodeId, 'title' => $title, + 'content' => $content, 'resource_type' => 'image', 'file_path' => $filePath, ]); } else { ResourceChangeRequest::recordCreate($userId, $nodeId, [ 'title' => $title, + 'content' => $content, 'resource_type' => 'image', 'file_path' => $filePath, ]); diff --git a/app/Http/Requests/Resource/BulkImageStoreRequest.php b/app/Http/Requests/Resource/BulkImageStoreRequest.php index 08c2357a..f539f7ae 100644 --- a/app/Http/Requests/Resource/BulkImageStoreRequest.php +++ b/app/Http/Requests/Resource/BulkImageStoreRequest.php @@ -31,6 +31,7 @@ public function rules(): array 'custom_titles.*' => 'required|string|max:100', 'files' => 'required|array|min:1|max:20', 'files.*' => 'required|image|mimes:jpg,jpeg,png,webp|max:5120', + 'content' => 'nullable|string', ]; } } diff --git a/resources/js/components/admin/BulkImageModal.vue b/resources/js/components/admin/BulkImageModal.vue index 715f768a..07903f34 100644 --- a/resources/js/components/admin/BulkImageModal.vue +++ b/resources/js/components/admin/BulkImageModal.vue @@ -8,6 +8,7 @@ import { Loader2, AlertCircle, X, + ChevronDown, } from 'lucide-vue-next'; import { ref, computed, watch, onUnmounted } from 'vue'; import BaseModal from '@/components/BaseModal.vue'; @@ -38,6 +39,8 @@ const isProcessingServer = ref(false); const namingStrategy = ref<'serial' | 'original'>('serial'); const namingPrefix = ref('image'); const startNumber = ref(1); +const showMoreOptions = ref(false); +const content = ref(''); const MAX_IMAGES = 20; @@ -138,6 +141,8 @@ const clearAll = () => { compressionStatusText.value = ''; uploadProgress.value = null; isProcessingServer.value = false; + content.value = ''; + showMoreOptions.value = false; }; const handleClose = () => { @@ -218,6 +223,7 @@ const submitForm = async () => { start_number: startNumber.value, files: filesToUpload, custom_titles: processedTitles.value, + content: content.value.trim() ? content.value.trim() : null, }; router.post('/resources/bulk/images', payload, { @@ -465,6 +471,44 @@ const submitForm = async () => { + +
+ + +
+ +