Merge branch 'fix/v14311-settings' into release/v1.43.11

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/nl.json
#	messages/pl.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 14:13:51 +02:00
commit eabfeaef08
74 files changed
+2545 -436

No files matched your search

@@ -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([]);
});
});
+10 -12
View File
@@ -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.
+92
View File
@@ -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<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>;
}
async function readInputs(userId: string): Promise<EventDefaultInputs> {
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<boolean> {
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<Record<EventType, boolean>> {
return eventDefaultsFor(await readInputs(userId));
}
+6 -1
View File
@@ -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",
+1
View File
@@ -37,6 +37,7 @@ export interface RejectedProfileField {
*/
export const PROFILE_FIELD_LABEL_KEYS: Record<string, string> = {
email: "auth.email",
displayName: "onboarding.baseline.displayNameLabel",
heightCm: "settings.height",
dateOfBirth: "settings.dateOfBirth",
gender: "settings.gender",