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; +};