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

@@ -52,7 +52,7 @@ class DeclarationMentionController extends Controller
$validated['message'],
));
Cache::forget("user:{$targetUser->id}:unread_notifications");
Cache::forget("user:{$targetUser->id}:workspace:{$workspace->id}:unread_notifications");
return back()->with('flash', ['type' => 'success', 'message' => 'Notification envoyée.']);
}

View File

@@ -52,7 +52,7 @@ class FolderMentionController extends Controller
$validated['message'],
));
Cache::forget("user:{$targetUser->id}:unread_notifications");
Cache::forget("user:{$targetUser->id}:workspace:{$workspace->id}:unread_notifications");
return back()->with('flash', ['type' => 'success', 'message' => 'Notification envoyée.']);
}

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();
}

View File

@@ -2,6 +2,8 @@
namespace App\Http\Middleware;
use App\Models\Declaration;
use App\Models\User;
use App\Models\WorkspaceUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -81,26 +83,52 @@ class HandleInertiaRequests extends Middleware
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'userNotifications' => [
'unread_count' => $user ? Cache::remember(
"user:{$user->id}:unread_notifications",
'unread_count' => $user && $currentWorkspace ? Cache::remember(
"user:{$user->id}:workspace:{$currentWorkspace['id']}:unread_notifications",
60,
fn () => $user->unreadNotifications()->count()
fn () => $user->unreadNotifications()
->whereJsonContains('data->workspace_id', $currentWorkspace['id'])
->count()
) : 0,
'readUrl' => fn () => $user ? route('notifications.read', ['id' => '__ID__']) : null,
'readAllUrl' => fn () => $user ? route('notifications.readAll') : null,
'items' => Inertia::defer(function () use ($user) {
if (! $user) {
'notificationsUrl' => fn () => $user ? route('notifications.index') : null,
'items' => Inertia::defer(function () use ($user, $currentWorkspace) {
if (! $user || ! $currentWorkspace) {
return [];
}
try {
return $user->notifications()->latest()->take(10)->get()->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'data' => $n->data,
'read_at' => $n->read_at?->toISOString(),
'created_at' => $n->created_at->diffForHumans(),
])->all();
$notifications = $user->notifications()
->whereJsonContains('data->workspace_id', $currentWorkspace['id'])
->latest()
->take(10)
->get();
$declarationIds = $notifications->pluck('data.declaration_id')->filter()->unique()->values();
$senderIds = $notifications->pluck('data.sender_id')->filter()->unique()->values();
$declarations = Declaration::whereIn('id', $declarationIds)->pluck('title', 'id');
$senders = User::whereIn('id', $senderIds)->pluck('name', 'id');
return $notifications->map(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(),
];
})->all();
} catch (\Throwable) {
return [];
}