Milestone: Full in-app notification center shipped — bell badge in nav, paginated list, per-type icons, mark read, mark all read.
What got built
4 files, zero TypeScript errors, zero any types:
src/app/(app)/notifications/actions.ts — Full rewrite: getNotifications(page), markRead(id), markAllRead(), getUnreadCount(). All four actions use notifications.view permission, getTenantDb() tenant-scoping, Zod input validation, and revalidatePath after mutations. markRead() double-checks userId ownership before writing.src/app/(app)/notifications/page.tsx — Server component; fetches page 1 SSR, passes typed data to client. Metadata set to Notifications | VIBE CRM.src/app/(app)/notifications/notifications-client.tsx — Full client: notification cards with per-type lucide icon + colour, relative timestamps, gold left-border accent on unread, "Mark all read" button, optimistic state updates, load-more pagination, empty state.src/components/notifications/notification-bell.tsx — Compact bell icon + badge for nav/header. Fetches unread count on mount via server action. Badge shows count (capped at 99+), red dot, links to /notifications.
Technical highlights worth posting about
markRead() is idempotent: if readAt is already set it returns { ok: true } immediately — safe to call twice without double-writingmarkRead() has two layers of ownership verification: getTenantDb() extension blocks cross-tenant access at the Prisma level, then the action explicitly checks existing.userId === ctx.userId — both must pass- Optimistic state in
NotificationsClient: cards update to "read" instantly via onRead callback; server revalidation runs in background — no visible flicker useTransition + startLoadMoreTransition keeps the current list visible while the next page loads — no layout shift- Bell badge uses
cancelled flag in useEffect cleanup to prevent setState on unmounted component — no memory leak NotificationType config map drives icon, colour, badge variant, and label from a single source — adding a new notification type means one object entry, nothing else changes
Stack used
Next.js 16 App Router + TypeScript strict + Prisma 6 + Clerk + shadcn/ui (Button, Badge, Skeleton) + lucide-react + Zod
Potential X/Twitter angles
- "Built a full notification center for VIBE CRM today. Bell badge, paginated list, per-type icons, mark-all-read. Here's how I keep it secure: two ownership checks per mutation — Prisma tenant extension + explicit userId match."
- "useTransition for load-more: the existing list stays visible while the next page loads. No spinner replacing content, no layout shift. One hook, smooth UX."
- "Notification bell badge that can't accidentally leak cross-tenant unread counts: getTenantDb() auto-scopes the count query to the right org via AsyncLocalStorage. Zero chance of seeing another tenant's data."
- "Idempotent server actions: markRead() checks if readAt is already set before writing. Safe to call twice, safe to retry on network failure. Build your mutations this way."
Next sprint targets
- Wire NotificationBell into Sidebar nav
- Trigger notifications from existing actions (lead_assigned on convertLeadToContact, task_due_soon/task_overdue from task engine)
- Real-time badge refresh via polling interval or Server-Sent Events