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

-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Medikamenten-Erinnerung",
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
"eventMeasurementAnomaly": "Auffälliger Messwert",
"eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.",
"eventComplianceLow": "Niedrige Therapietreue",
"eventComplianceLowDesc": "Wird gesendet, wenn deine Therapietreue in den letzten Tagen unter 80 % fällt.",
"eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen",
"eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.",
"eventSystemAlert": "Systemmeldung",
"eventSystemAlertDesc": "Allgemeine Mitteilungen vom HealthLog-Server, etwa Wartungsfenster oder Konto-Hinweise.",
"eventPersonalRecord": "Persönlicher Rekord",
"eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.",
"eventMoodReminder": "Stimmungs-Erinnerung",
"eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren.",
"eventCyclePeriodSoon": "Periode steht bevor",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Medication reminder",
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
"eventMeasurementAnomaly": "Unusual measurement",
"eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.",
"eventComplianceLow": "Low adherence",
"eventComplianceLowDesc": "Sent when your adherence over the last few days drops below 80 %.",
"eventWithingsSyncFailed": "Withings sync failed",
"eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.",
"eventSystemAlert": "System notice",
"eventSystemAlertDesc": "General messages from the HealthLog server, e.g. maintenance windows or account notes.",
"eventPersonalRecord": "Personal record",
"eventPersonalRecordDesc": "Sent when a reading beats your previous best.",
"eventMoodReminder": "Mood reminder",
"eventMoodReminderDesc": "Daily nudge to log your current mood.",
"eventCyclePeriodSoon": "Period coming up",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Recordatorio de medicación",
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
"eventMeasurementAnomaly": "Medición inusual",
"eventMeasurementAnomalyDesc": "Se envía cuando una lectura está claramente fuera de tu rango habitual.",
"eventComplianceLow": "Cumplimiento bajo",
"eventComplianceLowDesc": "Se envía cuando tu tasa de toma de los últimos días baja del 80 %.",
"eventWithingsSyncFailed": "Error de sincronización con Withings",
"eventWithingsSyncFailedDesc": "Se envía cuando la sincronización con Withings falla varios intentos seguidos.",
"eventSystemAlert": "Aviso del sistema",
"eventSystemAlertDesc": "Mensajes generales del servidor HealthLog, por ejemplo ventanas de mantenimiento o avisos de cuenta.",
"eventPersonalRecord": "Récord personal",
"eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.",
"eventMoodReminder": "Recordatorio de ánimo",
"eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual.",
"eventCyclePeriodSoon": "Período próximo",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Rappel de médicament",
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
"eventMeasurementAnomaly": "Mesure inhabituelle",
"eventMeasurementAnomalyDesc": "Envoyé quand une valeur sort nettement de votre plage habituelle.",
"eventComplianceLow": "Observance faible",
"eventComplianceLowDesc": "Envoyé quand votre taux de prise des derniers jours passe sous les 80 %.",
"eventWithingsSyncFailed": "Échec de la synchronisation Withings",
"eventWithingsSyncFailedDesc": "Envoyé quand la synchronisation Withings échoue plusieurs fois d'affilée.",
"eventSystemAlert": "Notification système",
"eventSystemAlertDesc": "Messages généraux du serveur HealthLog, par exemple maintenance ou avis de compte.",
"eventPersonalRecord": "Record personnel",
"eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.",
"eventMoodReminder": "Rappel d'humeur",
"eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment.",
"eventCyclePeriodSoon": "Règles à venir",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Promemoria farmaco",
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
"eventMeasurementAnomaly": "Misurazione insolita",
"eventMeasurementAnomalyDesc": "Inviato quando un valore esce nettamente dal tuo intervallo abituale.",
"eventComplianceLow": "Aderenza bassa",
"eventComplianceLowDesc": "Inviato quando il tuo tasso di assunzione degli ultimi giorni scende sotto l'80 %.",
"eventWithingsSyncFailed": "Sincronizzazione Withings non riuscita",
"eventWithingsSyncFailedDesc": "Inviato quando la sincronizzazione Withings fallisce per più tentativi consecutivi.",
"eventSystemAlert": "Avviso di sistema",
"eventSystemAlertDesc": "Messaggi generali dal server HealthLog, ad esempio finestre di manutenzione o note sull'account.",
"eventPersonalRecord": "Record personale",
"eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.",
"eventMoodReminder": "Promemoria umore",
"eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale.",
"eventCyclePeriodSoon": "Ciclo in arrivo",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "복약 알림",
"eventMedicationReminderDesc": "예정된 복용 시각마다 보내요.",
"eventMeasurementAnomaly": "이상 측정값",
"eventMeasurementAnomalyDesc": "측정값이 평소 범위를 크게 벗어나면 보내요.",
"eventComplianceLow": "낮은 복약 순응도",
"eventComplianceLowDesc": "최근 며칠 복약 순응도가 80% 아래로 떨어지면 보내요.",
"eventWithingsSyncFailed": "Withings 동기화 실패",
"eventWithingsSyncFailedDesc": "Withings 동기화가 여러 번 연달아 실패하면 보내요.",
"eventSystemAlert": "시스템 공지",
"eventSystemAlertDesc": "HealthLog 서버에서 오는 일반 메시지예요. 예를 들어 점검 시간이나 계정 안내.",
"eventPersonalRecord": "개인 기록",
"eventPersonalRecordDesc": "측정값이 이전 최고 기록을 넘으면 보내요.",
"eventMoodReminder": "기분 알림",
"eventMoodReminderDesc": "지금 기분을 기록하도록 매일 가볍게 알려줘요.",
"eventCyclePeriodSoon": "생리 예정",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Medicatieherinnering",
"eventMedicationReminderDesc": "Wordt verstuurd op elk gepland innamemoment.",
"eventMeasurementAnomaly": "Ongebruikelijke meting",
"eventMeasurementAnomalyDesc": "Wordt verstuurd als een meetwaarde ver buiten je gebruikelijke bereik ligt.",
"eventComplianceLow": "Lage therapietrouw",
"eventComplianceLowDesc": "Wordt verstuurd als je therapietrouw over de afgelopen dagen onder 80% zakt.",
"eventWithingsSyncFailed": "Withings-synchronisatie mislukt",
"eventWithingsSyncFailedDesc": "Wordt verstuurd als de Withings-synchronisatie meerdere keren achter elkaar mislukt.",
"eventSystemAlert": "Systeembericht",
"eventSystemAlertDesc": "Algemene berichten van de HealthLog-server, bijv. onderhoudsvensters of opmerkingen over je account.",
"eventPersonalRecord": "Persoonlijk record",
"eventPersonalRecordDesc": "Wordt verstuurd als een meetwaarde je vorige beste overtreft.",
"eventMoodReminder": "Stemmingsherinnering",
"eventMoodReminderDesc": "Dagelijks seintje om je huidige stemming vast te leggen.",
"eventCyclePeriodSoon": "Menstruatie op komst",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Przypomnienie o leku",
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
"eventMeasurementAnomaly": "Nietypowy pomiar",
"eventMeasurementAnomalyDesc": "Wysyłane, gdy odczyt znacznie odbiega od Twojego zwykłego zakresu.",
"eventComplianceLow": "Niska zgodność",
"eventComplianceLowDesc": "Wysyłane, gdy odsetek przyjętych dawek z ostatnich dni spadnie poniżej 80 %.",
"eventWithingsSyncFailed": "Synchronizacja Withings nie powiodła się",
"eventWithingsSyncFailedDesc": "Wysyłane, gdy synchronizacja Withings nie powiedzie się kilka razy z rzędu.",
"eventSystemAlert": "Powiadomienie systemowe",
"eventSystemAlertDesc": "Ogólne komunikaty z serwera HealthLog, na przykład okna serwisowe lub uwagi do konta.",
"eventPersonalRecord": "Rekord osobisty",
"eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.",
"eventMoodReminder": "Przypomnienie o nastroju",
"eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój.",
"eventCyclePeriodSoon": "Zbliża się okres",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Lembrete de medicamento",
"eventMedicationReminderDesc": "Enviado em cada horário programado de dose.",
"eventMeasurementAnomaly": "Medição incomum",
"eventMeasurementAnomalyDesc": "Enviado quando uma leitura está bem fora da sua faixa habitual.",
"eventComplianceLow": "Adesão baixa",
"eventComplianceLowDesc": "Enviado quando sua adesão nos últimos dias cai abaixo de 80%.",
"eventWithingsSyncFailed": "Falha na sincronização do Withings",
"eventWithingsSyncFailedDesc": "Enviado quando a sincronização do Withings falha várias tentativas seguidas.",
"eventSystemAlert": "Aviso do sistema",
"eventSystemAlertDesc": "Mensagens gerais do servidor do HealthLog, como janelas de manutenção ou notas da conta.",
"eventPersonalRecord": "Recorde pessoal",
"eventPersonalRecordDesc": "Enviado quando uma leitura supera seu recorde anterior.",
"eventMoodReminder": "Lembrete de humor",
"eventMoodReminderDesc": "Lembrete diário para registrar seu humor atual.",
"eventCyclePeriodSoon": "Menstruação se aproximando",
-8
View File
@@ -9314,16 +9314,8 @@
},
"eventMedicationReminder": "Напоминание о лекарстве",
"eventMedicationReminderDesc": "Отправляется в каждое запланированное время приёма.",
"eventMeasurementAnomaly": "Необычное измерение",
"eventMeasurementAnomalyDesc": "Отправляется, когда измерение заметно выходит за ваш обычный диапазон.",
"eventComplianceLow": "Низкая приверженность",
"eventComplianceLowDesc": "Отправляется, когда приверженность за последние несколько дней падает ниже 80 %.",
"eventWithingsSyncFailed": "Сбой синхронизации Withings",
"eventWithingsSyncFailedDesc": "Отправляется, когда синхронизация Withings не удаётся несколько раз подряд.",
"eventSystemAlert": "Системное уведомление",
"eventSystemAlertDesc": "Общие сообщения от сервера HealthLog, например об окнах обслуживания или заметки об аккаунте.",
"eventPersonalRecord": "Личный рекорд",
"eventPersonalRecordDesc": "Отправляется, когда измерение превосходит ваш прежний лучший результат.",
"eventMoodReminder": "Напоминание о настроении",
"eventMoodReminderDesc": "Ежедневное напоминание отметить текущее настроение.",
"eventCyclePeriodSoon": "Скоро менструация",
@@ -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({
@@ -131,3 +131,49 @@ describe("event defaults: the matrix shows what delivery does", () => {
);
});
});
describe("events nothing sends", () => {
it("are listed for nobody, and their stored rows are not served", async () => {
const userId = await seedAccount(false);
const prisma = getPrismaClient();
const channel = await prisma.notificationChannel.findFirstOrThrow({
where: { userId },
});
// Rows written while the four switches were still offered.
await prisma.notificationPreference.createMany({
data: [
{ channelId: channel.id, eventType: "PERSONAL_RECORD", enabled: true },
{
channelId: channel.id,
eventType: "MEASUREMENT_ANOMALY",
enabled: false,
},
{
channelId: channel.id,
eventType: "MEDICATION_REMINDER",
enabled: false,
},
],
});
const { GET } = await import("@/app/api/notifications/preferences/route");
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
eventTypes: string[];
preferences: { eventType: string; enabled: boolean }[];
};
};
const { UNSENT_EVENT_TYPES } = await import("@/lib/notifications/types");
for (const unsent of UNSENT_EVENT_TYPES) {
expect(body.data.eventTypes).not.toContain(unsent);
}
expect(body.data.preferences).toEqual([
expect.objectContaining({
eventType: "MEDICATION_REMINDER",
enabled: false,
}),
]);
});
});