mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user