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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-07 21:29:57 +03:00
parent 730e07fcfc
commit 288d14758f
6 changed files with 189 additions and 60 deletions
+42
View File
@@ -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<void> {
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);
+1 -25
View File
@@ -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 }}
>
<NotificationsPopover notifications={sampleNotifications} />
<NotificationsPopover />
<SidebarTrigger />
</motion.div>
</SidebarHeader>
@@ -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 (
<Menu>
<MenuTrigger render={<Button variant="ghost" size="icon" className="rounded-full" aria-label="Open notifications" />}><BellIcon className="size-5" /></MenuTrigger>
<MenuPopup side="right" className="w-80 my-6">
<Menu
onOpenChange={(open) => {
// Viewing the list clears the unread badge.
if (open && unread > 0) markAllRead();
}}
>
<MenuTrigger
render={
<Button
aria-label={t("nav.notifications")}
className="relative rounded-full"
size="icon"
variant="ghost"
/>
}
>
<BellIcon className="size-5" />
{unread > 0 && (
<span className="-top-0.5 -right-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
{unread > 9 ? "9+" : unread}
</span>
)}
</MenuTrigger>
<MenuPopup side="right" className="my-6 w-80">
<MenuGroup>
<MenuGroupLabel>{t("nav.notifications")}</MenuGroupLabel>
</MenuGroup>
<MenuSeparator />
{notifications.map(({ id, avatar, fallback, text, time }) => (
<MenuItem key={id} className="flex items-start gap-3">
<Avatar className="size-8">
<AvatarImage src={avatar} alt="Avatar" />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">{text}</span>
<span className="text-xs text-muted-foreground">{time}</span>
</div>
</MenuItem>
))}
<MenuSeparator />
<MenuItem className="justify-center text-sm text-muted-foreground hover:text-primary">
{t("nav.viewAllNotifications")}
</MenuItem>
{items.length === 0 ? (
<div className="px-3 py-6 text-center text-muted-foreground text-sm">
{t("nav.notificationsEmpty")}
</div>
) : (
items.map((n) => (
<MenuItem className="flex items-start gap-3" key={n.id}>
<Avatar className="size-8">
<AvatarFallback>{n.actorInitials ?? "•"}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="font-medium text-sm">{n.text}</span>
<span className="text-muted-foreground text-xs">
{relativeTime(n.createdAt)}
</span>
</div>
</MenuItem>
))
)}
</MenuPopup>
</Menu>
);
@@ -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…",
+32
View File
@@ -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<NotificationsResponse> {
return apiFetch<NotificationsResponse>("/api/notifications");
}
export function markNotificationRead(id: string): Promise<void> {
return apiFetch<void>(`/api/notifications/${id}/read`, { method: "PATCH" });
}
export function markAllNotificationsRead(): Promise<void> {
return apiFetch<void>("/api/notifications/read-all", { method: "POST" });
}
+52
View File
@@ -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<Notification[]>([]);
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 };
}