mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
88200a9273
commit
e94257928a
4 files changed
+131
-47
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user