From dc704b5e63c45a9102e6590d63829ef979191deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:01:53 +0200 Subject: [PATCH 01/10] fix(notifications): show the mood reminder's real default in the matrix The /notifications matrix read the constant default table, where the mood reminder is off, while delivery derives it from the reminder card. An account receiving the reminder saw every one of its channel switches off. The no-row default now lives in one resolver that delivery and the preferences read both use. GET /api/notifications/preferences publishes it as `eventDefaults`, the matrix renders a switch without a stored row from that map, and the reminder card refreshes the matrix when it flips. --- docs/api/openapi.yaml | 53 ++++++- .../api/notifications/preferences/route.ts | 7 + src/app/notifications/__tests__/page.test.tsx | 54 +++++++ src/app/notifications/page.tsx | 17 ++- .../settings/mood-reminder-card.tsx | 13 +- .../__tests__/event-defaults.test.ts | 112 ++++++++++++++ src/lib/notifications/dispatcher.ts | 22 ++- src/lib/notifications/event-defaults.ts | 92 ++++++++++++ src/lib/openapi/routes/notifications.ts | 7 +- .../notification-event-defaults.test.ts | 137 ++++++++++++++++++ 10 files changed, 487 insertions(+), 27 deletions(-) create mode 100644 src/lib/notifications/__tests__/event-defaults.test.ts create mode 100644 src/lib/notifications/event-defaults.ts create mode 100644 tests/integration/notification-event-defaults.test.ts 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(), + ); + }); +}); From 9a8b95cfd0cffdcd87d841be74da12cd5f9cf9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:02:13 +0200 Subject: [PATCH 02/10] fix(ui): let ConfirmButton's dialog wear the destructive style ConfirmButton took a `destructive` prop and dropped it on the way to its dialog, so every delete, revoke and remove built on it confirmed with a neutral primary button while ConfirmDialog confirmed the same kind of act in red. The prop is forwarded now, and the call sites whose act takes something away pass it. A guard holds every confirmation whose slot names a destroying act to the destructive style; resets stay neutral. --- .../confirm-destructive-style-guard.test.ts | 104 ++++++++++++++++++ src/app/coach/plans/page.tsx | 1 + .../admin/ai-server-key-section.tsx | 1 + .../admin/central-codex-section.tsx | 1 + src/components/cycle/log-day-sheet.tsx | 2 + .../records/about-me-note-manager.tsx | 1 + src/components/records/audit-c-panel.tsx | 2 + .../settings/access/grants-given-card.tsx | 1 + .../settings/access/grants-received-card.tsx | 1 + .../settings/access/managed-profile-card.tsx | 1 + .../access/managed-profile-guardians.tsx | 1 + .../account-section/avatar-section.tsx | 1 + .../settings/ai/codex-provider-form.tsx | 1 + .../settings/coach-memory-section.tsx | 1 + .../settings/coach-reminders-section.tsx | 1 + .../settings/environment-section.tsx | 1 + .../settings/security-sessions-card.tsx | 2 + .../settings/trusted-devices-card.tsx | 2 + src/components/timeline/life-event-form.tsx | 1 + .../ui/__tests__/confirm-button.test.tsx | 53 +++++++++ src/components/ui/confirm-button.tsx | 2 + 21 files changed, 181 insertions(+) create mode 100644 src/__tests__/confirm-destructive-style-guard.test.ts diff --git a/src/__tests__/confirm-destructive-style-guard.test.ts b/src/__tests__/confirm-destructive-style-guard.test.ts new file mode 100644 index 000000000..44ae1df62 --- /dev/null +++ b/src/__tests__/confirm-destructive-style-guard.test.ts @@ -0,0 +1,104 @@ +/** + * A confirmation whose act takes something away wears the destructive style. + * + * `ConfirmButton` accepted a `destructive` prop and dropped it on the way to + * its dialog, so every delete, revoke and remove built on it asked its + * question over a neutral primary button — while the documents vault, which + * uses `ConfirmDialog`, asked the same question in red. The primitive forwards + * it now (`confirm-button.test.tsx` pins the wiring); this file pins that the + * call sites ask for it. + * + * The judgement "does this act destroy" is read off the `slot` the call site + * already names: a slot that says delete, remove, revoke, renounce, forget, + * clear or disconnect is that act. A reset is deliberately not in the list: + * it puts back a default the person can change again, and stays neutral. + * A slot without one of those words is not checked, which is the honest + * limit of a name-based rule; the slot is the one label every call site + * carries, and it is what the destructive-control registry is keyed on too. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const DESTROYING = /(delete|remove|revoke|renounce|forget|clear|disconnect)/i; +const TAGS = new Set(["ConfirmButton", "ConfirmDialog"]); + +interface Site { + file: string; + slot: string; + destructive: boolean; +} + +function slotText(attr: ts.JsxAttribute): string | null { + const init = attr.initializer; + if (!init) return null; + if (ts.isStringLiteral(init)) return init.text; + if (ts.isJsxExpression(init) && init.expression) { + const expr = init.expression; + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + return expr.text; + } + if (ts.isTemplateExpression(expr)) return expr.head.text; + } + return null; +} + +function collect(): Site[] { + const sites: Site[] = []; + for (const rel of walkSourceFiles(SRC, { + floor: 1200, + extensions: [".tsx"], + })) { + if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue; + const text = readFileSync(join(SRC, rel), "utf8"); + if (!text.includes(" { + const opening = ts.isJsxSelfClosingElement(node) + ? node + : ts.isJsxElement(node) + ? node.openingElement + : null; + if (opening && TAGS.has(opening.tagName.getText(sf))) { + let slot: string | null = null; + let destructive = false; + for (const prop of opening.attributes.properties) { + if (!ts.isJsxAttribute(prop)) continue; + const name = prop.name.getText(sf); + if (name === "slot") slot = slotText(prop); + if (name === "destructive") destructive = true; + } + if (slot !== null) sites.push({ file: rel, slot, destructive }); + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + return sites; +} + +describe("destroying confirmations wear the destructive style", () => { + const sites = collect(); + + it("finds the confirmations it is meant to check", () => { + // A matcher that found nothing would agree with any tree. + expect(sites.filter((s) => DESTROYING.test(s.slot)).length).toBeGreaterThan( + 15, + ); + }); + + it("every one whose slot names a destroying act passes `destructive`", () => { + const missing = sites + .filter((s) => DESTROYING.test(s.slot) && !s.destructive) + .map((s) => `${s.file}: ${s.slot}`); + expect( + missing, + "Pass `destructive` so the confirm button is red, or rename the slot if the act does not take anything away.", + ).toEqual([]); + }); +}); diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index f60a3cfc4..cd646d1e5 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -191,6 +191,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) { {(readOnly || group === "past") && ( } @@ -1471,6 +1472,7 @@ export function SymptomRow({ } variant="outline" diff --git a/src/components/records/audit-c-panel.tsx b/src/components/records/audit-c-panel.tsx index 6f040d9b4..c986cff7d 100644 --- a/src/components/records/audit-c-panel.tsx +++ b/src/components/records/audit-c-panel.tsx @@ -176,6 +176,7 @@ export function AuditCPanel({ className="text-destructive size-11 shrink-0 sm:size-9" icon={} slot={`audit-c-remove-${entry.id}`} + destructive /> ))} @@ -214,6 +215,7 @@ export function AuditCPanel({ className="text-destructive size-11 shrink-0 sm:size-9" icon={} slot="audit-c-legacy-remove" + destructive /> ) : null} diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index 71dc4d500..bf7a01b72 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -127,6 +127,7 @@ export function GrantsGivenCard() { return ( } diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index 78bfce68f..e8d0a18c1 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -210,6 +210,7 @@ export function CodexProviderForm({ ) : isConnected ? (