From f0b49c4b96bc8da2fd14d05070721a1aa1099a4d Mon Sep 17 00:00:00 2001 From: Hardik Date: Sat, 16 May 2026 16:16:06 +0530 Subject: [PATCH] feat(notifications): in-app bell with real-time badge and per-recipient messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (migration: 20260516104351_notification_isread_link): - Notification.isRead Boolean @default(false) — tracks unread state - Notification.link String? — deep-link URL for each notification lib/notifier.ts: - buildInAppBody(): per-recipient message text, context-aware e.g. managers see "Maria Santos submitted PO-2024-12345 for your review" submitters see "Your PO-2024-12345 has been approved" accounts see "PO-2024-12345 approved — ready for payment" - buildInAppLink(): routes to the correct page per recipient role (submitter → /po/[id] or /po/[id]/receipt; manager → /approvals/[id]; accounts → /payments; etc.) - Notifications written with both body and link on every event API: - GET /api/notifications — returns { unreadCount, notifications[] } for the session user; last 20 ordered by sentAt desc - PATCH /api/notifications/read — marks all (or specific ids) as read NotificationBell (components/layout/notification-bell.tsx): - Bell icon in header with red unread count badge - Polls /api/notifications every 30 seconds - When unread count increases vs previous tick: bounces the bell icon and pulses the badge for 3 seconds to signal a new arrival - Click opens dropdown panel: - Unread items highlighted with blue-dot indicator and bolder text - Each item is a Link to the notification's deep-link URL - "Mark all read" button in panel header - Auto-marks all as read when panel opens (optimistic update + API call) - Closes on outside click - "Showing 20 most recent" footer when list is at limit Header: receives initialUnreadCount and initialNotifications as props Portal layout: fetches initial notification data server-side to avoid a loading flash on first render; dates serialised to ISO strings Co-Authored-By: Claude Sonnet 4.6 --- App/pelagia-portal/app/(portal)/layout.tsx | 25 ++- .../app/api/notifications/read/route.ts | 32 +++ .../app/api/notifications/route.ts | 29 +++ .../components/layout/header.tsx | 22 +- .../components/layout/notification-bell.tsx | 191 ++++++++++++++++++ App/pelagia-portal/lib/notifier.ts | 94 ++++++++- .../migration.sql | 3 + App/pelagia-portal/prisma/schema.prisma | 2 + 8 files changed, 389 insertions(+), 9 deletions(-) create mode 100644 App/pelagia-portal/app/api/notifications/read/route.ts create mode 100644 App/pelagia-portal/app/api/notifications/route.ts create mode 100644 App/pelagia-portal/components/layout/notification-bell.tsx create mode 100644 App/pelagia-portal/prisma/migrations/20260516104351_notification_isread_link/migration.sql diff --git a/App/pelagia-portal/app/(portal)/layout.tsx b/App/pelagia-portal/app/(portal)/layout.tsx index d563085..bae3fe7 100644 --- a/App/pelagia-portal/app/(portal)/layout.tsx +++ b/App/pelagia-portal/app/(portal)/layout.tsx @@ -1,4 +1,5 @@ import { auth } from "@/auth"; +import { db } from "@/lib/db"; import { redirect } from "next/navigation"; import { Sidebar } from "@/components/layout/sidebar"; import { Header } from "@/components/layout/header"; @@ -11,11 +12,33 @@ export default async function PortalLayout({ const session = await auth(); if (!session?.user) redirect("/login"); + const [notifications, unreadCount] = await Promise.all([ + db.notification.findMany({ + where: { userId: session.user.id }, + orderBy: { sentAt: "desc" }, + take: 20, + select: { id: true, body: true, link: true, isRead: true, sentAt: true, poId: true }, + }), + db.notification.count({ + where: { userId: session.user.id, isRead: false }, + }), + ]); + + // Dates must be serialised before being passed to Client Components + const serialisedNotifications = notifications.map((n) => ({ + ...n, + sentAt: n.sentAt.toISOString(), + })); + return (
-
+
{children}
diff --git a/App/pelagia-portal/app/api/notifications/read/route.ts b/App/pelagia-portal/app/api/notifications/read/route.ts new file mode 100644 index 0000000..434d9de --- /dev/null +++ b/App/pelagia-portal/app/api/notifications/read/route.ts @@ -0,0 +1,32 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// PATCH /api/notifications/read +// Body: { ids?: string[] } — if omitted, mark all unread as read +export async function PATCH(request: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + let ids: string[] | undefined; + try { + const body = await request.json(); + ids = body.ids; + } catch { + // no body — mark all + } + + if (ids && ids.length > 0) { + await db.notification.updateMany({ + where: { id: { in: ids }, userId: session.user.id }, + data: { isRead: true }, + }); + } else { + await db.notification.updateMany({ + where: { userId: session.user.id, isRead: false }, + data: { isRead: true }, + }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/App/pelagia-portal/app/api/notifications/route.ts b/App/pelagia-portal/app/api/notifications/route.ts new file mode 100644 index 0000000..f30bce1 --- /dev/null +++ b/App/pelagia-portal/app/api/notifications/route.ts @@ -0,0 +1,29 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { NextResponse } from "next/server"; + +export async function GET() { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const [notifications, unreadCount] = await Promise.all([ + db.notification.findMany({ + where: { userId: session.user.id }, + orderBy: { sentAt: "desc" }, + take: 20, + select: { + id: true, + body: true, + link: true, + isRead: true, + sentAt: true, + poId: true, + }, + }), + db.notification.count({ + where: { userId: session.user.id, isRead: false }, + }), + ]); + + return NextResponse.json({ notifications, unreadCount }); +} diff --git a/App/pelagia-portal/components/layout/header.tsx b/App/pelagia-portal/components/layout/header.tsx index a40691c..0189acc 100644 --- a/App/pelagia-portal/components/layout/header.tsx +++ b/App/pelagia-portal/components/layout/header.tsx @@ -4,6 +4,7 @@ import { signOut } from "next-auth/react"; import { LogOut } from "lucide-react"; import type { Role } from "@prisma/client"; import { CartIcon } from "./cart-icon"; +import { NotificationBell } from "./notification-bell"; const ROLE_LABELS: Record = { TECHNICAL: "Technical", @@ -17,16 +18,31 @@ const ROLE_LABELS: Record = { const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"]; -interface HeaderProps { - user: { name: string; email: string; role: Role }; +interface NotificationItem { + id: string; + body: string; + link: string | null; + isRead: boolean; + sentAt: string; + poId: string | null; } -export function Header({ user }: HeaderProps) { +interface HeaderProps { + user: { name: string; email: string; role: Role }; + initialUnreadCount: number; + initialNotifications: NotificationItem[]; +} + +export function Header({ user, initialUnreadCount, initialNotifications }: HeaderProps) { return (
{CART_ROLES.includes(user.role) && } +

{user.name}

{ROLE_LABELS[user.role]}

diff --git a/App/pelagia-portal/components/layout/notification-bell.tsx b/App/pelagia-portal/components/layout/notification-bell.tsx new file mode 100644 index 0000000..75a54f5 --- /dev/null +++ b/App/pelagia-portal/components/layout/notification-bell.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { Bell } from "lucide-react"; + +interface NotificationItem { + id: string; + body: string; + link: string | null; + isRead: boolean; + sentAt: string; + poId: string | null; +} + +interface Props { + initialUnreadCount: number; + initialNotifications: NotificationItem[]; +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +export function NotificationBell({ initialUnreadCount, initialNotifications }: Props) { + const [open, setOpen] = useState(false); + const [unreadCount, setUnreadCount] = useState(initialUnreadCount); + const [notifications, setNotifications] = useState(initialNotifications); + const [newArrival, setNewArrival] = useState(false); + const prevCountRef = useRef(initialUnreadCount); + const panelRef = useRef(null); + + // ── Poll for new notifications every 30 s ─────────────────────────────────── + const fetchNotifications = useCallback(async () => { + try { + const res = await fetch("/api/notifications", { cache: "no-store" }); + if (!res.ok) return; + const data: { unreadCount: number; notifications: NotificationItem[] } = await res.json(); + + setNotifications(data.notifications); + + if (data.unreadCount > prevCountRef.current) { + // New notification arrived — flash the bell + setNewArrival(true); + setTimeout(() => setNewArrival(false), 3000); + } + prevCountRef.current = data.unreadCount; + setUnreadCount(data.unreadCount); + } catch { + // network error — silently ignore + } + }, []); + + useEffect(() => { + const id = setInterval(fetchNotifications, 30_000); + return () => clearInterval(id); + }, [fetchNotifications]); + + // ── Close on outside click ────────────────────────────────────────────────── + useEffect(() => { + function handleClick(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + // ── Mark all read when panel opens ───────────────────────────────────────── + async function handleOpen() { + const next = !open; + setOpen(next); + if (next && unreadCount > 0) { + setUnreadCount(0); + prevCountRef.current = 0; + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + await fetch("/api/notifications/read", { method: "PATCH" }); + } + } + + return ( +
+ {/* Bell button */} + + + {/* Dropdown panel */} + {open && ( +
+ {/* Header */} +
+

Notifications

+ {notifications.length > 0 && ( + + )} +
+ + {/* List */} +
+ {notifications.length === 0 ? ( +
+ No notifications yet +
+ ) : ( + notifications.map((n) => { + const content = ( +
+ {/* Unread dot */} + +
+

+ {n.body} +

+

{timeAgo(n.sentAt)}

+
+
+ ); + + return n.link ? ( + setOpen(false)} + className="block" + > + {content} + + ) : ( +
{content}
+ ); + }) + )} +
+ + {/* Footer */} + {notifications.length === 20 && ( +
+ Showing 20 most recent +
+ )} +
+ )} +
+ ); +} diff --git a/App/pelagia-portal/lib/notifier.ts b/App/pelagia-portal/lib/notifier.ts index 1847e13..1b7f0c5 100644 --- a/App/pelagia-portal/lib/notifier.ts +++ b/App/pelagia-portal/lib/notifier.ts @@ -31,10 +31,13 @@ export async function notify({ event, po, recipients, note }: NotifyParams) { await Promise.allSettled( recipients.map(async (recipient) => { + const body = buildInAppBody(event, po, recipient); + const link = buildInAppLink(event, po, recipient); + let status = "sent"; if (isDev) { console.log( - `\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildBody(event, po, note)}\n` + `\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n` ); } else { try { @@ -49,13 +52,92 @@ export async function notify({ event, po, recipients, note }: NotifyParams) { status = "failed"; } } + await db.notification.create({ - data: { subject, body: subject, status, poId: po.id, userId: recipient.id }, + data: { subject, body, link, status, poId: po.id, userId: recipient.id }, }); }) ); } +// ── In-app message ──────────────────────────────────────────────────────────── + +function buildInAppBody( + event: NotificationEvent, + po: PurchaseOrder & { submitter: User }, + recipient: User +): string { + const pn = po.poNumber; + const submitter = po.submitter.name; + + switch (event) { + case "PO_SUBMITTED": + // Manager sees who submitted; submitter gets a confirmation + return recipient.id === po.submitterId + ? `Your PO ${pn} has been submitted for review` + : `${submitter} submitted PO ${pn} for your review`; + + case "PO_APPROVED": + case "PO_APPROVED_WITH_NOTE": + return recipient.id === po.submitterId + ? `Your PO ${pn} has been approved` + : `PO ${pn} approved — ready for payment`; + + case "PO_REJECTED": + return `Your PO ${pn} has been rejected`; + + case "EDITS_REQUESTED": + return `Edits requested on your PO ${pn}`; + + case "VENDOR_ID_REQUESTED": + return `Vendor ID needed before PO ${pn} can be approved`; + + case "VENDOR_ID_PROVIDED": + return `Vendor ID provided for PO ${pn} — ready to review`; + + case "PAYMENT_PROCESSING": + return `Payment is being processed for PO ${pn}`; + + case "PAYMENT_SENT": + return `Payment confirmed for PO ${pn} — please confirm receipt`; + + case "RECEIPT_CONFIRMED": + return `Receipt confirmed — PO ${pn} is now closed`; + + default: + return `Update on PO ${pn}`; + } +} + +function buildInAppLink( + event: NotificationEvent, + po: PurchaseOrder & { submitter: User }, + recipient: User +): string { + switch (event) { + case "PO_SUBMITTED": + return recipient.id === po.submitterId ? `/po/${po.id}` : `/approvals/${po.id}`; + case "PO_APPROVED": + case "PO_APPROVED_WITH_NOTE": + return recipient.id === po.submitterId ? `/po/${po.id}` : `/payments`; + case "PO_REJECTED": + case "EDITS_REQUESTED": + case "VENDOR_ID_REQUESTED": + return `/po/${po.id}`; + case "VENDOR_ID_PROVIDED": + return `/approvals/${po.id}`; + case "PAYMENT_PROCESSING": + case "RECEIPT_CONFIRMED": + return `/po/${po.id}`; + case "PAYMENT_SENT": + return `/po/${po.id}/receipt`; + default: + return `/po/${po.id}`; + } +} + +// ── Email subject ───────────────────────────────────────────────────────────── + function buildSubject(event: NotificationEvent, poNumber: string): string | null { const base = `PO ${poNumber}`; const map: Record = { @@ -73,6 +155,8 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null return map[event] ?? null; } +// ── Email HTML ──────────────────────────────────────────────────────────────── + function buildHtml( event: NotificationEvent, po: PurchaseOrder & { submitter: User }, @@ -87,7 +171,7 @@ function buildHtml( Pelagia Portal

Hi ${recipient.name},

-

${buildBody(event, po, note)}

+

${buildEmailBody(event, po, note)}

PO Number: ${po.poNumber}
Title: ${po.title}
@@ -100,7 +184,7 @@ function buildHtml( `; } -function buildBody( +function buildEmailBody( event: NotificationEvent, po: PurchaseOrder & { submitter: User }, note?: string @@ -120,7 +204,7 @@ function buildBody( case "EDITS_REQUESTED": return `Edits have been requested on ${po.poNumber}. Please update the order and resubmit.${noteHtml}`; case "VENDOR_ID_REQUESTED": - return `A vendor ID is required before ${po.poNumber} can be approved. Please update the PO with the correct vendor details.`; + return `A vendor ID is required before ${po.poNumber} can be approved.`; case "VENDOR_ID_PROVIDED": return `The vendor ID has been provided for ${po.poNumber}. It is ready for your review.`; case "PAYMENT_PROCESSING": diff --git a/App/pelagia-portal/prisma/migrations/20260516104351_notification_isread_link/migration.sql b/App/pelagia-portal/prisma/migrations/20260516104351_notification_isread_link/migration.sql new file mode 100644 index 0000000..a092128 --- /dev/null +++ b/App/pelagia-portal/prisma/migrations/20260516104351_notification_isread_link/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Notification" ADD COLUMN "isRead" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "link" TEXT; diff --git a/App/pelagia-portal/prisma/schema.prisma b/App/pelagia-portal/prisma/schema.prisma index bd2a6f4..45082c5 100644 --- a/App/pelagia-portal/prisma/schema.prisma +++ b/App/pelagia-portal/prisma/schema.prisma @@ -327,6 +327,8 @@ model Notification { id String @id @default(cuid()) subject String body String + link String? + isRead Boolean @default(false) sentAt DateTime @default(now()) status String @default("sent")