From 288d14758f26fd42d1fd1812b8dd6066c2db3a81 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sun, 7 Jun 2026 21:29:57 +0300 Subject: [PATCH] feat: real notifications in the sidebar bell Auto-generate notifications on patient record create/update (fan out to the other clinic members, pushed live), and wire the sidebar bell to real data via a useNotifications hook over the shared socket: live unread badge, real list, mark-all-read on open. Drops the hardcoded sample array and the dead "View all" link. Co-Authored-By: Claude Opus 4.8 --- backend/src/routes/patients.ts | 42 ++++++++ .../components/sidebar-02/app-sidebar.tsx | 26 +---- .../sidebar-02/nav-notifications.tsx | 95 ++++++++++++------- frontend/lib/i18n/locales/en/translation.json | 2 +- frontend/lib/notifications.ts | 32 +++++++ frontend/lib/use-notifications.ts | 52 ++++++++++ 6 files changed, 189 insertions(+), 60 deletions(-) create mode 100644 frontend/lib/notifications.ts create mode 100644 frontend/lib/use-notifications.ts diff --git a/backend/src/routes/patients.ts b/backend/src/routes/patients.ts index 91bff90..a437d6b 100644 --- a/backend/src/routes/patients.ts +++ b/backend/src/routes/patients.ts @@ -7,11 +7,41 @@ import { requireOrg, requirePermission, } from "../middleware/auth.js"; +import { emitToUser } from "../realtime.js"; import { recordActivity } from "../services/activity.js"; +import { listClinicMembers } from "../services/messaging.js"; +import { createNotification } from "../services/notifications.js"; import * as service from "../services/patients.js"; export const patientsRouter = Router(); +// Notify the rest of the clinic about a patient record change (best-effort, +// pushed live over the socket). +async function notifyClinic( + orgId: string, + actor: { id: string; name: string }, + text: string, + fileNumber: string, +): Promise { + try { + const members = await listClinicMembers(orgId, actor.id); + for (const m of members) { + const notification = await createNotification({ + orgId, + userId: m.id, + type: "record_change", + text, + entityType: "patient", + entityId: fileNumber, + actorName: actor.name, + }); + if (notification) emitToUser(m.id, "notification:new", notification); + } + } catch (err) { + console.error("Failed to notify clinic:", err); + } +} + // Every patient route requires a signed-in user with an active clinic. patientsRouter.use(requireAuth, requireOrg); @@ -64,6 +94,12 @@ patientsRouter.post( patientName: created.name, patientFileNumber: created.fileNumber, }); + await notifyClinic( + req.organizationId!, + { id: req.user!.id, name: req.user!.name }, + `${req.user!.name} added patient ${created.name}`, + created.fileNumber, + ); res.status(201).json(created); } catch (err) { next(err); @@ -92,6 +128,12 @@ patientsRouter.put( patientName: updated.name, patientFileNumber: updated.fileNumber, }); + await notifyClinic( + req.organizationId!, + { id: req.user!.id, name: req.user!.name }, + `${req.user!.name} updated ${updated.name}'s record`, + updated.fileNumber, + ); res.json(updated); } catch (err) { next(err); diff --git a/frontend/components/sidebar-02/app-sidebar.tsx b/frontend/components/sidebar-02/app-sidebar.tsx index 2a52154..74a97c7 100644 --- a/frontend/components/sidebar-02/app-sidebar.tsx +++ b/frontend/components/sidebar-02/app-sidebar.tsx @@ -23,30 +23,6 @@ import DashboardNavigation from "@/components/sidebar-02/nav-main"; import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications"; import { NavUser } from "@/components/sidebar-02/nav-user"; -const sampleNotifications = [ - { - id: "1", - avatar: "/avatars/01.png", - fallback: "LR", - text: "New lab results are available.", - time: "10m ago", - }, - { - id: "2", - avatar: "/avatars/02.png", - fallback: "PC", - text: "A patient chart was updated.", - time: "1h ago", - }, - { - id: "3", - avatar: "/avatars/03.png", - fallback: "CT", - text: "New message from the care team.", - time: "2h ago", - }, -]; - export function DashboardSidebar() { const { state } = useSidebar(); const { t } = useTranslation(); @@ -106,7 +82,7 @@ export function DashboardSidebar() { animate={{ opacity: 1 }} transition={{ duration: 0.8 }} > - + diff --git a/frontend/components/sidebar-02/nav-notifications.tsx b/frontend/components/sidebar-02/nav-notifications.tsx index 9989d5d..86fcd53 100644 --- a/frontend/components/sidebar-02/nav-notifications.tsx +++ b/frontend/components/sidebar-02/nav-notifications.tsx @@ -1,6 +1,9 @@ "use client"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { BellIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Menu, @@ -11,47 +14,71 @@ import { MenuSeparator, MenuTrigger, } from "@/components/ui/menu"; -import { BellIcon } from "lucide-react"; -import { useTranslation } from "react-i18next"; +import { useNotifications } from "@/lib/use-notifications"; -type Notification = { - id: string; - avatar: string; - fallback: string; - text: string; - time: string; -}; +// ISO timestamp -> "just now" / "10m ago" / "3h ago" / "2d ago". +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} -export function NotificationsPopover({ - notifications, -}: { - notifications: Notification[]; -}) { +export function NotificationsPopover() { const { t } = useTranslation(); + const { items, unread, markAllRead } = useNotifications(); + return ( - - }> - + { + // Viewing the list clears the unread badge. + if (open && unread > 0) markAllRead(); + }} + > + + } + > + + {unread > 0 && ( + + {unread > 9 ? "9+" : unread} + + )} + + {t("nav.notifications")} - {notifications.map(({ id, avatar, fallback, text, time }) => ( - - - - {fallback} - -
- {text} - {time} -
-
- ))} - - - {t("nav.viewAllNotifications")} - + {items.length === 0 ? ( +
+ {t("nav.notificationsEmpty")} +
+ ) : ( + items.map((n) => ( + + + {n.actorInitials ?? "•"} + +
+ {n.text} + + {relativeTime(n.createdAt)} + +
+
+ )) + )}
); diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index e523136..20cf883 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -56,7 +56,7 @@ "activity": "Activity", "settings": "Settings", "notifications": "Notifications", - "viewAllNotifications": "View all notifications", + "notificationsEmpty": "You're all caught up.", "quickNav": "Quick nav", "commandGroup": "Go to", "commandPlaceholder": "Search pages…", diff --git a/frontend/lib/notifications.ts b/frontend/lib/notifications.ts new file mode 100644 index 0000000..1ba1754 --- /dev/null +++ b/frontend/lib/notifications.ts @@ -0,0 +1,32 @@ +import { apiFetch } from "@/lib/api-client"; + +// A notification for the signed-in user. Mirrors the backend +// `src/types/notification.ts`. +export type Notification = { + id: string; + type: string; + text: string; + read: boolean; + entityType: string | null; + entityId: string | null; + actorName: string | null; + actorInitials: string | null; + createdAt: string; +}; + +export type NotificationsResponse = { + notifications: Notification[]; + unread: number; +}; + +export function listNotifications(): Promise { + return apiFetch("/api/notifications"); +} + +export function markNotificationRead(id: string): Promise { + return apiFetch(`/api/notifications/${id}/read`, { method: "PATCH" }); +} + +export function markAllNotificationsRead(): Promise { + return apiFetch("/api/notifications/read-all", { method: "POST" }); +} diff --git a/frontend/lib/use-notifications.ts b/frontend/lib/use-notifications.ts new file mode 100644 index 0000000..300f57d --- /dev/null +++ b/frontend/lib/use-notifications.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; + +import { + type Notification, + listNotifications, + markAllNotificationsRead, +} from "@/lib/notifications"; +import { getSocket } from "@/lib/socket"; + +// Loads the signed-in user's notifications and keeps them live via the shared +// socket ("notification:new"). Exposes a markAllRead action for the popover. +export function useNotifications() { + const [items, setItems] = useState([]); + const [unread, setUnread] = useState(0); + + useEffect(() => { + let active = true; + listNotifications() + .then((res) => { + if (active) { + setItems(res.notifications); + setUnread(res.unread); + } + }) + .catch(() => { + /* api-client redirects on 401 */ + }); + + const socket = getSocket(); + const onNew = (n: Notification) => { + setItems((prev) => [n, ...prev].slice(0, 30)); + setUnread((u) => u + 1); + }; + socket.on("notification:new", onNew); + return () => { + active = false; + socket.off("notification:new", onNew); + }; + }, []); + + const markAllRead = async () => { + setItems((prev) => prev.map((n) => ({ ...n, read: true }))); + setUnread(0); + try { + await markAllNotificationsRead(); + } catch { + /* best-effort */ + } + }; + + return { items, unread, markAllRead }; +}