diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 804b538d..40e2650b 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 @@ -1011,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 @@ -1471,6 +1644,17 @@ 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 + } + notify_review_deadlines: { Args: never; Returns: undefined } objective_closure: { Args: { p_targets: string[] } Returns: { @@ -1559,6 +1743,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 +1916,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..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"; @@ -33,6 +34,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 +65,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(...)`. @@ -86,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/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/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 new file mode 100644 index 00000000..d8f85bcc --- /dev/null +++ b/api/tests/notifications.test.ts @@ -0,0 +1,521 @@ +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"; +import { + createGuide, + createGuideBase, + createGuideRevision, + createPublishedGuide, + createVote, +} from "./factories/guides"; +import { + createGuideReviewCase, + createPanelMember, + createReviewCase, + createReviewPanel, +} from "./factories/reviews"; +import { expectToMatchSpec } from "./openapi"; + +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 NotificationsResponse; +} + +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 NotificationsResponse; + 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, token } = await makeUser(); + + const { error } = await admin.rpc("notify", { + p_recipient_id: userId, + p_type: "carrier_pigeon", + }); + + expect(error?.code).toBe("22023"); + expect(await unreadCount(token)).toBe(0); + }); +}); diff --git a/app/public/assets/notifications/notifications-empty.png b/app/public/assets/notifications/notifications-empty.png new file mode 100644 index 00000000..85ba930f Binary files /dev/null and b/app/public/assets/notifications/notifications-empty.png differ 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..eabefdb7 --- /dev/null +++ b/app/src/components/__tests__/notificationSettings.test.tsx @@ -0,0 +1,92 @@ +// @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 = (name: RegExp) => screen.getByRole("switch", { name }); + +describe("notification settings page", () => { + beforeEach(() => { + vi.clearAllMocks(); + getNotificationPreferences.mockResolvedValue(preferences); + setNotificationPreference.mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + }); + + it("turns an enabled option off after a successful save", async () => { + render(); + await waitFor(() => + expect(toggle(/upvotes/i).hasAttribute("disabled")).toBe(false) + ); + + fireEvent.click(toggle(/upvotes/i)); + + await waitFor(() => + expect(toggle(/upvotes/i).hasAttribute("disabled")).toBe(false) + ); + expect(toggle(/upvotes/i).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/i).hasAttribute("disabled")).toBe(false) + ); + + fireEvent.click(toggle(/upvotes/i)); + + await screen.findByRole("alert"); + + expect(toggle(/upvotes/i).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..b06c3018 --- /dev/null +++ b/app/src/components/notifications/__tests__/NotificationPanel.test.tsx @@ -0,0 +1,150 @@ +// @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.findByRole("button", { name: /guide.*approved/i }) + ); + + 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 () => { + listNotifications.mockResolvedValue({ + notifications: [row({ read_at: "2026-07-28T10:00:00Z" })], + has_more: false, + }); + render(); + + fireEvent.click( + await screen.findByRole("button", { name: /guide.*approved/i }) + ); + + await waitFor(() => + expect(screen.getByText(/\b2\s+unread\b/i)).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/i)).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: /mark.*all.*read/i })); + + 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(/no.*notifications/i)).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.findByRole("button", { name: /try again|retry/i }) + ); + + expect( + await screen.findByRole("button", { name: /guide.*approved/i }) + ).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..96d7e42e --- /dev/null +++ b/app/src/lib/__tests__/notifications.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +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" }, +}; + +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); + } + } + ); +}); + +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(); + }); +}); + +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( + notification(type, { ...guideData, case_id: "c1" }, { actor }) + ); + + 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 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(view?.target).toBeNull(); + }); +}); + +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(); + } + ); +}); + +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(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/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..9295090f --- /dev/null +++ b/app/src/lib/notifications.ts @@ -0,0 +1,209 @@ +import { + CircleCheck, + CircleX, + ClipboardList, + Clock, + GitBranch, + Pencil, + ThumbsDown, + ThumbsUp, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { + Notification, + NotificationCategory, + NotificationType, +} from "@bluelearn/schemas"; + +// 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 } } + | { + 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: (notification) => + reviewDecision(notification, "guide"), + variant_review_decision: (notification) => + reviewDecision(notification, "variant"), + guide_upvotes: (notification) => votes(notification, "up"), + guide_downvotes: (notification) => votes(notification, "down"), + + 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, + }), + + 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: "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, + }; + }, +}; + +export function renderNotification(notification: Notification) { + return notificationRenderers[notification.type]?.(notification) ?? null; +} + +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; + 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" }, + ], + }, +]; 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..feaec584 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -373,6 +373,66 @@ 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` + +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: + +- `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`. + +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`. +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..757396cf --- /dev/null +++ b/supabase/migrations/20261027120000_notifications.sql @@ -0,0 +1,275 @@ +-- 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 + 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(); + +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; 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;