fix(notifications): drop the four switches nothing sends

The notification matrix listed "Unusual measurement", "Low adherence",
"Withings sync failed" and "Personal record" with a switch per channel,
three of them on by default, yet nothing dispatches any of them: a reading
far outside its range or a week of missed doses produced no message.

eventTypesForRole now leaves them out for every role, next to the
admin-only filter, and the preferences read no longer serves rows stored
for them. Their labels in all locales and their tap targets are deleted.
The types stay in EVENT_TYPES, so the preference write and the stored rows
keep parsing; no migration.

The label and tap-target tests walked EVENT_TYPES and so pinned the four
in place; they now walk the event types that are sent.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:53:01 +01:00
1 parent aae029d94e
commit 8057bb20bc
20 files changed
+207 -140

No files matched your search

@@ -2606,7 +2606,7 @@
"lib/notifications/senders/telegram.ts": 4,
"lib/notifications/senders/web-push.ts": 5,
"lib/notifications/senders/webhook.ts": 1,
"lib/notifications/types.ts": 21,
"lib/notifications/types.ts": 20,
"lib/notifications/web-push-clear.ts": 1,
"lib/nutrients/__tests__/catalog.test.ts": 1,
"lib/nutrients/catalog.ts": 8,
+13 -8
View File
@@ -84,20 +84,25 @@ export const GET = apiHandler(async () => {
globallyEnabled: globalToggles[ch.type] ?? true,
}));
// Admin-only events (backup health) are listed for admins only, and an
// event nothing sends is listed for nobody: a switch for either would
// switch nothing. A stored row for an unlisted event stays in the table and
// is not served.
const eventTypes: string[] = eventTypesForRole(user.role);
const preferences = channels.flatMap((ch) =>
ch.preferences.map((p) => ({
channelId: p.channelId,
eventType: p.eventType,
enabled: p.enabled,
})),
ch.preferences
.filter((p) => eventTypes.includes(p.eventType))
.map((p) => ({
channelId: p.channelId,
eventType: p.eventType,
enabled: p.enabled,
})),
);
return apiSuccess({
channels: channelData,
preferences,
// Admin-only events (backup health) are listed for admins only: nobody
// else is ever sent one, so a switch for it would switch nothing.
eventTypes: eventTypesForRole(user.role),
eventTypes,
// What an event does on a channel with no stored row, resolved for this
// account by the same function the dispatcher asks. A client renders a
// switch without a row from this map, never from its own copy of the
@@ -143,8 +143,8 @@ describe("NotificationsPage", () => {
},
],
preferences: [],
eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"],
eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false },
eventTypes: ["MOOD_REMINDER", "DAILY_BRIEFING"],
eventDefaults: { MOOD_REMINDER: true, DAILY_BRIEFING: false },
};
const html = render();
const state = (label: string) =>
@@ -152,10 +152,10 @@ describe("NotificationsPage", () => {
`aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`,
).exec(html);
const mood = state("Mood reminder");
const record = state("Personal record");
const briefing = state("Daily briefing");
expect(mood, "mood switch rendered").not.toBeNull();
expect(mood?.[1] ?? mood?.[2]).toBe("checked");
expect(record?.[1] ?? record?.[2]).toBe("unchecked");
expect(briefing?.[1] ?? briefing?.[2]).toBe("unchecked");
});
it("a stored row still wins over the default", () => {
@@ -1,7 +1,8 @@
/**
* Every event type the /notifications matrix renders has words to render.
*
* The matrix builds its rows from `EVENT_TYPES` and derives each row's label
* The matrix builds its rows from the event list the preferences read serves
* (`eventTypesForRole`) and derives each row's label
* and description key from the event name, so a new event type silently gets a
* row whether or not anybody wrote the two strings for it. That is not a
* theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key
@@ -20,7 +21,10 @@
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { EVENT_TYPES } from "@/lib/notifications/types";
import {
UNSENT_EVENT_TYPES,
eventTypesForRole,
} from "@/lib/notifications/types";
import {
eventDescriptionTranslationKey,
eventTranslationKey,
@@ -60,14 +64,16 @@ describe("notification event labels", () => {
);
});
it("has a label and a description for every event type, in every locale", () => {
it("has a label and a description for every listed event type, in every locale", () => {
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
expect(EVENT_TYPES.length).toBeGreaterThan(10);
// ADMIN sees the widest list: every event that has a switch at all.
const listed = eventTypesForRole("ADMIN");
expect(listed.length).toBeGreaterThan(10);
const missing: string[] = [];
for (const locale of LOCALES) {
const root = bundle(locale);
for (const eventType of EVENT_TYPES) {
for (const eventType of listed) {
for (const key of [
eventTranslationKey(eventType),
eventDescriptionTranslationKey(eventType),
@@ -82,6 +88,24 @@ describe("notification event labels", () => {
expect(missing).toEqual([]);
});
it("keeps no label for an event nothing sends", () => {
const leftover: string[] = [];
for (const locale of LOCALES) {
const root = bundle(locale);
for (const eventType of UNSENT_EVENT_TYPES) {
for (const key of [
eventTranslationKey(eventType),
eventDescriptionTranslationKey(eventType),
]) {
if (resolve(root, key) !== undefined) {
leftover.push(`${locale} still has ${key}`);
}
}
}
}
expect(leftover).toEqual([]);
});
});
describe("the delegated-dose push copy", () => {
@@ -0,0 +1,40 @@
/**
* The event types a person is shown a switch for.
*
* The preferences read lists `eventTypesForRole(role)` as the matrix rows and
* serves stored rows only for those. An event nothing sends is listed for
* nobody; backup health for admins only.
*/
import { describe, expect, it } from "vitest";
import {
EVENT_TYPES,
UNSENT_EVENT_TYPES,
eventTypesForRole,
isSentEventType,
} from "@/lib/notifications/types";
describe("eventTypesForRole", () => {
it.each(["USER", "ADMIN"])("lists no event nothing sends (%s)", (role) => {
const listed: string[] = eventTypesForRole(role);
expect(listed).toContain("MEDICATION_REMINDER");
for (const unsent of UNSENT_EVENT_TYPES) {
expect(listed).not.toContain(unsent);
}
});
it("lists backup health for an admin only", () => {
expect(eventTypesForRole("USER")).not.toContain("BACKUP_HEALTH");
expect(eventTypesForRole("ADMIN")).toContain("BACKUP_HEALTH");
});
it("keeps the declaration order", () => {
expect(eventTypesForRole("ADMIN")).toEqual(
EVENT_TYPES.filter((type) => isSentEventType(type)),
);
});
it("knows no event outside the union", () => {
expect(isSentEventType("NOT_AN_EVENT")).toBe(false);
});
});
@@ -1,9 +1,9 @@
/**
* The event → tap-target map and its resolver.
*
* Walks `EVENT_TYPES` rather than the map's own keys, so an event type added
* to the union without a decision about where it leads fails here (the
* `Record<EventType, …>` type already refuses to compile without one; this
* Walks the sent event types rather than the map's own keys, so an event type
* added to the union without a decision about where it leads fails here (the
* `Record<SentEventType, …>` type already refuses to compile without one; this
* pins that every entry is also a usable path).
*/
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -19,6 +19,8 @@ import { buildNtfyMessage } from "@/lib/notifications/senders/ntfy";
import { buildWebhookBody } from "@/lib/notifications/senders/webhook";
import {
EVENT_TYPES,
UNSENT_EVENT_TYPES,
isSentEventType,
type EventType,
type NotificationPayload,
} from "@/lib/notifications/types";
@@ -35,6 +37,8 @@ import {
/** Events never shown to the user, so they carry no target. */
const SILENT: ReadonlySet<EventType> = new Set(["MEDICATION_INTAKE_SYNC"]);
const SENT = EVENT_TYPES.filter(isSentEventType);
const ORIGINAL = {
APP_URL: process.env.APP_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
@@ -48,9 +52,9 @@ afterEach(() => {
});
describe("NOTIFICATION_TAP_PATHS", () => {
it("names a target for every event type", () => {
expect(EVENT_TYPES.length).toBeGreaterThan(0);
for (const eventType of EVENT_TYPES) {
it("names a target for every sent event type", () => {
expect(SENT.length).toBeGreaterThan(0);
for (const eventType of SENT) {
expect(
Object.prototype.hasOwnProperty.call(NOTIFICATION_TAP_PATHS, eventType),
`${eventType} has no tap target`,
@@ -68,17 +72,20 @@ describe("NOTIFICATION_TAP_PATHS", () => {
it("keeps the targets the issue names", () => {
expect(NOTIFICATION_TAP_PATHS.MOOD_REMINDER).toBe("/mood?add=1");
expect(NOTIFICATION_TAP_PATHS.DAILY_BRIEFING).toBe("/");
expect(NOTIFICATION_TAP_PATHS.WITHINGS_SYNC_FAILED).toBe(
"/settings/integrations",
);
expect(NOTIFICATION_TAP_PATHS.MEDICATION_REMINDER).toBe("/medications");
});
it("has no entry for an event type outside the union", () => {
it("has no entry for an event type outside the sent set", () => {
expect(Object.keys(NOTIFICATION_TAP_PATHS).sort()).toEqual(
[...EVENT_TYPES].sort(),
[...SENT].sort(),
);
});
it("gives an event nothing sends no target", () => {
for (const eventType of UNSENT_EVENT_TYPES) {
expect(resolveTapPath({ eventType }), eventType).toBeNull();
}
});
});
describe("resolveTapPath", () => {
@@ -212,7 +219,7 @@ describe("tap target on every kind and channel", () => {
it.each(variants)("$name", async ({ extra, neutral }) => {
process.env.APP_URL = "https://health.example";
let checked = 0;
for (const eventType of EVENT_TYPES) {
for (const eventType of SENT) {
const payload = await withDeliveryLinks(
{ userId: "u1", title: "t", message: "m", eventType, ...extra },
"guardian-1",
@@ -244,6 +251,6 @@ describe("tap target on every kind and channel", () => {
).toBe(url);
checked += 1;
}
expect(checked).toBe(EVENT_TYPES.length - SILENT.size);
expect(checked).toBe(SENT.length - SILENT.size);
});
});
+13 -10
View File
@@ -13,29 +13,30 @@
* - Webhook → `url` in the body (absolute URL)
* - Email → a link line under the body (absolute URL)
*
* The map is typed `Record<EventType, …>`, so a new event type without a
* target does not compile, and `tap-target.test.ts` walks `EVENT_TYPES` so a
* target that stops resolving fails there too.
* The map is typed `Record<SentEventType, …>`, so a new event type without a
* target does not compile, and `tap-target.test.ts` walks the sent event
* types so a target that stops resolving fails there too. An event type
* nothing sends (`UNSENT_EVENT_TYPES`) has no entry and resolves to no target.
*
* A producer may name a more precise path in `metadata.url` (the low-stock
* alert opens the medication's stock tab, the Coach nudge opens its own
* conversation). That override is accepted only as a same-origin path, so a
* value that names another host can never become a click target.
*/
import type { EventType, NotificationPayload } from "@/lib/notifications/types";
import type {
EventType,
NotificationPayload,
SentEventType,
} from "@/lib/notifications/types";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
/**
* The default in-app path per event type. `null` means the event is never
* shown to the user (a silent background push), so it carries no target.
*/
export const NOTIFICATION_TAP_PATHS: Record<EventType, string | null> = {
export const NOTIFICATION_TAP_PATHS: Record<SentEventType, string | null> = {
MEDICATION_REMINDER: "/medications",
MEASUREMENT_ANOMALY: "/measurements",
COMPLIANCE_LOW: "/insights/medications",
WITHINGS_SYNC_FAILED: "/settings/integrations",
SYSTEM_ALERT: "/",
PERSONAL_RECORD: "/achievements",
// `?add=1` opens the mood entry sheet on the first paint, and only in the
// recipient's own record: a session switched into another record opens the
// page without the sheet (`src/app/mood/page-client.tsx`). No other target
@@ -97,7 +98,9 @@ export function resolveTapPath(
"eventType" | "metadata" | "discreet" | "recordUserId" | "recipientUserId"
>,
): string | null {
const fallback = NOTIFICATION_TAP_PATHS[payload.eventType];
const paths: Partial<Record<EventType, string | null>> =
NOTIFICATION_TAP_PATHS;
const fallback = paths[payload.eventType];
if (fallback === null || fallback === undefined) return null;
if (isManagedGuardianDelivery(payload)) return NEUTRAL_TAP_PATH;
if (payload.discreet) return NEUTRAL_TAP_PATH;
+39 -17
View File
@@ -2,9 +2,8 @@ import type { Locale } from "@/lib/i18n/config";
import type { NotificationQuickAction } from "@/lib/notifications/action-token";
/**
* Notification system type definitions.
* Channels: Telegram, ntfy, Web Push
* Events: medication reminders, anomalies, compliance, etc.
* Notification system type definitions: the channels a notification travels
* on and the event types it can carry.
*/
export const CHANNEL_TYPES = [
@@ -30,13 +29,12 @@ export type ChannelType = (typeof CHANNEL_TYPES)[number];
export const EVENT_TYPES = [
"MEDICATION_REMINDER",
// The next three and PERSONAL_RECORD are never sent; see
// `UNSENT_EVENT_TYPES`.
"MEASUREMENT_ANOMALY",
"COMPLIANCE_LOW",
"WITHINGS_SYNC_FAILED",
"SYSTEM_ALERT",
// v1.4.25 — PR detection. Default OFF (see DEFAULT_EVENT_PREFS
// below) so a multi-year Apple Health backfill doesn't fire hundreds
// of pushes during initial sync.
"PERSONAL_RECORD",
// v0.5.4 ios-coord — daily mood-reminder nudge. Default OFF on the
// per-event policy (see DEFAULT_EVENT_PREFS) and additionally gated
@@ -112,8 +110,7 @@ export const EVENT_TYPES = [
// a deterministic floor). It is a nudge, NEVER the anomaly channel: it is
// dispatched non-urgent, fired at most once per user per local day (the
// `push_attempts` ledger is the frequency anchor), and only inside a local
// morning window, so a concerning reading always stays on
// `MEASUREMENT_ANOMALY` and this never bypasses Focus.
// morning window, so it never bypasses Focus.
"DAILY_BRIEFING",
// v1.36.x — somebody with a write grant on this record marked one of the
// owner's doses. The only notification a delegated write produces: every
@@ -154,11 +151,9 @@ export type ManagedGuardianFanoutEvent =
* exists. The dispatcher reads this to decide whether to fire on a
* channel that has never been explicitly toggled for the event.
*
* Every event defaults to ON (the original opt-out model) EXCEPT
* `PERSONAL_RECORD`: PRs are silent by default and require the user
* to explicitly enable them in `/settings/notifications`. Rationale —
* a fresh iOS install backfilling years of HealthKit history would
* otherwise saturate the user's lock-screen on first launch.
* Events default to ON (the original opt-out model); the ones that
* default OFF say why beside their entry. The entries for
* `UNSENT_EVENT_TYPES` decide nothing, since nothing dispatches them.
*/
export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
MEDICATION_REMINDER: true,
@@ -229,11 +224,38 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
/** Event types only an admin is ever sent, and only an admin is shown. */
export const ADMIN_ONLY_EVENT_TYPES: readonly EventType[] = ["BACKUP_HEALTH"];
/**
* Event types nothing sends. They stay in `EVENT_TYPES` because the
* preference write and stored preference rows still name them, but nobody is
* shown a switch for them and they carry no tap target: a switch for an event
* nobody emits would switch nothing. Withings failures reach admins as
* `SYSTEM_ALERT`.
*/
export const UNSENT_EVENT_TYPES = [
"MEASUREMENT_ANOMALY",
"COMPLIANCE_LOW",
"WITHINGS_SYNC_FAILED",
"PERSONAL_RECORD",
] as const satisfies readonly EventType[];
/** The event types something sends. */
export type SentEventType = Exclude<
EventType,
(typeof UNSENT_EVENT_TYPES)[number]
>;
export function isSentEventType(type: string): type is SentEventType {
return (
(EVENT_TYPES as readonly string[]).includes(type) &&
!(UNSENT_EVENT_TYPES as readonly string[]).includes(type)
);
}
/** The event types a person with `role` can receive, in matrix order. */
export function eventTypesForRole(role: string): EventType[] {
return role === "ADMIN"
? [...EVENT_TYPES]
: EVENT_TYPES.filter((type) => !ADMIN_ONLY_EVENT_TYPES.includes(type));
export function eventTypesForRole(role: string): SentEventType[] {
return EVENT_TYPES.filter(isSentEventType).filter(
(type) => role === "ADMIN" || !ADMIN_ONLY_EVENT_TYPES.includes(type),
);
}
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
+2 -2
View File
@@ -76,7 +76,7 @@ const notificationPreferencesResponse = z
eventTypes: z
.array(z.string())
.describe(
"Every event category the server knows, in declaration order — the list a client renders rows for.",
"The event categories this account is shown a switch for, in declaration order — the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.",
),
eventDefaults: z
.record(eventTypeEnum, z.boolean())
@@ -87,7 +87,7 @@ const notificationPreferencesResponse = z
.meta({
id: "NotificationPreferencesResponse",
description:
"The caller's notification channels, their stored per-event overrides, and the full event vocabulary.",
"The caller's notification channels, their stored per-event overrides, and the event categories they can switch.",
});
const notificationPreferenceRequest = notificationPreferenceSchema.meta({