From e94257928a5acef1cb275aa7881eb27b479b84fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 15:07:05 +0200 Subject: [PATCH] fix(notifications): fall back to the shared defaults on an older payload A preferences response cached by a build before eventDefaults existed drew every default-on event as off in the matrix until the next refetch. The no-row rule moves to a client-safe module the server read still wraps, and the matrix applies it when the map is absent. --- src/app/notifications/page.tsx | 11 ++- .../__tests__/event-defaults.test.ts | 32 +++++++ src/lib/notifications/event-default-rule.ts | 83 +++++++++++++++++++ src/lib/notifications/event-defaults.ts | 52 +++--------- 4 files changed, 131 insertions(+), 47 deletions(-) create mode 100644 src/lib/notifications/event-default-rule.ts diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index ca5390281..5ff3c6766 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -17,6 +17,7 @@ import { eventTranslationKey, } from "@/lib/notifications/event-labels"; import { queryKeys } from "@/lib/query-keys"; +import { preferenceCellEnabled } from "@/lib/notifications/event-default-rule"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { Card, @@ -48,7 +49,8 @@ interface PreferencesData { * 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; + /** Absent on a response cached by a build before the field. */ + eventDefaults?: Record; } export default function NotificationsPage() { @@ -113,14 +115,11 @@ export default function NotificationsPage() { }); function isEnabled(channelId: string, eventType: string): boolean { - const pref = data?.preferences?.find( - (p) => p.channelId === channelId && p.eventType === eventType, - ); - if (pref) return pref.enabled; // 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; + // A cached payload without the map falls back to the shared rule. + return preferenceCellEnabled(data, channelId, eventType); } // Cross-links to the other two notification surfaces (reminder types + diff --git a/src/lib/notifications/__tests__/event-defaults.test.ts b/src/lib/notifications/__tests__/event-defaults.test.ts index af5d05ec9..3771f3094 100644 --- a/src/lib/notifications/__tests__/event-defaults.test.ts +++ b/src/lib/notifications/__tests__/event-defaults.test.ts @@ -25,6 +25,7 @@ import { resolveEventDefault, resolveEventDefaults, } from "../event-defaults"; +import { preferenceCellEnabled } from "../event-default-rule"; const findUnique = vi.mocked(prisma.user.findUnique); @@ -110,3 +111,34 @@ describe("no client keeps a copy of the table", () => { expect(offenders).toEqual([]); }); }); + +describe("the matrix cell on a payload without eventDefaults", () => { + it("falls back to the shared rule, not to everything off", () => { + // A response cached by a build before the field carries preferences + // only. Reading an absent map as false drew every default-on event as + // off until the next refetch. + const cached = { preferences: [] }; + const onByDefault = EVENT_TYPES.filter( + (type) => eventDefaultFor(type, { moodReminderEnabled: false }) === true, + ); + expect(onByDefault.length).toBeGreaterThan(0); + for (const type of onByDefault) { + expect(preferenceCellEnabled(cached, "c1", type), type).toBe(true); + } + // The mood reminder follows a setting the screen does not hold. + expect(preferenceCellEnabled(cached, "c1", "MOOD_REMINDER")).toBe(false); + }); + + it("still prefers a stored row, then the server's map", () => { + const data = { + preferences: [ + { channelId: "c1", eventType: "MOOD_REMINDER", enabled: true }, + ], + eventDefaults: { MEDICATION_REMINDER: false }, + }; + expect(preferenceCellEnabled(data, "c1", "MOOD_REMINDER")).toBe(true); + expect(preferenceCellEnabled(data, "c2", "MEDICATION_REMINDER")).toBe( + false, + ); + }); +}); diff --git a/src/lib/notifications/event-default-rule.ts b/src/lib/notifications/event-default-rule.ts new file mode 100644 index 000000000..73606b21e --- /dev/null +++ b/src/lib/notifications/event-default-rule.ts @@ -0,0 +1,83 @@ +/** + * The no-row default rule itself, without the account read: client-safe, so + * a screen that has to stand in for a missing server answer applies the same + * rule instead of a copy of the table (`./event-defaults` reads the account + * and calls this). + */ +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. + */ +export const ACCOUNT_DERIVED: Partial< + Record boolean> +> = { + MOOD_REMINDER: (inputs) => inputs.moodReminderEnabled, +}; + +export 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; +} + +/** + * Whether the matrix shows `eventType` on `channelId` as on: the stored row + * when there is one, else the server's resolved default for the account. A + * payload without `eventDefaults` (a response cached by a build before the + * field) falls back to this rule rather than to all-off; the mood reminder, + * whose default follows an account setting the screen does not hold, reads + * off until the server answers. + */ +export function preferenceCellEnabled( + data: + | { + preferences?: ReadonlyArray<{ + channelId: string; + eventType: string; + enabled: boolean; + }>; + eventDefaults?: Partial>; + } + | undefined, + channelId: string, + eventType: string, +): boolean { + const pref = data?.preferences?.find( + (p) => p.channelId === channelId && p.eventType === eventType, + ); + if (pref) return pref.enabled; + const resolved = data?.eventDefaults?.[eventType]; + if (typeof resolved === "boolean") return resolved; + return eventDefaultFor(eventType, { moodReminderEnabled: false }); +} diff --git a/src/lib/notifications/event-defaults.ts b/src/lib/notifications/event-defaults.ts index baa113120..765035827 100644 --- a/src/lib/notifications/event-defaults.ts +++ b/src/lib/notifications/event-defaults.ts @@ -14,52 +14,22 @@ * person reads and the delivery they get cannot disagree again. */ import { prisma } from "@/lib/db"; +import { + ACCOUNT_DERIVED, + eventDefaultsFor, + isEventType, + type EventDefaultInputs, +} from "@/lib/notifications/event-default-rule"; 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; -} +export { + eventDefaultFor, + eventDefaultsFor, + type EventDefaultInputs, +} from "@/lib/notifications/event-default-rule"; async function readInputs(userId: string): Promise { const user = await prisma.user.findUnique({