feat: add notification center with bell dropdown, full page, and workspace scoping (Story 3.3)

Enhance NotificationDropdown with type-specific icons, French description builder,
click-to-navigate with mark-as-read, and "Voir toutes les notifications" link.
Add full notifications page at /notifications with pagination (25/page), individual
mark-as-read, and empty state. Includes code review fixes: workspace-scoped unread
count and dropdown items, race condition fix (mark-as-read before navigate),
efficient markAllAsRead via direct update, deleted declaration URL handling,
and per-workspace cache keys. 7 new feature tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:26:40 +01:00
parent c7ecbd0ee7
commit 32e11db2b5
11 changed files with 833 additions and 69 deletions

View File

@@ -2,12 +2,61 @@
namespace App\Http\Controllers;
use App\Concerns\HasWorkspaceScope;
use App\Models\Declaration;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
class NotificationController extends Controller
{
use HasWorkspaceScope;
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace();
$notifications = $request->user()
->notifications()
->whereJsonContains('data->workspace_id', $workspace->id)
->latest()
->paginate(25);
$declarationIds = collect($notifications->items())->pluck('data.declaration_id')->filter()->unique()->values();
$senderIds = collect($notifications->items())->pluck('data.sender_id')->filter()->unique()->values();
$declarations = Declaration::whereIn('id', $declarationIds)->pluck('title', 'id');
$senders = User::whereIn('id', $senderIds)->pluck('name', 'id');
$notifications->through(function ($n) use ($declarations, $senders) {
$data = $n->data;
$declarationId = $data['declaration_id'] ?? null;
$senderId = $data['sender_id'] ?? null;
$declarationExists = $declarationId && isset($declarations[$declarationId]);
$data['declaration_title'] = $declarationExists ? $declarations[$declarationId] : null;
$data['sender_name'] = $senderId ? ($senders[$senderId] ?? null) : null;
$data['url'] = $declarationExists ? route('declarations.show', $declarationId) : null;
return [
'id' => $n->id,
'type' => class_basename($n->type),
'data' => $data,
'read_at' => $n->read_at?->toISOString(),
'created_at' => $n->created_at->diffForHumans(),
];
});
return Inertia::render('notifications/Index', [
'notifications' => $notifications,
'markAllReadUrl' => route('notifications.readAll'),
'readUrl' => route('notifications.read', ['id' => '__ID__']),
]);
}
public function markAsRead(Request $request, string $id): RedirectResponse
{
$request->user()
@@ -16,16 +65,22 @@ class NotificationController extends Controller
->firstOrFail()
->markAsRead();
Cache::forget("user:{$request->user()->id}:unread_notifications");
$workspaceId = $request->session()->get('current_workspace_id');
Cache::forget("user:{$request->user()->id}:workspace:{$workspaceId}:unread_notifications");
return back();
}
public function markAllAsRead(Request $request): RedirectResponse
{
$request->user()->unreadNotifications->markAsRead();
$workspace = $this->currentWorkspace();
Cache::forget("user:{$request->user()->id}:unread_notifications");
$request->user()
->unreadNotifications()
->whereJsonContains('data->workspace_id', $workspace->id)
->update(['read_at' => now()]);
Cache::forget("user:{$request->user()->id}:workspace:{$workspace->id}:unread_notifications");
return back();
}