diff --git a/app/Http/Controllers/Admin/NotificationController.php b/app/Http/Controllers/Admin/NotificationController.php new file mode 100644 index 0000000..f58e7f5 --- /dev/null +++ b/app/Http/Controllers/Admin/NotificationController.php @@ -0,0 +1,81 @@ +user()->isAn('admin')) { + return view('admin.notifications.index', [ + 'users' => User::query()->orderBy('first_name')->get(), + 'notifications' => $request->user()->notifications()->latest()->limit(10)->get(), + ]); + } + + return view('notifications.index', [ + 'notifications' => $request->user()->notifications()->latest()->get(), + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($request->user()->isAn('admin'), 403); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string', 'max:2000'], + 'target' => ['required', 'in:all,role,users'], + 'role' => ['required_if:target,role', 'in:admin,user'], + 'user_ids' => ['array'], + 'user_ids.*' => ['integer', 'exists:users,id'], + ]); + + $notification = new InAppNotification( + title: $validated['title'], + body: $validated['body'], + url: route('notifications.index'), + ); + + $recipients = match ($validated['target']) { + 'all' => User::query()->get(), + 'role' => User::query()->whereIs($validated['role'])->get(), + 'users' => User::query()->whereIn('id', $validated['user_ids'] ?? [])->get(), + }; + + Notification::send($recipients, $notification); + + return back()->with('status', 'Notification sent.'); + } + + public function markRead(Request $request, DatabaseNotification $notification): RedirectResponse + { + if ($notification->notifiable_id !== $request->user()?->id) { + abort(403); + } + + $notification->markAsRead(); + + return back(); + } + + public function markUnread(Request $request, DatabaseNotification $notification): RedirectResponse + { + if ($notification->notifiable_id !== $request->user()?->id) { + abort(403); + } + + $notification->markAsUnread(); + + return back(); + } +} diff --git a/app/Notifications/InAppNotification.php b/app/Notifications/InAppNotification.php new file mode 100644 index 0000000..9d12fe6 --- /dev/null +++ b/app/Notifications/InAppNotification.php @@ -0,0 +1,31 @@ + $this->title, + 'body' => $this->body, + 'url' => $this->url, + ]; + } +} diff --git a/database/migrations/2026_04_10_111853_create_notifications_table.php b/database/migrations/2026_04_10_111853_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_04_10_111853_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index df68027..526bfae 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -44,6 +44,11 @@ 'href' => route('admin.users.index'), 'icon' => 'heroicon-o-users', ], + [ + 'label' => 'Notifications', + 'href' => route('admin.notifications.index'), + 'icon' => 'heroicon-o-bell', + ], [ 'label' => 'Roles', 'href' => route('admin.roles.index'), diff --git a/resources/views/admin/notifications/index.blade.php b/resources/views/admin/notifications/index.blade.php new file mode 100644 index 0000000..a40e8bf --- /dev/null +++ b/resources/views/admin/notifications/index.blade.php @@ -0,0 +1,106 @@ +@php + $toolbarItems = [ + [ + 'label' => 'Ideas', + 'href' => route('home'), + 'icon' => 'heroicon-o-light-bulb', + ], + [ + 'label' => 'Planning', + 'href' => route('planning.index'), + 'icon' => 'heroicon-o-clipboard-document-list', + ], + [ + 'label' => 'Development', + 'href' => route('development.index'), + 'icon' => 'heroicon-o-code-bracket-square', + ], + [ + 'label' => 'Testing', + 'href' => route('testing.index'), + 'icon' => 'heroicon-o-beaker', + ], + [ + 'label' => 'Security', + 'href' => route('security.index'), + 'icon' => 'heroicon-o-shield-check', + ], + [ + 'label' => 'Ops', + 'href' => route('ops.index'), + 'icon' => 'heroicon-o-command-line', + ], + ]; + + $contextItems = [ + ['label' => 'Dashboard', 'href' => route('admin.dashboard'), 'icon' => 'heroicon-o-squares-2x2'], + ['label' => 'Users', 'href' => route('admin.users.index'), 'icon' => 'heroicon-o-users'], + ['label' => 'Notifications', 'href' => route('admin.notifications.index'), 'current' => true, 'icon' => 'heroicon-o-bell'], + ['label' => 'Roles', 'href' => route('admin.roles.index'), 'icon' => 'heroicon-o-shield-check'], + ]; +@endphp + + +
+ + +
+ +
+ @csrf + + + + + + + + + + + + + + + + + @foreach ($users as $user) + + @endforeach + + + Send notification + +
+ + +
+ @forelse ($notifications as $notification) +
+
+
+

{{ $notification->data['title'] ?? 'Notification' }}

+

{{ $notification->data['body'] ?? '' }}

+
+ + {{ $notification->read_at ? 'Read' : 'Unread' }} + +
+
+ @empty +

No notifications yet.

+ @endforelse +
+
+
+
+
diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..ef728ac --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,86 @@ +@php + $toolbarItems = [ + [ + 'label' => 'Ideas', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-light-bulb', + ], + [ + 'label' => 'Planning', + 'href' => route('planning.index'), + 'icon' => 'heroicon-o-clipboard-document-list', + ], + [ + 'label' => 'Development', + 'href' => route('development.index'), + 'icon' => 'heroicon-o-code-bracket-square', + ], + [ + 'label' => 'Testing', + 'href' => route('testing.index'), + 'icon' => 'heroicon-o-beaker', + ], + [ + 'label' => 'Security', + 'href' => route('security.index'), + 'icon' => 'heroicon-o-shield-check', + ], + [ + 'label' => 'Ops', + 'href' => route('ops.index'), + 'icon' => 'heroicon-o-command-line', + ], + ]; + + $contextItems = [ + ['label' => 'Inbox', 'href' => route('notifications.index'), 'current' => true, 'icon' => 'heroicon-o-bell'], + ]; +@endphp + + +
+ + + +
+ @forelse ($notifications as $notification) +
+
+
+

{{ $notification->data['title'] ?? 'Notification' }}

+

{{ $notification->data['body'] ?? '' }}

+
+ + {{ $notification->read_at ? 'Read' : 'Unread' }} + +
+ +
+ @if ($notification->read_at) +
+ @csrf + Mark unread +
+ @else +
+ @csrf + Mark read +
+ @endif +
+
+ @empty +

You have no notifications yet.

+ @endforelse +
+
+
+
diff --git a/routes/web.php b/routes/web.php index fa0bd40..0a5f6c3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ except(['show']); Route::post('/ideas/{idea}/refine', [IdeaController::class, 'refine'])->name('ideas.refine'); Route::post('/ideas/{idea}/propose', [IdeaController::class, 'propose'])->name('ideas.propose'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::post('/notifications/{notification}/read', [NotificationController::class, 'markRead'])->name('notifications.read'); + Route::post('/notifications/{notification}/unread', [NotificationController::class, 'markUnread'])->name('notifications.unread'); }); Route::view('/planning', 'section-placeholder', ['title' => 'Planning'])->name('planning.index'); @@ -31,5 +35,9 @@ Route::put('/users/{user}', [UserController::class, 'update'])->name('users.update'); Route::post('/users/{user}/impersonate', [ImpersonationController::class, 'store'])->name('users.impersonate'); Route::delete('/impersonation', [ImpersonationController::class, 'destroy'])->name('impersonation.destroy'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::post('/notifications', [NotificationController::class, 'store'])->name('notifications.store'); + Route::post('/notifications/{notification}/read', [NotificationController::class, 'markRead'])->name('notifications.read'); + Route::post('/notifications/{notification}/unread', [NotificationController::class, 'markUnread'])->name('notifications.unread'); Route::view('/roles', 'section-placeholder', ['title' => 'Roles'])->name('roles.index'); }); diff --git a/tests/Feature/Admin/NotificationsTest.php b/tests/Feature/Admin/NotificationsTest.php new file mode 100644 index 0000000..60c60cf --- /dev/null +++ b/tests/Feature/Admin/NotificationsTest.php @@ -0,0 +1,77 @@ +create(); + $admin->assign('admin'); + $users = User::factory()->count(2)->create(); + + $this->actingAs($admin) + ->post(route('admin.notifications.store'), [ + 'title' => 'System update', + 'body' => 'We have a new internal update.', + 'target' => 'all', + ]) + ->assertRedirect(); + + Notification::assertSentTo($users[0], InAppNotification::class); + Notification::assertSentTo($users[1], InAppNotification::class); + Notification::assertSentTo($admin, InAppNotification::class); +}); + +it('allows admins to target notifications by role or user list', function (): void { + Notification::fake(); + + $admin = User::factory()->create(); + $admin->assign('admin'); + $targetAdmin = User::factory()->create(); + $targetAdmin->assign('admin'); + $targetUser = User::factory()->create(); + + $this->actingAs($admin) + ->post(route('admin.notifications.store'), [ + 'title' => 'Role update', + 'body' => 'This is for admins only.', + 'target' => 'role', + 'role' => 'admin', + ]) + ->assertRedirect(); + + Notification::assertSentTo($admin, InAppNotification::class); + Notification::assertSentTo($targetAdmin, InAppNotification::class); + Notification::assertNotSentTo($targetUser, InAppNotification::class); + + Notification::fake(); + + $this->actingAs($admin) + ->post(route('admin.notifications.store'), [ + 'title' => 'Chosen people', + 'body' => 'Only specific users should get this.', + 'target' => 'users', + 'user_ids' => [$targetUser->id], + ]) + ->assertRedirect(); + + Notification::assertSentTo($targetUser, InAppNotification::class); + Notification::assertNotSentTo($admin, InAppNotification::class); +}); + +it('forbids non-admins from sending notifications', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.notifications.store'), [ + 'title' => 'Nope', + 'body' => 'Should not send.', + 'target' => 'all', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Notifications/InboxTest.php b/tests/Feature/Notifications/InboxTest.php new file mode 100644 index 0000000..6d46f8a --- /dev/null +++ b/tests/Feature/Notifications/InboxTest.php @@ -0,0 +1,54 @@ +create(); + + $user->notify(new InAppNotification( + title: 'Welcome', + body: 'Your inbox is ready.', + url: route('home') + )); + + $notification = $user->notifications()->first(); + + $this->actingAs($user) + ->get(route('notifications.index')) + ->assertSuccessful() + ->assertSee('Welcome', false) + ->assertSee('Mark read', false); + + $this->actingAs($user) + ->post(route('notifications.read', $notification)) + ->assertRedirect(); + + $notification->refresh(); + + expect($notification->read_at)->not->toBeNull(); + + $this->actingAs($user) + ->get(route('notifications.index')) + ->assertSuccessful() + ->assertSee('Mark unread', false); +}); + +it('blocks users from changing other users notifications', function (): void { + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $owner->notify(new InAppNotification( + title: 'Private', + body: 'Only one account should see this.' + )); + + $notification = $owner->notifications()->first(); + + $this->actingAs($other) + ->post(route('notifications.read', $notification)) + ->assertForbidden(); +});