Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/web/lib/coursemap/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationInbox> {
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<number> {
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;
}
139 changes: 139 additions & 0 deletions apps/web/tests/notifications-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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(<NotificationsMenu />);

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(<NotificationsMenu />);

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(<NotificationsMenu />);

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(<NotificationsMenu />);

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(<NotificationsMenu />);

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();
});
40 changes: 40 additions & 0 deletions apps/web/types/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading