diff --git a/apps/web/lib/coursemap/notifications.ts b/apps/web/lib/coursemap/notifications.ts new file mode 100644 index 00000000..060ac1d7 --- /dev/null +++ b/apps/web/lib/coursemap/notifications.ts @@ -0,0 +1,89 @@ +"use server"; + +import { getAuthViewer } from "@/lib/auth/viewer"; +import { createClient } from "@/lib/supabase/server"; + +export type Notification = { + id: string; + /** + * The database constraint decides which kinds exist. The bell draws the ones + * it recognises with their own icon and anything newer with the bell itself, + * so a row added by a later migration is never invisible to an older client. + */ + kind: string; + title: string; + body: string | null; + href: string | null; + readAt: string | null; + createdAt: string; +}; + +export type NotificationInbox = { + notifications: Notification[]; + /** Unread rows across the whole inbox, not only the page that was read. */ + unreadCount: number; +}; + +/** One screenful. The bell is a glance, not a history. */ +const INBOX_LIMIT = 20; + +/** + * A signed-out visitor, and a deployment with no Supabase configured, both have + * an empty inbox rather than a failure. The bell sits in the shell on every + * page, so it must stay quiet where there is nobody to notify. + */ +const EMPTY_INBOX: NotificationInbox = { notifications: [], unreadCount: 0 }; + +/** + * The newest notifications belonging to the signed-in user. Own-row policies do + * the filtering, so this never names a user; a request without a session simply + * reads nothing. + */ +export async function loadNotifications(): Promise { + if (!(await getAuthViewer())) return EMPTY_INBOX; + const supabase = await createClient(); + + const [{ data, error }, { count, error: countError }] = await Promise.all([ + supabase + .from("notifications") + .select("id,kind,title,body,href,read_at,created_at") + .order("created_at", { ascending: false }) + .limit(INBOX_LIMIT), + supabase + .from("notifications") + .select("id", { count: "exact", head: true }) + .is("read_at", null), + ]); + if (error) throw error; + if (countError) throw countError; + + return { + notifications: (data ?? []).map((row) => ({ + id: row.id, + kind: row.kind, + title: row.title, + body: row.body, + href: row.href, + readAt: row.read_at, + createdAt: row.created_at, + })), + unreadCount: count ?? 0, + }; +} + +/** + * Marks notifications read. Omitting the ids marks every unread row. The + * database function scopes the update to the caller, so the ids are a + * narrowing rather than a permission. + */ +export async function markNotificationsRead( + notificationIds?: string[], +): Promise { + if (!(await getAuthViewer())) return 0; + const supabase = await createClient(); + const { data, error } = await supabase.rpc("mark_notifications_read", { + p_notification_ids: notificationIds, + }); + if (error) throw error; + return data ?? 0; +} diff --git a/apps/web/tests/notifications-menu.test.tsx b/apps/web/tests/notifications-menu.test.tsx new file mode 100644 index 00000000..b907393d --- /dev/null +++ b/apps/web/tests/notifications-menu.test.tsx @@ -0,0 +1,139 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, test, vi } from "vitest"; +import { NotificationsMenu } from "../ui/shell/notifications-menu"; + +const inbox = vi.hoisted(() => ({ + loadNotifications: vi.fn(), + markNotificationsRead: vi.fn(), +})); +vi.mock("@/lib/coursemap/notifications", () => inbox); + +function notification(overrides: Record = {}) { + return { + id: "run-1", + kind: "import_run", + title: "Import run #7 completed", + body: "3 records ready to review.", + href: "/admin/courses/imports?run=7", + readAt: null, + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + inbox.markNotificationsRead.mockResolvedValue(1); +}); + +test("an empty inbox reads as finished, not as a failure", async () => { + inbox.loadNotifications.mockResolvedValue({ + notifications: [], + unreadCount: 0, + }); + const user = userEvent.setup(); + render(); + + const bell = screen.getByRole("button", { name: "Notifications" }); + await user.click(bell); + + expect(await screen.findByText("You are all caught up")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /try again/i })).toBeNull(); + expect( + screen.queryByRole("button", { name: /mark all as read/i }), + ).toBeNull(); +}); + +test("the bell counts unread rows and opening does not mark them read", async () => { + inbox.loadNotifications.mockResolvedValue({ + notifications: [ + notification(), + notification({ + id: "risk-1", + kind: "plan_risk", + title: "COMP2400 lost its Semester 2 offering", + body: null, + href: null, + readAt: new Date().toISOString(), + }), + ], + unreadCount: 1, + }); + const user = userEvent.setup(); + render(); + + const bell = await screen.findByRole("button", { + name: "Notifications, 1 unread", + }); + await user.click(bell); + + expect( + await screen.findByText("Import run #7 completed"), + ).toBeInTheDocument(); + expect( + screen.getByText("COMP2400 lost its Semester 2 offering"), + ).toBeInTheDocument(); + expect(screen.getAllByText("Unread")).toHaveLength(1); + expect(inbox.markNotificationsRead).not.toHaveBeenCalled(); +}); + +test("opening a notification marks that row read and clears the count", async () => { + inbox.loadNotifications.mockResolvedValue({ + notifications: [notification()], + unreadCount: 1, + }); + const user = userEvent.setup(); + render(); + + await user.click( + await screen.findByRole("button", { name: "Notifications, 1 unread" }), + ); + await user.click( + await screen.findByRole("link", { name: /Import run #7 completed/ }), + ); + + expect(inbox.markNotificationsRead).toHaveBeenCalledWith(["run-1"]); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Notifications" }), + ).toBeInTheDocument(), + ); +}); + +test("mark all as read empties the count in one action", async () => { + inbox.loadNotifications.mockResolvedValue({ + notifications: [notification(), notification({ id: "run-2" })], + unreadCount: 2, + }); + const user = userEvent.setup(); + render(); + + await user.click( + await screen.findByRole("button", { name: "Notifications, 2 unread" }), + ); + await user.click( + await screen.findByRole("button", { name: "Mark all as read" }), + ); + + expect(inbox.markNotificationsRead).toHaveBeenCalledWith(undefined); + expect(screen.queryByText("Unread")).toBeNull(); +}); + +test("an unreachable inbox says so and offers another attempt", async () => { + inbox.loadNotifications.mockRejectedValue(new Error("offline")); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Notifications" })); + + expect( + await screen.findByText("Notifications are unavailable"), + ).toBeInTheDocument(); + inbox.loadNotifications.mockResolvedValue({ + notifications: [], + unreadCount: 0, + }); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByText("You are all caught up")).toBeInTheDocument(); +}); diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts index 318e46f4..8c0df089 100644 --- a/apps/web/types/database.ts +++ b/apps/web/types/database.ts @@ -2453,6 +2453,42 @@ export type Database = { } Relationships: [] } + notifications: { + Row: { + body: string | null + created_at: string + dedupe_key: string | null + href: string | null + id: string + kind: string + read_at: string | null + title: string + user_id: string + } + Insert: { + body?: string | null + created_at?: string + dedupe_key?: string | null + href?: string | null + id?: string + kind: string + read_at?: string | null + title: string + user_id: string + } + Update: { + body?: string | null + created_at?: string + dedupe_key?: string | null + href?: string | null + id?: string + kind?: string + read_at?: string | null + title?: string + user_id?: string + } + Relationships: [] + } offering_sessions: { Row: { academic_period_code: string @@ -3605,6 +3641,10 @@ export type Database = { Args: { p_item_year_id: number } Returns: undefined } + mark_notifications_read: { + Args: { p_notification_ids?: string[] } + Returns: number + } move_current_user_plan_item: { Args: { p_before_plan_item_id?: string diff --git a/apps/web/ui/shell/notifications-menu.tsx b/apps/web/ui/shell/notifications-menu.tsx index 37b12356..347f498b 100644 --- a/apps/web/ui/shell/notifications-menu.tsx +++ b/apps/web/ui/shell/notifications-menu.tsx @@ -1,153 +1,368 @@ "use client"; -import { useState } from "react"; +import Link from "next/link"; +import { useCallback, useEffect, useState, useTransition } from "react"; import { + AlertCircle, Bell, BookOpen, CalendarDays, CheckCheck, - Sparkles, + Inbox, + PackageCheck, + TriangleAlert, + type LucideIcon, } from "lucide-react"; import { Badge } from "@coursemap/ui/components/badge"; import { Button } from "@coursemap/ui/primitives/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@coursemap/ui/primitives/dropdown-menu"; + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@coursemap/ui/primitives/empty"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@coursemap/ui/primitives/popover"; +import { ScrollArea } from "@coursemap/ui/primitives/scroll-area"; +import { Skeleton } from "@coursemap/ui/primitives/skeleton"; +import { + loadNotifications, + markNotificationsRead, + type Notification, +} from "@/lib/coursemap/notifications"; +import { cn } from "@/lib/cn"; + +/** + * A row carrying a kind this build has not heard of still draws, with the bell + * itself, rather than dropping out of the inbox. + */ +const kindIcons: Record = { + import_run: PackageCheck, + key_date: CalendarDays, + plan_risk: TriangleAlert, + published_change: BookOpen, +}; + +const relative = new Intl.RelativeTimeFormat("en-AU", { numeric: "auto" }); +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** + * Coarse and forgiving. The exact minute never matters in an inbox, and the + * value is recomputed on every render rather than ticking, so an open menu + * cannot disagree with itself. + */ +function timeAgo(iso: string) { + const elapsed = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(elapsed)) return ""; + if (elapsed < MINUTE) return "Just now"; + if (elapsed < HOUR) + return relative.format(-Math.round(elapsed / MINUTE), "minute"); + if (elapsed < DAY) + return relative.format(-Math.round(elapsed / HOUR), "hour"); + if (elapsed < 7 * DAY) + return relative.format(-Math.round(elapsed / DAY), "day"); + return new Intl.DateTimeFormat("en-AU", { + day: "numeric", + month: "short", + }).format(new Date(iso)); +} + +/** + * Today and Earlier, not New and Read. Grouping on read state would make a row + * jump to another heading the moment it was read, which loses the reader's + * place; grouping on age keeps every row where it was put. + */ +function groupOf(iso: string) { + return Date.now() - new Date(iso).getTime() < DAY ? "Today" : "Earlier"; +} + +function InboxSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ +
+ + +
+
+ ))} + Loading notifications... +
+ ); +} + +function NotificationRow({ + notification, + onOpen, +}: { + notification: Notification; + onOpen: () => void; +}) { + const Icon = kindIcons[notification.kind] ?? Bell; + const unread = notification.readAt === null; + const content = ( + <> + + + + + {notification.title} + + {notification.body && ( + + {notification.body} + + )} + + {timeAgo(notification.createdAt)} + + + {unread ? ( + <> +