diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d38b30a9f..6344339df 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -82724,10 +82724,58 @@ components: items: type: string description: Every event category the server knows, in declaration order — the list a client renders rows for. + eventDefaults: + type: object + propertyNames: + type: string + enum: + - MEDICATION_REMINDER + - MEASUREMENT_ANOMALY + - COMPLIANCE_LOW + - WITHINGS_SYNC_FAILED + - SYSTEM_ALERT + - PERSONAL_RECORD + - MOOD_REMINDER + - CYCLE_PERIOD_SOON + - CYCLE_PERIOD_CONFIRM + - CYCLE_FERTILE_SOON + - COACH_NUDGE + - MEDICATION_LOW_STOCK + - MEASUREMENT_REMINDER + - MEDICATION_INTAKE_SYNC + - SECURITY_ALERT + - DAILY_BRIEFING + - SHARED_RECORD_INTAKE + description: Notification category a per-channel preference can toggle. + additionalProperties: + type: boolean + required: + - MEDICATION_REMINDER + - MEASUREMENT_ANOMALY + - COMPLIANCE_LOW + - WITHINGS_SYNC_FAILED + - SYSTEM_ALERT + - PERSONAL_RECORD + - MOOD_REMINDER + - CYCLE_PERIOD_SOON + - CYCLE_PERIOD_CONFIRM + - CYCLE_FERTILE_SOON + - COACH_NUDGE + - MEDICATION_LOW_STOCK + - MEASUREMENT_REMINDER + - MEDICATION_INTAKE_SYNC + - SECURITY_ALERT + - DAILY_BRIEFING + - SHARED_RECORD_INTAKE + description: What each event does on a channel with no stored row, resolved for this account by the function delivery + uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting + (`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from + a copy of the defaults. Since v1.43.11. required: - channels - preferences - eventTypes + - eventDefaults additionalProperties: false description: The caller's notification channels, their stored per-event overrides, and the full event vocabulary. NotificationChannelSummary: @@ -82797,9 +82845,8 @@ components: - eventType - enabled additionalProperties: false - description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the - server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood - and cycle reminders). Absence is therefore not 'disabled'. + description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows + `eventDefaults` on the same response. Absence is therefore not 'disabled'. PutNotificationPreferenceResponse: type: object properties: diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 0fbe2fa4d..ad39a9675 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -12,6 +12,7 @@ import { notificationPreferenceSchema } from "@/lib/validations/notifications"; import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; import type { ChannelType } from "@/lib/notifications/types"; import { decrypt, encrypt } from "@/lib/crypto"; +import { resolveEventDefaults } from "@/lib/notifications/event-defaults"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -92,6 +93,12 @@ export const GET = apiHandler(async () => { channels: channelData, preferences, eventTypes: EVENT_TYPES as unknown as string[], + // What an event does on a channel with no stored row, resolved for this + // account by the same function the dispatcher asks. A client renders a + // switch without a row from this map, never from its own copy of the + // defaults: the mood reminder's follows the reminder card, which a + // constant table cannot know. + eventDefaults: await resolveEventDefaults(user.id), }); }); diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx index c2743c816..83b3160cd 100644 --- a/src/app/notifications/__tests__/page.test.tsx +++ b/src/app/notifications/__tests__/page.test.tsx @@ -126,4 +126,58 @@ describe("NotificationsPage", () => { const html = render(); expect(html).toContain("won’t receive notifications"); }); + + it("a switch without a stored row shows the server's default for this account", () => { + // The mood reminder is delivered when the reminder card is on, with no + // per-channel row. The constant table says OFF for it, so a matrix that + // read the table showed the switch off while the reminder kept arriving. + // The page renders what the server resolved, row-less events included. + queryState.data = { + channels: [ + { + id: "c1", + type: "TELEGRAM", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + ], + preferences: [], + eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"], + eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false }, + }; + const html = render(); + const state = (label: string) => + new RegExp( + `aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`, + ).exec(html); + const mood = state("Mood reminder"); + const record = state("Personal record"); + expect(mood, "mood switch rendered").not.toBeNull(); + expect(mood?.[1] ?? mood?.[2]).toBe("checked"); + expect(record?.[1] ?? record?.[2]).toBe("unchecked"); + }); + + it("a stored row still wins over the default", () => { + queryState.data = { + channels: [ + { + id: "c1", + type: "TELEGRAM", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + ], + preferences: [ + { channelId: "c1", eventType: "MOOD_REMINDER", enabled: false }, + ], + eventTypes: ["MOOD_REMINDER"], + eventDefaults: { MOOD_REMINDER: true }, + }; + const html = render(); + expect(html).toMatch( + /aria-label="Mood reminder - Telegram"[^>]*data-state="unchecked"|data-state="unchecked"[^>]*aria-label="Mood reminder - Telegram"/, + ); + }); }); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 4c14bd9e3..ca5390281 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -12,10 +12,6 @@ import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import Link from "next/link"; import { Bell, Settings, AlertCircle } from "lucide-react"; -import { - EVENT_DEFAULT_ENABLED, - type EventType, -} from "@/lib/notifications/types"; import { eventDescriptionTranslationKey, eventTranslationKey, @@ -48,6 +44,11 @@ interface PreferencesData { channels: ChannelInfo[]; preferences: Preference[]; eventTypes: string[]; + /** + * What each event does on a channel with no stored row, resolved by the + * server for this account with the function the dispatcher uses. + */ + eventDefaults: Record; } export default function NotificationsPage() { @@ -116,10 +117,10 @@ export default function NotificationsPage() { (p) => p.channelId === channelId && p.eventType === eventType, ); if (pref) return pref.enabled; - // v1.4.25 — per-event default. Most events stay opt-out - // (no row = ON); PERSONAL_RECORD flips to opt-in (no row = OFF) - // so a backfill doesn't fire hundreds of pushes on first sync. - return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true; + // No row: the server's resolved default for this account. Read, never + // recomputed here: the mood reminder's follows the reminder card, and a + // copy of the constant table showed it off while it was being delivered. + return data?.eventDefaults?.[eventType] === true; } // Cross-links to the other two notification surfaces (reminder types + diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 4b1f65528..99694e083 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -112,9 +112,16 @@ export function MoodReminderCard({ : t("notifications.moodReminder.disabledToast"), ); setMsgType("success"); - await queryClient.invalidateQueries({ - queryKey: queryKeys.userProfile(), - }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.userProfile(), + }), + // The /notifications matrix shows the reminder's channel switches + // from the server's `eventDefaults`, which follow this card. + queryClient.invalidateQueries({ + queryKey: queryKeys.notificationsPreferences(), + }), + ]); setOptimistic(null); scheduleClear(); } else { diff --git a/src/lib/notifications/__tests__/event-defaults.test.ts b/src/lib/notifications/__tests__/event-defaults.test.ts new file mode 100644 index 000000000..af5d05ec9 --- /dev/null +++ b/src/lib/notifications/__tests__/event-defaults.test.ts @@ -0,0 +1,112 @@ +/** + * One answer to "what does an event do on a channel with no stored row". + * + * The dispatcher and the `/notifications` matrix used to answer it apart: the + * dispatcher derived the mood reminder from the reminder card, the matrix read + * the constant table, and an account receiving the reminder saw it switched + * off. Both now read `event-defaults.ts`; this file pins what it answers and + * that nothing on the client keeps its own copy of the table. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { walkSourceFiles } from "@/__tests__/helpers/source-files"; +import { EVENT_DEFAULT_ENABLED, EVENT_TYPES } from "@/lib/notifications/types"; +import { + eventDefaultFor, + eventDefaultsFor, + resolveEventDefault, + resolveEventDefaults, +} from "../event-defaults"; + +const findUnique = vi.mocked(prisma.user.findUnique); + +beforeEach(() => { + findUnique.mockReset(); +}); + +describe("eventDefaultsFor", () => { + it("follows the reminder card for the mood reminder", () => { + expect(eventDefaultsFor({ moodReminderEnabled: true }).MOOD_REMINDER).toBe( + true, + ); + expect(eventDefaultsFor({ moodReminderEnabled: false }).MOOD_REMINDER).toBe( + false, + ); + }); + + it("answers every other event from the constant table", () => { + const map = eventDefaultsFor({ moodReminderEnabled: true }); + for (const eventType of EVENT_TYPES) { + if (eventType === "MOOD_REMINDER") continue; + expect(map[eventType], eventType).toBe(EVENT_DEFAULT_ENABLED[eventType]); + } + }); + + it("covers every event the server knows", () => { + expect( + Object.keys(eventDefaultsFor({ moodReminderEnabled: false })).sort(), + ).toEqual([...EVENT_TYPES].sort()); + }); + + it("keeps the opt-out default for an event it does not know", () => { + expect( + eventDefaultFor("NOT_AN_EVENT", { moodReminderEnabled: false }), + ).toBe(true); + }); +}); + +describe("the account reads", () => { + it("the map and the dispatcher's per-event answer agree", async () => { + findUnique.mockResolvedValue({ moodReminderEnabled: true } as never); + const map = await resolveEventDefaults("u1"); + for (const eventType of EVENT_TYPES) { + expect(await resolveEventDefault("u1", eventType), eventType).toBe( + map[eventType], + ); + } + }); + + it("reads the account only for an event whose default follows it", async () => { + await resolveEventDefault("u1", "MEDICATION_REMINDER"); + expect(findUnique).not.toHaveBeenCalled(); + findUnique.mockResolvedValue({ moodReminderEnabled: false } as never); + expect(await resolveEventDefault("u1", "MOOD_REMINDER")).toBe(false); + expect(findUnique).toHaveBeenCalledTimes(1); + }); +}); + +describe("no client keeps a copy of the table", () => { + it("nothing under src/app or src/components imports EVENT_DEFAULT_ENABLED", () => { + const root = join(process.cwd(), "src"); + const offenders: string[] = []; + for (const dir of ["app", "components"]) { + // The floor makes a walk that found nothing fail instead of passing. + for (const rel of walkSourceFiles(join(root, dir), { floor: 300 })) { + if (rel.includes("__tests__/")) continue; + const text = readFileSync(join(root, dir, rel), "utf8"); + if (text.includes("EVENT_DEFAULT_ENABLED")) { + offenders.push(`${dir}/${rel}`); + } + } + } + // The same matcher has to find the server modules that do hold the + // table, or an empty offender list proves nothing about the matcher. + const serverHolders = walkSourceFiles(join(root, "lib"), { floor: 300 }) + .filter((rel) => !rel.includes("__tests__/")) + .filter((rel) => + readFileSync(join(root, "lib", rel), "utf8").includes( + "EVENT_DEFAULT_ENABLED", + ), + ); + expect(serverHolders.length).toBeGreaterThan(1); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index d51d0958e..153217a17 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -8,9 +8,8 @@ import type { WebhookChannelConfig, EmailChannelConfig, ChannelType, - EventType, } from "@/lib/notifications/types"; -import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types"; +import { resolveEventDefault } from "@/lib/notifications/event-defaults"; import { sendViaTelegram } from "@/lib/notifications/senders/telegram"; import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; @@ -222,9 +221,7 @@ export async function dispatchNotification( // lock-screen on first launch. The user enables it explicitly // from /settings/notifications once they've seen the badge a few // times and decided they want push. - let defaultEnabled = - EVENT_DEFAULT_ENABLED[recipientPayload.eventType as EventType] ?? true; - + // // v1.7.0 — MOOD_REMINDER single source of truth = the visible card. // The event used to default OFF, layered on top of the per-user // `moodReminderEnabled` flag, so a user who flipped the card still @@ -234,13 +231,14 @@ export async function dispatchNotification( // opt-out (a `NotificationPreference` row with `enabled = false`) is // still honoured below via `pref.enabled` — only the no-row default // is derived from the card. - if (recipientPayload.eventType === "MOOD_REMINDER") { - const user = await prisma.user.findUnique({ - where: { id: delivery.recipientUserId }, - select: { moodReminderEnabled: true }, - }); - defaultEnabled = user?.moodReminderEnabled === true; - } + // + // The resolution lives in `event-defaults.ts` because the /notifications + // matrix has to show the same answer: it reads the map the preferences + // route publishes from that module, never the constant table. + const defaultEnabled = await resolveEventDefault( + delivery.recipientUserId, + recipientPayload.eventType, + ); // Operator kill switches for the three channels that carry one, read at // most once per dispatch and only when such a channel is actually in the // cascade — a cascade of APNs + webhook + email pays nothing for it. diff --git a/src/lib/notifications/event-defaults.ts b/src/lib/notifications/event-defaults.ts new file mode 100644 index 000000000..baa113120 --- /dev/null +++ b/src/lib/notifications/event-defaults.ts @@ -0,0 +1,92 @@ +/** + * What an event does on a channel that carries no stored preference row. + * + * Most events have a constant answer (`EVENT_DEFAULT_ENABLED`). One does not: + * the mood reminder has no switch of its own at the channel layer, it follows + * the reminder card (`User.moodReminderEnabled`). The dispatcher has resolved + * it that way since v1.7.0, while the `/notifications` matrix read the + * constant table and showed the reminder as off on every channel of an account + * that was receiving it. + * + * This module is the one answer both read. The dispatcher asks per event, the + * preferences read publishes the whole map as `eventDefaults`, and a client + * renders that map instead of keeping a copy of the table — so the switch a + * person reads and the delivery they get cannot disagree again. + */ +import { prisma } from "@/lib/db"; +import { + EVENT_DEFAULT_ENABLED, + EVENT_TYPES, + type EventType, +} from "@/lib/notifications/types"; + +/** The account settings a default can follow. */ +export interface EventDefaultInputs { + moodReminderEnabled: boolean; +} + +/** + * The events whose default is read off the account rather than the constant + * table. Kept as a map so the account read below happens only for them. + */ +const ACCOUNT_DERIVED: Partial< + Record boolean> +> = { + MOOD_REMINDER: (inputs) => inputs.moodReminderEnabled, +}; + +function isEventType(value: string): value is EventType { + return (EVENT_TYPES as readonly string[]).includes(value); +} + +/** The no-row default for one event, given the account's settings. */ +export function eventDefaultFor( + eventType: string, + inputs: EventDefaultInputs, +): boolean { + if (!isEventType(eventType)) return true; + const derived = ACCOUNT_DERIVED[eventType]; + return derived ? derived(inputs) : EVENT_DEFAULT_ENABLED[eventType]; +} + +/** Every event's no-row default, given the account's settings. */ +export function eventDefaultsFor( + inputs: EventDefaultInputs, +): Record { + return Object.fromEntries( + EVENT_TYPES.map((eventType) => [ + eventType, + eventDefaultFor(eventType, inputs), + ]), + ) as Record; +} + +async function readInputs(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { moodReminderEnabled: true }, + }); + return { moodReminderEnabled: user?.moodReminderEnabled === true }; +} + +/** + * The dispatcher's question. Reads the account only for an event whose + * default follows it, so the common event types pay nothing. + */ +export async function resolveEventDefault( + userId: string, + eventType: string, +): Promise { + if (!isEventType(eventType)) return true; + const derived = ACCOUNT_DERIVED[eventType]; + return derived + ? derived(await readInputs(userId)) + : EVENT_DEFAULT_ENABLED[eventType]; +} + +/** The preferences read's answer: the whole map for one account. */ +export async function resolveEventDefaults( + userId: string, +): Promise> { + return eventDefaultsFor(await readInputs(userId)); +} diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index 45ce29dbc..7c639e155 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -66,7 +66,7 @@ const notificationPreference = z .meta({ id: "NotificationPreference", description: - "An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood and cycle reminders). Absence is therefore not 'disabled'.", + "An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows `eventDefaults` on the same response. Absence is therefore not 'disabled'.", }); const notificationPreferencesResponse = z @@ -78,6 +78,11 @@ const notificationPreferencesResponse = z .describe( "Every event category the server knows, in declaration order — the list a client renders rows for.", ), + eventDefaults: z + .record(eventTypeEnum, z.boolean()) + .describe( + "What each event does on a channel with no stored row, resolved for this account by the function delivery uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting (`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from a copy of the defaults. Since v1.43.11.", + ), }) .meta({ id: "NotificationPreferencesResponse", diff --git a/tests/integration/notification-event-defaults.test.ts b/tests/integration/notification-event-defaults.test.ts new file mode 100644 index 000000000..fee0d689b --- /dev/null +++ b/tests/integration/notification-event-defaults.test.ts @@ -0,0 +1,137 @@ +/** + * The `/notifications` matrix and delivery give one answer for an event with + * no stored preference row, against a real account row. + * + * The mood reminder is the event this exists for: it has no switch of its own + * at the channel layer and follows the reminder card. Delivery read the card, + * the matrix read a constant table, and an account receiving the reminder saw + * it switched off on every channel. The preferences read now publishes + * `eventDefaults` from the resolver delivery asks; this suite flips the real + * column and checks the published switch and the actual send move together. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const ntfySendMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: (...args: unknown[]) => ntfySendMock(...args), +})); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USERNAME = "event-defaults"; + +async function seedAccount(moodReminderEnabled: boolean): Promise { + const prisma = getPrismaClient(); + const { encrypt } = await import("@/lib/crypto"); + const user = await prisma.user.create({ + data: { + username: USERNAME, + email: `${USERNAME}@example.test`, + moodReminderEnabled, + }, + }); + await prisma.notificationChannel.create({ + data: { + userId: user.id, + type: "NTFY", + enabled: true, + config: encrypt( + JSON.stringify({ serverUrl: "https://ntfy.example", topic: "health" }), + ), + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user.id; +} + +async function readDefaults(): Promise> { + const { GET } = await import("@/app/api/notifications/preferences/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { preferences: unknown[]; eventDefaults: Record }; + }; + // The case is about the no-row path; a stored row would decide instead. + expect(body.data.preferences).toEqual([]); + return body.data.eventDefaults; +} + +async function dispatchMood(userId: string) { + const { dispatchNotification } = + await import("@/lib/notifications/dispatcher"); + return dispatchNotification({ + userId, + eventType: "MOOD_REMINDER", + title: "How are you today?", + message: "Log your mood", + }); +} + +beforeEach(async () => { + vi.clearAllMocks(); + ntfySendMock.mockResolvedValue({ ok: true }); + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("event defaults: the matrix shows what delivery does", () => { + it("reminder card on: the matrix shows ON and the reminder is sent", async () => { + const userId = await seedAccount(true); + + expect((await readDefaults()).MOOD_REMINDER).toBe(true); + + await dispatchMood(userId); + expect(ntfySendMock).toHaveBeenCalledTimes(1); + }); + + it("reminder card off: the matrix shows OFF and nothing is sent", async () => { + const userId = await seedAccount(false); + + expect((await readDefaults()).MOOD_REMINDER).toBe(false); + + await dispatchMood(userId); + expect(ntfySendMock).not.toHaveBeenCalled(); + }); + + it("publishes a default for every event the server knows", async () => { + await seedAccount(false); + const { EVENT_TYPES } = await import("@/lib/notifications/types"); + expect(Object.keys(await readDefaults()).sort()).toEqual( + [...EVENT_TYPES].sort(), + ); + }); +});