diff --git a/GEMINI.md b/GEMINI.md index 92b28f44..ff85e444 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,7 +1,7 @@ # Project Guidelines & Automated Checks ## Formatting and Linting -When asked to fix or check formatting/linting issues, or right before the final commit prior to pushing, run the automated fix commands directly instead of manually inspecting and fixing errors one by one: +When asked to fix or check formatting/linting issues, or before committing changes, run the automated fix commands directly instead of manually inspecting and fixing errors one by one: ```bash npm run format && composer lint && npm run lint @@ -16,7 +16,7 @@ npm run format && composer lint && npm run lint ``` 2. Switch to that branch. 3. Use atomic commits where applicable. - 4. Run formatting and linting checks only before the last commit: + 4. Always run formatting and linting checks before committing: ```bash npm run format && composer lint && npm run lint ``` @@ -26,6 +26,3 @@ npm run format && composer lint && npm run lint git push -u origin ``` 7. Create a Pull Request (PR) with a clear, respective title and description linking relevant issues. - - - diff --git a/app/Http/Controllers/Admin/PeerSettingsController.php b/app/Http/Controllers/Admin/PeerSettingsController.php new file mode 100644 index 00000000..612e9823 --- /dev/null +++ b/app/Http/Controllers/Admin/PeerSettingsController.php @@ -0,0 +1,62 @@ + 'syllabus', + 'icon' => '🔥', + 'message' => "That syllabus isn't going to finish itself! Open the books!", + ], + [ + 'id' => 'phone_down', + 'icon' => '📱', + 'message' => 'Close the tabs, put the phone on DND, and start studying.', + ], + ]; + + public static function getPresets(): array + { + return AppSetting::get('peer_poke_presets', self::DEFAULT_PRESETS) ?? self::DEFAULT_PRESETS; + } + + public function edit() + { + $enabled = (bool) AppSetting::get('peer_poke_enabled', true); + $cooldownMinutes = (int) AppSetting::get('peer_poke_cooldown_minutes', 360); + $presets = self::getPresets(); + + return Inertia::render('admin/PeerSettings', [ + 'settings' => [ + 'enabled' => $enabled, + 'cooldown_minutes' => $cooldownMinutes, + 'presets' => $presets, + ], + ]); + } + + public function update(Request $request) + { + $validated = $request->validate([ + 'enabled' => 'required|boolean', + 'cooldown_minutes' => 'required|integer|min:1|max:10080', + 'presets' => 'required|array|min:1|max:20', + 'presets.*.id' => 'required|string|max:50', + 'presets.*.icon' => 'required|string|max:10', + 'presets.*.message' => 'required|string|max:200', + ]); + + AppSetting::set('peer_poke_enabled', $validated['enabled'], 'boolean'); + AppSetting::set('peer_poke_cooldown_minutes', $validated['cooldown_minutes'], 'integer'); + AppSetting::set('peer_poke_presets', $validated['presets'], 'json'); + + return redirect()->back()->with('success', 'Peer & Study Poke settings updated successfully.'); + } +} diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 69c7ce59..e1886426 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Http\Controllers\Admin\PeerSettingsController; +use App\Models\AppSetting; use App\Models\BlogComment; use App\Models\BlogReaction; use App\Models\ForumAnswer; @@ -10,7 +12,10 @@ use App\Models\Resource; use App\Models\User; use App\Models\UserAppreciation; +use App\Notifications\StudyPokeNotification; use App\Notifications\UserAppreciationNotification; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class UserProfileController extends Controller @@ -90,7 +95,16 @@ public function show(string $username) ->take(30) ->get(); - + $pokeEnabled = (bool) AppSetting::get('peer_poke_enabled', true); + $canPoke = $pokeEnabled && ! $isOwner && ($user->allow_pokes ?? true); + $isPokeOnCooldown = $canPoke && auth()->check() && Cache::has('study_poke:'.auth()->id().":{$user->id}"); + $pokePresets = $pokeEnabled ? PeerSettingsController::getPresets() : []; + $pokeData = [ + 'enabled' => $pokeEnabled, + 'canPoke' => $canPoke, + 'isCooldown' => $isPokeOnCooldown, + 'presets' => $pokePresets, + ]; // Early return if activity is locked for this visitor if ($isLocked) { @@ -105,6 +119,7 @@ public function show(string $username) 'suggestedUsers' => $suggestedUsers, 'appreciators' => $appreciators, 'appreciating' => $appreciating, + 'pokeData' => $pokeData, ]); } @@ -133,7 +148,6 @@ public function show(string $username) $totalBlogViews = (int) $user->blogs()->where('is_published', true)->sum('views'); $sharedResourcesCount = Resource::where('user_id', $user->id)->count(); - // Recent Community Activities $recentForumPosts = ForumPost::where('user_id', $user->id) ->latest('id') @@ -277,7 +291,59 @@ public function show(string $username) 'appreciations' => $recentAppreciations->values(), ], 'suggestedUsers' => $suggestedUsers, + 'pokeData' => $pokeData, + ]); + } + + public function poke(Request $request, User $user) + { + $currentAuthUser = auth()->user(); + + if ($currentAuthUser->id === $user->id) { + return back()->with('error', 'You cannot poke yourself.'); + } + + $enabled = (bool) AppSetting::get('peer_poke_enabled', true); + if (! $enabled) { + return back()->with('error', 'Pokes are currently disabled.'); + } + + if (! ($user->allow_pokes ?? true)) { + return back()->with('error', 'This user has disabled pokes.'); + } + + $validated = $request->validate([ + 'preset_id' => 'required|string', ]); + + $presets = PeerSettingsController::getPresets(); + $selectedPreset = collect($presets)->firstWhere('id', $validated['preset_id']); + + if (! $selectedPreset) { + return back()->with('error', 'The selected poke message is invalid.'); + } + + $cooldownKey = "study_poke:{$currentAuthUser->id}:{$user->id}"; + $cooldownMinutes = (int) AppSetting::get('peer_poke_cooldown_minutes', 360); + $cooldownSeconds = max(30, $cooldownMinutes * 60); + + if (! Cache::add($cooldownKey, true, $cooldownSeconds)) { + return back()->with('error', 'You are on cooldown for poking this peer.'); + } + + try { + $user->notify(new StudyPokeNotification( + sender: $currentAuthUser, + message: $selectedPreset['message'], + icon: $selectedPreset['icon'] ?? '⚡', + presetId: $selectedPreset['id'], + )); + } catch (\Throwable $e) { + Cache::forget($cooldownKey); + throw $e; + } + + return back()->with('success', "You poked {$user->name}! ⚡"); } public function toggleAppreciate(User $user) diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php index 734b5539..922ea1d1 100644 --- a/app/Http/Requests/Profile/UpdateProfileRequest.php +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -40,6 +40,7 @@ public function rules(): array 'about' => ['sometimes', 'nullable', 'string', 'max:1000', new CleanText], 'institution' => ['sometimes', 'nullable', 'string', 'max:255', new CleanText], 'activity_privacy' => ['sometimes', 'string', Rule::in(['public', 'appreciators_only', 'private'])], + 'allow_pokes' => ['sometimes', 'boolean'], 'facebook' => ['sometimes', 'nullable', 'string', 'max:255'], 'instagram' => ['sometimes', 'nullable', 'string', 'max:255'], 'github' => ['sometimes', 'nullable', 'string', 'max:255'], diff --git a/app/Models/User.php b/app/Models/User.php index 29e025c0..1b0a7fe1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -51,6 +51,7 @@ class User extends Authenticatable 'title', 'institution', 'activity_privacy', + 'allow_pokes', 'facebook', 'instagram', 'github', @@ -105,6 +106,7 @@ protected function casts(): array 'password' => 'hashed', 'receive_emails' => 'boolean', 'is_verified' => 'boolean', + 'allow_pokes' => 'boolean', ]; } diff --git a/app/Notifications/StudyPokeNotification.php b/app/Notifications/StudyPokeNotification.php new file mode 100644 index 00000000..e0600ace --- /dev/null +++ b/app/Notifications/StudyPokeNotification.php @@ -0,0 +1,52 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'study_poke', + 'title' => "{$this->sender->name} poked you! {$this->icon}", + 'message' => $this->message, + 'url' => route('user.profile', ['username' => $this->sender->username]), + 'sender_id' => $this->sender->id, + 'sender_name' => $this->sender->name, + 'sender_username' => $this->sender->username, + 'sender_image' => $this->sender->image_url, + 'poke_icon' => $this->icon, + 'poke_message' => $this->message, + 'preset_id' => $this->presetId, + ]; + } +} diff --git a/database/migrations/2026_09_11_172620_add_manage_peers_permission.php b/database/migrations/2026_09_11_172620_add_manage_peers_permission.php new file mode 100644 index 00000000..2cf7c3db --- /dev/null +++ b/database/migrations/2026_09_11_172620_add_manage_peers_permission.php @@ -0,0 +1,39 @@ +forgetCachedPermissions(); + + $permission = Permission::findOrCreate('manage peers', 'web'); + + $admin = Role::where('name', 'admin')->where('guard_name', 'web')->first(); + if ($admin) { + $admin->givePermissionTo($permission); + } + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + + $permission = Permission::where('name', 'manage peers')->where('guard_name', 'web')->first(); + $permission?->delete(); + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + } +}; diff --git a/database/migrations/2026_09_11_223000_add_allow_pokes_to_users_table.php b/database/migrations/2026_09_11_223000_add_allow_pokes_to_users_table.php new file mode 100644 index 00000000..0ff148b3 --- /dev/null +++ b/database/migrations/2026_09_11_223000_add_allow_pokes_to_users_table.php @@ -0,0 +1,28 @@ +boolean('allow_pokes')->default(true)->after('activity_privacy'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('allow_pokes'); + }); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 4962fd9c..0329cf44 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -95,6 +95,11 @@ public function run(): void */ Permission::findOrCreate('manage forums'); + /* + * Peer & Poke management + */ + Permission::findOrCreate('manage peers'); + $admin->syncPermissions(Permission::all()); // Administrators have unrestricted access to all features. diff --git a/resources/js/components/NotificationDropdown.vue b/resources/js/components/NotificationDropdown.vue index aa434bdc..b62e67fb 100644 --- a/resources/js/components/NotificationDropdown.vue +++ b/resources/js/components/NotificationDropdown.vue @@ -15,6 +15,7 @@ import { ArrowBigUp, Clock, LifeBuoy, + Zap, } from 'lucide-vue-next'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { getCsrfToken } from '@/lib/useCsrf'; @@ -576,6 +577,12 @@ onBeforeUnmount(() => { " class="h-4 w-4 text-indigo-500" /> + +
+
+
+ + + Receive Pokes + +
+

+ সহপাঠীদের থেকে Poke পেতে এই অপশনটি অন রাখুন। +

+
+ + +
+ diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index c8375e76..866143f4 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -25,8 +25,10 @@ import { UploadCloud, Users, X, + Zap, } from 'lucide-vue-next'; import { computed, ref, watch } from 'vue'; +import BaseModal from '@/components/BaseModal.vue'; import EmptyState from '@/components/EmptyState.vue'; import UserListItem from '@/components/UserListItem.vue'; import VerifiedBadge from '@/components/VerifiedBadge.vue'; @@ -59,6 +61,17 @@ const props = defineProps<{ isLocked?: boolean; lockReason?: 'private' | 'appreciators_only' | null; activityPrivacy?: string; + pokeData?: { + enabled: boolean; + canPoke: boolean; + isCooldown: boolean; + cooldownUntil: number | null; + presets: Array<{ + id: string; + icon: string; + message: string; + }>; + }; appreciators?: Array<{ id: number; name: string; @@ -232,6 +245,7 @@ const localAppreciationsCount = ref(props.appreciationsCount); const showAppreciatorsModal = ref(false); const showAppreciatingModal = ref(false); const showGuestModal = ref(false); +const guestModalAction = ref<'appreciate' | 'poke'>('appreciate'); watch( () => props.isAppreciated, @@ -247,8 +261,64 @@ watch( }, ); +const showPokeModal = ref(false); +const selectedPresetId = ref(''); +const isSubmittingPoke = ref(false); +const localPokeCooldown = ref(props.pokeData?.isCooldown ?? false); + +watch( + () => props.pokeData?.isCooldown, + (val) => { + localPokeCooldown.value = val ?? false; + }, +); + +const handleOpenPokeModal = () => { + if (!currentUser.value) { + guestModalAction.value = 'poke'; + showGuestModal.value = true; + + return; + } + + if (props.pokeData?.presets && props.pokeData.presets.length > 0) { + if (!selectedPresetId.value) { + selectedPresetId.value = props.pokeData.presets[0].id; + } + } + + showPokeModal.value = true; +}; + +const handleSendPoke = () => { + if (!selectedPresetId.value) { + return; + } + + isSubmittingPoke.value = true; + router.post( + `/u/${props.profileUser.id}/poke`, + { + preset_id: selectedPresetId.value, + }, + { + preserveScroll: true, + onSuccess: (page) => { + if ((page.props as any).flash?.success) { + showPokeModal.value = false; + localPokeCooldown.value = true; + } + }, + onFinish: () => { + isSubmittingPoke.value = false; + }, + }, + ); +}; + const handleAppreciate = () => { if (!currentUser.value) { + guestModalAction.value = 'appreciate'; showGuestModal.value = true; return; @@ -429,36 +499,62 @@ const timeAgo = formatTimeAgo; - + + + :title=" + localIsAppreciated + ? 'Appreciating (click to remove)' + : 'Appreciate this member' + " + > + + {{ + localIsAppreciated + ? 'Appreciating' + : 'Appreciate' + }} + + @@ -1594,19 +1690,35 @@ const timeAgo = formatTimeAgo;
- + +

- Sign in to Appreciate + {{ + guestModalAction === 'poke' + ? 'Sign in to Poke' + : 'Sign in to Appreciate' + }}

- You need to be logged in to send appreciation and support - fellow students and contributors. + {{ + guestModalAction === 'poke' + ? 'You need to be logged in to send pokes to fellow peers.' + : 'You need to be logged in to send appreciation and support fellow students and contributors.' + }}

@@ -1628,4 +1740,66 @@ const timeAgo = formatTimeAgo;
+ + + + +
+ +
+ + +
diff --git a/resources/js/pages/admin/PeerSettings.vue b/resources/js/pages/admin/PeerSettings.vue new file mode 100644 index 00000000..5b8c7fa2 --- /dev/null +++ b/resources/js/pages/admin/PeerSettings.vue @@ -0,0 +1,356 @@ + + + diff --git a/routes/admin.php b/routes/admin.php index a3cd2fdc..6b44d211 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Admin\ForumController as AdminForumController; use App\Http\Controllers\Admin\NodeController as AdminNodeController; use App\Http\Controllers\Admin\NoticeController as AdminNoticeController; +use App\Http\Controllers\Admin\PeerSettingsController; use App\Http\Controllers\Admin\ResourceController as AdminResourceController; use App\Http\Controllers\Admin\SubjectController as AdminSubjectController; use App\Http\Controllers\Admin\SupportTicketController as AdminSupportTicketController; @@ -147,3 +148,8 @@ Route::get('/forums/settings', [AdminForumController::class, 'settings'])->name('forums.settings.edit'); Route::post('/forums/settings', [AdminForumController::class, 'updateSettings'])->name('forums.settings.update'); }); + +Route::middleware('permission:manage peers')->group(function () { + Route::get('/peers/settings', [PeerSettingsController::class, 'edit'])->name('peers.settings.edit'); + Route::post('/peers/settings', [PeerSettingsController::class, 'update'])->name('peers.settings.update'); +}); diff --git a/routes/web.php b/routes/web.php index 3e6728b1..007873ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,6 +36,7 @@ Route::post('/resources/{resource}/complete', [ResourceController::class, 'toggleComplete'])->name('resources.complete'); Route::post('/nodes/{node}/vote', [NodeController::class, 'vote'])->name('nodes.vote'); Route::post('/u/{user}/appreciate', [UserProfileController::class, 'toggleAppreciate'])->name('user.appreciate'); + Route::post('/u/{user}/poke', [UserProfileController::class, 'poke'])->name('user.poke'); Route::get('/support/my-tickets', [SupportTicketController::class, 'myTickets'])->name('support.my-tickets'); Route::post('/support/tickets', [SupportTicketController::class, 'store'])->name('support.tickets.store'); diff --git a/tests/Feature/StudyPokeTest.php b/tests/Feature/StudyPokeTest.php new file mode 100644 index 00000000..15b5ae8c --- /dev/null +++ b/tests/Feature/StudyPokeTest.php @@ -0,0 +1,181 @@ +seed(RolePermissionSeeder::class); +}); + +test('guests are redirected when attempting to poke a user', function () { + $targetUser = User::factory()->create(['username' => 'bob']); + + $this->post("/u/{$targetUser->id}/poke", ['preset_id' => 'syllabus']) + ->assertRedirect('/login'); +}); + +test('users cannot poke their own profile', function () { + $user = User::factory()->create(['username' => 'alice']); + + $this->actingAs($user) + ->post("/u/{$user->id}/poke", ['preset_id' => 'syllabus']) + ->assertSessionHas('error'); +}); + +test('poke with invalid preset_id is rejected', function () { + $userA = User::factory()->create(['username' => 'alice']); + $userB = User::factory()->create(['username' => 'bob']); + + $this->actingAs($userA) + ->post("/u/{$userB->id}/poke", ['preset_id' => 'invalid_preset_123']) + ->assertSessionHas('error'); +}); + +test('authenticated user can poke another user and dispatch notification', function () { + Notification::fake(); + Cache::flush(); + + $userA = User::factory()->create(['username' => 'alice']); + $userB = User::factory()->create(['username' => 'bob']); + + $response = $this->actingAs($userA) + ->post("/u/{$userB->id}/poke", ['preset_id' => 'syllabus']); + + $response->assertSessionHas('success'); + + Notification::assertSentTo( + $userB, + StudyPokeNotification::class, + function (StudyPokeNotification $notification) use ($userA) { + return $notification->sender->id === $userA->id + && $notification->presetId === 'syllabus'; + } + ); + + // Verify cooldown is in cache + $cacheKey = "study_poke:{$userA->id}:{$userB->id}"; + expect(Cache::has($cacheKey))->toBeTrue(); +}); + +test('subsequent pokes during cooldown are blocked', function () { + Notification::fake(); + Cache::flush(); + + $userA = User::factory()->create(['username' => 'alice']); + $userB = User::factory()->create(['username' => 'bob']); + + // First poke succeeds + $this->actingAs($userA) + ->post("/u/{$userB->id}/poke", ['preset_id' => 'syllabus']) + ->assertSessionHas('success'); + + // Second poke fails due to cooldown + $this->actingAs($userA) + ->post("/u/{$userB->id}/poke", ['preset_id' => 'phone_down']) + ->assertSessionHas('error'); + + Notification::assertSentTimes(StudyPokeNotification::class, 1); +}); + +test('cannot poke user who has turned off allow_pokes', function () { + Notification::fake(); + Cache::flush(); + + $userA = User::factory()->create(['username' => 'alice']); + $userB = User::factory()->create([ + 'username' => 'bob', + 'allow_pokes' => false, + ]); + + $this->actingAs($userA) + ->post("/u/{$userB->id}/poke", ['preset_id' => 'syllabus']) + ->assertSessionHas('error'); + + Notification::assertNothingSent(); +}); + +test('user profile passes pokeData with active presets and cooldown status', function () { + Cache::flush(); + + $userA = User::factory()->create(['username' => 'alice']); + $userB = User::factory()->create(['username' => 'bob']); + + $response = $this->actingAs($userA)->get("/u/{$userB->username}"); + + $response->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('User/Show') + ->has('pokeData') + ->where('pokeData.enabled', true) + ->where('pokeData.canPoke', true) + ->where('pokeData.isCooldown', false) + ->has('pokeData.presets') + ); +}); + +test('guest viewing user profile sees pokeData with canPoke true', function () { + Cache::flush(); + + $user = User::factory()->create(['username' => 'bob']); + + $response = $this->get("/u/{$user->username}"); + + $response->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('User/Show') + ->has('pokeData') + ->where('pokeData.enabled', true) + ->where('pokeData.canPoke', true) + ->where('pokeData.isCooldown', false) + ); +}); + +test('admin can update peer and poke settings with manage peers permission', function () { + $permission = Permission::findOrCreate('manage peers'); + $adminRole = Role::findOrCreate('admin'); + $adminRole->givePermissionTo($permission); + + $adminUser = User::factory()->create([ + 'email_verified_at' => now(), + 'is_verified' => true, + ]); + $adminUser->assignRole('admin'); + $adminUser->givePermissionTo('view admin'); + + $response = $this->actingAs($adminUser)->get('/admin/peers/settings'); + $response->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('admin/PeerSettings') + ->has('settings.presets') + ); + + $newPresets = [ + [ + 'id' => 'custom_1', + 'icon' => '🚀', + 'message' => 'Blast off into your study session now!', + ], + ]; + + $updateResponse = $this->actingAs($adminUser) + ->post('/admin/peers/settings', [ + 'enabled' => true, + 'cooldown_minutes' => 720, + 'presets' => $newPresets, + ]); + + $updateResponse->assertRedirect(); + + expect(AppSetting::get('peer_poke_cooldown_minutes'))->toBe(720); + expect(AppSetting::get('peer_poke_presets'))->toBe($newPresets); +});