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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 15:07:05 +02:00
1 parent 88200a9273
commit e94257928a
4 files changed
+131 -47

No files matched your search

+5 -6
View File
@@ -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<string, boolean>;
/** Absent on a response cached by a build before the field. */
eventDefaults?: Record<string, boolean>;
}
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 +
@@ -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,
);
});
});
@@ -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<EventType, (inputs: EventDefaultInputs) => 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<EventType, boolean> {
return Object.fromEntries(
EVENT_TYPES.map((eventType) => [
eventType,
eventDefaultFor(eventType, inputs),
]),
) as Record<EventType, boolean>;
}
/**
* 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<Record<string, boolean>>;
}
| 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 });
}
+11 -41
View File
@@ -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<EventType, (inputs: EventDefaultInputs) => 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<EventType, boolean> {
return Object.fromEntries(
EVENT_TYPES.map((eventType) => [
eventType,
eventDefaultFor(eventType, inputs),
]),
) as Record<EventType, boolean>;
}
export {
eventDefaultFor,
eventDefaultsFor,
type EventDefaultInputs,
} from "@/lib/notifications/event-default-rule";
async function readInputs(userId: string): Promise<EventDefaultInputs> {
const user = await prisma.user.findUnique({