diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..f4f2254d2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..891a3d113 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index a2b612bad..ba52d239e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index af97df036..e9de10274 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index fed51449c..8c812c639 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..9c3a66065 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "복약 알림", "eventMedicationReminderDesc": "예정된 복용 시각마다 보내요.", - "eventMeasurementAnomaly": "이상 측정값", - "eventMeasurementAnomalyDesc": "측정값이 평소 범위를 크게 벗어나면 보내요.", - "eventComplianceLow": "낮은 복약 순응도", - "eventComplianceLowDesc": "최근 며칠 복약 순응도가 80% 아래로 떨어지면 보내요.", - "eventWithingsSyncFailed": "Withings 동기화 실패", - "eventWithingsSyncFailedDesc": "Withings 동기화가 여러 번 연달아 실패하면 보내요.", "eventSystemAlert": "시스템 공지", "eventSystemAlertDesc": "HealthLog 서버에서 오는 일반 메시지예요. 예를 들어 점검 시간이나 계정 안내.", - "eventPersonalRecord": "개인 기록", - "eventPersonalRecordDesc": "측정값이 이전 최고 기록을 넘으면 보내요.", "eventMoodReminder": "기분 알림", "eventMoodReminderDesc": "지금 기분을 기록하도록 매일 가볍게 알려줘요.", "eventCyclePeriodSoon": "생리 예정", diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..e8140cc87 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..123971e26 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..ea15f0429 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..6ca4cdc82 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Напоминание о лекарстве", "eventMedicationReminderDesc": "Отправляется в каждое запланированное время приёма.", - "eventMeasurementAnomaly": "Необычное измерение", - "eventMeasurementAnomalyDesc": "Отправляется, когда измерение заметно выходит за ваш обычный диапазон.", - "eventComplianceLow": "Низкая приверженность", - "eventComplianceLowDesc": "Отправляется, когда приверженность за последние несколько дней падает ниже 80 %.", - "eventWithingsSyncFailed": "Сбой синхронизации Withings", - "eventWithingsSyncFailedDesc": "Отправляется, когда синхронизация Withings не удаётся несколько раз подряд.", "eventSystemAlert": "Системное уведомление", "eventSystemAlertDesc": "Общие сообщения от сервера HealthLog, например об окнах обслуживания или заметки об аккаунте.", - "eventPersonalRecord": "Личный рекорд", - "eventPersonalRecordDesc": "Отправляется, когда измерение превосходит ваш прежний лучший результат.", "eventMoodReminder": "Напоминание о настроении", "eventMoodReminderDesc": "Ежедневное напоминание отметить текущее настроение.", "eventCyclePeriodSoon": "Скоро менструация", diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..dd511fc2a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 2a05c0a13..9d4518230 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -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 diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx index 83b3160cd..14017af1f 100644 --- a/src/app/notifications/__tests__/page.test.tsx +++ b/src/app/notifications/__tests__/page.test.tsx @@ -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", () => { diff --git a/src/lib/notifications/__tests__/event-labels.test.ts b/src/lib/notifications/__tests__/event-labels.test.ts index ff2464768..b0ea9bd5a 100644 --- a/src/lib/notifications/__tests__/event-labels.test.ts +++ b/src/lib/notifications/__tests__/event-labels.test.ts @@ -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", () => { diff --git a/src/lib/notifications/__tests__/event-types-for-role.test.ts b/src/lib/notifications/__tests__/event-types-for-role.test.ts new file mode 100644 index 000000000..9c0ea467f --- /dev/null +++ b/src/lib/notifications/__tests__/event-types-for-role.test.ts @@ -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); + }); +}); diff --git a/src/lib/notifications/__tests__/tap-target.test.ts b/src/lib/notifications/__tests__/tap-target.test.ts index 258a4edce..31b903d67 100644 --- a/src/lib/notifications/__tests__/tap-target.test.ts +++ b/src/lib/notifications/__tests__/tap-target.test.ts @@ -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` 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` 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 = 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); }); }); diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts index 60e1ac7c1..5e894ca8e 100644 --- a/src/lib/notifications/tap-target.ts +++ b/src/lib/notifications/tap-target.ts @@ -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`, 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`, 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 = { +export const NOTIFICATION_TAP_PATHS: Record = { 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> = + 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; diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index ea05bf42a..a86526350 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -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 = { MEDICATION_REMINDER: true, @@ -229,11 +224,38 @@ export const EVENT_DEFAULT_ENABLED: Record = { /** 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 = { diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index 7c639e155..b3ab7d429 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -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({ diff --git a/tests/integration/notification-event-defaults.test.ts b/tests/integration/notification-event-defaults.test.ts index 588a4700b..be8d325e3 100644 --- a/tests/integration/notification-event-defaults.test.ts +++ b/tests/integration/notification-event-defaults.test.ts @@ -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, + }), + ]); + }); +});