From fa9940e786e9f7665d082dbf9949a28f96b56203 Mon Sep 17 00:00:00 2001 From: solarisael Date: Tue, 27 Oct 2026 16:21:38 -0300 Subject: [PATCH 1/2] feat(notifications): add in-app notifications foundation Signed-off-by: solarisael --- api/src/database.types.ts | 156 ++++++ api/src/index.ts | 4 +- api/src/routes/notifications.ts | 164 ++++++ api/src/services/notification.service.ts | 209 +++++++ api/tests/notifications.test.ts | 517 ++++++++++++++++++ .../notifications/notifications-empty.png | Bin 0 -> 69733 bytes app/src/components/Navbar.tsx | 3 + .../__tests__/notificationSettings.test.tsx | 128 +++++ .../notifications/NotificationBell.tsx | 85 +++ .../notifications/NotificationPanel.tsx | 264 +++++++++ .../notifications/NotificationRow.tsx | 65 +++ .../__tests__/NotificationPanel.test.tsx | 156 ++++++ .../components/sidebar/SettingsSidebar.tsx | 7 +- app/src/lib/__tests__/notifications.test.ts | 119 ++++ app/src/lib/api/notifications.ts | 86 +++ app/src/lib/notifications.ts | 125 +++++ app/src/routeTree.gen.ts | 21 + app/src/routes/settings.notifications.tsx | 124 +++++ docs/database-schema.md | 39 ++ packages/schemas/src/index.ts | 1 + packages/schemas/src/notifications/enums.ts | 27 + packages/schemas/src/notifications/index.ts | 3 + .../schemas/src/notifications/requests.ts | 34 ++ .../schemas/src/notifications/responses.ts | 47 ++ .../20261027120000_notifications.sql | 279 ++++++++++ 25 files changed, 2661 insertions(+), 2 deletions(-) create mode 100644 api/src/routes/notifications.ts create mode 100644 api/src/services/notification.service.ts create mode 100644 api/tests/notifications.test.ts create mode 100644 app/public/assets/notifications/notifications-empty.png create mode 100644 app/src/components/__tests__/notificationSettings.test.tsx create mode 100644 app/src/components/notifications/NotificationBell.tsx create mode 100644 app/src/components/notifications/NotificationPanel.tsx create mode 100644 app/src/components/notifications/NotificationRow.tsx create mode 100644 app/src/components/notifications/__tests__/NotificationPanel.test.tsx create mode 100644 app/src/lib/__tests__/notifications.test.ts create mode 100644 app/src/lib/api/notifications.ts create mode 100644 app/src/lib/notifications.ts create mode 100644 app/src/routes/settings.notifications.tsx create mode 100644 packages/schemas/src/notifications/enums.ts create mode 100644 packages/schemas/src/notifications/index.ts create mode 100644 packages/schemas/src/notifications/requests.ts create mode 100644 packages/schemas/src/notifications/responses.ts create mode 100644 supabase/migrations/20261027120000_notifications.sql diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 804b538d..5636e8ea 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -482,6 +482,139 @@ export type Database = { }, ] } + notification_preferences: { + Row: { + channel: Database["public"]["Enums"]["notification_channel"] + enabled: boolean + type: string + user_id: string + } + Insert: { + channel?: Database["public"]["Enums"]["notification_channel"] + enabled: boolean + type: string + user_id: string + } + Update: { + channel?: Database["public"]["Enums"]["notification_channel"] + enabled?: boolean + type?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "notification_preferences_type_fkey" + columns: ["type"] + isOneToOne: false + referencedRelation: "notification_types" + referencedColumns: ["type"] + }, + { + foreignKeyName: "notification_preferences_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "dashboard_members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "notification_preferences_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + notification_types: { + Row: { + category: Database["public"]["Enums"]["notification_category"] + default_enabled: boolean + type: string + } + Insert: { + category: Database["public"]["Enums"]["notification_category"] + default_enabled: boolean + type: string + } + Update: { + category?: Database["public"]["Enums"]["notification_category"] + default_enabled?: boolean + type?: string + } + Relationships: [] + } + notifications: { + Row: { + actor_id: string | null + data: Json + group_count: number + group_key: string | null + id: string + occurred_at: string + read_at: string | null + recipient_id: string + type: string + } + Insert: { + actor_id?: string | null + data?: Json + group_count?: number + group_key?: string | null + id?: string + occurred_at?: string + read_at?: string | null + recipient_id: string + type: string + } + Update: { + actor_id?: string | null + data?: Json + group_count?: number + group_key?: string | null + id?: string + occurred_at?: string + read_at?: string | null + recipient_id?: string + type?: string + } + Relationships: [ + { + foreignKeyName: "notifications_actor_id_fkey" + columns: ["actor_id"] + isOneToOne: false + referencedRelation: "dashboard_members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "notifications_actor_id_fkey" + columns: ["actor_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "notifications_recipient_id_fkey" + columns: ["recipient_id"] + isOneToOne: false + referencedRelation: "dashboard_members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "notifications_recipient_id_fkey" + columns: ["recipient_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "notifications_type_fkey" + columns: ["type"] + isOneToOne: false + referencedRelation: "notification_types" + referencedColumns: ["type"] + }, + ] + } objective_revision_edges: { Row: { from_node_id: string @@ -1471,6 +1604,16 @@ export type Database = { title: string }[] } + notify: { + Args: { + p_actor_id?: string + p_data?: Json + p_group_key?: string + p_recipient_id: string + p_type: string + } + Returns: undefined + } objective_closure: { Args: { p_targets: string[] } Returns: { @@ -1559,6 +1702,12 @@ export type Database = { edge_type: "prerequisite" | "related" knowledge_type: "theoretical" | "practical" node_status: "draft" | "published" | "archived" + notification_category: + | "guide_statuses" + | "variant_statuses" + | "community_engagement" + | "review_workflows" + notification_channel: "in_app" objective_revision_status: "draft" | "published" review_outcome: "approved" | "rejected" revision_status: "draft" | "submitted" @@ -1726,6 +1875,13 @@ export const Constants = { edge_type: ["prerequisite", "related"], knowledge_type: ["theoretical", "practical"], node_status: ["draft", "published", "archived"], + notification_category: [ + "guide_statuses", + "variant_statuses", + "community_engagement", + "review_workflows", + ], + notification_channel: ["in_app"], objective_revision_status: ["draft", "published"], review_outcome: ["approved", "rejected"], revision_status: ["draft", "submitted"], diff --git a/api/src/index.ts b/api/src/index.ts index 48ea9e89..70962576 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -33,6 +33,7 @@ import { reviewsRouter } from "./routes/reviews"; import { mediaRouter } from "./routes/media"; import { searchRouter } from "./routes/search"; import { dashboardRouter } from "./routes/dashboard"; +import { notificationsRouter } from "./routes/notifications"; let specHandler: MiddlewareHandler | undefined; const openApiHandler: MiddlewareHandler = (c, next) => { @@ -63,7 +64,8 @@ const app = new Hono() .route("/reviews", reviewsRouter) .route("/media", mediaRouter) .route("/search", searchRouter) - .route("/dashboard", dashboardRouter); + .route("/dashboard", dashboardRouter) + .route("/notifications", notificationsRouter); // Services throw ServiceError to signal HTTP-meaningful failures; map them to // JSON here so handlers stay free of repeated `if (error) return c.json(...)`. diff --git a/api/src/routes/notifications.ts b/api/src/routes/notifications.ts new file mode 100644 index 00000000..b0a61474 --- /dev/null +++ b/api/src/routes/notifications.ts @@ -0,0 +1,164 @@ +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import type { HonoEnv } from "../types"; +import { requireUser } from "../middleware/auth.middleware"; +import { rateLimitMiddleware } from "../middleware/rate-limit.middleware"; +import { CONTRIBUTION } from "../middleware/rateLimits"; +import { + countUnreadNotifications, + listNotificationPreferences, + listNotifications, + markAllNotificationsRead, + markNotificationRead, + setNotificationPreference, +} from "../services/notification.service"; +import { jsonContent, errorResponses, validate } from "../lib/openapi"; +import { + markAllNotificationsReadSchema, + notificationIdParamSchema, + notificationPreferencesResponseSchema, + notificationsQuerySchema, + notificationsResponseSchema, + notificationTypeParamSchema, + successResponseSchema, + unreadNotificationCountResponseSchema, + updateNotificationPreferenceSchema, +} from "@bluelearn/schemas"; + +export const notificationsRouter = new Hono() + .get( + "/", + describeRoute({ + tags: ["notifications"], + summary: "List your notifications, newest first", + responses: { + 200: jsonContent(notificationsResponseSchema, "One page"), + ...errorResponses(400, 401), + }, + }), + requireUser, + validate("query", notificationsQuerySchema), + async (c) => { + const page = await listNotifications( + c.get("supabase"), + c.get("user").id, + c.req.valid("query") + ); + return c.json(page, 200); + } + ) + + .get( + "/unread-count", + describeRoute({ + tags: ["notifications"], + summary: "Count your unread notifications", + responses: { + 200: jsonContent(unreadNotificationCountResponseSchema, "Count"), + ...errorResponses(401), + }, + }), + requireUser, + async (c) => { + const count = await countUnreadNotifications( + c.get("supabase"), + c.get("user").id + ); + return c.json({ count }, 200); + } + ) + + .post( + "/read-all", + describeRoute({ + tags: ["notifications"], + summary: "Mark your unread notifications read", + responses: { + 200: jsonContent(successResponseSchema, "Marked read"), + ...errorResponses(400, 401), + }, + }), + requireUser, + validate("json", markAllNotificationsReadSchema), + async (c) => { + await markAllNotificationsRead( + c.get("supabase"), + c.get("user").id, + c.req.valid("json") + ); + return c.json({ success: true }, 200); + } + ) + + .post( + "/:id/read", + describeRoute({ + tags: ["notifications"], + summary: "Mark one notification read", + responses: { + 200: jsonContent(successResponseSchema, "Marked read"), + ...errorResponses(400, 401, 404), + }, + }), + requireUser, + validate("param", notificationIdParamSchema), + async (c) => { + await markNotificationRead( + c.get("supabase"), + c.get("user").id, + c.req.valid("param").id + ); + return c.json({ success: true }, 200); + } + ) + + .get( + "/preferences", + describeRoute({ + tags: ["notifications"], + summary: "List your notification preferences", + responses: { + 200: jsonContent( + notificationPreferencesResponseSchema, + "One entry per notification type" + ), + ...errorResponses(401), + }, + }), + requireUser, + async (c) => { + const preferences = await listNotificationPreferences( + c.get("supabase"), + c.get("user").id + ); + return c.json({ preferences }, 200); + } + ) + + .put( + "/preferences/:type", + describeRoute({ + tags: ["notifications"], + summary: "Turn one notification type on or off", + responses: { + 200: jsonContent(successResponseSchema, "Saved"), + ...errorResponses(400, 401, 404), + }, + }), + requireUser, + rateLimitMiddleware({ + ...CONTRIBUTION, + bucket: "notification-preferences", + }), + validate("param", notificationTypeParamSchema), + validate("json", updateNotificationPreferenceSchema), + async (c) => { + await setNotificationPreference( + c.get("supabase"), + c.get("user").id, + c.req.valid("param").type, + c.req.valid("json").enabled + ); + return c.json({ success: true }, 200); + } + ); diff --git a/api/src/services/notification.service.ts b/api/src/services/notification.service.ts new file mode 100644 index 00000000..f3d00f7f --- /dev/null +++ b/api/src/services/notification.service.ts @@ -0,0 +1,209 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { + MarkAllNotificationsRead, + Notification, + NotificationPreference, + NotificationType, + NotificationsQuery, +} from "@bluelearn/schemas"; +import type { Database } from "../database.types"; +import { ServiceError } from "../lib/service-error"; + +// Every query runs on the caller's client, so RLS limits the rows to the +// caller's own notifications and preferences. public.notify() is the only +// writer of notifications; this service only reads them and sets read_at. + +type DB = SupabaseClient; + +const NOTIFICATION_COLUMNS = ` + id, + type, + group_count, + data, + occurred_at, + read_at, + notification_types!inner ( category ), + actor:profiles!notifications_actor_id_fkey ( id, username ) +`; + +export async function listNotifications( + supabase: DB, + userId: string, + query: NotificationsQuery +) { + const first = (query.page - 1) * query.limit; + + let request = supabase + .from("notifications") + .select(NOTIFICATION_COLUMNS) + .eq("recipient_id", userId) + .order("occurred_at", { ascending: false }) + .order("id", { ascending: false }); + + if (query.filter === "unread") request = request.is("read_at", null); + if (query.category) + request = request.eq("notification_types.category", query.category); + + // Read one extra row to learn whether another page exists. + const { data, error } = await request.range(first, first + query.limit); + + if (error) { + console.error(error); + throw new ServiceError("Failed to load notifications", 500); + } + + const notifications: Notification[] = data + .slice(0, query.limit) + .map((row) => ({ + id: row.id, + type: row.type as NotificationType, + category: row.notification_types.category, + actor: row.actor, + count: row.group_count, + data: row.data as Record, + occurred_at: row.occurred_at, + read_at: row.read_at, + })); + + return { notifications, has_more: data.length > query.limit }; +} + +export async function countUnreadNotifications(supabase: DB, userId: string) { + const { count, error } = await supabase + .from("notifications") + .select("id", { count: "exact", head: true }) + .eq("recipient_id", userId) + .is("read_at", null); + + if (error) { + console.error(error); + throw new ServiceError("Failed to count notifications", 500); + } + + return count ?? 0; +} + +// Marking a notification that is already read keeps its first read time. +export async function markNotificationRead( + supabase: DB, + userId: string, + id: string +) { + const { data: marked, error } = await supabase + .from("notifications") + .update({ read_at: new Date().toISOString() }) + .eq("id", id) + .eq("recipient_id", userId) + .is("read_at", null) + .select("id"); + + if (error) { + console.error(error); + throw new ServiceError("Failed to mark the notification read", 500); + } + + if (marked.length > 0) return; + + const { data: existing, error: lookupError } = await supabase + .from("notifications") + .select("id") + .eq("id", id) + .eq("recipient_id", userId) + .maybeSingle(); + + if (lookupError) { + console.error(lookupError); + throw new ServiceError("Failed to mark the notification read", 500); + } + + if (!existing) throw new ServiceError("Notification not found", 404); +} + +export async function markAllNotificationsRead( + supabase: DB, + userId: string, + input: MarkAllNotificationsRead +) { + let request = supabase + .from("notifications") + .update({ read_at: new Date().toISOString() }) + .eq("recipient_id", userId) + .is("read_at", null); + + if (input.category) { + const { data: types, error: typesError } = await supabase + .from("notification_types") + .select("type") + .eq("category", input.category); + + if (typesError) { + console.error(typesError); + throw new ServiceError("Failed to mark notifications read", 500); + } + + request = request.in( + "type", + types.map((row) => row.type) + ); + } + + const { error } = await request; + + if (error) { + console.error(error); + throw new ServiceError("Failed to mark notifications read", 500); + } +} + +// Every registered type, with the caller's choice or the type default. +export async function listNotificationPreferences( + supabase: DB, + userId: string +): Promise { + const [types, saved] = await Promise.all([ + supabase + .from("notification_types") + .select("type, category, default_enabled") + .order("type"), + supabase + .from("notification_preferences") + .select("type, enabled") + .eq("user_id", userId) + .eq("channel", "in_app"), + ]); + + if (types.error || saved.error) { + console.error(types.error ?? saved.error); + throw new ServiceError("Failed to load notification preferences", 500); + } + + const savedByType = new Map(saved.data.map((row) => [row.type, row.enabled])); + + return types.data.map((row) => ({ + type: row.type as NotificationType, + category: row.category, + enabled: savedByType.get(row.type) ?? row.default_enabled, + })); +} + +export async function setNotificationPreference( + supabase: DB, + userId: string, + type: NotificationType, + enabled: boolean +) { + const { error } = await supabase + .from("notification_preferences") + .upsert( + { user_id: userId, type, channel: "in_app", enabled }, + { onConflict: "user_id,type,channel" } + ); + + if (error) { + // 23503: the type is not in public.notification_types. + if (error.code === "23503") + throw new ServiceError("Unknown notification type", 404); + console.error(error); + throw new ServiceError("Failed to save the notification preference", 500); + } +} diff --git a/api/tests/notifications.test.ts b/api/tests/notifications.test.ts new file mode 100644 index 00000000..e937bbbf --- /dev/null +++ b/api/tests/notifications.test.ts @@ -0,0 +1,517 @@ +import { describe, it, expect } from "vitest"; +import { createClient } from "@supabase/supabase-js"; +import app from "../src/index"; +import type { Database } from "../src/database.types"; +import { admin, auth, env, jsonAuth, makeUser } from "./helpers"; +import { + createGuide, + createGuideBase, + createGuideRevision, + createPublishedGuide, + createVote, +} from "./factories/guides"; +import { + createGuideReviewCase, + createPanelMember, + createReviewCase, + createReviewPanel, +} from "./factories/reviews"; +import { expectToMatchSpec } from "./openapi"; + +type ListBody = { + notifications: Array<{ + id: string; + type: string; + category: string; + actor: { id: string; username: string } | null; + count: number; + data: Record; + read_at: string | null; + }>; + has_more: boolean; +}; + +async function list(token: string, query = "") { + const res = await app.request(`/notifications${query}`, auth(token), env); + expect(res.status).toBe(200); + return (await res.json()) as ListBody; +} + +async function unreadCount(token: string) { + const res = await app.request( + "/notifications/unread-count", + auth(token), + env + ); + expect(res.status).toBe(200); + return ((await res.json()) as { count: number }).count; +} + +// A guide revision by `authorId` under review by a one-seat panel. The +// panelist's decision closes the case through close_review_panel. +async function seedCaseForAuthor(authorId: string, title: string) { + const panelist = await makeUser(); + const base = await createGuideBase(); + const guide = await createGuide(base.id); + const revision = await createGuideRevision(guide.id, { + title, + author_id: authorId, + }); + const reviewCase = await createReviewCase(authorId, { + case_type: "guide_publish", + status: "in_review", + }); + const panel = await createReviewPanel(reviewCase.id, { + target_seat_count: 1, + }); + await createPanelMember(panel.id, panelist.userId); + await createGuideReviewCase(reviewCase.id, revision.id); + return { reviewCase, panelist }; +} + +async function decide( + token: string, + caseId: string, + decision: "approved" | "rejected" +) { + const res = await app.request( + `/reviews/cases/${caseId}/decisions`, + jsonAuth( + token, + "POST", + decision === "approved" + ? { decision } + : { decision, notes: "Missing steps.", reasons: ["clarity_issue"] } + ), + env + ); + expect(res.status).toBe(200); +} + +// A client that acts as the user directly against PostgREST, the way a +// hostile browser could, without the API in between. +function userClient(token: string) { + return createClient( + env.SUPABASE_URL, + env.SUPABASE_PUBLISHABLE_KEY, + { + global: { headers: { Authorization: `Bearer ${token}` } }, + auth: { persistSession: false, autoRefreshToken: false }, + } + ); +} + +describe("guide_review_decision", () => { + it("tells the revision author when the panel approves", async () => { + const author = await makeUser(); + const { reviewCase, panelist } = await seedCaseForAuthor( + author.userId, + "How to speed" + ); + + await decide(panelist.token, reviewCase.id, "approved"); + + const body = await list(author.token); + expect(body.notifications).toHaveLength(1); + expect(body.notifications[0]).toMatchObject({ + type: "guide_review_decision", + category: "guide_statuses", + actor: null, + count: 1, + read_at: null, + data: { + case_id: reviewCase.id, + outcome: "approved", + title: "How to speed", + }, + }); + }); + + it("tells the author about a rejection too", async () => { + const author = await makeUser(); + const { reviewCase, panelist } = await seedCaseForAuthor( + author.userId, + "Topology" + ); + + await decide(panelist.token, reviewCase.id, "rejected"); + + const body = await list(author.token); + expect(body.notifications.map((n) => n.data.outcome)).toEqual(["rejected"]); + }); + + it("does not notify the panelist who decided", async () => { + const author = await makeUser(); + const { reviewCase, panelist } = await seedCaseForAuthor( + author.userId, + "Topology" + ); + + await decide(panelist.token, reviewCase.id, "approved"); + + expect((await list(panelist.token)).notifications).toEqual([]); + }); + + it("stays quiet while the case is only moving into review", async () => { + const author = await makeUser(); + const { reviewCase } = await seedCaseForAuthor(author.userId, "Topology"); + await admin + .from("review_cases") + .update({ status: "pending" }) + .eq("id", reviewCase.id) + .throwOnError(); + await admin + .from("review_cases") + .update({ status: "in_review" }) + .eq("id", reviewCase.id) + .throwOnError(); + + expect(await unreadCount(author.token)).toBe(0); + }); +}); + +describe("guide_upvotes", () => { + it("groups upvotes on one guide into one unread row", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ + authorId: author.userId, + title: "Sorting", + }); + const first = await makeUser(); + const second = await makeUser(); + + await createVote(first.userId, guide.id); + await createVote(second.userId, guide.id); + + const body = await list(author.token); + expect(body.notifications).toHaveLength(1); + expect(body.notifications[0]).toMatchObject({ + type: "guide_upvotes", + count: 2, + actor: { id: second.userId }, + data: { guide_id: guide.id, title: "Sorting", variant_slug: "main" }, + }); + }); + + it("keeps separate rows for separate guides", async () => { + const author = await makeUser(); + const one = await createPublishedGuide({ authorId: author.userId }); + const two = await createPublishedGuide({ authorId: author.userId }); + const voter = await makeUser(); + + await createVote(voter.userId, one.guide.id); + await createVote(voter.userId, two.guide.id); + + expect(await unreadCount(author.token)).toBe(2); + }); + + it("starts a new row after the author reads the group", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote((await makeUser()).userId, guide.id); + const [read] = (await list(author.token)).notifications; + + await app.request( + `/notifications/${read.id}/read`, + jsonAuth(author.token, "POST", {}), + env + ); + await createVote((await makeUser()).userId, guide.id); + + const body = await list(author.token); + expect(body.notifications).toHaveLength(2); + expect(body.notifications[0]).toMatchObject({ count: 1, read_at: null }); + expect(body.notifications[1].id).toBe(read.id); + }); + + it("ignores downvotes, self-votes, and re-saving an upvote", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + const voter = await makeUser(); + + await createVote(author.userId, guide.id); + await createVote(voter.userId, guide.id, { + direction: "down", + reason: "unclear", + }); + expect(await unreadCount(author.token)).toBe(0); + + await admin + .from("votes") + .update({ direction: "up", reason: null }) + .eq("voter_id", voter.userId) + .eq("guide_id", guide.id) + .throwOnError(); + await admin + .from("votes") + .update({ direction: "up", note: "still good" }) + .eq("voter_id", voter.userId) + .eq("guide_id", guide.id) + .throwOnError(); + + const body = await list(author.token); + expect(body.notifications.map((n) => n.count)).toEqual([1]); + }); +}); + +describe("GET /notifications", () => { + it("401s without a token", async () => { + const res = await app.request("/notifications", {}, env); + expect(res.status).toBe(401); + await expectToMatchSpec(res, "GET", "/notifications"); + }); + + it("matches the spec and pages newest first", async () => { + const author = await makeUser(); + const voter = await makeUser(); + const guides = []; + for (let i = 0; i < 3; i++) { + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote(voter.userId, guide.id); + guides.push(guide.id); + } + + const res = await app.request( + "/notifications?limit=2", + auth(author.token), + env + ); + await expectToMatchSpec(res, "GET", "/notifications"); + const first = (await res.json()) as ListBody; + const second = await list(author.token, "?limit=2&page=2"); + + expect(first.has_more).toBe(true); + expect(second.has_more).toBe(false); + expect( + [...first.notifications, ...second.notifications].map( + (n) => n.data.guide_id + ) + ).toEqual([...guides].reverse()); + }); + + it("filters by unread and by category", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote((await makeUser()).userId, guide.id); + const { reviewCase, panelist } = await seedCaseForAuthor( + author.userId, + "Topology" + ); + await decide(panelist.token, reviewCase.id, "approved"); + + const community = await list( + author.token, + "?category=community_engagement" + ); + expect(community.notifications.map((n) => n.type)).toEqual([ + "guide_upvotes", + ]); + + await app.request( + `/notifications/${community.notifications[0].id}/read`, + jsonAuth(author.token, "POST", {}), + env + ); + const unread = await list(author.token, "?filter=unread"); + expect(unread.notifications.map((n) => n.type)).toEqual([ + "guide_review_decision", + ]); + }); + + it("never shows another user's notifications", async () => { + const author = await makeUser(); + const stranger = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote((await makeUser()).userId, guide.id); + + expect((await list(stranger.token)).notifications).toEqual([]); + expect(await unreadCount(stranger.token)).toBe(0); + expect(await unreadCount(author.token)).toBe(1); + }); +}); + +describe("marking notifications read", () => { + it("404s on another user's notification and leaves it unread", async () => { + const author = await makeUser(); + const stranger = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote((await makeUser()).userId, guide.id); + const [target] = (await list(author.token)).notifications; + + const res = await app.request( + `/notifications/${target.id}/read`, + jsonAuth(stranger.token, "POST", {}), + env + ); + + expect(res.status).toBe(404); + await expectToMatchSpec(res, "POST", "/notifications/{id}/read"); + expect(await unreadCount(author.token)).toBe(1); + }); + + it("is idempotent and keeps the first read time", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote((await makeUser()).userId, guide.id); + const [target] = (await list(author.token)).notifications; + + const first = await app.request( + `/notifications/${target.id}/read`, + jsonAuth(author.token, "POST", {}), + env + ); + await expectToMatchSpec(first, "POST", "/notifications/{id}/read"); + const [afterFirst] = (await list(author.token)).notifications; + const second = await app.request( + `/notifications/${target.id}/read`, + jsonAuth(author.token, "POST", {}), + env + ); + const [afterSecond] = (await list(author.token)).notifications; + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(afterFirst.read_at).not.toBeNull(); + expect(afterSecond.read_at).toBe(afterFirst.read_at); + }); + + it("marks all read, or only one category", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote((await makeUser()).userId, guide.id); + const { reviewCase, panelist } = await seedCaseForAuthor( + author.userId, + "Topology" + ); + await decide(panelist.token, reviewCase.id, "approved"); + + const scoped = await app.request( + "/notifications/read-all", + jsonAuth(author.token, "POST", { category: "guide_statuses" }), + env + ); + await expectToMatchSpec(scoped, "POST", "/notifications/read-all"); + const unread = await list(author.token, "?filter=unread"); + expect(unread.notifications.map((n) => n.type)).toEqual(["guide_upvotes"]); + + await app.request( + "/notifications/read-all", + jsonAuth(author.token, "POST", {}), + env + ); + expect(await unreadCount(author.token)).toBe(0); + }); +}); + +describe("notification preferences", () => { + it("lists every type with the defaults from the design", async () => { + const user = await makeUser(); + + const res = await app.request( + "/notifications/preferences", + auth(user.token), + env + ); + await expectToMatchSpec(res, "GET", "/notifications/preferences"); + const { preferences } = (await res.json()) as { + preferences: Array<{ type: string; enabled: boolean }>; + }; + + expect( + Object.fromEntries(preferences.map((p) => [p.type, p.enabled])) + ).toEqual({ + guide_review_decision: true, + variant_created: true, + variant_review_decision: false, + variant_edited: false, + guide_upvotes: true, + guide_downvotes: false, + review_assignment: false, + review_deadline: true, + }); + }); + + it("stops a type once the user turns it off, and resumes it", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + + const off = await app.request( + "/notifications/preferences/guide_upvotes", + jsonAuth(author.token, "PUT", { enabled: false }), + env + ); + await expectToMatchSpec(off, "PUT", "/notifications/preferences/{type}"); + await createVote((await makeUser()).userId, guide.id); + expect(await unreadCount(author.token)).toBe(0); + + await app.request( + "/notifications/preferences/guide_upvotes", + jsonAuth(author.token, "PUT", { enabled: true }), + env + ); + await createVote((await makeUser()).userId, guide.id); + expect(await unreadCount(author.token)).toBe(1); + }); + + it("400s an unknown type", async () => { + const user = await makeUser(); + + const res = await app.request( + "/notifications/preferences/carrier_pigeon", + jsonAuth(user.token, "PUT", { enabled: true }), + env + ); + + expect(res.status).toBe(400); + }); +}); + +describe("database guards", () => { + it("refuses notify() to a signed-in user", async () => { + const attacker = await makeUser(); + const victim = await makeUser(); + + const { error } = await userClient(attacker.token).rpc("notify", { + p_recipient_id: victim.userId, + p_type: "guide_upvotes", + p_data: { title: "phishing" }, + }); + + expect(error?.code).toBe("42501"); + expect(await unreadCount(victim.token)).toBe(0); + }); + + it("lets a recipient change read_at and nothing else", async () => { + const author = await makeUser(); + const { guide } = await createPublishedGuide({ authorId: author.userId }); + await createVote((await makeUser()).userId, guide.id); + const [target] = (await list(author.token)).notifications; + const client = userClient(author.token); + + const forged = await client + .from("notifications") + .update({ data: { title: "forged" } }) + .eq("id", target.id); + const read = await client + .from("notifications") + .update({ read_at: new Date().toISOString() }) + .eq("id", target.id); + + expect(forged.error?.code).toBe("42501"); + expect(read.error).toBeNull(); + const [after] = (await list(author.token)).notifications; + expect(after.data.title).toBe(target.data.title); + expect(after.read_at).not.toBeNull(); + }); + + it("refuses an unknown type inside notify()", async () => { + const { userId } = await makeUser(); + + const { error } = await admin.rpc("notify", { + p_recipient_id: userId, + p_type: "carrier_pigeon", + }); + + expect(error?.message).toContain("Unknown notification type"); + }); +}); diff --git a/app/public/assets/notifications/notifications-empty.png b/app/public/assets/notifications/notifications-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..85ba930f46372bca9ff0d304b3311db4553bd90e GIT binary patch literal 69733 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 literal 0 HcmV?d00001 diff --git a/app/src/components/Navbar.tsx b/app/src/components/Navbar.tsx index 7c0d534c..aa8db31d 100644 --- a/app/src/components/Navbar.tsx +++ b/app/src/components/Navbar.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { Logo } from "@/components/Logo"; +import { NotificationBell } from "@/components/notifications/NotificationBell"; const navItems: Array<{ label: string; to: string }> = [ { label: "Browse", to: "/browse" }, @@ -136,6 +137,8 @@ export function Navbar() { )} + {session && } + {session ? ( /* Desktop Profile Dropdown */

diff --git a/app/src/components/__tests__/notificationSettings.test.tsx b/app/src/components/__tests__/notificationSettings.test.tsx new file mode 100644 index 00000000..ef137527 --- /dev/null +++ b/app/src/components/__tests__/notificationSettings.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComponentType } from "react"; +import { Route } from "@/routes/settings.notifications"; + +const { getNotificationPreferences, setNotificationPreference } = vi.hoisted( + () => ({ + getNotificationPreferences: vi.fn(), + setNotificationPreference: vi.fn(), + }) +); + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (options: unknown) => ({ options }), +})); + +vi.mock("@/lib/api/notifications", () => ({ + getNotificationPreferences, + setNotificationPreference, +})); + +const SettingsPage = Route.options.component as ComponentType; + +const preferences = [ + { type: "guide_review_decision", category: "guide_statuses", enabled: true }, + { type: "variant_created", category: "guide_statuses", enabled: true }, + { + type: "variant_review_decision", + category: "variant_statuses", + enabled: false, + }, + { type: "variant_edited", category: "variant_statuses", enabled: false }, + { type: "guide_upvotes", category: "community_engagement", enabled: true }, + { + type: "guide_downvotes", + category: "community_engagement", + enabled: false, + }, + { type: "review_assignment", category: "review_workflows", enabled: false }, + { type: "review_deadline", category: "review_workflows", enabled: true }, +]; + +const toggle = (label: string | RegExp) => + screen.getByRole("switch", { name: label }); + +describe("notification settings page", () => { + beforeEach(() => { + vi.clearAllMocks(); + getNotificationPreferences.mockResolvedValue(preferences); + setNotificationPreference.mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + }); + + it("enables only the types that are sent", async () => { + render(); + await waitFor(() => + expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) + ); + + expect(toggle("Guide review decision").hasAttribute("disabled")).toBe( + false + ); + for (const label of [ + "Variant created", + "Variant review decision", + "Variant edited", + "Downvotes", + "Assignments", + "Deadlines approaching", + ]) { + expect(toggle(new RegExp(label)).hasAttribute("disabled")).toBe(true); + } + expect(screen.getAllByText("Coming soon")).toHaveLength(6); + }); + + it("shows each saved choice", async () => { + render(); + await waitFor(() => + expect(toggle("Upvotes").getAttribute("aria-checked")).toBe("true") + ); + + expect(toggle(/Downvotes/).getAttribute("aria-checked")).toBe("false"); + expect(toggle(/Deadlines approaching/).getAttribute("aria-checked")).toBe( + "true" + ); + }); + + it("keeps a saved change", async () => { + render(); + await waitFor(() => + expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) + ); + + fireEvent.click(toggle("Upvotes")); + + await waitFor(() => + expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) + ); + expect(toggle("Upvotes").getAttribute("aria-checked")).toBe("false"); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("reverts a change the server refuses", async () => { + setNotificationPreference.mockRejectedValue(new Error("500")); + vi.spyOn(console, "error").mockImplementation(() => {}); + render(); + await waitFor(() => + expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) + ); + + fireEvent.click(toggle("Upvotes")); + + expect((await screen.findByRole("alert")).textContent).toContain( + "Could not save" + ); + expect(toggle("Upvotes").getAttribute("aria-checked")).toBe("true"); + }); +}); diff --git a/app/src/components/notifications/NotificationBell.tsx b/app/src/components/notifications/NotificationBell.tsx new file mode 100644 index 00000000..d3e7f143 --- /dev/null +++ b/app/src/components/notifications/NotificationBell.tsx @@ -0,0 +1,85 @@ +import { Bell } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { getUnreadNotificationCount } from "@/lib/api/notifications"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { NotificationPanel } from "@/components/notifications/NotificationPanel"; + +// The count updates once a minute and on window focus, so a new notification +// can take up to a minute to show. Push updates (a Durable Object or a queue +// consumer) when that delay is too slow or the poll load matters. +const POLL_MS = 60_000; + +export function NotificationBell() { + const [open, setOpen] = useState(false); + const [unread, setUnread] = useState(0); + + const refreshCount = useCallback(async (signal?: AbortSignal) => { + try { + setUnread(await getUnreadNotificationCount({ signal })); + } catch (error) { + // A failed poll keeps the last count. The next poll tries again. + if (!signal?.aborted) console.error(error); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + const poll = () => { + if (document.visibilityState === "visible") + void refreshCount(controller.signal); + }; + + poll(); + const timer = window.setInterval(poll, POLL_MS); + window.addEventListener("focus", poll); + + return () => { + controller.abort(); + window.clearInterval(timer); + window.removeEventListener("focus", poll); + }; + }, [refreshCount]); + + const label = + unread > 0 ? `Notifications, ${unread} unread` : "Notifications"; + + return ( + + + + + + + setOpen(false)} + /> + + + ); +} diff --git a/app/src/components/notifications/NotificationPanel.tsx b/app/src/components/notifications/NotificationPanel.tsx new file mode 100644 index 00000000..17bf2f76 --- /dev/null +++ b/app/src/components/notifications/NotificationPanel.tsx @@ -0,0 +1,264 @@ +import { Link, useNavigate } from "@tanstack/react-router"; +import { ChevronDown, Settings } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import type { + Notification, + NotificationCategory, + NotificationFilter, +} from "@bluelearn/schemas"; + +import { + listNotifications, + markAllNotificationsRead, + markNotificationRead, +} from "@/lib/api/notifications"; +import { notificationSections, renderNotification } from "@/lib/notifications"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { NotificationRow } from "@/components/notifications/NotificationRow"; + +type View = { + label: string; + filter: NotificationFilter; + category?: NotificationCategory; +}; + +const FILTER_VIEWS: Array = [ + { label: "All", filter: "all" }, + { label: "Unread", filter: "unread" }, +]; + +const CATEGORY_VIEWS: Array = notificationSections.map((section) => ({ + label: section.label, + filter: "all", + category: section.category, +})); + +type NotificationPanelProps = { + unread: number; + onUnreadChange: (unread: number) => void; + onRefreshUnread: () => Promise; + onNavigate: () => void; +}; + +export function NotificationPanel({ + unread, + onUnreadChange, + onRefreshUnread, + onNavigate, +}: NotificationPanelProps) { + const navigate = useNavigate(); + const [view, setView] = useState(FILTER_VIEWS[0]); + const [items, setItems] = useState>([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + + const load = useCallback( + async (nextPage: number, signal?: AbortSignal) => { + setLoading(true); + setFailed(false); + + try { + const result = await listNotifications( + { page: nextPage, filter: view.filter, category: view.category }, + { signal } + ); + // A row can move to the next page when a new event arrives, so drop + // repeats by id. + setItems((current) => { + const kept = nextPage === 1 ? [] : current; + const seen = new Set(kept.map((item) => item.id)); + return [ + ...kept, + ...result.notifications.filter((item) => !seen.has(item.id)), + ]; + }); + setPage(nextPage); + setHasMore(result.has_more); + } catch (error) { + if (signal?.aborted) return; + + console.error(error); + setFailed(true); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, + [view] + ); + + useEffect(() => { + const controller = new AbortController(); + void load(1, controller.signal); + return () => controller.abort(); + }, [load]); + + const open = async (item: Notification) => { + const target = renderNotification(item)?.target; + + if (!item.read_at) { + const readAt = new Date().toISOString(); + setItems((current) => + current.map((row) => + row.id === item.id ? { ...row, read_at: readAt } : row + ) + ); + onUnreadChange(Math.max(0, unread - 1)); + // The page opens even if this fails; the next count poll corrects the + // badge. + markNotificationRead(item.id).catch((error: unknown) => { + console.error(error); + }); + } + + if (target) { + onNavigate(); + await navigate(target); + } + }; + + const markAll = async () => { + try { + await markAllNotificationsRead(view.category); + } catch (error) { + console.error(error); + return; + } + + const readAt = new Date().toISOString(); + setItems((current) => + current.map((row) => (row.read_at ? row : { ...row, read_at: readAt })) + ); + await onRefreshUnread(); + }; + + const viewItem = (option: View) => ( + setView(option)} + > + {option.label} + + ); + + const shown = items.flatMap((item) => { + const rendered = renderNotification(item); + return rendered ? [{ item, rendered }] : []; + }); + const isEmpty = shown.length === 0 && !loading; + + return ( + <> +
+ + + + + + {FILTER_VIEWS.map(viewItem)} + + {CATEGORY_VIEWS.map(viewItem)} + + + + {unread > 0 && ( + + {unread} unread + + )} + +
+ {unread > 0 && ( + + )} + +
+
+ +
+ {failed && ( +
+

Could not load your notifications.

+ +
+ )} + + {!failed && isEmpty && ( +
+ +

+ There are no new notifications +

+
+ )} + + {!failed && !isEmpty && ( +
    + {shown.map(({ item, rendered }) => ( +
  • + open(item)} + /> +
  • + ))} +
+ )} + + {hasMore && !failed && ( +
+ +
+ )} +
+ + ); +} diff --git a/app/src/components/notifications/NotificationRow.tsx b/app/src/components/notifications/NotificationRow.tsx new file mode 100644 index 00000000..d88e6cd3 --- /dev/null +++ b/app/src/components/notifications/NotificationRow.tsx @@ -0,0 +1,65 @@ +import type { Notification } from "@bluelearn/schemas"; + +import type { NotificationView } from "@/lib/notifications"; +import { getAvatarUrl } from "@/lib/profile"; +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +const dateFormat = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +type NotificationRowProps = { + notification: Notification; + view: NotificationView; + onOpen: () => void; +}; + +export function NotificationRow({ + notification, + view, + onOpen, +}: NotificationRowProps) { + const { actor, occurred_at, read_at } = notification; + + return ( + + ); +} diff --git a/app/src/components/notifications/__tests__/NotificationPanel.test.tsx b/app/src/components/notifications/__tests__/NotificationPanel.test.tsx new file mode 100644 index 00000000..056a154e --- /dev/null +++ b/app/src/components/notifications/__tests__/NotificationPanel.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import type { ReactNode } from "react"; +import type { Notification } from "@bluelearn/schemas"; +import { NotificationPanel } from "@/components/notifications/NotificationPanel"; + +const { listNotifications, markNotificationRead, markAllNotificationsRead } = + vi.hoisted(() => ({ + listNotifications: vi.fn(), + markNotificationRead: vi.fn(), + markAllNotificationsRead: vi.fn(), + })); + +vi.mock("@/lib/api/notifications", () => ({ + listNotifications, + markNotificationRead, + markAllNotificationsRead, +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + children, + onClick, + }: { + children: ReactNode; + onClick?: () => void; + }) => ( +
+ {children} + + ), + useNavigate: () => () => Promise.resolve(), +})); + +const row = (overrides: Partial): Notification => ({ + id: crypto.randomUUID(), + type: "guide_review_decision", + category: "guide_statuses", + actor: null, + count: 1, + data: { outcome: "approved", title: "How to speed", case_id: "c1" }, + occurred_at: "2026-07-27T10:00:00Z", + read_at: null, + ...overrides, +}); + +// Holds the unread count the way NotificationBell does, so the test reads the +// count from the panel header. +function Harness({ initialUnread }: { initialUnread: number }) { + const [unread, setUnread] = useState(initialUnread); + return ( + { + setUnread(0); + return Promise.resolve(); + }} + onNavigate={() => {}} + /> + ); +} + +describe("NotificationPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + markNotificationRead.mockResolvedValue(undefined); + markAllNotificationsRead.mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + }); + + it("marks a row read when it is opened", async () => { + listNotifications.mockResolvedValue({ + notifications: [row({})], + has_more: false, + }); + render(); + + fireEvent.click(await screen.findByText("Guide approved")); + + expect(await screen.findByText("1 unread")).toBeTruthy(); + expect(screen.queryByLabelText("Unread")).toBeNull(); + }); + + it("does not lower the count when a read row is opened", async () => { + listNotifications.mockResolvedValue({ + notifications: [row({ read_at: "2026-07-28T10:00:00Z" })], + has_more: false, + }); + render(); + + fireEvent.click(await screen.findByText("Guide approved")); + + await waitFor(() => expect(screen.getByText("2 unread")).toBeTruthy()); + }); + + it("clears every unread marker with Mark all as read", async () => { + listNotifications.mockResolvedValue({ + notifications: [row({}), row({ data: { outcome: "rejected" } })], + has_more: false, + }); + render(); + expect(await screen.findAllByLabelText("Unread")).toHaveLength(2); + + fireEvent.click(screen.getByText("Mark all as read")); + + await waitFor(() => expect(screen.queryByLabelText("Unread")).toBeNull()); + expect(screen.queryByText(/unread$/)).toBeNull(); + }); + + it("shows the empty state when there is nothing to show", async () => { + listNotifications.mockResolvedValue({ notifications: [], has_more: false }); + render(); + + expect( + await screen.findByText("There are no new notifications") + ).toBeTruthy(); + }); + + it("skips types that have no renderer yet", async () => { + listNotifications.mockResolvedValue({ + notifications: [ + row({ type: "guide_downvotes", category: "community_engagement" }), + ], + has_more: false, + }); + render(); + + expect( + await screen.findByText("There are no new notifications") + ).toBeTruthy(); + }); + + it("offers a retry when loading fails", async () => { + listNotifications + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue({ notifications: [row({})], has_more: false }); + vi.spyOn(console, "error").mockImplementation(() => {}); + render(); + + fireEvent.click(await screen.findByText("Try again")); + + expect(await screen.findByText("Guide approved")).toBeTruthy(); + }); +}); diff --git a/app/src/components/sidebar/SettingsSidebar.tsx b/app/src/components/sidebar/SettingsSidebar.tsx index fda10332..b4f49cf3 100644 --- a/app/src/components/sidebar/SettingsSidebar.tsx +++ b/app/src/components/sidebar/SettingsSidebar.tsx @@ -1,5 +1,5 @@ import { Link, useRouterState } from "@tanstack/react-router"; -import { Palette, Settings, User } from "lucide-react"; +import { Bell, Palette, Settings, User } from "lucide-react"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/separator"; @@ -19,6 +19,11 @@ const items = [ to: "/settings/appearance", icon: Palette, }, + { + label: "Notifications", + to: "/settings/notifications", + icon: Bell, + }, ]; export const SettingsSidebar = () => { diff --git a/app/src/lib/__tests__/notifications.test.ts b/app/src/lib/__tests__/notifications.test.ts new file mode 100644 index 00000000..eef930ab --- /dev/null +++ b/app/src/lib/__tests__/notifications.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { Notification } from "@bluelearn/schemas"; +import { + isNotificationTypeLive, + notificationSections, + renderNotification, +} from "@/lib/notifications"; + +const base: Omit = { + id: "00000000-0000-4000-8000-000000000001", + actor: null, + count: 1, + occurred_at: "2026-07-27T10:00:00Z", + read_at: null, +}; + +const decision = (data: Record): Notification => ({ + ...base, + type: "guide_review_decision", + category: "guide_statuses", + data, +}); + +const upvotes = (count: number, data: Record) => + ({ + ...base, + type: "guide_upvotes", + category: "community_engagement", + actor: { id: "00000000-0000-4000-8000-000000000002", username: "mira" }, + count, + data, + }) satisfies Notification; + +describe("guide_review_decision", () => { + it("links an approval to its review case", () => { + const view = renderNotification( + decision({ outcome: "approved", title: "How to speed", case_id: "c1" }) + ); + + expect(view).toMatchObject({ + title: "Guide approved", + body: 'Your guide "How to speed" has been approved', + target: { to: "/review/$caseId", params: { caseId: "c1" } }, + }); + }); + + it("says a rejection is a rejection", () => { + const view = renderNotification( + decision({ outcome: "rejected", title: "Topology", case_id: "c1" }) + ); + + expect(view?.title).toBe("Guide rejected"); + expect(view?.body).toBe('Your guide "Topology" was not approved'); + }); + + it("has no link when the case id is missing", () => { + const view = renderNotification(decision({ outcome: "approved" })); + + expect(view?.target).toBeNull(); + expect(view?.body).toBe('Your guide "Untitled" has been approved'); + }); +}); + +describe("guide_upvotes", () => { + const data = { title: "Sorting", base_slug: "sorting", variant_slug: "main" }; + + it("names the one voter", () => { + const view = renderNotification(upvotes(1, data)); + + expect(view).toMatchObject({ + title: "New upvote", + body: '@mira upvoted "Sorting"', + target: { + to: "/guides/$slug/$variantSlug", + params: { slug: "sorting", variantSlug: "main" }, + }, + }); + }); + + it("counts the other voters, singular and plural", () => { + expect(renderNotification(upvotes(2, data))?.body).toBe( + '@mira and 1 other upvoted "Sorting"' + ); + expect(renderNotification(upvotes(5, data))).toMatchObject({ + title: "5 new upvotes", + body: '@mira and 4 others upvoted "Sorting"', + }); + }); + + it("has no link without both slugs", () => { + const view = renderNotification(upvotes(1, { title: "Sorting" })); + + expect(view?.target).toBeNull(); + }); +}); + +describe("registry", () => { + it("renders nothing for a type that is not sent yet", () => { + const view = renderNotification({ + ...base, + type: "guide_downvotes", + category: "community_engagement", + data: {}, + }); + + expect(view).toBeNull(); + expect(isNotificationTypeLive("guide_downvotes")).toBe(false); + expect(isNotificationTypeLive("guide_upvotes")).toBe(true); + }); + + it("lists every type in exactly one settings section", () => { + const types = notificationSections.flatMap((s) => + s.types.map((t) => t.type) + ); + + expect(new Set(types).size).toBe(types.length); + expect(types).toHaveLength(8); + }); +}); diff --git a/app/src/lib/api/notifications.ts b/app/src/lib/api/notifications.ts new file mode 100644 index 00000000..fef2b3e9 --- /dev/null +++ b/app/src/lib/api/notifications.ts @@ -0,0 +1,86 @@ +import type { + NotificationCategory, + NotificationFilter, + NotificationType, +} from "@bluelearn/schemas"; +import { client } from "@/lib/api/apiClient"; +import { assertOk } from "@/lib/api/apiHelpers"; + +const notifications = client.notifications; + +type FetchOptions = { signal?: AbortSignal }; + +export type NotificationsPageQuery = { + page?: number; + filter?: NotificationFilter; + category?: NotificationCategory; +}; + +export async function listNotifications( + { page = 1, filter = "all", category }: NotificationsPageQuery = {}, + { signal }: FetchOptions = {} +) { + const res = await notifications.$get( + { + query: { + page: String(page), + filter, + ...(category ? { category } : {}), + }, + }, + { init: { signal } } + ); + await assertOk(res); + + return res.json(); +} + +export async function getUnreadNotificationCount({ + signal, +}: FetchOptions = {}) { + const res = await notifications["unread-count"].$get(undefined, { + init: { signal }, + }); + await assertOk(res); + + const { count } = await res.json(); + return count; +} + +export async function markNotificationRead(id: string) { + const res = await notifications[":id"].read.$post({ param: { id } }); + await assertOk(res); +} + +// Without a category, every unread notification is marked read. +export async function markAllNotificationsRead( + category?: NotificationCategory +) { + const res = await notifications["read-all"].$post({ + json: category ? { category } : {}, + }); + await assertOk(res); +} + +export async function getNotificationPreferences({ + signal, +}: FetchOptions = {}) { + const res = await notifications.preferences.$get(undefined, { + init: { signal }, + }); + await assertOk(res); + + const { preferences } = await res.json(); + return preferences; +} + +export async function setNotificationPreference( + type: NotificationType, + enabled: boolean +) { + const res = await notifications.preferences[":type"].$put({ + param: { type }, + json: { enabled }, + }); + await assertOk(res); +} diff --git a/app/src/lib/notifications.ts b/app/src/lib/notifications.ts new file mode 100644 index 00000000..15d064d1 --- /dev/null +++ b/app/src/lib/notifications.ts @@ -0,0 +1,125 @@ +import { CircleCheck, CircleX, ThumbsUp } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { + Notification, + NotificationCategory, + NotificationType, +} from "@bluelearn/schemas"; + +// The app half of a notification type. The database half is a row in +// public.notification_types and a public.notify() call. A type without a +// renderer is not sent yet: the bell skips it and its setting is disabled. + +export type NotificationTarget = + | { to: "/review/$caseId"; params: { caseId: string } } + | { + to: "/guides/$slug/$variantSlug"; + params: { slug: string; variantSlug: string }; + }; + +export type NotificationView = { + title: string; + body: string; + // Null when the data no longer names a page to open. + target: NotificationTarget | null; + // Shown in place of the avatar when nobody acted, such as a panel outcome. + icon: LucideIcon; +}; + +type Renderer = (notification: Notification) => NotificationView; + +export const notificationRenderers: Partial< + Record +> = { + guide_review_decision: ({ data }) => { + const approved = data.outcome === "approved"; + const guideTitle = text(data.title) ?? "Untitled"; + const caseId = text(data.case_id); + + return { + title: approved ? "Guide approved" : "Guide rejected", + body: approved + ? `Your guide "${guideTitle}" has been approved` + : `Your guide "${guideTitle}" was not approved`, + target: caseId ? { to: "/review/$caseId", params: { caseId } } : null, + icon: approved ? CircleCheck : CircleX, + }; + }, + + guide_upvotes: ({ data, actor, count }) => { + const guideTitle = text(data.title) ?? "your guide"; + const slug = text(data.base_slug); + const variantSlug = text(data.variant_slug); + const who = actor ? `@${actor.username}` : "Someone"; + const others = count - 1; + const otherVoters = others === 1 ? "1 other" : `${others} others`; + + return { + title: count === 1 ? "New upvote" : `${count} new upvotes`, + body: + others === 0 + ? `${who} upvoted "${guideTitle}"` + : `${who} and ${otherVoters} upvoted "${guideTitle}"`, + target: + slug && variantSlug + ? { + to: "/guides/$slug/$variantSlug", + params: { slug, variantSlug }, + } + : null, + icon: ThumbsUp, + }; + }, +}; + +export function renderNotification(notification: Notification) { + return notificationRenderers[notification.type]?.(notification) ?? null; +} + +export function isNotificationTypeLive(type: NotificationType) { + return type in notificationRenderers; +} + +// Settings page order and wording, from the design in issue #531. +export const notificationSections: Array<{ + category: NotificationCategory; + label: string; + types: Array<{ type: NotificationType; label: string }>; +}> = [ + { + category: "guide_statuses", + label: "Guide statuses", + types: [ + { type: "guide_review_decision", label: "Guide review decision" }, + { type: "variant_created", label: "Variant created" }, + ], + }, + { + category: "variant_statuses", + label: "Variant statuses", + types: [ + { type: "variant_review_decision", label: "Variant review decision" }, + { type: "variant_edited", label: "Variant edited" }, + ], + }, + { + category: "community_engagement", + label: "Community engagement", + types: [ + { type: "guide_upvotes", label: "Upvotes" }, + { type: "guide_downvotes", label: "Downvotes" }, + ], + }, + { + category: "review_workflows", + label: "Review workflows", + types: [ + { type: "review_assignment", label: "Assignments" }, + { type: "review_deadline", label: "Deadlines approaching" }, + ], + }, +]; + +function text(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : null; +} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index b5fadd33..e939a2f0 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as GuidesIndexRouteImport } from './routes/guides/index' import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' import { Route as SubjectsSlugRouteImport } from './routes/subjects.$slug' import { Route as SettingsProfileRouteImport } from './routes/settings.profile' +import { Route as SettingsNotificationsRouteImport } from './routes/settings.notifications' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as SettingsAccountRouteImport } from './routes/settings.account' import { Route as ReviewCaseIdRouteImport } from './routes/review.$caseId' @@ -164,6 +165,11 @@ const SettingsProfileRoute = SettingsProfileRouteImport.update({ path: '/profile', getParentRoute: () => SettingsRoute, } as any) +const SettingsNotificationsRoute = SettingsNotificationsRouteImport.update({ + id: '/notifications', + path: '/notifications', + getParentRoute: () => SettingsRoute, +} as any) const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ id: '/appearance', path: '/appearance', @@ -274,6 +280,7 @@ export interface FileRoutesByFullPath { '/review/$caseId': typeof ReviewCaseIdRoute '/settings/account': typeof SettingsAccountRoute '/settings/appearance': typeof SettingsAppearanceRoute + '/settings/notifications': typeof SettingsNotificationsRoute '/settings/profile': typeof SettingsProfileRoute '/subjects/$slug': typeof SubjectsSlugRoute '/dashboard/': typeof DashboardIndexRoute @@ -308,6 +315,7 @@ export interface FileRoutesByTo { '/review/$caseId': typeof ReviewCaseIdRoute '/settings/account': typeof SettingsAccountRoute '/settings/appearance': typeof SettingsAppearanceRoute + '/settings/notifications': typeof SettingsNotificationsRoute '/settings/profile': typeof SettingsProfileRoute '/subjects/$slug': typeof SubjectsSlugRoute '/dashboard': typeof DashboardIndexRoute @@ -350,6 +358,7 @@ export interface FileRoutesById { '/review/$caseId': typeof ReviewCaseIdRoute '/settings/account': typeof SettingsAccountRoute '/settings/appearance': typeof SettingsAppearanceRoute + '/settings/notifications': typeof SettingsNotificationsRoute '/settings/profile': typeof SettingsProfileRoute '/subjects/$slug': typeof SubjectsSlugRoute '/dashboard/': typeof DashboardIndexRoute @@ -393,6 +402,7 @@ export interface FileRouteTypes { | '/review/$caseId' | '/settings/account' | '/settings/appearance' + | '/settings/notifications' | '/settings/profile' | '/subjects/$slug' | '/dashboard/' @@ -427,6 +437,7 @@ export interface FileRouteTypes { | '/review/$caseId' | '/settings/account' | '/settings/appearance' + | '/settings/notifications' | '/settings/profile' | '/subjects/$slug' | '/dashboard' @@ -468,6 +479,7 @@ export interface FileRouteTypes { | '/review/$caseId' | '/settings/account' | '/settings/appearance' + | '/settings/notifications' | '/settings/profile' | '/subjects/$slug' | '/dashboard/' @@ -668,6 +680,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProfileRouteImport parentRoute: typeof SettingsRoute } + '/settings/notifications': { + id: '/settings/notifications' + path: '/notifications' + fullPath: '/settings/notifications' + preLoaderRoute: typeof SettingsNotificationsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/appearance': { id: '/settings/appearance' path: '/appearance' @@ -857,6 +876,7 @@ const ReviewRouteWithChildren = interface SettingsRouteChildren { SettingsAccountRoute: typeof SettingsAccountRoute SettingsAppearanceRoute: typeof SettingsAppearanceRoute + SettingsNotificationsRoute: typeof SettingsNotificationsRoute SettingsProfileRoute: typeof SettingsProfileRoute SettingsIndexRoute: typeof SettingsIndexRoute } @@ -864,6 +884,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsAccountRoute: SettingsAccountRoute, SettingsAppearanceRoute: SettingsAppearanceRoute, + SettingsNotificationsRoute: SettingsNotificationsRoute, SettingsProfileRoute: SettingsProfileRoute, SettingsIndexRoute: SettingsIndexRoute, } diff --git a/app/src/routes/settings.notifications.tsx b/app/src/routes/settings.notifications.tsx new file mode 100644 index 00000000..2dfe9121 --- /dev/null +++ b/app/src/routes/settings.notifications.tsx @@ -0,0 +1,124 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import type { NotificationType } from "@bluelearn/schemas"; + +import { + getNotificationPreferences, + setNotificationPreference, +} from "@/lib/api/notifications"; +import { + isNotificationTypeLive, + notificationSections, +} from "@/lib/notifications"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; + +export const Route = createFileRoute("/settings/notifications")({ + component: RouteComponent, +}); + +function RouteComponent() { + const [enabled, setEnabled] = useState + > | null>(null); + const [saving, setSaving] = useState>(new Set()); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + + getNotificationPreferences({ signal: controller.signal }) + .then((preferences) => + setEnabled( + Object.fromEntries(preferences.map((p) => [p.type, p.enabled])) + ) + ) + .catch((cause: unknown) => { + if (controller.signal.aborted) return; + console.error(cause); + setError("Could not load your notification settings."); + }); + + return () => controller.abort(); + }, []); + + const toggle = async (type: NotificationType, next: boolean) => { + setEnabled((current) => ({ ...current, [type]: next })); + setSaving((current) => new Set(current).add(type)); + setError(null); + + try { + await setNotificationPreference(type, next); + } catch (cause) { + console.error(cause); + setEnabled((current) => ({ ...current, [type]: !next })); + setError("Could not save that change. Try again."); + } finally { + setSaving((current) => { + const rest = new Set(current); + rest.delete(type); + return rest; + }); + } + }; + + return ( +
+
+

+ Notifications +

+
+ + + + {error && ( +

+ {error} +

+ )} + +
+ {notificationSections.map((section) => ( +
+

+ {section.label} +

+ +
    + {section.types.map(({ type, label }) => { + const live = isNotificationTypeLive(type); + const id = `notification-${type}`; + + return ( +
  • + + toggle(type, next)} + /> +
  • + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/docs/database-schema.md b/docs/database-schema.md index a2352a7e..7ffa25da 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -373,6 +373,45 @@ The manifest of object-storage assets, so a purge can delete media reliably inst - `revision_id`: FK to `guide_revisions`. - `asset_id`: FK to `media_assets`. The pair `(revision_id, asset_id)` is the primary key. +### `notification_types`, `notifications`, and `notification_preferences` + +In-app notifications. The function `public.notify()` is the only writer of `notifications`. It runs inside the transaction of the event, so a notification commits or rolls back with that event. + +`notification_types` is the registry. It has one row for each type: + +- `type`: primary key, for example `guide_upvotes`. +- `category`: one of `guide_statuses`, `variant_statuses`, `community_engagement`, `review_workflows`. The settings page and the inbox filter use it. +- `default_enabled`: applies until the user saves a preference. + +`notifications`: + +- `recipient_id`: FK to `profiles.id`. Only the recipient can read the row. +- `type`: FK to `notification_types`. +- `actor_id`: FK to `profiles.id`. It is null for system events, for example a panel outcome. +- `group_key`: events with the same key share one unread row. A null key gives each event its own row. +- `group_count`: the number of events in the row. +- `data`: ids, slugs, and titles that the app needs to show and link the row. Store only text that the recipient can already read. +- `occurred_at`: the time of the latest event in the row. +- `read_at`: null while the row is unread. + +`notification_preferences`: + +- `user_id`, `type`, `channel`: the composite primary key. `channel` is `in_app` now. Email adds a value to the enum. +- `enabled`: the choice of the user. A missing row means the type default applies. + +Constraints: + +- A partial unique index on `(recipient_id, type, group_key)` covers unread rows only. `notify()` adds to the open row. When the recipient reads that row, the next event starts a new row. +- `notify()` skips a recipient who is also the actor. It also skips a recipient who turned the type off. It raises an error for a type that is not in the registry. +- Clients cannot run `notify()`. A recipient can update only `read_at`. + +To add a type: + +1. Insert one row into `notification_types`. +2. Call `notify()` where the event happens. Use a trigger on the table that records the event, or call it from the RPC that owns the event. +3. Add the type to `notificationTypeSchema` in `packages/schemas`. +4. Add a renderer in `app/src/lib/notifications.ts`. The settings page enables the toggle of a type when the type has a renderer. + ### `review_cases`, `review_panels`, and `review_decisions` Verifier gates, post-publish re-reviews, disputes, and appeals all share the same shape: an odd-numbered random panel, a majority outcome, and an independent written justification per member. They share one root object (`review_cases`) plus one panel table and one decision table. Type-specific fields hang off the root in **specialized tables** (`guide_review_cases`, `re_review_cases`, `disputes`, `appeals`), each keyed 1:1 on `case_id`. The root carries what every workflow has in common (lifecycle, who opened it, timestamps); the satellite carries only what that one case type needs. diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index a45a06e0..a7f94a88 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -8,5 +8,6 @@ export * from "./identity"; export * from "./objectives"; export * from "./graph"; export * from "./media"; +export * from "./notifications"; export * from "./review"; export * from "./search"; diff --git a/packages/schemas/src/notifications/enums.ts b/packages/schemas/src/notifications/enums.ts new file mode 100644 index 00000000..fc825cee --- /dev/null +++ b/packages/schemas/src/notifications/enums.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +// Mirrors public.notification_category. +export const notificationCategorySchema = z.enum([ + "guide_statuses", + "variant_statuses", + "community_engagement", + "review_workflows", +]); + +// Mirrors the rows of public.notification_types. +export const notificationTypeSchema = z.enum([ + "guide_review_decision", + "variant_created", + "variant_review_decision", + "variant_edited", + "guide_upvotes", + "guide_downvotes", + "review_assignment", + "review_deadline", +]); + +export const notificationFilterSchema = z.enum(["all", "unread"]); + +export type NotificationCategory = z.infer; +export type NotificationType = z.infer; +export type NotificationFilter = z.infer; diff --git a/packages/schemas/src/notifications/index.ts b/packages/schemas/src/notifications/index.ts new file mode 100644 index 00000000..c812d120 --- /dev/null +++ b/packages/schemas/src/notifications/index.ts @@ -0,0 +1,3 @@ +export * from "./enums"; +export * from "./requests"; +export * from "./responses"; diff --git a/packages/schemas/src/notifications/requests.ts b/packages/schemas/src/notifications/requests.ts new file mode 100644 index 00000000..1638b6d1 --- /dev/null +++ b/packages/schemas/src/notifications/requests.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { paginationSchema } from "../pagination"; +import { + notificationCategorySchema, + notificationFilterSchema, + notificationTypeSchema, +} from "./enums"; + +export const notificationsQuerySchema = paginationSchema.extend({ + filter: notificationFilterSchema.default("all"), + category: notificationCategorySchema.optional(), +}); + +export const notificationIdParamSchema = z.object({ + id: z.uuid(), +}); + +// Without a category, every unread notification is marked read. +export const markAllNotificationsReadSchema = z.object({ + category: notificationCategorySchema.optional(), +}); + +export const notificationTypeParamSchema = z.object({ + type: notificationTypeSchema, +}); + +export const updateNotificationPreferenceSchema = z.object({ + enabled: z.boolean(), +}); + +export type NotificationsQuery = z.infer; +export type MarkAllNotificationsRead = z.infer< + typeof markAllNotificationsReadSchema +>; diff --git a/packages/schemas/src/notifications/responses.ts b/packages/schemas/src/notifications/responses.ts new file mode 100644 index 00000000..8b021992 --- /dev/null +++ b/packages/schemas/src/notifications/responses.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { notificationCategorySchema, notificationTypeSchema } from "./enums"; + +export const notificationActorSchema = z.object({ + id: z.uuid(), + username: z.string(), +}); + +export const notificationSchema = z.object({ + id: z.uuid(), + type: notificationTypeSchema, + category: notificationCategorySchema, + // Null for system events, such as a panel outcome. + actor: notificationActorSchema.nullable(), + // How many events the row groups, for example 5 upvotes on one guide. + count: z.number().int().min(1), + // Each type sets its own fields. The app reads them in its renderer. + data: z.record(z.string(), z.unknown()), + occurred_at: z.iso.datetime({ offset: true }), + read_at: z.iso.datetime({ offset: true }).nullable(), +}); + +export const notificationsResponseSchema = z.strictObject({ + notifications: z.array(notificationSchema), + has_more: z.boolean(), +}); + +export const unreadNotificationCountResponseSchema = z.strictObject({ + count: z.number().int().min(0), +}); + +export const notificationPreferenceSchema = z.object({ + type: notificationTypeSchema, + category: notificationCategorySchema, + enabled: z.boolean(), +}); + +export const notificationPreferencesResponseSchema = z.strictObject({ + preferences: z.array(notificationPreferenceSchema), +}); + +export type NotificationActor = z.infer; +export type Notification = z.infer; +export type NotificationsResponse = z.infer; +export type NotificationPreference = z.infer< + typeof notificationPreferenceSchema +>; diff --git a/supabase/migrations/20261027120000_notifications.sql b/supabase/migrations/20261027120000_notifications.sql new file mode 100644 index 00000000..60caf568 --- /dev/null +++ b/supabase/migrations/20261027120000_notifications.sql @@ -0,0 +1,279 @@ +-- In-app notifications. +-- +-- public.notify() is the only writer. It runs inside the transaction that +-- caused the event, so a notification commits or rolls back with its event. +-- To add a notification type, follow "To add a type" in +-- docs/database-schema.md. + +create type public.notification_category as enum ( + 'guide_statuses', + 'variant_statuses', + 'community_engagement', + 'review_workflows' +); + +-- Preferences are kept per channel. Email adds a value here. +create type public.notification_channel as enum ('in_app'); + +create table public.notification_types ( + type text primary key, + category public.notification_category not null, + -- Applies until the user saves a preference for the type. + default_enabled boolean not null +); + +insert into public.notification_types (type, category, default_enabled) values + ('guide_review_decision', 'guide_statuses', true), + ('variant_created', 'guide_statuses', true), + ('variant_review_decision', 'variant_statuses', false), + ('variant_edited', 'variant_statuses', false), + ('guide_upvotes', 'community_engagement', true), + ('guide_downvotes', 'community_engagement', false), + ('review_assignment', 'review_workflows', false), + ('review_deadline', 'review_workflows', true); + +create table public.notifications ( + id uuid primary key default gen_random_uuid(), + recipient_id uuid not null references public.profiles (id) on delete cascade, + type text not null references public.notification_types (type) on delete cascade, + -- Null for system events, such as a panel outcome. + actor_id uuid references public.profiles (id) on delete set null, + -- Events with the same key share one unread row and raise its count. + -- A null key gives every event its own row. + group_key text, + group_count integer not null default 1, + -- Ids, slugs and titles that the app needs to show and link the row. + -- Store only text that the recipient can already read. + data jsonb not null default '{}'::jsonb, + -- The time of the latest event in the row. + occurred_at timestamptz not null default now(), + read_at timestamptz, + constraint notifications_group_count_positive check (group_count > 0) +); + +-- One open row per group. Reading the row closes the group, so the next event +-- starts a new row. +create unique index notifications_open_group_idx + on public.notifications (recipient_id, type, group_key) + where read_at is null and group_key is not null; + +create index notifications_recipient_occurred_idx + on public.notifications (recipient_id, occurred_at desc); + +create index notifications_recipient_unread_idx + on public.notifications (recipient_id) + where read_at is null; + +-- A missing row means the type default applies. +create table public.notification_preferences ( + user_id uuid not null references public.profiles (id) on delete cascade, + type text not null references public.notification_types (type) on delete cascade, + channel public.notification_channel not null default 'in_app', + enabled boolean not null, + primary key (user_id, type, channel) +); + +create or replace function public.notify( + p_recipient_id uuid, + p_type text, + p_actor_id uuid default null, + p_data jsonb default '{}'::jsonb, + p_group_key text default null +) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_enabled boolean; +begin + -- Nobody hears about their own actions. + if p_recipient_id is null or p_recipient_id = p_actor_id then + return; + end if; + + select coalesce(p.enabled, t.default_enabled) + into v_enabled + from public.notification_types t + left join public.notification_preferences p + on p.type = t.type + and p.user_id = p_recipient_id + and p.channel = 'in_app' + where t.type = p_type; + + if not found then + raise exception 'Unknown notification type: %', p_type + using errcode = '22023'; + end if; + if not v_enabled then + return; + end if; + + insert into public.notifications + (recipient_id, type, actor_id, group_key, data) + values + (p_recipient_id, p_type, p_actor_id, p_group_key, coalesce(p_data, '{}'::jsonb)) + on conflict (recipient_id, type, group_key) + where read_at is null and group_key is not null + do update set + group_count = public.notifications.group_count + 1, + actor_id = excluded.actor_id, + data = excluded.data, + occurred_at = now(); +end; +$$; + +-- Only database code and the service role may notify. A client cannot use +-- this function to send a notification to another user. +revoke execute on function public.notify(uuid, text, uuid, jsonb, text) + from public, anon, authenticated; +grant execute on function public.notify(uuid, text, uuid, jsonb, text) + to service_role; + +-- guide_review_decision: the revision author hears the panel outcome. +-- close_review_panel sets the case status, so this trigger sees every outcome. +create or replace function public.notify_guide_review_decision() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_author_id uuid; + v_title text; +begin + select gr.author_id, gr.title + into v_author_id, v_title + from public.guide_review_cases grc + join public.guide_revisions gr on gr.id = grc.guide_revision_id + where grc.case_id = new.id; + + if not found then + return null; + end if; + + perform public.notify( + v_author_id, + 'guide_review_decision', + null, + jsonb_build_object( + 'case_id', new.id, + 'outcome', new.status, + 'title', v_title + ) + ); + return null; +end; +$$; + +revoke execute on function public.notify_guide_review_decision() + from public, anon, authenticated; + +create trigger review_cases_notify_guide_decision + after update of status on public.review_cases + for each row + when ( + new.status in ('approved', 'rejected') + and old.status is distinct from new.status + ) + execute function public.notify_guide_review_decision(); + +-- guide_upvotes: the guide author hears about new upvotes, one row per guide. +create or replace function public.notify_guide_upvote() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_author_id uuid; + v_title text; + v_base_slug text; + v_variant_slug text; +begin + -- Re-voting overwrites the row. Only a change to 'up' is a new upvote. + if tg_op = 'UPDATE' and old.direction = 'up' then + return null; + end if; + + select g.author_id, gr.title, b.slug, g.slug + into v_author_id, v_title, v_base_slug, v_variant_slug + from public.guides g + join public.guide_bases b on b.id = g.guide_base_id + left join public.guide_revisions gr on gr.id = g.current_revision_id + where g.id = new.guide_id; + + perform public.notify( + v_author_id, + 'guide_upvotes', + new.voter_id, + jsonb_build_object( + 'guide_id', new.guide_id, + 'title', v_title, + 'base_slug', v_base_slug, + 'variant_slug', v_variant_slug + ), + new.guide_id::text + ); + return null; +end; +$$; + +revoke execute on function public.notify_guide_upvote() + from public, anon, authenticated; + +create trigger votes_notify_guide_upvote + after insert or update of direction on public.votes + for each row + when (new.direction = 'up') + execute function public.notify_guide_upvote(); + +-- Row-level security policies +alter table public.notification_types enable row level security; +alter table public.notifications enable row level security; +alter table public.notification_preferences enable row level security; + +create policy "Anyone can view notification types" + on public.notification_types for select + using (true); + +create policy "Users can view their own notifications" + on public.notifications for select + to authenticated + using (recipient_id = (select auth.uid())); + +create policy "Users can mark their own notifications read" + on public.notifications for update + to authenticated + using (recipient_id = (select auth.uid())) + with check (recipient_id = (select auth.uid())); + +create policy "Users can view their own notification preferences" + on public.notification_preferences for select + to authenticated + using (user_id = (select auth.uid())); + +create policy "Users can add their own notification preferences" + on public.notification_preferences for insert + to authenticated + with check (user_id = (select auth.uid())); + +create policy "Users can change their own notification preferences" + on public.notification_preferences for update + to authenticated + using (user_id = (select auth.uid())) + with check (user_id = (select auth.uid())); + +-- New tables get every privilege by default (20260713120000). Set the grants +-- again: the registry is read-only, and read_at is the only column a +-- recipient can change. +revoke all on public.notification_types from anon, authenticated; +grant select on public.notification_types to anon, authenticated; + +revoke all on public.notifications from anon, authenticated; +grant select on public.notifications to authenticated; +grant update (read_at) on public.notifications to authenticated; + +revoke all on public.notification_preferences from anon, authenticated; +grant select, insert, update on public.notification_preferences to authenticated; From 3647e9b9fc3629fcb828bcf3459ca2a5b3684180 Mon Sep 17 00:00:00 2001 From: solarisael Date: Mon, 2 Nov 2026 16:11:39 -0300 Subject: [PATCH 2/2] feat(notifications): wire remaining in-app events Signed-off-by: solarisael --- api/src/database.types.ts | 41 + api/src/index.ts | 2 + api/src/services/review.service.ts | 9 + api/tests/notification-events.test.ts | 832 ++++++++++++++++++ api/tests/notifications.test.ts | 38 +- .../__tests__/notificationSettings.test.tsx | 58 +- .../__tests__/NotificationPanel.test.tsx | 50 +- app/src/lib/__tests__/notifications.test.ts | 288 ++++-- app/src/lib/notifications.ts | 166 +++- docs/database-schema.md | 23 +- .../20261027120000_notifications.sql | 4 - .../20261102120000_notification_events.sql | 300 +++++++ 12 files changed, 1579 insertions(+), 232 deletions(-) create mode 100644 api/tests/notification-events.test.ts create mode 100644 supabase/migrations/20261102120000_notification_events.sql diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 5636e8ea..40e2650b 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -1144,6 +1144,46 @@ export type Database = { }, ] } + review_deadline_notifications: { + Row: { + expires_at: string + member_id: string + seat_id: string + } + Insert: { + expires_at: string + member_id: string + seat_id: string + } + Update: { + expires_at?: string + member_id?: string + seat_id?: string + } + Relationships: [ + { + foreignKeyName: "review_deadline_notifications_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "dashboard_members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "review_deadline_notifications_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "review_deadline_notifications_seat_id_fkey" + columns: ["seat_id"] + isOneToOne: false + referencedRelation: "panel_members" + referencedColumns: ["id"] + }, + ] + } review_decision_reasons: { Row: { decision_id: string @@ -1614,6 +1654,7 @@ export type Database = { } Returns: undefined } + notify_review_deadlines: { Args: never; Returns: undefined } objective_closure: { Args: { p_targets: string[] } Returns: { diff --git a/api/src/index.ts b/api/src/index.ts index 70962576..5c1d26e1 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -13,6 +13,7 @@ import { ServiceError } from "./lib/service-error"; import { assemblePendingPanels, sweepExpiredReviewSeats, + notifyReviewDeadlines, } from "./services/review.service"; import { promoteAllCanonicals } from "./services/promotion.service"; import type { Database } from "./database.types"; @@ -88,6 +89,7 @@ async function scheduled(event: ScheduledController, env: Bindings) { await Promise.allSettled([ assemblePendingPanels(supabase), sweepExpiredReviewSeats(supabase), + notifyReviewDeadlines(supabase), ]); } if (event.cron === "0 */12 * * *") await promoteAllCanonicals(supabase); diff --git a/api/src/services/review.service.ts b/api/src/services/review.service.ts index 23ca1b6a..cf615429 100644 --- a/api/src/services/review.service.ts +++ b/api/src/services/review.service.ts @@ -660,3 +660,12 @@ export async function sweepExpiredReviewSeats(supabase: DB) { console.error(error); } } + +export async function notifyReviewDeadlines(supabase: DB) { + const { error } = await supabase.rpc("notify_review_deadlines"); + + if (error) { + console.error(error); + throw new ServiceError("Failed to send review deadline notifications", 500); + } +} diff --git a/api/tests/notification-events.test.ts b/api/tests/notification-events.test.ts new file mode 100644 index 00000000..77ede09e --- /dev/null +++ b/api/tests/notification-events.test.ts @@ -0,0 +1,832 @@ +import { createClient } from "@supabase/supabase-js"; +import type { NotificationsResponse } from "@bluelearn/schemas"; +import { describe, expect, it } from "vitest"; +import app from "../src/index"; +import type { Database } from "../src/database.types"; +import { admin, auth, env, jsonAuth, makeUser, type Insert } from "./helpers"; +import { + createGuide, + createGuideBase, + createGuideRevision, + createPublishedGuide, + createVote, +} from "./factories/guides"; +import { + createGuideReviewCase, + createPanelMember, + createReviewCase, + createReviewPanel, +} from "./factories/reviews"; + +async function notifications(token: string, type?: string) { + const res = await app.request("/notifications", auth(token), env); + + expect(res.status).toBe(200); + + const body = (await res.json()) as NotificationsResponse; + + return body.notifications.filter((row) => !type || row.type === type); +} + +async function preference(token: string, type: string, enabled: boolean) { + const res = await app.request( + `/notifications/preferences/${type}`, + jsonAuth(token, "PUT", { enabled }), + env + ); + + expect(res.status).toBe(200); +} + +async function read(token: string, id: string) { + const res = await app.request( + `/notifications/${id}/read`, + jsonAuth(token, "POST", {}), + env + ); + + expect(res.status).toBe(200); +} + +async function reviewRevision( + revisionId: string, + authorId: string, + caseType: Insert<"review_cases">["case_type"] = "guide_publish" +) { + const panelist = await makeUser(); + const reviewCase = await createReviewCase(authorId, { + case_type: caseType, + status: "in_review", + }); + await createGuideReviewCase(reviewCase.id, revisionId); + const panel = await createReviewPanel(reviewCase.id); + await createPanelMember(panel.id, panelist.userId); + + return { + reviewCase, + async decide(outcome: "approved" | "rejected" = "approved") { + const res = await app.request( + `/reviews/cases/${reviewCase.id}/decisions`, + jsonAuth( + panelist.token, + "POST", + outcome === "approved" + ? { decision: outcome } + : { + decision: outcome, + notes: "Missing required steps.", + reasons: ["clarity_issue"], + } + ), + env + ); + + expect(res.status).toBe(200); + + const { data } = await admin + .from("review_cases") + .select("status") + .eq("id", reviewCase.id) + .single() + .throwOnError(); + + expect(data?.status).toBe(outcome); + }, + }; +} + +async function deadlineSweep() { + await admin.rpc("notify_review_deadlines").throwOnError(); +} + +const hoursFromNow = (hours: number) => + new Date(Date.now() + hours * 60 * 60 * 1000).toISOString(); + +async function reviewSeat( + memberId: string, + options: { + caseStatus?: Insert<"review_cases">["status"]; + seatStatus?: Insert<"panel_members">["status"]; + expiresAt?: string | null; + closed?: boolean; + linked?: boolean; + } = {} +) { + const author = await makeUser(); + const reviewCase = await createReviewCase(author.userId, { + status: options.caseStatus ?? "in_review", + }); + const title = `Review ${crypto.randomUUID()}`; + + if (options.linked !== false) { + const base = await createGuideBase(); + const guide = await createGuide(base.id, { author_id: author.userId }); + const revision = await createGuideRevision(guide.id, { + author_id: author.userId, + title, + }); + await createGuideReviewCase(reviewCase.id, revision.id); + } + + const panel = await createReviewPanel(reviewCase.id, { + ...(options.closed + ? { closed_at: new Date().toISOString(), outcome: "approved" as const } + : {}), + }); + const seat = await createPanelMember(panel.id, memberId, { + status: options.seatStatus ?? "assigned", + expires_at: options.expiresAt ?? hoursFromNow(24), + }); + + // The insert trigger supplies an expiry for null; a later cleared expiry is + // the real nullable state the sweep must also exclude. + if (options.expiresAt === null) { + await admin + .from("panel_members") + .update({ expires_at: null }) + .eq("id", seat.id) + .throwOnError(); + } + + return { reviewCase, panel, seat, title }; +} + +describe("variant publication notifications", () => { + it("waits for first publication and uses its revision author, not the guide owner", async () => { + const canonicalAuthor = await makeUser(); + const owner = await makeUser(); + const revisionAuthor = await makeUser(); + const { base } = await createPublishedGuide({ + authorId: canonicalAuthor.userId, + }); + const variant = await createGuide(base.id, { + author_id: owner.userId, + slug: "alternative", + }); + const draft = await createGuideRevision(variant.id, { + author_id: revisionAuthor.userId, + title: "Alternative proof", + status: "draft", + }); + + await admin + .from("guide_revisions") + .update({ body: "Autosaved draft" }) + .eq("id", draft.id) + .throwOnError(); + + expect(await notifications(canonicalAuthor.token)).toEqual([]); + + await admin + .from("guide_revisions") + .update({ status: "submitted" }) + .eq("id", draft.id) + .throwOnError(); + const review = await reviewRevision(draft.id, revisionAuthor.userId); + + expect(await notifications(canonicalAuthor.token)).toEqual([]); + + await review.decide(); + + expect(await notifications(canonicalAuthor.token)).toEqual([ + expect.objectContaining({ + type: "variant_created", + actor: expect.objectContaining({ id: revisionAuthor.userId }), + count: 1, + data: { + guide_id: variant.id, + title: "Alternative proof", + base_slug: base.slug, + variant_slug: "alternative", + }, + }), + ]); + expect(await notifications(owner.token)).toEqual([]); + }); + + it("notifies the original variant author only for another author's published edit", async () => { + const canonicalAuthor = await makeUser(); + const owner = await makeUser(); + const editor = await makeUser(); + + await preference(owner.token, "variant_edited", true); + + const { base } = await createPublishedGuide({ + authorId: canonicalAuthor.userId, + }); + const variant = await createGuide(base.id, { + author_id: owner.userId, + slug: "variant", + }); + const first = await createGuideRevision(variant.id, { + author_id: owner.userId, + }); + const initialReview = await reviewRevision(first.id, owner.userId); + + await initialReview.decide(); + + expect(await notifications(owner.token, "variant_edited")).toEqual([]); + + const edit = await createGuideRevision(variant.id, { + author_id: editor.userId, + title: "Revised variant", + }); + + await admin + .from("guide_revisions") + .update({ body: "Not yet published" }) + .eq("id", edit.id) + .throwOnError(); + + expect(await notifications(owner.token, "variant_edited")).toEqual([]); + + const editReview = await reviewRevision( + edit.id, + editor.userId, + "guide_edit" + ); + + await editReview.decide(); + + const [notice] = await notifications(owner.token, "variant_edited"); + + expect(notice).toMatchObject({ + actor: { id: editor.userId }, + count: 1, + data: { + guide_id: variant.id, + title: "Revised variant", + base_slug: base.slug, + variant_slug: "variant", + }, + }); + + const ownEdit = await createGuideRevision(variant.id, { + author_id: owner.userId, + }); + const ownReview = await reviewRevision( + ownEdit.id, + owner.userId, + "guide_edit" + ); + + await ownReview.decide(); + + expect( + (await notifications(owner.token, "variant_edited")).map((n) => n.id) + ).toEqual([notice.id]); + expect( + await notifications(canonicalAuthor.token, "variant_created") + ).toHaveLength(1); + expect(await notifications(editor.token, "variant_edited")).toEqual([]); + }); + + it("suppresses first-publication notices when the revision author owns the canonical guide", async () => { + const author = await makeUser(); + const { base } = await createPublishedGuide({ authorId: author.userId }); + const variant = await createGuide(base.id, { author_id: author.userId }); + const revision = await createGuideRevision(variant.id, { + author_id: author.userId, + }); + const review = await reviewRevision(revision.id, author.userId); + + await review.decide(); + + expect(await notifications(author.token, "variant_created")).toEqual([]); + }); +}); + +describe("review decision type separation", () => { + it.each(["approved", "rejected"] as const)( + "routes %s variant decisions to the revision author only", + async (outcome) => { + const canonicalAuthor = await makeUser(); + const owner = await makeUser(); + const revisionAuthor = await makeUser(); + + await preference(revisionAuthor.token, "variant_review_decision", true); + + const { base } = await createPublishedGuide({ + authorId: canonicalAuthor.userId, + }); + const variant = await createGuide(base.id, { author_id: owner.userId }); + const revision = await createGuideRevision(variant.id, { + author_id: revisionAuthor.userId, + title: "Variant decision", + }); + const review = await reviewRevision(revision.id, revisionAuthor.userId); + + await review.decide(outcome); + + expect(await notifications(revisionAuthor.token)).toEqual([ + expect.objectContaining({ + type: "variant_review_decision", + actor: null, + data: { + case_id: review.reviewCase.id, + outcome, + title: revision.title, + }, + }), + ]); + expect(await notifications(owner.token)).toEqual([]); + expect( + await notifications(canonicalAuthor.token, "guide_review_decision") + ).toEqual([]); + } + ); + + it("keeps a first canonical submission on guide_review_decision even with no canonical pointer", async () => { + const author = await makeUser(); + const base = await createGuideBase(); + const guide = await createGuide(base.id, { author_id: author.userId }); + const revision = await createGuideRevision(guide.id, { + author_id: author.userId, + }); + const review = await reviewRevision(revision.id, author.userId); + + await review.decide(); + + expect(await notifications(author.token)).toEqual([ + expect.objectContaining({ + type: "guide_review_decision", + actor: null, + data: { + case_id: review.reviewCase.id, + outcome: "approved", + title: revision.title, + }, + }), + ]); + }); +}); + +describe("downvote events", () => { + it("requires opt-in, groups by guide independently of upvotes, ignores unchanged votes, and reopens after read", async () => { + const author = await makeUser(); + const first = await makeUser(); + const second = await makeUser(); + const { base, guide } = await createPublishedGuide({ + authorId: author.userId, + title: "Voting", + }); + + await createVote(first.userId, guide.id, { + direction: "down", + reason: "unclear", + }); + + expect(await notifications(author.token)).toEqual([]); + + await preference(author.token, "guide_downvotes", true); + await createVote(second.userId, guide.id, { + direction: "down", + reason: "unclear", + }); + await admin + .from("votes") + .update({ direction: "down", reason: "unclear" }) + .eq("voter_id", second.userId) + .eq("guide_id", guide.id) + .throwOnError(); + await createVote(author.userId, guide.id, { + direction: "down", + reason: "unclear", + }); + + const [down] = await notifications(author.token); + + expect(down).toMatchObject({ + type: "guide_downvotes", + count: 1, + actor: { id: second.userId }, + data: { + guide_id: guide.id, + title: "Voting", + base_slug: base.slug, + variant_slug: "main", + }, + }); + + await admin + .from("votes") + .update({ direction: "up", reason: null }) + .eq("voter_id", first.userId) + .eq("guide_id", guide.id) + .throwOnError(); + await admin + .from("votes") + .update({ direction: "down", reason: "unclear" }) + .eq("voter_id", first.userId) + .eq("guide_id", guide.id) + .throwOnError(); + + expect(await notifications(author.token, "guide_downvotes")).toEqual([ + expect.objectContaining({ + id: down.id, + count: 2, + actor: expect.objectContaining({ id: first.userId }), + }), + ]); + expect(await notifications(author.token, "guide_upvotes")).toEqual([ + expect.objectContaining({ count: 1 }), + ]); + + const another = await createPublishedGuide({ authorId: author.userId }); + await createVote(first.userId, another.guide.id, { + direction: "down", + reason: "unclear", + }); + await read(author.token, down.id); + await createVote((await makeUser()).userId, guide.id, { + direction: "down", + reason: "unclear", + }); + const rows = await notifications(author.token, "guide_downvotes"); + + expect(rows).toHaveLength(3); + expect(rows.find((n) => n.id === down.id)).toMatchObject({ + count: 2, + read_at: expect.any(String), + }); + expect(rows.filter((n) => n.read_at === null)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + count: 1, + data: expect.objectContaining({ guide_id: guide.id }), + }), + expect.objectContaining({ + count: 1, + data: expect.objectContaining({ guide_id: another.guide.id }), + }), + ]) + ); + }); +}); + +describe("review assignments", () => { + it("notifies an updated assignee once and gives them their own reminder at the same expiry", async () => { + const previous = await makeUser(); + const replacement = await makeUser(); + + await preference(previous.token, "review_assignment", true); + await preference(replacement.token, "review_assignment", true); + + const initial = await reviewSeat(previous.userId); + + await deadlineSweep(); + + const [previousReminder] = await notifications( + previous.token, + "review_deadline" + ); + + expect(previousReminder).toMatchObject({ + data: { seat_id: initial.seat.id, expires_at: initial.seat.expires_at }, + }); + + await read(previous.token, previousReminder.id); + + await admin + .from("panel_members") + .update({ + member_id: replacement.userId, + status: "assigned", + assigned_at: new Date().toISOString(), + expires_at: initial.seat.expires_at, + }) + .eq("id", initial.seat.id) + .throwOnError(); + + const expectedData = { + case_id: initial.reviewCase.id, + seat_id: initial.seat.id, + title: initial.title, + expires_at: initial.seat.expires_at, + }; + const [assignment] = await notifications( + replacement.token, + "review_assignment" + ); + + expect(assignment).toMatchObject({ + actor: null, + count: 1, + data: expectedData, + }); + + await read(replacement.token, assignment.id); + + await admin + .from("panel_members") + .update({ member_id: replacement.userId, status: "assigned" }) + .eq("id", initial.seat.id) + .throwOnError(); + await Promise.all([deadlineSweep(), deadlineSweep()]); + + expect(await notifications(replacement.token, "review_assignment")).toEqual( + [ + expect.objectContaining({ + id: assignment.id, + count: 1, + read_at: expect.any(String), + }), + ] + ); + expect(await notifications(replacement.token, "review_deadline")).toEqual([ + expect.objectContaining({ actor: null, count: 1, data: expectedData }), + ]); + expect( + await notifications(previous.token, "review_assignment") + ).toHaveLength(1); + expect(await notifications(previous.token, "review_deadline")).toEqual([ + expect.objectContaining({ + id: previousReminder.id, + count: 1, + read_at: expect.any(String), + }), + ]); + }); + + it("is opt-in at assignment time and includes newly inserted replacement seats", async () => { + const member = await makeUser(); + const initial = await reviewSeat(member.userId); + + expect(await notifications(member.token)).toEqual([]); + + await preference(member.token, "review_assignment", true); + + expect(await notifications(member.token)).toEqual([]); + + await admin + .from("panel_members") + .update({ status: "replaced" }) + .eq("id", initial.seat.id) + .throwOnError(); + + const replacementMember = await makeUser(); + await preference(replacementMember.token, "review_assignment", true); + const replacement = await createPanelMember( + initial.panel.id, + replacementMember.userId + ); + + expect(await notifications(replacementMember.token)).toEqual([ + expect.objectContaining({ + type: "review_assignment", + actor: null, + data: { + case_id: initial.reviewCase.id, + seat_id: replacement.id, + title: initial.title, + expires_at: replacement.expires_at, + }, + }), + ]); + + const pending = await reviewSeat(member.userId, { + caseStatus: "pending", + linked: false, + }); + + expect(await notifications(member.token)).toEqual([ + expect.objectContaining({ + type: "review_assignment", + actor: null, + data: { + case_id: pending.reviewCase.id, + seat_id: pending.seat.id, + title: null, + expires_at: pending.seat.expires_at, + }, + }), + ]); + }); + + it.each([ + { seatStatus: "completed" as const }, + { seatStatus: "replaced" as const }, + { seatStatus: "recused" as const }, + { closed: true }, + { caseStatus: "approved" as const }, + { caseStatus: "rejected" as const }, + ])("excludes ineligible assignment %j", async (options) => { + const member = await makeUser(); + await preference(member.token, "review_assignment", true); + + await reviewSeat(member.userId, options); + + expect(await notifications(member.token)).toEqual([]); + }); +}); + +describe("review deadline sweeps", () => { + it("skips a deleted reviewer without blocking another reviewer's reminder", async () => { + const departed = await makeUser(); + const member = await makeUser(); + const abandoned = await reviewSeat(departed.userId); + const active = await reviewSeat(member.userId); + + await admin + .from("profiles") + .delete() + .eq("id", departed.userId) + .throwOnError(); + + const { error } = await admin.auth.admin.deleteUser(departed.userId); + + expect(error).toBeNull(); + + const { data: seat } = await admin + .from("panel_members") + .select("member_id") + .eq("id", abandoned.seat.id) + .single() + .throwOnError(); + + expect(seat?.member_id).toBeNull(); + + await deadlineSweep(); + + expect(await notifications(member.token)).toEqual([ + expect.objectContaining({ + type: "review_deadline", + data: expect.objectContaining({ seat_id: active.seat.id }), + }), + ]); + }); + + it("delivers through the five-minute cron once per seat and expiry, even after read", async () => { + const member = await makeUser(); + const near = await reviewSeat(member.userId); + + await app.scheduled({ cron: "*/5 * * * *" } as ScheduledController, env); + + const [notice] = await notifications(member.token); + + expect(notice).toMatchObject({ + type: "review_deadline", + actor: null, + count: 1, + data: { + case_id: near.reviewCase.id, + seat_id: near.seat.id, + title: near.title, + expires_at: near.seat.expires_at, + }, + }); + expect(await notifications(member.token)).toHaveLength(1); + + await read(member.token, notice.id); + await app.scheduled({ cron: "*/5 * * * *" } as ScheduledController, env); + + expect(await notifications(member.token)).toEqual([ + expect.objectContaining({ + id: notice.id, + count: 1, + read_at: expect.any(String), + }), + ]); + + const changedExpiry = hoursFromNow(36); + const { data: changedSeat } = await admin + .from("panel_members") + .update({ expires_at: changedExpiry }) + .eq("id", near.seat.id) + .select("expires_at") + .single() + .throwOnError(); + + await deadlineSweep(); + await deadlineSweep(); + + const rows = await notifications(member.token); + + expect(rows).toHaveLength(2); + expect(rows.find((n) => n.read_at === null)).toMatchObject({ + type: "review_deadline", + count: 1, + data: { + case_id: near.reviewCase.id, + seat_id: near.seat.id, + expires_at: changedSeat?.expires_at, + }, + }); + }); + + it("keeps separate seats with the same expiry and supports cases without a linked title", async () => { + const member = await makeUser(); + const expiry = hoursFromNow(24); + const first = await reviewSeat(member.userId, { expiresAt: expiry }); + const second = await reviewSeat(member.userId, { + expiresAt: expiry, + linked: false, + }); + + await deadlineSweep(); + await deadlineSweep(); + + const rows = await notifications(member.token); + expect(rows).toHaveLength(2); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "review_deadline", + actor: null, + count: 1, + data: { + case_id: first.reviewCase.id, + seat_id: first.seat.id, + title: first.title, + expires_at: first.seat.expires_at, + }, + }), + expect.objectContaining({ + type: "review_deadline", + actor: null, + count: 1, + data: { + case_id: second.reviewCase.id, + seat_id: second.seat.id, + title: null, + expires_at: second.seat.expires_at, + }, + }), + ]) + ); + }); + + it.each([ + { expiresAt: hoursFromNow(72) }, + { expiresAt: hoursFromNow(-1) }, + { expiresAt: null }, + { seatStatus: "completed" as const }, + { seatStatus: "replaced" as const }, + { seatStatus: "recused" as const }, + { closed: true }, + { caseStatus: "pending" as const }, + { caseStatus: "approved" as const }, + { caseStatus: "rejected" as const }, + ])("excludes an ineligible deadline %j", async (options) => { + const member = await makeUser(); + await reviewSeat(member.userId, options); + await deadlineSweep(); + + expect(await notifications(member.token)).toEqual([]); + }); + + it("consumes an eligible reminder while opted out instead of replaying on opt-in", async () => { + const member = await makeUser(); + await preference(member.token, "review_deadline", false); + + const near = await reviewSeat(member.userId); + + await deadlineSweep(); + + expect(await notifications(member.token)).toEqual([]); + + await preference(member.token, "review_deadline", true); + await deadlineSweep(); + + expect(await notifications(member.token)).toEqual([]); + + await admin + .from("panel_members") + .update({ expires_at: hoursFromNow(36) }) + .eq("id", near.seat.id) + .throwOnError(); + + await deadlineSweep(); + + expect(await notifications(member.token)).toEqual([ + expect.objectContaining({ + type: "review_deadline", + data: expect.objectContaining({ seat_id: near.seat.id }), + }), + ]); + }); + + it("denies the sweep RPC to a normal signed-in client without consuming the reminder", async () => { + const member = await makeUser(); + await reviewSeat(member.userId); + + const client = createClient( + env.SUPABASE_URL, + env.SUPABASE_PUBLISHABLE_KEY, + { + global: { headers: { Authorization: `Bearer ${member.token}` } }, + auth: { persistSession: false, autoRefreshToken: false }, + } + ); + + const { error } = await client.rpc("notify_review_deadlines"); + + expect(error?.code).toBe("42501"); + expect(await notifications(member.token)).toEqual([]); + + await deadlineSweep(); + expect(await notifications(member.token)).toEqual([ + expect.objectContaining({ type: "review_deadline" }), + ]); + }); +}); diff --git a/api/tests/notifications.test.ts b/api/tests/notifications.test.ts index e937bbbf..d8f85bcc 100644 --- a/api/tests/notifications.test.ts +++ b/api/tests/notifications.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { createClient } from "@supabase/supabase-js"; +import type { NotificationsResponse } from "@bluelearn/schemas"; import app from "../src/index"; import type { Database } from "../src/database.types"; import { admin, auth, env, jsonAuth, makeUser } from "./helpers"; @@ -18,23 +19,12 @@ import { } from "./factories/reviews"; import { expectToMatchSpec } from "./openapi"; -type ListBody = { - notifications: Array<{ - id: string; - type: string; - category: string; - actor: { id: string; username: string } | null; - count: number; - data: Record; - read_at: string | null; - }>; - has_more: boolean; -}; - async function list(token: string, query = "") { const res = await app.request(`/notifications${query}`, auth(token), env); + expect(res.status).toBe(200); - return (await res.json()) as ListBody; + + return (await res.json()) as NotificationsResponse; } async function unreadCount(token: string) { @@ -43,7 +33,9 @@ async function unreadCount(token: string) { auth(token), env ); + expect(res.status).toBe(200); + return ((await res.json()) as { count: number }).count; } @@ -66,6 +58,7 @@ async function seedCaseForAuthor(authorId: string, title: string) { }); await createPanelMember(panel.id, panelist.userId); await createGuideReviewCase(reviewCase.id, revision.id); + return { reviewCase, panelist }; } @@ -85,6 +78,7 @@ async function decide( ), env ); + expect(res.status).toBe(200); } @@ -112,6 +106,7 @@ describe("guide_review_decision", () => { await decide(panelist.token, reviewCase.id, "approved"); const body = await list(author.token); + expect(body.notifications).toHaveLength(1); expect(body.notifications[0]).toMatchObject({ type: "guide_review_decision", @@ -137,6 +132,7 @@ describe("guide_review_decision", () => { await decide(panelist.token, reviewCase.id, "rejected"); const body = await list(author.token); + expect(body.notifications.map((n) => n.data.outcome)).toEqual(["rejected"]); }); @@ -184,6 +180,7 @@ describe("guide_upvotes", () => { await createVote(second.userId, guide.id); const body = await list(author.token); + expect(body.notifications).toHaveLength(1); expect(body.notifications[0]).toMatchObject({ type: "guide_upvotes", @@ -219,6 +216,7 @@ describe("guide_upvotes", () => { await createVote((await makeUser()).userId, guide.id); const body = await list(author.token); + expect(body.notifications).toHaveLength(2); expect(body.notifications[0]).toMatchObject({ count: 1, read_at: null }); expect(body.notifications[1].id).toBe(read.id); @@ -250,6 +248,7 @@ describe("guide_upvotes", () => { .throwOnError(); const body = await list(author.token); + expect(body.notifications.map((n) => n.count)).toEqual([1]); }); }); @@ -257,6 +256,7 @@ describe("guide_upvotes", () => { describe("GET /notifications", () => { it("401s without a token", async () => { const res = await app.request("/notifications", {}, env); + expect(res.status).toBe(401); await expectToMatchSpec(res, "GET", "/notifications"); }); @@ -277,7 +277,7 @@ describe("GET /notifications", () => { env ); await expectToMatchSpec(res, "GET", "/notifications"); - const first = (await res.json()) as ListBody; + const first = (await res.json()) as NotificationsResponse; const second = await list(author.token, "?limit=2&page=2"); expect(first.has_more).toBe(true); @@ -303,6 +303,7 @@ describe("GET /notifications", () => { author.token, "?category=community_engagement" ); + expect(community.notifications.map((n) => n.type)).toEqual([ "guide_upvotes", ]); @@ -313,6 +314,7 @@ describe("GET /notifications", () => { env ); const unread = await list(author.token, "?filter=unread"); + expect(unread.notifications.map((n) => n.type)).toEqual([ "guide_review_decision", ]); @@ -500,18 +502,20 @@ describe("database guards", () => { expect(forged.error?.code).toBe("42501"); expect(read.error).toBeNull(); const [after] = (await list(author.token)).notifications; + expect(after.data.title).toBe(target.data.title); expect(after.read_at).not.toBeNull(); }); it("refuses an unknown type inside notify()", async () => { - const { userId } = await makeUser(); + const { userId, token } = await makeUser(); const { error } = await admin.rpc("notify", { p_recipient_id: userId, p_type: "carrier_pigeon", }); - expect(error?.message).toContain("Unknown notification type"); + expect(error?.code).toBe("22023"); + expect(await unreadCount(token)).toBe(0); }); }); diff --git a/app/src/components/__tests__/notificationSettings.test.tsx b/app/src/components/__tests__/notificationSettings.test.tsx index ef137527..eabefdb7 100644 --- a/app/src/components/__tests__/notificationSettings.test.tsx +++ b/app/src/components/__tests__/notificationSettings.test.tsx @@ -47,8 +47,7 @@ const preferences = [ { type: "review_deadline", category: "review_workflows", enabled: true }, ]; -const toggle = (label: string | RegExp) => - screen.getByRole("switch", { name: label }); +const toggle = (name: RegExp) => screen.getByRole("switch", { name }); describe("notification settings page", () => { beforeEach(() => { @@ -61,52 +60,18 @@ describe("notification settings page", () => { cleanup(); }); - it("enables only the types that are sent", async () => { + it("turns an enabled option off after a successful save", async () => { render(); await waitFor(() => - expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) + expect(toggle(/upvotes/i).hasAttribute("disabled")).toBe(false) ); - expect(toggle("Guide review decision").hasAttribute("disabled")).toBe( - false - ); - for (const label of [ - "Variant created", - "Variant review decision", - "Variant edited", - "Downvotes", - "Assignments", - "Deadlines approaching", - ]) { - expect(toggle(new RegExp(label)).hasAttribute("disabled")).toBe(true); - } - expect(screen.getAllByText("Coming soon")).toHaveLength(6); - }); - - it("shows each saved choice", async () => { - render(); - await waitFor(() => - expect(toggle("Upvotes").getAttribute("aria-checked")).toBe("true") - ); - - expect(toggle(/Downvotes/).getAttribute("aria-checked")).toBe("false"); - expect(toggle(/Deadlines approaching/).getAttribute("aria-checked")).toBe( - "true" - ); - }); - - it("keeps a saved change", async () => { - render(); - await waitFor(() => - expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) - ); - - fireEvent.click(toggle("Upvotes")); + fireEvent.click(toggle(/upvotes/i)); await waitFor(() => - expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) + expect(toggle(/upvotes/i).hasAttribute("disabled")).toBe(false) ); - expect(toggle("Upvotes").getAttribute("aria-checked")).toBe("false"); + expect(toggle(/upvotes/i).getAttribute("aria-checked")).toBe("false"); expect(screen.queryByRole("alert")).toBeNull(); }); @@ -115,14 +80,13 @@ describe("notification settings page", () => { vi.spyOn(console, "error").mockImplementation(() => {}); render(); await waitFor(() => - expect(toggle("Upvotes").hasAttribute("disabled")).toBe(false) + expect(toggle(/upvotes/i).hasAttribute("disabled")).toBe(false) ); - fireEvent.click(toggle("Upvotes")); + fireEvent.click(toggle(/upvotes/i)); - expect((await screen.findByRole("alert")).textContent).toContain( - "Could not save" - ); - expect(toggle("Upvotes").getAttribute("aria-checked")).toBe("true"); + await screen.findByRole("alert"); + + expect(toggle(/upvotes/i).getAttribute("aria-checked")).toBe("true"); }); }); diff --git a/app/src/components/notifications/__tests__/NotificationPanel.test.tsx b/app/src/components/notifications/__tests__/NotificationPanel.test.tsx index 056a154e..b06c3018 100644 --- a/app/src/components/notifications/__tests__/NotificationPanel.test.tsx +++ b/app/src/components/notifications/__tests__/NotificationPanel.test.tsx @@ -87,10 +87,12 @@ describe("NotificationPanel", () => { }); render(); - fireEvent.click(await screen.findByText("Guide approved")); + fireEvent.click( + await screen.findByRole("button", { name: /guide.*approved/i }) + ); - expect(await screen.findByText("1 unread")).toBeTruthy(); - expect(screen.queryByLabelText("Unread")).toBeNull(); + expect(await screen.findByText(/\b1\s+unread\b/i)).toBeTruthy(); + expect(screen.queryByLabelText(/unread/i)).toBeNull(); }); it("does not lower the count when a read row is opened", async () => { @@ -100,9 +102,13 @@ describe("NotificationPanel", () => { }); render(); - fireEvent.click(await screen.findByText("Guide approved")); + fireEvent.click( + await screen.findByRole("button", { name: /guide.*approved/i }) + ); - await waitFor(() => expect(screen.getByText("2 unread")).toBeTruthy()); + await waitFor(() => + expect(screen.getByText(/\b2\s+unread\b/i)).toBeTruthy() + ); }); it("clears every unread marker with Mark all as read", async () => { @@ -111,35 +117,19 @@ describe("NotificationPanel", () => { has_more: false, }); render(); - expect(await screen.findAllByLabelText("Unread")).toHaveLength(2); + expect(await screen.findAllByLabelText(/unread/i)).toHaveLength(2); - fireEvent.click(screen.getByText("Mark all as read")); + fireEvent.click(screen.getByRole("button", { name: /mark.*all.*read/i })); - await waitFor(() => expect(screen.queryByLabelText("Unread")).toBeNull()); - expect(screen.queryByText(/unread$/)).toBeNull(); + await waitFor(() => expect(screen.queryByLabelText(/unread/i)).toBeNull()); + expect(screen.queryByText(/\bunread\b/i)).toBeNull(); }); it("shows the empty state when there is nothing to show", async () => { listNotifications.mockResolvedValue({ notifications: [], has_more: false }); render(); - expect( - await screen.findByText("There are no new notifications") - ).toBeTruthy(); - }); - - it("skips types that have no renderer yet", async () => { - listNotifications.mockResolvedValue({ - notifications: [ - row({ type: "guide_downvotes", category: "community_engagement" }), - ], - has_more: false, - }); - render(); - - expect( - await screen.findByText("There are no new notifications") - ).toBeTruthy(); + expect(await screen.findByText(/no.*notifications/i)).toBeTruthy(); }); it("offers a retry when loading fails", async () => { @@ -149,8 +139,12 @@ describe("NotificationPanel", () => { vi.spyOn(console, "error").mockImplementation(() => {}); render(); - fireEvent.click(await screen.findByText("Try again")); + fireEvent.click( + await screen.findByRole("button", { name: /try again|retry/i }) + ); - expect(await screen.findByText("Guide approved")).toBeTruthy(); + expect( + await screen.findByRole("button", { name: /guide.*approved/i }) + ).toBeTruthy(); }); }); diff --git a/app/src/lib/__tests__/notifications.test.ts b/app/src/lib/__tests__/notifications.test.ts index eef930ab..96d7e42e 100644 --- a/app/src/lib/__tests__/notifications.test.ts +++ b/app/src/lib/__tests__/notifications.test.ts @@ -1,119 +1,219 @@ import { describe, expect, it } from "vitest"; -import type { Notification } from "@bluelearn/schemas"; -import { - isNotificationTypeLive, - notificationSections, - renderNotification, -} from "@/lib/notifications"; - -const base: Omit = { - id: "00000000-0000-4000-8000-000000000001", - actor: null, - count: 1, - occurred_at: "2026-07-27T10:00:00Z", - read_at: null, +import type { Notification, NotificationType } from "@bluelearn/schemas"; +import { notificationSections, renderNotification } from "@/lib/notifications"; + +function notification( + type: NotificationType, + data: Record = {}, + overrides: Partial = {} +): Notification { + return { + id: "00000000-0000-4000-8000-000000000001", + type, + category: + notificationSections.find((section) => + section.types.some((entry) => entry.type === type) + )?.category ?? "guide_statuses", + actor: null, + count: 1, + occurred_at: "2026-07-27T10:00:00Z", + read_at: null, + data, + ...overrides, + }; +} + +const actor = { + id: "00000000-0000-4000-8000-000000000002", + username: "mira", +}; +const guideData = { + guide_id: "g1", + title: "Sorting", + base_slug: "sorting", + variant_slug: "alternative", +}; +const guideTarget = { + to: "/guides/$slug/$variantSlug", + params: { slug: "sorting", variantSlug: "alternative" }, +}; +const reviewTarget = { + to: "/review/$caseId", + params: { caseId: "c1" }, }; -const decision = (data: Record): Notification => ({ - ...base, - type: "guide_review_decision", - category: "guide_statuses", - data, +describe.each([ + ["guide_review_decision", /\bguide\b/i], + ["variant_review_decision", /\bvariant\b/i], +] as const)("%s", (type, kind) => { + it.each(["approved", "rejected"])( + "shows the %s outcome and links to the review case", + (outcome) => { + const view = renderNotification( + notification(type, { ...guideData, outcome, case_id: "c1" }) + ); + + expect(view?.title).toMatch(kind); + expect(view?.title).toMatch(new RegExp(`\\b${outcome}\\b`, "i")); + expect(view?.body).toMatch(kind); + expect(view?.body).toMatch(/sorting/i); + expect(view?.target).toEqual(reviewTarget); + + const rejection = /not\s+approved|rejected|declined/i; + + if (outcome === "rejected") { + expect(view?.body).toMatch(rejection); + } else { + expect(view?.body).not.toMatch(rejection); + } + } + ); }); -const upvotes = (count: number, data: Record) => - ({ - ...base, - type: "guide_upvotes", - category: "community_engagement", - actor: { id: "00000000-0000-4000-8000-000000000002", username: "mira" }, - count, - data, - }) satisfies Notification; - -describe("guide_review_decision", () => { - it("links an approval to its review case", () => { - const view = renderNotification( - decision({ outcome: "approved", title: "How to speed", case_id: "c1" }) - ); - - expect(view).toMatchObject({ - title: "Guide approved", - body: 'Your guide "How to speed" has been approved', - target: { to: "/review/$caseId", params: { caseId: "c1" } }, - }); +describe.each([ + ["guide_upvotes", /upvot/i, /downvot/i], + ["guide_downvotes", /downvot/i, /upvot/i], +] as const)("%s", (type, direction, opposite) => { + it.each([ + { count: 1, otherVoters: null }, + { count: 2, otherVoters: /\b1\s+other\b/i }, + { count: 5, otherVoters: /\b4\s+others\b/i }, + ])( + "shows the direction and voters for $count events", + ({ count, otherVoters }) => { + const view = renderNotification( + notification(type, guideData, { actor, count }) + ); + + expect(view?.title).toMatch(direction); + expect(view?.body).toMatch(direction); + expect(view?.body).not.toMatch(opposite); + expect(view?.body).toMatch(/@mira\b/i); + expect(view?.body).toMatch(/sorting/i); + expect(view?.target).toEqual(guideTarget); + + if (otherVoters) { + expect(view?.title).toMatch(new RegExp(`\\b${count}\\b`)); + expect(view?.body).toMatch(otherVoters); + } else { + expect(view?.body).not.toMatch(/\bothers?\b/i); + } + } + ); + + it("keeps the vote direction when actor and title details are absent", () => { + const view = renderNotification(notification(type)); + + expect(view?.body).toMatch(direction); + expect(view?.body).not.toMatch(/undefined|null/i); + expect(view?.target).toBeNull(); }); +}); - it("says a rejection is a rejection", () => { +describe.each([ + ["variant_created", /publish/i], + ["variant_edited", /edit/i], +] as const)("%s", (type, action) => { + it("shows the actor and variant and opens the guide instead of the review", () => { const view = renderNotification( - decision({ outcome: "rejected", title: "Topology", case_id: "c1" }) + notification(type, { ...guideData, case_id: "c1" }, { actor }) ); - expect(view?.title).toBe("Guide rejected"); - expect(view?.body).toBe('Your guide "Topology" was not approved'); - }); - - it("has no link when the case id is missing", () => { - const view = renderNotification(decision({ outcome: "approved" })); - - expect(view?.target).toBeNull(); - expect(view?.body).toBe('Your guide "Untitled" has been approved'); + expect(view?.body).toMatch(/@mira\b/i); + expect(view?.body).toMatch(/sorting/i); + expect(view?.body).toMatch(action); + expect(view?.target).toEqual(guideTarget); }); }); -describe("guide_upvotes", () => { - const data = { title: "Sorting", base_slug: "sorting", variant_slug: "main" }; - - it("names the one voter", () => { - const view = renderNotification(upvotes(1, data)); - - expect(view).toMatchObject({ - title: "New upvote", - body: '@mira upvoted "Sorting"', - target: { - to: "/guides/$slug/$variantSlug", - params: { slug: "sorting", variantSlug: "main" }, - }, - }); - }); - - it("counts the other voters, singular and plural", () => { - expect(renderNotification(upvotes(2, data))?.body).toBe( - '@mira and 1 other upvoted "Sorting"' +describe("guide target boundaries", () => { + it.each([ + {}, + { base_slug: "sorting" }, + { variant_slug: "alternative" }, + { base_slug: "sorting", variant_slug: "" }, + { base_slug: " ", variant_slug: "alternative" }, + { base_slug: 42, variant_slug: "alternative" }, + { base_slug: "sorting", variant_slug: null }, + ])("refuses a partial guide target for %j", (data) => { + const view = renderNotification( + notification("variant_created", { ...data, case_id: "c1" }) ); - expect(renderNotification(upvotes(5, data))).toMatchObject({ - title: "5 new upvotes", - body: '@mira and 4 others upvoted "Sorting"', - }); - }); - - it("has no link without both slugs", () => { - const view = renderNotification(upvotes(1, { title: "Sorting" })); expect(view?.target).toBeNull(); }); }); -describe("registry", () => { - it("renders nothing for a type that is not sent yet", () => { - const view = renderNotification({ - ...base, - type: "guide_downvotes", - category: "community_engagement", - data: {}, +describe.each(["review_assignment", "review_deadline"] as const)( + "%s", + (type) => { + it("opens the review case instead of the guide or seat", () => { + const view = renderNotification( + notification(type, { + ...guideData, + case_id: "c1", + seat_id: "seat1", + expires_at: "2031-11-02T12:00:00Z", + }) + ); + + expect(view?.body).toMatch(/sorting/i); + expect(view?.target).toEqual(reviewTarget); }); + } +); + +describe("review target boundaries", () => { + it.each([undefined, null, "", " ", 42])( + "refuses a missing or invalid case id %s instead of using the guide or seat", + (caseId) => { + const view = renderNotification( + notification("review_assignment", { + ...guideData, + case_id: caseId, + seat_id: "seat1", + }) + ); + + expect(view?.target).toBeNull(); + } + ); +}); - expect(view).toBeNull(); - expect(isNotificationTypeLive("guide_downvotes")).toBe(false); - expect(isNotificationTypeLive("guide_upvotes")).toBe(true); - }); - - it("lists every type in exactly one settings section", () => { - const types = notificationSections.flatMap((s) => - s.types.map((t) => t.type) +describe("review_deadline expiry", () => { + it("shows the actual expiry time instead of a fixed reminder window", () => { + const early = renderNotification( + notification("review_deadline", { + expires_at: "2031-11-02T12:00:00Z", + }) + ); + const late = renderNotification( + notification("review_deadline", { + expires_at: "2031-11-02T18:00:00Z", + }) ); - expect(new Set(types).size).toBe(types.length); - expect(types).toHaveLength(8); + expect(early?.body).toMatch(/\b2031\b/); + expect(late?.body).toMatch(/\b2031\b/); + expect(early?.body).not.toBe(late?.body); + expect(early?.body).not.toMatch(/\b48\s+hours?\b/i); }); + + it.each([null, "", "not a date", 42])( + "keeps the review target without inventing an expiry for %s", + (expiresAt) => { + const view = renderNotification( + notification("review_deadline", { + expires_at: expiresAt, + case_id: "c1", + }) + ); + + expect(view?.body).toMatch(/deadline|review/i); + expect(view?.body).not.toMatch( + /Invalid Date|NaN|undefined|null|48\s+hours/i + ); + expect(view?.target).toEqual(reviewTarget); + } + ); }); diff --git a/app/src/lib/notifications.ts b/app/src/lib/notifications.ts index 15d064d1..9295090f 100644 --- a/app/src/lib/notifications.ts +++ b/app/src/lib/notifications.ts @@ -1,4 +1,13 @@ -import { CircleCheck, CircleX, ThumbsUp } from "lucide-react"; +import { + CircleCheck, + CircleX, + ClipboardList, + Clock, + GitBranch, + Pencil, + ThumbsDown, + ThumbsUp, +} from "lucide-react"; import type { LucideIcon } from "lucide-react"; import type { Notification, @@ -6,9 +15,8 @@ import type { NotificationType, } from "@bluelearn/schemas"; -// The app half of a notification type. The database half is a row in -// public.notification_types and a public.notify() call. A type without a -// renderer is not sent yet: the bell skips it and its setting is disabled. +// Each type pairs a database registry entry and event source with a renderer. +// The bell hides types without a renderer, and settings disables their toggles. export type NotificationTarget = | { to: "/review/$caseId"; params: { caseId: string } } @@ -31,43 +39,58 @@ type Renderer = (notification: Notification) => NotificationView; export const notificationRenderers: Partial< Record > = { - guide_review_decision: ({ data }) => { - const approved = data.outcome === "approved"; - const guideTitle = text(data.title) ?? "Untitled"; - const caseId = text(data.case_id); + guide_review_decision: (notification) => + reviewDecision(notification, "guide"), + variant_review_decision: (notification) => + reviewDecision(notification, "variant"), + guide_upvotes: (notification) => votes(notification, "up"), + guide_downvotes: (notification) => votes(notification, "down"), - return { - title: approved ? "Guide approved" : "Guide rejected", - body: approved - ? `Your guide "${guideTitle}" has been approved` - : `Your guide "${guideTitle}" was not approved`, - target: caseId ? { to: "/review/$caseId", params: { caseId } } : null, - icon: approved ? CircleCheck : CircleX, - }; - }, + variant_created: ({ data, actor }) => ({ + title: "New variant", + body: `${actorName(actor)} published a variant "${text(data.title) ?? "Untitled"}" of your guide`, + target: guideTarget(data), + icon: GitBranch, + }), + + variant_edited: ({ data, actor }) => ({ + title: "Variant edited", + body: `${actorName(actor)} edited your variant "${text(data.title) ?? "Untitled"}"`, + target: guideTarget(data), + icon: Pencil, + }), - guide_upvotes: ({ data, actor, count }) => { - const guideTitle = text(data.title) ?? "your guide"; - const slug = text(data.base_slug); - const variantSlug = text(data.variant_slug); - const who = actor ? `@${actor.username}` : "Someone"; - const others = count - 1; - const otherVoters = others === 1 ? "1 other" : `${others} others`; + review_assignment: ({ data }) => ({ + title: "Review assigned", + body: `You have been assigned to review "${text(data.title) ?? "Untitled"}"`, + target: reviewTarget(data), + icon: ClipboardList, + }), + + review_deadline: ({ data }) => { + const title = text(data.title) ?? "Untitled"; + const expiresAt = text(data.expires_at); + const expiry = expiresAt ? new Date(expiresAt) : null; + + const deadline = + expiry && Number.isFinite(expiry.getTime()) + ? expiry.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }) + : null; return { - title: count === 1 ? "New upvote" : `${count} new upvotes`, - body: - others === 0 - ? `${who} upvoted "${guideTitle}"` - : `${who} and ${otherVoters} upvoted "${guideTitle}"`, - target: - slug && variantSlug - ? { - to: "/guides/$slug/$variantSlug", - params: { slug, variantSlug }, - } - : null, - icon: ThumbsUp, + title: "Review deadline approaching", + body: deadline + ? `Your review of "${title}" is due ${deadline}` + : `The deadline for your review of "${title}" is approaching`, + target: reviewTarget(data), + icon: Clock, }; }, }; @@ -80,6 +103,71 @@ export function isNotificationTypeLive(type: NotificationType) { return type in notificationRenderers; } +function reviewDecision( + { data }: Notification, + kind: "guide" | "variant" +): NotificationView { + const approved = data.outcome === "approved"; + const title = text(data.title) ?? "Untitled"; + const label = kind === "guide" ? "Guide" : "Variant"; + + return { + title: `${label} ${approved ? "approved" : "rejected"}`, + body: approved + ? `Your ${kind} "${title}" has been approved` + : `Your ${kind} "${title}" was not approved`, + target: reviewTarget(data), + icon: approved ? CircleCheck : CircleX, + }; +} + +function votes( + { data, actor, count }: Notification, + direction: "up" | "down" +): NotificationView { + const title = text(data.title) ?? "your guide"; + const who = actorName(actor); + const others = count - 1; + const otherVoters = others === 1 ? "1 other" : `${others} others`; + const verb = `${direction}voted`; + + return { + title: + count === 1 ? `New ${direction}vote` : `${count} new ${direction}votes`, + body: + others === 0 + ? `${who} ${verb} "${title}"` + : `${who} and ${otherVoters} ${verb} "${title}"`, + target: guideTarget(data), + icon: direction === "up" ? ThumbsUp : ThumbsDown, + }; +} + +function actorName(actor: Notification["actor"]) { + const username = text(actor?.username); + + return username ? `@${username}` : "Someone"; +} + +function guideTarget(data: Notification["data"]): NotificationTarget | null { + const slug = text(data.base_slug); + const variantSlug = text(data.variant_slug); + + return slug && variantSlug + ? { to: "/guides/$slug/$variantSlug", params: { slug, variantSlug } } + : null; +} + +function reviewTarget(data: Notification["data"]): NotificationTarget | null { + const caseId = text(data.case_id); + + return caseId ? { to: "/review/$caseId", params: { caseId } } : null; +} + +function text(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + // Settings page order and wording, from the design in issue #531. export const notificationSections: Array<{ category: NotificationCategory; @@ -119,7 +207,3 @@ export const notificationSections: Array<{ ], }, ]; - -function text(value: unknown) { - return typeof value === "string" && value.length > 0 ? value : null; -} diff --git a/docs/database-schema.md b/docs/database-schema.md index 7ffa25da..feaec584 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -375,7 +375,7 @@ The manifest of object-storage assets, so a purge can delete media reliably inst ### `notification_types`, `notifications`, and `notification_preferences` -In-app notifications. The function `public.notify()` is the only writer of `notifications`. It runs inside the transaction of the event, so a notification commits or rolls back with that event. +The function `public.notify()` is the only writer of in-app notifications. Each notification commits or rolls back with its event. `notification_types` is the registry. It has one row for each type: @@ -405,6 +405,27 @@ Constraints: - `notify()` skips a recipient who is also the actor. It also skips a recipient who turned the type off. It raises an error for a type that is not in the registry. - Clients cannot run `notify()`. A recipient can update only `read_at`. +Events: + +| Type | Event | Recipient | +| --- | --- | --- | +| `guide_review_decision` | A panel approves or rejects a canonical guide revision. | The revision author. | +| `variant_created` | A variant becomes public for the first time. | The canonical guide author. | +| `variant_review_decision` | A panel approves or rejects a variant revision. | The revision author. | +| `variant_edited` | A new revision becomes the public revision of a variant. | The original variant author. | +| `guide_upvotes` | A vote becomes an upvote. | The guide author. | +| `guide_downvotes` | A vote becomes a downvote. | The guide author. | +| `review_assignment` | A reviewer gets a new seat or replaces another reviewer in an existing seat. | The assigned reviewer. | +| `review_deadline` | An assigned seat has an expiry within the next 48 hours. | The assigned reviewer. | + +Draft changes do not send variant notifications. Upvotes and downvotes use separate unread groups for each guide. + +The five-minute worker calls `notify_review_deadlines()`. The function skips expired seats, completed seats, replaced seats, closed panels, and cases outside review. + +`review_deadline_notifications` records each processed reminder. Its primary key is `(seat_id, member_id, expires_at)`. Only the service role can read or change these records. + +The reminder record and its notification share one transaction. A repeated call cannot send the same reminder after the recipient reads or removes the notification. A new assignee or expiry permits a new reminder. A disabled preference consumes the reminder without a notification. Enabling that preference later does not replay the event. + To add a type: 1. Insert one row into `notification_types`. diff --git a/supabase/migrations/20261027120000_notifications.sql b/supabase/migrations/20261027120000_notifications.sql index 60caf568..757396cf 100644 --- a/supabase/migrations/20261027120000_notifications.sql +++ b/supabase/migrations/20261027120000_notifications.sql @@ -1,5 +1,3 @@ --- In-app notifications. --- -- public.notify() is the only writer. It runs inside the transaction that -- caused the event, so a notification commits or rolls back with its event. -- To add a notification type, follow "To add a type" in @@ -88,7 +86,6 @@ as $$ declare v_enabled boolean; begin - -- Nobody hears about their own actions. if p_recipient_id is null or p_recipient_id = p_actor_id then return; end if; @@ -229,7 +226,6 @@ create trigger votes_notify_guide_upvote when (new.direction = 'up') execute function public.notify_guide_upvote(); --- Row-level security policies alter table public.notification_types enable row level security; alter table public.notifications enable row level security; alter table public.notification_preferences enable row level security; diff --git a/supabase/migrations/20261102120000_notification_events.sql b/supabase/migrations/20261102120000_notification_events.sql new file mode 100644 index 00000000..f2619fcf --- /dev/null +++ b/supabase/migrations/20261102120000_notification_events.sql @@ -0,0 +1,300 @@ +drop trigger review_cases_notify_guide_decision on public.review_cases; +drop function public.notify_guide_review_decision(); + +create function public.notify_review_decision() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_revision record; + v_type text; +begin + select gr.author_id, gr.title, g.id as guide_id, b.canonical_guide_id + into v_revision + from public.guide_review_cases grc + join public.guide_revisions gr on gr.id = grc.guide_revision_id + join public.guides g on g.id = gr.guide_id + join public.guide_bases b on b.id = g.guide_base_id + where grc.case_id = new.id; + + if not found then + return null; + end if; + + -- The first canonical submission has no canonical pointer until approval publishes it. + v_type := case + when v_revision.canonical_guide_id is not null + and v_revision.canonical_guide_id <> v_revision.guide_id + then 'variant_review_decision' + else 'guide_review_decision' + end; + + perform public.notify( + v_revision.author_id, + v_type, + null, + jsonb_build_object( + 'case_id', new.id, + 'outcome', new.status, + 'title', v_revision.title + ) + ); + + return null; +end; +$$; + +revoke execute on function public.notify_review_decision() + from public, anon, authenticated; + +create trigger review_cases_notify_decision + after update of status on public.review_cases + for each row + when ( + new.status in ('approved', 'rejected') + and old.status is distinct from new.status + ) + execute function public.notify_review_decision(); + +-- Draft autosaves do not change the live revision. First publication and +-- later published edits notify through the same transaction as publication. +create function public.notify_variant_publication() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_base record; + v_revision record; + v_recipient_id uuid; + v_type text; +begin + select b.slug, b.canonical_guide_id, g.author_id + into v_base + from public.guide_bases b + left join public.guides g on g.id = b.canonical_guide_id + where b.id = new.guide_base_id; + + if v_base.canonical_guide_id is null + or v_base.canonical_guide_id = new.id then + return null; + end if; + + select gr.author_id, gr.title + into v_revision + from public.guide_revisions gr + where gr.id = new.current_revision_id; + + if old.current_revision_id is null then + v_type := 'variant_created'; + v_recipient_id := v_base.author_id; + else + v_type := 'variant_edited'; + v_recipient_id := new.author_id; + end if; + + perform public.notify( + v_recipient_id, + v_type, + v_revision.author_id, + jsonb_build_object( + 'guide_id', new.id, + 'title', v_revision.title, + 'base_slug', v_base.slug, + 'variant_slug', new.slug + ) + ); + + return null; +end; +$$; + +revoke execute on function public.notify_variant_publication() + from public, anon, authenticated; + +create trigger guides_notify_variant_publication + after update of current_revision_id on public.guides + for each row + when ( + new.status = 'published' + and new.current_revision_id is not null + and old.current_revision_id is distinct from new.current_revision_id + ) + execute function public.notify_variant_publication(); + +-- Votes of either direction form separate unread groups for each guide. +drop trigger votes_notify_guide_upvote on public.votes; +drop function public.notify_guide_upvote(); + +create function public.notify_guide_vote() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_guide record; + v_type text; +begin + if tg_op = 'UPDATE' and old.direction = new.direction then + return null; + end if; + + select g.author_id, gr.title, b.slug as base_slug, g.slug as variant_slug + into v_guide + from public.guides g + join public.guide_bases b on b.id = g.guide_base_id + left join public.guide_revisions gr on gr.id = g.current_revision_id + where g.id = new.guide_id; + + v_type := case new.direction + when 'up' then 'guide_upvotes' + else 'guide_downvotes' + end; + + perform public.notify( + v_guide.author_id, + v_type, + new.voter_id, + jsonb_build_object( + 'guide_id', new.guide_id, + 'title', v_guide.title, + 'base_slug', v_guide.base_slug, + 'variant_slug', v_guide.variant_slug + ), + new.guide_id::text + ); + + return null; +end; +$$; + +revoke execute on function public.notify_guide_vote() + from public, anon, authenticated; + +create trigger votes_notify_guide_vote + after insert or update of direction on public.votes + for each row + execute function public.notify_guide_vote(); + +create function public.notify_review_assignment() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case record; +begin + if tg_op = 'UPDATE' + and old.member_id = new.member_id + and old.status = 'assigned' then + return null; + end if; + + select rc.id, gr.title + into v_case + from public.review_panels rp + join public.review_cases rc on rc.id = rp.case_id + left join public.guide_review_cases grc on grc.case_id = rc.id + left join public.guide_revisions gr on gr.id = grc.guide_revision_id + where rp.id = new.panel_id + and rp.closed_at is null + and rc.status in ('pending', 'in_review'); + + if not found then + return null; + end if; + + perform public.notify( + new.member_id, + 'review_assignment', + null, + jsonb_build_object( + 'case_id', v_case.id, + 'seat_id', new.id, + 'title', v_case.title, + 'expires_at', new.expires_at + ) + ); + + return null; +end; +$$; + +revoke execute on function public.notify_review_assignment() + from public, anon, authenticated; + +create trigger panel_members_notify_assignment + after insert or update of member_id, status on public.panel_members + for each row + when (new.status = 'assigned') + execute function public.notify_review_assignment(); + +-- Keep the claim after the inbox row is read or removed. A new assignee or +-- expiry permits one new reminder. A disabled preference consumes this event too. +create table public.review_deadline_notifications ( + seat_id uuid not null references public.panel_members (id) on delete cascade, + member_id uuid not null references public.profiles (id) on delete cascade, + expires_at timestamptz not null, + primary key (seat_id, member_id, expires_at) +); + +alter table public.review_deadline_notifications enable row level security; +revoke all on public.review_deadline_notifications from public, anon, authenticated; +grant all on public.review_deadline_notifications to service_role; + +create function public.notify_review_deadlines() +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_seat record; +begin + for v_seat in + with claimed as ( + insert into public.review_deadline_notifications (seat_id, member_id, expires_at) + select pm.id, pm.member_id, pm.expires_at + from public.panel_members pm + join public.review_panels rp on rp.id = pm.panel_id + join public.review_cases rc on rc.id = rp.case_id + where pm.status = 'assigned' + and pm.member_id is not null + and rp.closed_at is null + and rc.status = 'in_review' + and pm.expires_at > now() + and pm.expires_at <= now() + interval '48 hours' + on conflict (seat_id, member_id, expires_at) do nothing + returning seat_id, member_id, expires_at + ) + select pm.id, c.member_id, c.expires_at, rc.id as case_id, gr.title + from claimed c + join public.panel_members pm on pm.id = c.seat_id + join public.review_panels rp on rp.id = pm.panel_id + join public.review_cases rc on rc.id = rp.case_id + left join public.guide_review_cases grc on grc.case_id = rc.id + left join public.guide_revisions gr on gr.id = grc.guide_revision_id + loop + perform public.notify( + v_seat.member_id, + 'review_deadline', + null, + jsonb_build_object( + 'case_id', v_seat.case_id, + 'seat_id', v_seat.id, + 'title', v_seat.title, + 'expires_at', v_seat.expires_at + ) + ); + end loop; +end; +$$; + +revoke execute on function public.notify_review_deadlines() + from public, anon, authenticated; +grant execute on function public.notify_review_deadlines() to service_role;