Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions app/Http/Controllers/Admin/NotificationController.php
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(),
]);
}
Comment on lines +16 to +28
Copy link

Copilot AI Apr 10, 2026

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 /notifications route. That means in-app notifications sent with url: 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).

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For target = users, user_ids is not required, so submitting the form with no selected users silently sends to an empty recipient list. Add validation to require a non-empty user_ids array when targeting specific users (and consider rejecting/ignoring user_ids when target is not users).

Suggested change
'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 uses AI. Check for mistakes.
]);

$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(),
Copy link

Copilot AI Apr 10, 2026

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.

Suggested change
'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 uses AI. Check for mistakes.
'users' => User::query()->whereIn('id', $validated['user_ids'] ?? [])->get(),
};

Notification::send($recipients, $notification);

return back()->with('status', 'Notification sent.');
}

Comment on lines +50 to +59
Copy link

Copilot AI Apr 10, 2026

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.

Suggested change
'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 uses AI. Check for mistakes.
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();
Comment on lines +71 to +79
Copy link

Copilot AI Apr 10, 2026

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.

Copilot uses AI. Check for mistakes.
}
}
31 changes: 31 additions & 0 deletions app/Notifications/InAppNotification.php
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');
}
};
5 changes: 5 additions & 0 deletions resources/views/admin/dashboard.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
106 changes: 106 additions & 0 deletions resources/views/admin/notifications/index.blade.php
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>
86 changes: 86 additions & 0 deletions resources/views/notifications/index.blade.php
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>
8 changes: 8 additions & 0 deletions routes/web.php
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;
Expand All @@ -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
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-admin notification inbox routes are wired to App\Http\Controllers\Admin\NotificationController, which is also responsible for the admin compose screen. This couples two separate concerns and forces role-based branching inside the controller. Consider using a dedicated (non-admin) controller for /notifications and reserving the Admin controller for /admin/notifications.

Copilot uses AI. Check for mistakes.
});

Route::view('/planning', 'section-placeholder', ['title' => 'Planning'])->name('planning.index');
Expand All @@ -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');
});
Loading
Loading