From a05a65191a2843cc84571f803ea965e0c7b9edc9 Mon Sep 17 00:00:00 2001 From: Kunj Hirapara Date: Tue, 15 Sep 2026 16:41:32 +0530 Subject: [PATCH] feat(notifications): give in-app notifications somewhere to go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-app notifications were text with no destination. A candidate told "your interview starts soon" had to work out for themselves that the join button lives on the home page, and a message longer than two lines was clamped with no way to read the rest — the rows were inert divs, so there was nothing to click and nothing to focus. Adds a pure resolver, src/lib/notificationLinks.ts, that turns a row into the places its recipient can actually go, plus a detail dialog for the full text. Two rules shape the links, and both are load-bearing: - Destinations are filtered through getRequiredRolesForPath, the same table middleware and RoleGuard read, rather than a second list that would drift from it. This is not hypothetical: `candidate` holds no permissions at all, so the obvious "Submit feedback" -> /dashboard/interviews link would have landed them on a denial panel. An unknown role — useUserRole reports undefined until the Convex query settles — withholds protected routes for the same reason. - Whatever survives that filter is promoted to primary, so a list that loses its lead action to a role check still reads as a call to action instead of a row of equally quiet links. getMyNotifications now joins each row to the slice of its interview the UI needs, chiefly streamCallId, without which "Join interview" cannot be built at all. Read once per interview rather than once per notification, since one scheduled round fans out a create, a reminder and a feedback nudge. streamCallId is not a capability: joining requires a Stream token minted for the caller's own streamUserId and Stream enforces call membership, so this hands out a destination, not access to it. buildMeetingHref duplicates resolveJoinTarget from #28 on purpose — that branch is unmerged, so importing it would not build here. Collapse the two once it lands. Rows are now buttons rather than divs, so the summary is keyboard reachable and announced as activatable. --- convex/notifications/index.ts | 64 ++- src/components/ui/NotificationBell.tsx | 299 ++++++++------ .../ui/NotificationDetailDialog.tsx | 190 +++++++++ src/components/ui/NotificationsPanel.tsx | 188 ++++++--- src/lib/notificationLinks.test.ts | 294 ++++++++++++++ src/lib/notificationLinks.ts | 375 ++++++++++++++++++ 6 files changed, 1240 insertions(+), 170 deletions(-) create mode 100644 src/components/ui/NotificationDetailDialog.tsx create mode 100644 src/lib/notificationLinks.test.ts create mode 100644 src/lib/notificationLinks.ts diff --git a/convex/notifications/index.ts b/convex/notifications/index.ts index c273067..5cc0f2e 100644 --- a/convex/notifications/index.ts +++ b/convex/notifications/index.ts @@ -420,10 +420,72 @@ export const getMyNotifications = query({ .order("desc") .take(75); - return notifications.filter( + const inAppNotifications = notifications.filter( (notification) => notification.channel === undefined || notification.channel === "in_app", ); + + /** + * Each row is joined to the slice of its interview the UI needs to offer a + * destination — chiefly `streamCallId`, without which "Join interview" + * cannot be built at all. + * + * Read once per interview rather than once per notification: a scheduled + * round fans out a create, a reminder and a feedback nudge, so the same + * interview is referenced several times in one page of results. + * + * On access: the recipient was a participant of this interview when the + * notification was written, and the title and time are already in the + * message text on the row. `streamCallId` is the one genuinely new field, + * and it is not a capability — joining requires a Stream token minted for + * the caller's own `streamUserId`, and Stream enforces call membership. So + * this hands out a destination, not access to it. + * + * `resolveNotificationActions` on the client still filters destinations by + * role, and every Convex function behind those pages re-checks regardless. + */ + const interviewIds = Array.from( + new Set( + inAppNotifications + .map((notification) => notification.interviewId) + .filter((interviewId): interviewId is NonNullable => + Boolean(interviewId), + ), + ), + ); + + const interviewEntries = await Promise.all( + interviewIds.map(async (interviewId) => { + const interview = await ctx.db.get(interviewId); + if (!interview) return null; + + return [ + interviewId, + { + streamCallId: interview.streamCallId, + status: interview.status, + title: interview.title, + startTime: interview.scheduledStartTime ?? interview.startTime, + endTime: interview.scheduledEndTime ?? interview.endTime ?? null, + }, + ] as const; + }), + ); + + const interviewsById = new Map( + interviewEntries.filter( + (entry): entry is NonNullable => entry !== null, + ), + ); + + return inAppNotifications.map((notification) => ({ + ...notification, + // Null rather than undefined so the client can tell "no interview + // attached" from "the interview was deleted or purged by retention". + interview: notification.interviewId + ? interviewsById.get(notification.interviewId) ?? null + : null, + })); }, }); diff --git a/src/components/ui/NotificationBell.tsx b/src/components/ui/NotificationBell.tsx index 3643e55..61f59a1 100644 --- a/src/components/ui/NotificationBell.tsx +++ b/src/components/ui/NotificationBell.tsx @@ -1,10 +1,14 @@ "use client"; import Link from "next/link"; +import { useState } from "react"; import { useMutation, useQuery } from "convex/react"; import { BellIcon, CheckIcon, CheckCheckIcon } from "lucide-react"; import { api } from "../../../convex/_generated/api"; +import type { Id } from "../../../convex/_generated/dataModel"; import { useUserRole } from "@/hooks/useUserRole"; +import { resolveNotificationActions } from "@/lib/notificationLinks"; +import type { AppRole } from "@/lib/routeAccess"; import { Badge } from "./badge"; import { Button } from "./button"; import { @@ -13,7 +17,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu"; -import { ScrollArea } from "./scroll-area"; +import NotificationDetailDialog, { + type NotificationDetail, +} from "./NotificationDetailDialog"; const formatNotificationLabel = (value?: string) => value ? value.replace(/_/g, " ") : "update"; @@ -37,7 +43,7 @@ const formatRelativeTime = (timestamp: number) => { }; function NotificationBell() { - const { isLoading, user } = useUserRole(); + const { isLoading, user, role } = useUserRole(); const notifications = useQuery( api.notifications.index.getMyNotifications, isLoading || !user ? "skip" : {}, @@ -45,6 +51,31 @@ function NotificationBell() { const markAsRead = useMutation(api.notifications.index.markNotificationAsRead); const markAllAsRead = useMutation(api.notifications.index.markAllNotificationsAsRead); + /* + * The dropdown and the dialog are controlled together on purpose. Radix + * unmounts DropdownMenuContent when the menu closes, so a Dialog rendered + * inside it would be torn down the moment it opened. The dialog therefore + * lives outside the menu, and selecting a row closes the menu and hands the + * row over. + */ + const [menuOpen, setMenuOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + + const viewerRole = (role ?? null) as AppRole | null; + + const openDetail = (notification: NotificationDetail) => { + setSelected(notification); + setDetailOpen(true); + setMenuOpen(false); + }; + + const handleMarkRead = (notificationId: string) => { + void markAsRead({ + notificationId: notificationId as Id<"notifications">, + }); + }; + const unreadNotifications = notifications?.filter((notification) => notification.status !== "read") ?? []; @@ -58,132 +89,178 @@ function NotificationBell() { const unreadCount = unreadNotifications.length; return ( - - - - - -
-
-

Notifications

-

- {unreadCount > 0 ? `${unreadCount} unread` : "You're all caught up"} -

-
-
+ <> + + + + + {unreadCount > 9 ? "9+" : unreadCount} + ) : null} - + + + +
+
+

Notifications

+

+ {unreadCount > 0 ? `${unreadCount} unread` : "You're all caught up"} +

+
+
+ {unreadCount > 0 ? ( + + ) : null} + +
-
- - {dropdownNotifications.length > 0 ? ( -
-
- {dropdownNotifications.map((notification) => { - const unread = notification.status !== "read"; - const timestamp = - notification.sentAt ?? notification.scheduledFor; - - return ( -
-
-
- - - - {unread ? ( - - ) : null} -
-
-
-
-

- {notification.title} -

-

- {notification.message} + + {dropdownNotifications.length > 0 ? ( +

+
+ {dropdownNotifications.map((notification) => { + const unread = notification.status !== "read"; + const timestamp = + notification.sentAt ?? notification.scheduledFor; + // Only the leading action is offered in the list. The rest + // are in the dialog, so a 22rem dropdown stays scannable. + const [primaryAction] = resolveNotificationActions({ + type: notification.type, + category: notification.category, + role: viewerRole, + interview: notification.interview, + }); + + return ( +
+
+
+ + + + {unread ? ( + + ) : null} +
+ {/* + A real button, so the whole summary is reachable by + keyboard and announced as activatable — the previous + div had no affordance at all. + */} +
-
+ +
+
+
{formatNotificationLabel(notification.category)} - {unread ? ( + {primaryAction ? ( - ) : ( - - Read - - )} + ) : null}
+ {unread ? ( + + ) : ( + + Read + + )}
-
- ); - })} + ); + })} +
-
- ) : ( -
-

No notifications yet

-

- Interview updates will appear here when they arrive. -

-
- )} - - + ) : ( +
+

No notifications yet

+

+ Interview updates will appear here when they arrive. +

+
+ )} + + + + + ); } diff --git a/src/components/ui/NotificationDetailDialog.tsx b/src/components/ui/NotificationDetailDialog.tsx new file mode 100644 index 0000000..df40a47 --- /dev/null +++ b/src/components/ui/NotificationDetailDialog.tsx @@ -0,0 +1,190 @@ +"use client"; + +import Link from "next/link"; +import { CheckIcon } from "lucide-react"; + +import { + describeNotificationMetadata, + resolveNotificationActions, + type NotificationInterviewRef, +} from "@/lib/notificationLinks"; +import type { AppRole } from "@/lib/routeAccess"; +import { Badge } from "./badge"; +import { Button } from "./button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./dialog"; + +/** + * The full text of one notification, plus somewhere to go next. + * + * The bell and the home panel both clamp the message to two lines, so anything + * longer than a sentence was unreadable and unrecoverable — there was no + * expanded view anywhere. This is that view, and it is also where the quick + * links live for rows whose action does not fit on a list item. + */ + +export type NotificationDetail = { + _id: string; + type?: string; + category?: string; + title: string; + message: string; + status: string; + scheduledFor: number; + sentAt?: number; + readAt?: number; + metadata?: string; + interview?: (NotificationInterviewRef & { title?: string }) | null; +}; + +const formatNotificationLabel = (value?: string) => + value ? value.replace(/_/g, " ") : "update"; + +const formatAbsoluteTime = (timestamp: number) => + new Intl.DateTimeFormat("en", { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(timestamp)); + +function NotificationDetailDialog({ + notification, + role, + open, + onOpenChange, + onMarkRead, +}: { + notification: NotificationDetail | null; + role: AppRole | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onMarkRead: (notificationId: string) => void; +}) { + // The caller never clears its selection, so a notification is still present + // throughout the closing transition; null only means "nothing opened yet". + if (!notification) return null; + + const unread = notification.status !== "read"; + const timestamp = notification.sentAt ?? notification.scheduledFor; + const fields = describeNotificationMetadata(notification.metadata); + const actions = resolveNotificationActions({ + type: notification.type, + category: notification.category, + role, + interview: notification.interview, + }); + + return ( + + + +
+ + {formatNotificationLabel(notification.category)} + + + {formatAbsoluteTime(timestamp)} + +
+ {notification.title} + + Full details for this notification. + +
+ +
+ {/* whitespace-pre-line, because a message composed server-side may + carry its own line breaks that the clamped list view swallowed. */} +

+ {notification.message} +

+ + {notification.interview?.title ? ( +

+ Interview:{" "} + + {notification.interview.title} + +

+ ) : null} + + {fields.length > 0 ? ( +
+ {fields.map((field) => ( +
+
{field.label}
+
+ {field.kind === "time" + ? formatAbsoluteTime(field.value) + : field.value} +
+
+ ))} +
+ ) : null} +
+ + + {unread ? ( + + ) : ( + + Read + {notification.readAt + ? ` · ${formatAbsoluteTime(notification.readAt)}` + : ""} + + )} + + {actions.length > 0 ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) : null} +
+
+
+ ); +} + +export default NotificationDetailDialog; diff --git a/src/components/ui/NotificationsPanel.tsx b/src/components/ui/NotificationsPanel.tsx index 9e13e7f..6b5bc1e 100644 --- a/src/components/ui/NotificationsPanel.tsx +++ b/src/components/ui/NotificationsPanel.tsx @@ -1,13 +1,20 @@ "use client"; import Link from "next/link"; +import { useState } from "react"; import { useMutation, useQuery } from "convex/react"; import { BellRingIcon } from "lucide-react"; import { api } from "../../../convex/_generated/api"; +import type { Id } from "../../../convex/_generated/dataModel"; import { useUserRole } from "@/hooks/useUserRole"; +import { resolveNotificationActions } from "@/lib/notificationLinks"; +import type { AppRole } from "@/lib/routeAccess"; import { Badge } from "./badge"; import { Button } from "./button"; import { Card, CardContent, CardHeader, CardTitle } from "./card"; +import NotificationDetailDialog, { + type NotificationDetail, +} from "./NotificationDetailDialog"; const formatNotificationLabel = (value?: string) => value ? value.replace(/_/g, " ") : "update"; @@ -21,13 +28,24 @@ const formatTimestamp = (timestamp: number) => }).format(new Date(timestamp)); function NotificationsPanel() { - const { isLoading, user } = useUserRole(); + const { isLoading, user, role } = useUserRole(); const notifications = useQuery( api.notifications.index.getMyNotifications, isLoading || !user ? "skip" : {}, ); const markAsRead = useMutation(api.notifications.index.markNotificationAsRead); + const [selected, setSelected] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + + const viewerRole = (role ?? null) as AppRole | null; + + const handleMarkRead = (notificationId: string) => { + void markAsRead({ + notificationId: notificationId as Id<"notifications">, + }); + }; + if (!notifications || notifications.length === 0) return null; const unreadCount = notifications.filter( @@ -35,79 +53,133 @@ function NotificationsPanel() { ).length; return ( - - -
-
- - - Recent notifications - -

- Your latest interview and workflow updates. -

-
-
- 0 ? "default" : "secondary"}> - {unreadCount} unread - - + <> + + +
+
+ + + Recent notifications + +

+ Your latest interview and workflow updates. +

+
+
+ 0 ? "default" : "secondary"}> + {unreadCount} unread + + +
-
- - - {notifications.map((notification) => { - const unread = notification.status !== "read"; + + + {notifications.map((notification) => { + const unread = notification.status !== "read"; + // Two at most in the list. A row already carries a category, a + // timestamp and "Mark read"; the full set lives in the dialog. + const actions = resolveNotificationActions({ + type: notification.type, + category: notification.category, + role: viewerRole, + interview: notification.interview, + }).slice(0, 2); - return ( -
+ return (
-
-
-
-

- {notification.title} -

-

- {notification.message} + key={notification._id} + className="rounded-2xl border border-border/60 bg-background/80 px-4 py-3" + > +

+
+ {/* + The summary is a button so the whole row opens the full + message. It used to be inert text with the message clamped + to two lines and no way to read the rest. + */} + +
+
+
+

+ {formatNotificationLabel(notification.category)}

+ {actions.map((action) => ( + + ))}
-

- {formatTimestamp(notification.sentAt ?? notification.scheduledFor)} -

-
-
-

- {formatNotificationLabel(notification.category)} -

{unread ? ( ) : null}
-
- ); - })} - - + ); + })} + + + + + ); } diff --git a/src/lib/notificationLinks.test.ts b/src/lib/notificationLinks.test.ts new file mode 100644 index 0000000..fab169f --- /dev/null +++ b/src/lib/notificationLinks.test.ts @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + buildMeetingHref, + describeNotificationMetadata, + parseNotificationMetadata, + resolveNotificationActions, +} from "./notificationLinks.ts"; + +const START = Date.UTC(2026, 8, 15, 12, 0, 0); +const END = START + 60 * 60 * 1000; + +const scheduledInterview = { + streamCallId: "call-abc", + status: "scheduled", + startTime: START, + endTime: END, +}; + +describe("buildMeetingHref", () => { + it("builds a meeting path for a usable call id", () => { + assert.equal(buildMeetingHref("abc-123"), "/meeting/abc-123"); + }); + + it("trims surrounding whitespace before building the path", () => { + assert.equal(buildMeetingHref(" abc-123 "), "/meeting/abc-123"); + }); + + it("encodes a call id so it cannot escape the route", () => { + // A raw "a/../b" resolves to "/b" and quietly navigates somewhere the + // notification never referred to. + assert.equal(buildMeetingHref("a/../b"), "/meeting/a%2F..%2Fb"); + }); + + it("returns null rather than '/meeting/' for a missing id", () => { + // "/meeting/" renders the route's own "meeting unavailable" state, which + // reads as a broken interview rather than a notification with no call + // attached. Better to render no button at all. + assert.equal(buildMeetingHref(""), null); + assert.equal(buildMeetingHref(" "), null); + assert.equal(buildMeetingHref(null), null); + assert.equal(buildMeetingHref(undefined), null); + }); +}); + +describe("resolveNotificationActions", () => { + it("offers Join as the primary action once the join window is open", () => { + const actions = resolveNotificationActions({ + type: "interview.reminder", + role: "candidate", + interview: scheduledInterview, + now: START + 5 * 60 * 1000, + }); + + assert.equal(actions[0].href, "/meeting/call-abc"); + assert.equal(actions[0].intent, "primary"); + assert.equal(actions[0].label, "Join interview"); + }); + + it("opens the join window shortly before the scheduled start", () => { + const actions = resolveNotificationActions({ + type: "interview.reminder", + role: "candidate", + interview: scheduledInterview, + now: START - 5 * 60 * 1000, + }); + + assert.equal(actions[0].href, "/meeting/call-abc"); + }); + + it("does not offer Join long before the interview starts", () => { + const actions = resolveNotificationActions({ + type: "interview.scheduled", + role: "candidate", + interview: scheduledInterview, + now: START - 6 * 60 * 60 * 1000, + }); + + assert.ok(!actions.some((action) => action.href.startsWith("/meeting/"))); + assert.equal(actions[0].href, "/calendar"); + assert.equal(actions[0].intent, "primary"); + }); + + it("does not offer Join after the interview has ended", () => { + const actions = resolveNotificationActions({ + type: "interview.reminder", + role: "candidate", + interview: scheduledInterview, + now: END + 60 * 60 * 1000, + }); + + assert.ok(!actions.some((action) => action.href.startsWith("/meeting/"))); + }); + + it("never offers Join for a cancelled interview inside its old window", () => { + const actions = resolveNotificationActions({ + type: "interview.cancelled", + role: "candidate", + interview: { ...scheduledInterview, status: "cancelled" }, + now: START + 5 * 60 * 1000, + }); + + assert.ok(!actions.some((action) => action.href.startsWith("/meeting/"))); + assert.equal(actions[0].href, "/calendar"); + }); + + it("keeps a candidate away from routes their role cannot open", () => { + // candidate holds no permissions at all, so /dashboard/interviews renders + // RoleGuard's denial panel. A quick link that lands there is worse than no + // quick link. + const actions = resolveNotificationActions({ + type: "feedback.reminder", + role: "candidate", + interview: scheduledInterview, + now: END + 60 * 60 * 1000, + }); + + assert.ok(!actions.some((action) => action.href.startsWith("/dashboard"))); + }); + + it("sends an interviewer to the interviews workspace for feedback", () => { + const actions = resolveNotificationActions({ + type: "feedback.reminder", + role: "interviewer", + interview: scheduledInterview, + now: END + 60 * 60 * 1000, + }); + + assert.equal(actions[0].href, "/dashboard/interviews"); + assert.equal(actions[0].label, "Submit feedback"); + }); + + it("withholds protected routes while the role is still unknown", () => { + // useUserRole reports undefined until the Convex query settles. Emitting a + // dashboard link during that window flashes a button that turns into a + // denial page if the viewer is a candidate. + const actions = resolveNotificationActions({ + type: "feedback.reminder", + role: null, + interview: scheduledInterview, + now: END + 60 * 60 * 1000, + }); + + assert.ok(!actions.some((action) => action.href.startsWith("/dashboard"))); + }); + + it("still offers the calendar when no interview is attached", () => { + const actions = resolveNotificationActions({ + type: "interview.scheduled", + role: "candidate", + interview: null, + now: START, + }); + + assert.equal(actions[0].href, "/calendar"); + }); + + it("returns no actions for a system notification", () => { + const actions = resolveNotificationActions({ + type: "system.announcement", + role: "admin", + interview: null, + now: START, + }); + + assert.deepEqual(actions, []); + }); + + it("falls back to the category when the type is unrecognised", () => { + // Rows written before a type existed are classified by category on read, + // which is what inferNotificationCategory does on the server. + const actions = resolveNotificationActions({ + type: "interview.something_new", + category: "interview_reminder", + role: "candidate", + interview: scheduledInterview, + now: START + 5 * 60 * 1000, + }); + + assert.equal(actions[0].href, "/meeting/call-abc"); + }); + + it("does not emit duplicate destinations", () => { + const actions = resolveNotificationActions({ + type: "interview.reminder", + role: "admin", + interview: scheduledInterview, + now: START + 5 * 60 * 1000, + }); + + const hrefs = actions.map((action) => action.href); + assert.equal(new Set(hrefs).size, hrefs.length); + }); + + it("marks exactly one action as primary", () => { + const actions = resolveNotificationActions({ + type: "interview.reminder", + role: "admin", + interview: scheduledInterview, + now: START + 5 * 60 * 1000, + }); + + assert.equal( + actions.filter((action) => action.intent === "primary").length, + 1, + ); + }); + + it("treats an interview with no end time as an hour long", () => { + const actions = resolveNotificationActions({ + type: "interview.reminder", + role: "candidate", + interview: { ...scheduledInterview, endTime: null }, + now: START + 30 * 60 * 1000, + }); + + assert.equal(actions[0].href, "/meeting/call-abc"); + }); +}); + +describe("parseNotificationMetadata", () => { + it("reads the fields the interview mutations write", () => { + const parsed = parseNotificationMetadata( + JSON.stringify({ startTime: START, timezone: "Asia/Kolkata" }), + ); + + assert.equal(parsed.startTime, START); + assert.equal(parsed.timezone, "Asia/Kolkata"); + }); + + it("survives malformed JSON instead of throwing at render time", () => { + assert.deepEqual(parseNotificationMetadata("{not json"), {}); + assert.deepEqual(parseNotificationMetadata(undefined), {}); + assert.deepEqual(parseNotificationMetadata(null), {}); + }); + + it("ignores values of the wrong type", () => { + const parsed = parseNotificationMetadata( + JSON.stringify({ startTime: "soon", reason: 42, timezone: "UTC" }), + ); + + assert.equal(parsed.startTime, undefined); + assert.equal(parsed.reason, undefined); + assert.equal(parsed.timezone, "UTC"); + }); + + it("ignores a JSON payload that is not an object", () => { + assert.deepEqual(parseNotificationMetadata("[1,2,3]"), {}); + assert.deepEqual(parseNotificationMetadata('"hello"'), {}); + assert.deepEqual(parseNotificationMetadata("null"), {}); + }); +}); + +describe("describeNotificationMetadata", () => { + it("labels a reschedule with both times in order", () => { + const fields = describeNotificationMetadata( + JSON.stringify({ + previousStartTime: START, + nextStartTime: START + 86_400_000, + reason: "Interviewer conflict", + }), + ); + + assert.deepEqual(fields, [ + { kind: "time", label: "Previously", value: START }, + { kind: "time", label: "Now starts", value: START + 86_400_000 }, + { kind: "text", label: "Reason", value: "Interviewer conflict" }, + ]); + }); + + it("describes a feedback reminder due date", () => { + const fields = describeNotificationMetadata( + JSON.stringify({ dueAt: END, timezone: "UTC" }), + ); + + assert.deepEqual(fields, [ + { kind: "time", label: "Due", value: END }, + { kind: "text", label: "Timezone", value: "UTC" }, + ]); + }); + + it("returns nothing to render for empty metadata", () => { + assert.deepEqual(describeNotificationMetadata(undefined), []); + assert.deepEqual(describeNotificationMetadata("{}"), []); + }); + + it("drops a blank reason rather than rendering an empty row", () => { + assert.deepEqual( + describeNotificationMetadata(JSON.stringify({ reason: " " })), + [], + ); + }); +}); diff --git a/src/lib/notificationLinks.ts b/src/lib/notificationLinks.ts new file mode 100644 index 0000000..03c94bb --- /dev/null +++ b/src/lib/notificationLinks.ts @@ -0,0 +1,375 @@ +import { getRequiredRolesForPath, type AppRole } from "./routeAccess.ts"; + +/** + * Turns a notification row into the places its recipient can actually go. + * + * The in-app notifications were text with no destination: a candidate told + * "your interview starts soon" had to work out for themselves that the join + * button lives on the home page. Everything here exists to answer "and then + * what?" for a given row. + * + * Two rules shape the output, and both are load-bearing: + * + * 1. **Never emit a route the viewer's role cannot open.** Destinations are + * filtered through `getRequiredRolesForPath`, the same table middleware and + * RoleGuard read, rather than a second hand-maintained list that would + * drift from it. `candidate` holds no permissions at all, so a + * /dashboard/interviews link would land them on a denial panel — worse than + * offering nothing. + * 2. **Exactly one action is primary.** The leading surviving action is + * promoted after filtering, so a list that loses its primary to a role + * check still reads as a call to action instead of a row of grey links. + * + * Imports use explicit `.ts` extensions because this module is covered by + * `node --test`, which has no bundler and no path mapping. See CLAUDE.md. + */ + +/** How early the join link appears, relative to the scheduled start. */ +const JOIN_WINDOW_LEAD_MS = 15 * 60 * 1000; + +/** How long the join link survives past the scheduled end, for overruns. */ +const JOIN_WINDOW_TRAIL_MS = 15 * 60 * 1000; + +/** Assumed length of an interview whose row carries no end time. */ +const DEFAULT_INTERVIEW_DURATION_MS = 60 * 60 * 1000; + +/** + * Statuses that mean "there is nothing to join", regardless of the clock. + * + * A cancelled interview still has a start time, so a window check alone would + * hand someone a join button for a call that is not happening. + */ +const NON_JOINABLE_STATUSES = new Set([ + "draft", + "completed", + "cancelled", + "no_show", + "passed", + "rejected", + "failed", + "succeeded", +]); + +export type NotificationActionIntent = "primary" | "secondary"; + +export type NotificationAction = { + href: string; + label: string; + intent: NotificationActionIntent; +}; + +export type NotificationInterviewRef = { + streamCallId?: string | null; + status?: string | null; + startTime?: number | null; + endTime?: number | null; +}; + +export type NotificationActionInput = { + type?: string | null; + category?: string | null; + role?: AppRole | null; + interview?: NotificationInterviewRef | null; + now?: number; +}; + +/** + * Builds `/meeting/`, or null when there is no usable id. + * + * Returning null rather than "/meeting/" matters: that path renders the route's + * own "meeting unavailable" state, which reads as a broken interview rather + * than a notification that simply has no call attached. + * + * Duplicated deliberately for now — `resolveJoinTarget` in + * src/lib/meetingNavigation.ts does the same job but lives on the unmerged + * PR #28 branch, so importing it would not build on main. Collapse the two once + * that lands. + */ +export const buildMeetingHref = ( + callId: string | null | undefined, +): string | null => { + const trimmed = callId?.trim(); + if (!trimmed) return null; + + return `/meeting/${encodeURIComponent(trimmed)}`; +}; + +type NotificationKind = + | "interview_join" + | "interview_cancelled" + | "feedback" + | "system"; + +/** + * Classifies a row by `type`, falling back to `category`. + * + * Rows predating a given type carry only a category, which is why the server's + * `inferNotificationCategory` exists; this mirrors that leniency so an older + * notification still gets links. + */ +const classifyNotification = ( + type: string | null | undefined, + category: string | null | undefined, +): NotificationKind => { + switch (type) { + case "interview.scheduled": + case "interview.rescheduled": + case "interview.updated": + case "interview.reminder": + return "interview_join"; + case "interview.cancelled": + return "interview_cancelled"; + case "feedback.reminder": + return "feedback"; + default: + break; + } + + switch (category) { + case "interview_schedule": + case "interview_update": + case "interview_reminder": + return "interview_join"; + case "feedback_reminder": + return "feedback"; + default: + return "system"; + } +}; + +const isJoinable = ( + interview: NotificationInterviewRef | null | undefined, + now: number, +): boolean => { + if (!interview) return false; + if (!interview.streamCallId?.trim()) return false; + + const status = interview.status ?? undefined; + if (status && NON_JOINABLE_STATUSES.has(status)) return false; + + const startTime = interview.startTime; + if (typeof startTime !== "number" || !Number.isFinite(startTime)) return false; + + const endTime = + typeof interview.endTime === "number" && Number.isFinite(interview.endTime) + ? interview.endTime + : startTime + DEFAULT_INTERVIEW_DURATION_MS; + + return ( + now >= startTime - JOIN_WINDOW_LEAD_MS && now <= endTime + JOIN_WINDOW_TRAIL_MS + ); +}; + +/** + * Whether `role` may load `href`, per the route table middleware enforces. + * + * An unknown role withholds protected routes: `useUserRole` reports undefined + * until the Convex query settles, and flashing a dashboard button that becomes + * a denial page for a candidate is the exact bad experience this module exists + * to remove. + */ +const isRouteOpenToRole = ( + href: string, + role: AppRole | null | undefined, +): boolean => { + const pathname = href.split(/[?#]/)[0]; + const requiredRoles = getRequiredRolesForPath(pathname); + + if (!requiredRoles) return true; + if (!role) return false; + + return requiredRoles.includes(role); +}; + +/** + * The destinations offered for a notification, most useful first. + * + * Returns an empty array when there is nowhere sensible to go — a system + * announcement has no page of its own, and the detail dialog still shows its + * full text. + */ +export const resolveNotificationActions = ( + input: NotificationActionInput, +): NotificationAction[] => { + const now = input.now ?? Date.now(); + const kind = classifyNotification(input.type, input.category); + + if (kind === "system") return []; + + const candidates: NotificationAction[] = []; + + if (kind === "interview_join") { + const meetingHref = isJoinable(input.interview, now) + ? buildMeetingHref(input.interview?.streamCallId) + : null; + + if (meetingHref) { + candidates.push({ + href: meetingHref, + label: "Join interview", + intent: "primary", + }); + } + } + + if (kind === "feedback") { + candidates.push({ + href: "/dashboard/interviews", + label: "Submit feedback", + intent: "primary", + }); + } + + candidates.push({ + href: "/calendar", + label: "View in calendar", + intent: "secondary", + }); + + if (kind === "interview_join" || kind === "interview_cancelled") { + candidates.push({ + href: "/dashboard/interviews", + label: "Open in interviews", + intent: "secondary", + }); + } + + const seen = new Set(); + const allowed = candidates.filter((action) => { + if (seen.has(action.href)) return false; + if (!isRouteOpenToRole(action.href, input.role)) return false; + + seen.add(action.href); + return true; + }); + + // Promote whatever survived the role filter, so the list always leads with a + // real call to action rather than a row of equally quiet links. + return allowed.map((action, index) => ({ + ...action, + intent: index === 0 ? "primary" : "secondary", + })); +}; + +export type NotificationMetadata = { + startTime?: number; + previousStartTime?: number; + nextStartTime?: number; + dueAt?: number; + reason?: string; + timezone?: string; +}; + +const readNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const readText = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +}; + +/** + * Reads the JSON blob the interview mutations stash on a notification. + * + * `metadata` is a free-form string column written by several call sites, so it + * is treated as untrusted: bad JSON, a non-object payload, or a field of the + * wrong type yields no field rather than throwing inside a render. + * + * Keys are assigned only when present, because `assert.deepStrictEqual` — and + * `Object.keys` in the dialog — distinguish `{}` from `{ reason: undefined }`. + */ +export const parseNotificationMetadata = ( + raw: string | null | undefined, +): NotificationMetadata => { + if (!raw) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {}; + } + + const source = parsed as Record; + const metadata: NotificationMetadata = {}; + + const startTime = readNumber(source.startTime); + if (startTime !== undefined) metadata.startTime = startTime; + + const previousStartTime = readNumber(source.previousStartTime); + if (previousStartTime !== undefined) { + metadata.previousStartTime = previousStartTime; + } + + const nextStartTime = readNumber(source.nextStartTime); + if (nextStartTime !== undefined) metadata.nextStartTime = nextStartTime; + + const dueAt = readNumber(source.dueAt); + if (dueAt !== undefined) metadata.dueAt = dueAt; + + const reason = readText(source.reason); + if (reason !== undefined) metadata.reason = reason; + + const timezone = readText(source.timezone); + if (timezone !== undefined) metadata.timezone = timezone; + + return metadata; +}; + +export type NotificationDetailField = + | { kind: "time"; label: string; value: number } + | { kind: "text"; label: string; value: string }; + +/** + * The metadata rows worth showing in the detail dialog, in reading order. + * + * Timestamps come back as numbers rather than formatted strings so the caller + * owns locale and timezone — and so this stays testable without depending on + * the machine's Intl data. + */ +export const describeNotificationMetadata = ( + raw: string | null | undefined, +): NotificationDetailField[] => { + const metadata = parseNotificationMetadata(raw); + const fields: NotificationDetailField[] = []; + + if (metadata.startTime !== undefined) { + fields.push({ kind: "time", label: "Starts", value: metadata.startTime }); + } + + if (metadata.previousStartTime !== undefined) { + fields.push({ + kind: "time", + label: "Previously", + value: metadata.previousStartTime, + }); + } + + if (metadata.nextStartTime !== undefined) { + fields.push({ + kind: "time", + label: "Now starts", + value: metadata.nextStartTime, + }); + } + + if (metadata.dueAt !== undefined) { + fields.push({ kind: "time", label: "Due", value: metadata.dueAt }); + } + + if (metadata.reason !== undefined) { + fields.push({ kind: "text", label: "Reason", value: metadata.reason }); + } + + if (metadata.timezone !== undefined) { + fields.push({ kind: "text", label: "Timezone", value: metadata.timezone }); + } + + return fields; +};