-
Notifications
You must be signed in to change notification settings - Fork 0
feat(admin): add in-app notifications #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5ce63ca
ce6048b
18194bf
c98be95
c7f5879
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,81 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| <?php | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| namespace App\Http\Controllers\Admin; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| use App\Http\Controllers\Controller; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| use App\Models\User; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| use App\Notifications\InAppNotification; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| use Illuminate\Http\RedirectResponse; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| use Illuminate\Http\Request; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| use Illuminate\Notifications\DatabaseNotification; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| use Illuminate\Support\Facades\Notification; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| use Illuminate\View\View; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| class NotificationController extends Controller | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| public function index(Request $request): View | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ($request->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'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+39
to
+40
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'user_ids' => ['array'], | |
| 'user_ids.*' => ['integer', 'exists:users,id'], | |
| 'user_ids' => ['exclude_unless:target,users', 'required_if:target,users', 'array', 'min:1'], | |
| 'user_ids.*' => ['exclude_unless:target,users', 'integer', 'exists:users,id'], |
Copilot
AI
Apr 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The role targeting options/validation include user, but the codebase only appears to create/assign the admin role (no user role is assigned on registration). As written, choosing role user will likely send to zero recipients. Either create/assign a user role consistently, or change the targeting logic/UI to represent “non-admin users” explicitly.
| 'role' => User::query()->whereIs($validated['role'])->get(), | |
| 'role' => $validated['role'] === 'admin' | |
| ? User::query()->whereIs('admin')->get() | |
| : User::query()->get()->reject(fn (User $user) => $user->isAn('admin')), |
Copilot
AI
Apr 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Recipient selection uses User::query()->get() (and similar) which loads all matching users into memory before dispatching notifications. For large user counts this can become slow and memory-heavy; consider chunking the query and sending in batches and/or queueing the notification delivery.
| '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.'); | |
| } | |
| 'all' => User::query(), | |
| 'role' => User::query()->whereIs($validated['role']), | |
| 'users' => User::query()->whereIn('id', $validated['user_ids'] ?? []), | |
| }; | |
| $this->sendNotificationInChunks($recipients, $notification); | |
| return back()->with('status', 'Notification sent.'); | |
| } | |
| private function sendNotificationInChunks(\Illuminate\Database\Eloquent\Builder $recipients, InAppNotification $notification): void | |
| { | |
| $recipients->chunkById(500, function ($users) use ($notification): void { | |
| Notification::send($users, $notification); | |
| }); | |
| } |
Copilot
AI
Apr 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
markUnread() is a new behavior but there is no test that actually hits the notifications.unread route and asserts read_at becomes null again. Adding a feature test for this route would help ensure unread toggling works end-to-end.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| <?php | ||
|
|
||
| namespace App\Notifications; | ||
|
|
||
| use Illuminate\Bus\Queueable; | ||
| use Illuminate\Notifications\Notification; | ||
|
|
||
| class InAppNotification extends Notification | ||
| { | ||
| use Queueable; | ||
|
|
||
| public function __construct( | ||
| public string $title, | ||
| public string $body, | ||
| public ?string $url = null, | ||
| ) {} | ||
|
|
||
| public function via(object $notifiable): array | ||
| { | ||
| return ['database']; | ||
| } | ||
|
|
||
| public function toArray(object $notifiable): array | ||
| { | ||
| return [ | ||
| 'title' => $this->title, | ||
| 'body' => $this->body, | ||
| 'url' => $this->url, | ||
| ]; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| <?php | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| return new class extends Migration | ||
| { | ||
| /** | ||
| * Run the migrations. | ||
| */ | ||
| public function up(): void | ||
| { | ||
| Schema::create('notifications', function (Blueprint $table) { | ||
| $table->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'); | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
||
| <x-layouts.app-shell | ||
| title="Notifications" | ||
| :toolbar-items="$toolbarItems" | ||
| :context-items="$contextItems" | ||
| :show-admin-entry="true" | ||
| > | ||
| <div class="mx-auto max-w-5xl space-y-8"> | ||
| <x-ui.page-header | ||
| eyebrow="Admin" | ||
| title="In-app notifications" | ||
| description="Send a private in-app notification to all users, a role, or selected users." | ||
| /> | ||
|
|
||
| <div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]"> | ||
| <x-ui.panel title="Compose notification" subtitle="Notifications stay inside the app and appear in each user's inbox."> | ||
| <form method="POST" action="{{ route('admin.notifications.store') }}" class="space-y-5"> | ||
| @csrf | ||
|
|
||
| <x-ui.input label="Title" name="title" required /> | ||
| <x-ui.textarea label="Body" name="body" rows="6" required /> | ||
|
|
||
| <x-ui.select label="Target" name="target" required> | ||
| <option value="all">All users</option> | ||
| <option value="role">Role</option> | ||
| <option value="users">Specific users</option> | ||
| </x-ui.select> | ||
|
|
||
| <x-ui.select label="Role" name="role"> | ||
| <option value="admin">Admin</option> | ||
| <option value="user">User</option> | ||
| </x-ui.select> | ||
|
|
||
| <x-ui.select label="Users" name="user_ids[]" multiple size="6"> | ||
| @foreach ($users as $user) | ||
| <option value="{{ $user->id }}">{{ $user->name }} ({{ $user->email }})</option> | ||
| @endforeach | ||
| </x-ui.select> | ||
|
|
||
| <x-ui.button type="submit">Send notification</x-ui.button> | ||
| </form> | ||
| </x-ui.panel> | ||
|
|
||
| <x-ui.panel title="Recent inbox items" subtitle="The admin account can also review its own in-app notifications here."> | ||
| <div class="space-y-3"> | ||
| @forelse ($notifications as $notification) | ||
| <div class="space-y-2 rounded-md bg-main px-4 py-4"> | ||
| <div class="flex items-start justify-between gap-4"> | ||
| <div> | ||
| <p class="font-medium text-copy">{{ $notification->data['title'] ?? 'Notification' }}</p> | ||
| <p class="text-sm text-muted">{{ $notification->data['body'] ?? '' }}</p> | ||
| </div> | ||
| <x-ui.badge :tone="$notification->read_at ? 'neutral' : 'accent'"> | ||
| {{ $notification->read_at ? 'Read' : 'Unread' }} | ||
| </x-ui.badge> | ||
| </div> | ||
| </div> | ||
| @empty | ||
| <p class="text-sm text-muted">No notifications yet.</p> | ||
| @endforelse | ||
| </div> | ||
| </x-ui.panel> | ||
| </div> | ||
| </div> | ||
| </x-layouts.app-shell> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
||
| <x-layouts.app-shell | ||
| title="Notifications" | ||
| :toolbar-items="$toolbarItems" | ||
| :context-items="$contextItems" | ||
| > | ||
| <div class="mx-auto max-w-5xl space-y-8"> | ||
| <x-ui.page-header | ||
| eyebrow="Inbox" | ||
| title="Notifications" | ||
| description="Private in-app notifications for your account." | ||
| /> | ||
|
|
||
| <x-ui.panel title="Recent items" subtitle="Unread items stay visible until you mark them read."> | ||
| <div class="space-y-3"> | ||
| @forelse ($notifications as $notification) | ||
| <div class="space-y-3 rounded-md bg-main px-4 py-4"> | ||
| <div class="flex items-start justify-between gap-4"> | ||
| <div> | ||
| <p class="font-medium text-copy">{{ $notification->data['title'] ?? 'Notification' }}</p> | ||
| <p class="text-sm text-muted">{{ $notification->data['body'] ?? '' }}</p> | ||
| </div> | ||
| <x-ui.badge :tone="$notification->read_at ? 'neutral' : 'accent'"> | ||
| {{ $notification->read_at ? 'Read' : 'Unread' }} | ||
| </x-ui.badge> | ||
| </div> | ||
|
|
||
| <div class="flex gap-3"> | ||
| @if ($notification->read_at) | ||
| <form method="POST" action="{{ route('notifications.unread', $notification->id) }}"> | ||
| @csrf | ||
| <x-ui.button variant="secondary" type="submit">Mark unread</x-ui.button> | ||
| </form> | ||
| @else | ||
| <form method="POST" action="{{ route('notifications.read', $notification->id) }}"> | ||
| @csrf | ||
| <x-ui.button variant="secondary" type="submit">Mark read</x-ui.button> | ||
| </form> | ||
| @endif | ||
| </div> | ||
| </div> | ||
| @empty | ||
| <p class="text-sm text-muted">You have no notifications yet.</p> | ||
| @endforelse | ||
| </div> | ||
| </x-ui.panel> | ||
| </div> | ||
| </x-layouts.app-shell> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| <?php | ||
|
|
||
| use App\Http\Controllers\Admin\ImpersonationController; | ||
| use App\Http\Controllers\Admin\NotificationController; | ||
| use App\Http\Controllers\Admin\UserController; | ||
| use App\Http\Controllers\IdeaController; | ||
| use Illuminate\Support\Facades\Route; | ||
|
|
@@ -13,6 +14,9 @@ | |
| Route::resource('ideas', IdeaController::class)->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'); | ||
|
Comment on lines
3
to
+19
|
||
| }); | ||
|
|
||
| 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'); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
index()returns the admin compose view whenever the current user is an admin, even on the non-admin/notificationsroute. That means in-app notifications sent withurl: route('notifications.index')will take admin recipients to the admin compose screen (and not the inbox UI with read/unread actions), which is likely unintended. Consider splitting user inbox vs admin compose into separate controllers/routes (e.g., always render the inbox for/notifications, and only render the compose screen on/admin/notifications).