From 427e25d1aeae2ea3d1200d3c0d73de9005f80b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 09:51:42 +0200 Subject: [PATCH 1/3] Add a notification for a dose somebody else marked The owner of a shared record gets an alert when a delegate marks one of their doses. It is the one exception to the rule that bookkeeping stays in the activity view: whether the tablets were taken is a fact about the owner's own body, and they should not have to go looking for it. Default ON, against the opt-in precedent of PERSONAL_RECORD, because the event only exists once the owner has invited somebody and given them write access. The per-channel row in the matrix is the valve for anyone who finds it noisy, and both positions of that preference are covered. --- .../__tests__/dispatcher.test.ts | 52 +++++++++++++++++++ src/lib/notifications/types.ts | 18 +++++++ 2 files changed, 70 insertions(+) diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index f350ec7b5..b8b543938 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -381,6 +381,58 @@ describe("dispatchNotification — per-event default policy (v1.4.25 W16c)", () expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); }); + + // v1.36.x — the delegated-dose event goes the other way from + // PERSONAL_RECORD: ON without a row, because the owner invited the person + // whose action produces it. Both positions of the preference are asserted, + // so "default ON" cannot be true only because the dispatcher ignores the + // event entirely. + it("SHARED_RECORD_INTAKE defaults ON when no preference row exists", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ + serverUrl: "https://ntfy.example", + topic: "t", + }), + preferences: [], + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + sendViaNtfyMock.mockResolvedValueOnce({ ok: true }); + + await dispatchNotification({ + eventType: "SHARED_RECORD_INTAKE", + userId: "u-1", + title: "Wren marked a dose", + message: "Ramipril: marked as taken by Wren.", + }); + + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + }); + + it("SHARED_RECORD_INTAKE is suppressed by an explicit per-channel opt-out", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ + serverUrl: "https://ntfy.example", + topic: "t", + }), + preferences: [{ eventType: "SHARED_RECORD_INTAKE", enabled: false }], + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + + await dispatchNotification({ + eventType: "SHARED_RECORD_INTAKE", + userId: "u-1", + title: "Wren marked a dose", + message: "Ramipril: marked as taken by Wren.", + }); + + expect(sendViaNtfyMock).not.toHaveBeenCalled(); + }); }); describe("dispatchNotification — MOOD_REMINDER single source of truth (v1.7.0)", () => { diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 102871b2e..25b494515 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -112,6 +112,16 @@ export const EVENT_TYPES = [ // morning window, so a concerning reading always stays on // `MEASUREMENT_ANOMALY` and this 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 + // other shared-record write is bookkeeping and stays in the owner's activity + // view, while "somebody said I took my tablets" is a fact about the owner's + // own body that they should not have to go looking for. Addressed to the + // OWNER — the delegate learns what they did from the response in their own + // session, not from a push (there is no channel that could carry it: the + // dispatcher addresses a userId, and under record substitution that userId + // is the owner by design). Default ON, see EVENT_DEFAULT_ENABLED. + "SHARED_RECORD_INTAKE", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -179,6 +189,14 @@ export const EVENT_DEFAULT_ENABLED: Record = { // channel that has no explicit enabled row, so a fresh account never // receives a morning push it did not ask for. DAILY_BRIEFING: false, + // v1.36.x — ON by default, deliberately against the PERSONAL_RECORD opt-in + // precedent. The event cannot exist unless the owner invited a second person + // and gave them write access, so there is no backfill class of volume to + // protect against; and an owner who has to discover a setting before they + // learn that somebody else marks their doses was never told at all. The row + // sits in the /notifications matrix from day one, so an owner who finds it + // noisy silences it per channel with one switch. + SHARED_RECORD_INTAKE: true, }; export const CHANNEL_TYPE_LABELS: Record = { From 696b4d7d1ccf018e38c7adbde9fe63aa05d63369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 09:51:53 +0200 Subject: [PATCH 2/3] Give every notification event type words to render The matrix derives each row's label from the event name, so a new event type gets a row whether or not anybody wrote the strings for it. That is how SECURITY_ALERT has been rendering its own raw key since v1.23: the forward i18n coverage test only sees literal call sites, and this key is assembled from a runtime value. Move the derivation out of the page so a test can compute the keys the page computes, assert both resolve in all six bundles, and name the missing one. Write the sign-in row's strings, and the strings for the delegated-dose row and its push body. --- messages/de.json | 9 ++ messages/en.json | 9 ++ messages/es.json | 9 ++ messages/fr.json | 9 ++ messages/it.json | 9 ++ messages/pl.json | 9 ++ src/app/notifications/page.tsx | 19 +-- .../__tests__/event-labels.test.ts | 114 ++++++++++++++++++ src/lib/notifications/event-labels.ts | 28 +++++ 9 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/lib/notifications/__tests__/event-labels.test.ts create mode 100644 src/lib/notifications/event-labels.ts diff --git a/messages/de.json b/messages/de.json index 68ee1bc1b..ae960aaca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.", "eventDailyBriefing": "Täglicher Überblick", "eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.", + "eventSecurityAlert": "Neue Anmeldung", + "eventSecurityAlertDesc": "Wird gesendet, wenn dein Konto auf einem unbekannten Gerät oder von einem unbekannten Ort verwendet wird.", + "eventSharedRecordIntake": "Jemand mit Zugriff markiert eine Dosis", + "eventSharedRecordIntakeDesc": "Wird nur gesendet, wenn eine andere Person es tut, nie bei deinen eigenen Einträgen.", "security": { "newDeviceTitle": "Neue Anmeldung bei deinem Konto", "newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.", "unknownLocation": "unbekannter Ort" }, + "sharedRecordIntake": { + "title": "{name} hat eine Dosis markiert", + "bodyTaken": "{medication}: von {name} als eingenommen markiert.", + "bodySkipped": "{medication}: von {name} als ausgelassen markiert." + }, "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.", "someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.", "reminderSettingsLink": "Erinnerungseinstellungen", diff --git a/messages/en.json b/messages/en.json index eaa97402e..59d320516 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.", "eventDailyBriefing": "Daily briefing", "eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.", + "eventSecurityAlert": "New sign-in", + "eventSecurityAlertDesc": "Sent when your account is used on a device or from a place we have not seen before.", + "eventSharedRecordIntake": "Someone you shared your record with marks a dose", + "eventSharedRecordIntakeDesc": "Only sent when another person does it, never for your own entries.", "security": { "newDeviceTitle": "New sign-in to your account", "newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.", "unknownLocation": "unknown location" }, + "sharedRecordIntake": { + "title": "{name} marked a dose", + "bodyTaken": "{medication}: marked as taken by {name}.", + "bodySkipped": "{medication}: marked as skipped by {name}." + }, "loginRequired": "Please sign in to manage notifications.", "someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.", "reminderSettingsLink": "Reminder settings", diff --git a/messages/es.json b/messages/es.json index 6b6cc1188..9105f8ebf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.", "eventDailyBriefing": "Resumen diario", "eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.", + "eventSecurityAlert": "Nuevo inicio de sesión", + "eventSecurityAlertDesc": "Se envía cuando se usa tu cuenta en un dispositivo o desde un lugar desconocidos.", + "eventSharedRecordIntake": "Alguien con acceso marca una dosis", + "eventSharedRecordIntakeDesc": "Solo se envía cuando lo hace otra persona, nunca con tus propias entradas.", "security": { "newDeviceTitle": "Nuevo inicio de sesión en tu cuenta", "newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.", "unknownLocation": "ubicación desconocida" }, + "sharedRecordIntake": { + "title": "{name} ha marcado una dosis", + "bodyTaken": "{medication}: marcada como tomada por {name}.", + "bodySkipped": "{medication}: marcada como omitida por {name}." + }, "loginRequired": "Inicia sesión para gestionar las notificaciones.", "someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.", "reminderSettingsLink": "Ajustes de recordatorios", diff --git a/messages/fr.json b/messages/fr.json index 6c6f91a58..72bd859ea 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.", "eventDailyBriefing": "Résumé quotidien", "eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.", + "eventSecurityAlert": "Nouvelle connexion", + "eventSecurityAlertDesc": "Envoyé quand votre compte est utilisé sur un appareil ou depuis un lieu inconnus.", + "eventSharedRecordIntake": "Une personne avec accès marque une dose", + "eventSharedRecordIntakeDesc": "Envoyé uniquement quand une autre personne le fait, jamais pour vos propres saisies.", "security": { "newDeviceTitle": "Nouvelle connexion à votre compte", "newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.", "unknownLocation": "lieu inconnu" }, + "sharedRecordIntake": { + "title": "{name} a marqué une dose", + "bodyTaken": "{medication} : marquée comme prise par {name}.", + "bodySkipped": "{medication} : marquée comme ignorée par {name}." + }, "loginRequired": "Connecte-toi pour gérer les notifications.", "someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.", "reminderSettingsLink": "Réglages des rappels", diff --git a/messages/it.json b/messages/it.json index d6e1847c4..5ad71e1af 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.", "eventDailyBriefing": "Riepilogo quotidiano", "eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.", + "eventSecurityAlert": "Nuovo accesso", + "eventSecurityAlertDesc": "Inviato quando il tuo account viene usato su un dispositivo o da un luogo sconosciuti.", + "eventSharedRecordIntake": "Una persona con accesso segna una dose", + "eventSharedRecordIntakeDesc": "Inviato solo quando lo fa un'altra persona, mai per le tue voci.", "security": { "newDeviceTitle": "Nuovo accesso al tuo account", "newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.", "unknownLocation": "posizione sconosciuta" }, + "sharedRecordIntake": { + "title": "{name} ha segnato una dose", + "bodyTaken": "{medication}: segnata come assunta da {name}.", + "bodySkipped": "{medication}: segnata come saltata da {name}." + }, "loginRequired": "Accedi per gestire le notifiche.", "someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.", "reminderSettingsLink": "Impostazioni promemoria", diff --git a/messages/pl.json b/messages/pl.json index 7ca42645b..d84ad5e7d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.", "eventDailyBriefing": "Codzienne podsumowanie", "eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.", + "eventSecurityAlert": "Nowe logowanie", + "eventSecurityAlertDesc": "Wysyłane, gdy Twoje konto zostanie użyte na nieznanym urządzeniu lub z nieznanego miejsca.", + "eventSharedRecordIntake": "Ktoś z dostępem oznacza dawkę", + "eventSharedRecordIntakeDesc": "Wysyłane tylko wtedy, gdy robi to inna osoba, nigdy przy Twoich własnych wpisach.", "security": { "newDeviceTitle": "Nowe logowanie do Twojego konta", "newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.", "unknownLocation": "nieznana lokalizacja" }, + "sharedRecordIntake": { + "title": "{name} oznaczył(a) dawkę", + "bodyTaken": "{medication}: oznaczona jako przyjęta przez {name}.", + "bodySkipped": "{medication}: oznaczona jako pominięta przez {name}." + }, "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.", "someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.", "reminderSettingsLink": "Ustawienia przypomnień", diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index f5c64067d..db199f853 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -15,6 +15,10 @@ import { EVENT_DEFAULT_ENABLED, type EventType, } from "@/lib/notifications/types"; +import { + eventDescriptionTranslationKey, + eventTranslationKey, +} from "@/lib/notifications/event-labels"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; @@ -38,17 +42,6 @@ interface PreferencesData { eventTypes: string[]; } -/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */ -function eventTranslationKey(eventType: string): string { - return ( - "notifications.event" + - eventType - .toLowerCase() - .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) - .replace(/^./, (c) => c.toUpperCase()) - ); -} - export default function NotificationsPage() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); @@ -265,7 +258,7 @@ export default function NotificationsPage() { {eventTypes.map((eventType, idx) => { const nameKey = eventTranslationKey(eventType); - const descKey = nameKey + "Desc"; + const descKey = eventDescriptionTranslationKey(eventType); return ( {eventTypes.map((eventType) => { const nameKey = eventTranslationKey(eventType); - const descKey = nameKey + "Desc"; + const descKey = eventDescriptionTranslationKey(eventType); return (
{ + return JSON.parse( + readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"), + ) as Record; +} + +function resolve(root: Record, key: string): unknown { + return key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[part] + : undefined, + root, + ); +} + +const LOCALES = readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); + +describe("notification event labels", () => { + it("derives the key the matrix renders under", () => { + expect(eventTranslationKey("MEDICATION_REMINDER")).toBe( + "notifications.eventMedicationReminder", + ); + expect(eventDescriptionTranslationKey("SHARED_RECORD_INTAKE")).toBe( + "notifications.eventSharedRecordIntakeDesc", + ); + }); + + it("has a label and a description for every event type, in every locale", () => { + expect(LOCALES.length).toBeGreaterThanOrEqual(6); + expect(EVENT_TYPES.length).toBeGreaterThan(10); + + const missing: string[] = []; + for (const locale of LOCALES) { + const root = bundle(locale); + for (const eventType of EVENT_TYPES) { + for (const key of [ + eventTranslationKey(eventType), + eventDescriptionTranslationKey(eventType), + ]) { + const value = resolve(root, key); + if (typeof value !== "string" || value.trim().length === 0) { + missing.push(`${locale} is missing ${key}`); + } + } + } + } + + expect(missing).toEqual([]); + }); +}); + +describe("the delegated-dose push copy", () => { + it("interpolates the same names in every locale", () => { + const keys = [ + "notifications.sharedRecordIntake.title", + "notifications.sharedRecordIntake.bodyTaken", + "notifications.sharedRecordIntake.bodySkipped", + ]; + const placeholders: Record = { + "notifications.sharedRecordIntake.title": ["{name}"], + "notifications.sharedRecordIntake.bodyTaken": ["{name}", "{medication}"], + "notifications.sharedRecordIntake.bodySkipped": [ + "{name}", + "{medication}", + ], + }; + + for (const locale of LOCALES) { + const root = bundle(locale); + for (const key of keys) { + const value = resolve(root, key); + expect(typeof value, `${locale} ${key}`).toBe("string"); + for (const token of placeholders[key]) { + expect(value as string, `${locale} ${key}`).toContain(token); + } + } + } + }); +}); diff --git a/src/lib/notifications/event-labels.ts b/src/lib/notifications/event-labels.ts new file mode 100644 index 000000000..ae7e725a8 --- /dev/null +++ b/src/lib/notifications/event-labels.ts @@ -0,0 +1,28 @@ +/** + * The i18n key an event type is rendered under. + * + * The /notifications matrix builds its rows from the event list the server + * publishes, so the label for a new event type is never written at a call site + * — it is derived here. Extracted from the page so the derivation can be + * pinned by a test without rendering anything, and so the guard that asserts + * every event type actually HAS its two strings (label + description, six + * locales) computes the key exactly the way the page does rather than a way + * that agrees with it today. + * + * `MEDICATION_REMINDER` → `notifications.eventMedicationReminder`; the + * description key is that plus `Desc`. + */ +export function eventTranslationKey(eventType: string): string { + return ( + "notifications.event" + + eventType + .toLowerCase() + .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) + .replace(/^./, (c) => c.toUpperCase()) + ); +} + +/** The description key that sits under the label in the matrix row. */ +export function eventDescriptionTranslationKey(eventType: string): string { + return `${eventTranslationKey(eventType)}Desc`; +} From bf7fba4af034bad7bc5eb581c124de6635429673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 09:52:02 +0200 Subject: [PATCH 3/3] Route the delegated-dose alert through one helper that refuses on self The intake routes call notifyDelegatedIntake with both account ids and the helper decides. Keeping the comparison here rather than at the call sites means a route added later cannot notify somebody about a dose they marked themselves, and a snooze is answered in one place as what it is: moving a dose, not resolving one. The medication name is read scoped to the owner, because that name goes onto the owner's lock screen. Nothing addresses the delegate: the dispatcher takes a single account id, and under record substitution that id is the owner. The person who marked the dose learns what they did from the response they are already awaiting. The integration test asserts the ledger row before the sender mock, so a dispatch nobody recorded cannot pass as a dispatch. --- .../__tests__/delegated-intake.test.ts | 198 ++++++++++++++ src/lib/notifications/delegated-intake.ts | 136 ++++++++++ .../delegated-intake-notification.test.ts | 253 ++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 src/lib/notifications/__tests__/delegated-intake.test.ts create mode 100644 src/lib/notifications/delegated-intake.ts create mode 100644 tests/integration/delegated-intake-notification.test.ts diff --git a/src/lib/notifications/__tests__/delegated-intake.test.ts b/src/lib/notifications/__tests__/delegated-intake.test.ts new file mode 100644 index 000000000..f659567a3 --- /dev/null +++ b/src/lib/notifications/__tests__/delegated-intake.test.ts @@ -0,0 +1,198 @@ +/** + * v1.36.x — "somebody else marked your dose" helper. + * + * What is pinned here, and why each one is a property rather than an + * observation: + * + * 1. The notification is addressed to the OWNER and names the acting person + * and the medication. The delegate is not a recipient anywhere in this + * file, because there is no delegate-directed push to assert. + * 2. The self refusal lives in the helper, so a future call site that forgets + * to compare ids still cannot notify somebody about their own dose. + * 3. A snooze is not a marking and produces nothing. + * 4. The medication is read scoped to the owner — the name that reaches a + * lock screen comes from the recipient's own rows. + * 5. Nothing here throws: a failed notification never fails the write that + * produced it. + * + * Mutation check (run before this file was committed): deleting the + * `actorId === ownerId` guard turns "fires nothing when the owner marks their + * own dose" red with `expected "spy" to not be called at all, but actually + * been called 1 times`. Deleting the `messageKey === null` return turns the + * snooze case red the same way. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dispatchLocalisedMock = vi.fn(); +const addWarningMock = vi.fn(); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: (...args: unknown[]) => + dispatchLocalisedMock(...args), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock, addMeta: vi.fn() }), +})); + +const userFindUnique = vi.fn(); +const medicationFindFirst = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + medication: { findFirst: (...a: unknown[]) => medicationFindFirst(...a) }, + }, +})); + +import { + delegatedIntakeMessageKey, + notifyDelegatedIntake, +} from "@/lib/notifications/delegated-intake"; + +const OWNER = "owner-account"; +const DELEGATE = "delegate-account"; +const MEDICATION = "med-1"; + +beforeEach(() => { + vi.clearAllMocks(); + userFindUnique.mockResolvedValue({ + username: "wren", + displayName: "Wren", + }); + medicationFindFirst.mockResolvedValue({ name: "Ramipril" }); + dispatchLocalisedMock.mockResolvedValue(undefined); +}); + +describe("delegatedIntakeMessageKey", () => { + it("maps a marking to its own body and a snooze to nothing", () => { + expect(delegatedIntakeMessageKey("taken")).toBe( + "notifications.sharedRecordIntake.bodyTaken", + ); + expect(delegatedIntakeMessageKey("skipped")).toBe( + "notifications.sharedRecordIntake.bodySkipped", + ); + expect(delegatedIntakeMessageKey("snoozed")).toBeNull(); + }); +}); + +describe("notifyDelegatedIntake", () => { + it("notifies the owner, naming the acting person and the medication", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock).toHaveBeenCalledTimes(1); + expect(dispatchLocalisedMock).toHaveBeenCalledWith({ + userId: OWNER, + eventType: "SHARED_RECORD_INTAKE", + titleKey: "notifications.sharedRecordIntake.title", + messageKey: "notifications.sharedRecordIntake.bodyTaken", + params: { name: "Wren", medication: "Ramipril" }, + metadata: { medicationId: MEDICATION }, + }); + }); + + it("carries the skipped body for a skipped dose", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "skipped", + }); + + expect(dispatchLocalisedMock.mock.calls[0][0]).toMatchObject({ + messageKey: "notifications.sharedRecordIntake.bodySkipped", + }); + }); + + it("falls back to the username when the acting account set no display name", async () => { + userFindUnique.mockResolvedValue({ username: "wren", displayName: null }); + + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock.mock.calls[0][0].params).toMatchObject({ + name: "wren", + }); + }); + + it("fires nothing when the owner marks their own dose", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: OWNER, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock).not.toHaveBeenCalled(); + // Not even a lookup: the refusal is the first thing the helper does. + expect(userFindUnique).not.toHaveBeenCalled(); + expect(medicationFindFirst).not.toHaveBeenCalled(); + }); + + it("fires nothing for a snooze", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "snoozed", + }); + + expect(dispatchLocalisedMock).not.toHaveBeenCalled(); + }); + + it("reads the medication scoped to the owner", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(medicationFindFirst).toHaveBeenCalledWith({ + where: { id: MEDICATION, userId: OWNER }, + select: { name: true }, + }); + }); + + it("stays silent, with a warning, when the medication is not the owner's", async () => { + medicationFindFirst.mockResolvedValue(null); + + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock).not.toHaveBeenCalled(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("medication=missing"), + ); + }); + + it("swallows a dispatch failure so the write that produced it survives", async () => { + dispatchLocalisedMock.mockRejectedValue(new Error("telegram is down")); + + await expect( + notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }), + ).resolves.toBeUndefined(); + + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("telegram is down"), + ); + }); +}); diff --git a/src/lib/notifications/delegated-intake.ts b/src/lib/notifications/delegated-intake.ts new file mode 100644 index 000000000..ec189555f --- /dev/null +++ b/src/lib/notifications/delegated-intake.ts @@ -0,0 +1,136 @@ +/** + * v1.36.x — "somebody else marked your dose". + * + * The one notification a delegated write produces. Everything else a delegate + * adds to a shared record (a reading, a lab result, an allergy) is bookkeeping + * and lives in the owner's activity view; a dose is a statement about the + * owner's own body, and the owner is the person best placed to decide whether + * they want to hear about it. So this rides the ordinary alert cascade with an + * ordinary per-channel preference row, and nothing about it is special-cased + * inside the dispatcher. + * + * Two properties are structural rather than observed: + * + * 1. **It refuses to fire on self.** The owner never gets a push about their + * own dose. The refusal lives HERE, not at the call sites, so a future + * route that calls this helper cannot forget it. + * 2. **It addresses the owner and only the owner.** The delegate gets their + * feedback from the response they are already awaiting, rendered in their + * own session. There is no delegate-directed push and this file is not the + * place to grow one: the dispatcher takes a `userId`, and under record + * substitution that id is the owner by design. + * + * The medication is named in the push body, the same verbosity class as + * MEDICATION_REMINDER, which has named medications on the lock screen since + * v1.4.40. No dose figures ride along. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; + +/** + * The dose states the intake routes write. `"snoozed"` is deliberately in the + * union: both intake routes can produce it, and the helper answering "that one + * is not a marking" in one place beats every call site remembering to. + */ +export type DelegatedIntakeStatus = "taken" | "skipped" | "snoozed"; + +/** + * Which body the push carries for a dose state, or `null` when the state is + * not a marking at all. + * + * Pure, exported, and tested on its own: a snooze moves a dose, it does not + * resolve it, and the web-push dose-clear draws the same line (`intake/route + * .ts` clears the pending reminder for taken / skipped only). An owner woken + * by "your carer postponed a dose by ten minutes" would be the noise this + * whole event type is trying not to be. + */ +export function delegatedIntakeMessageKey( + status: DelegatedIntakeStatus, +): string | null { + switch (status) { + case "taken": + return "notifications.sharedRecordIntake.bodyTaken"; + case "skipped": + return "notifications.sharedRecordIntake.bodySkipped"; + case "snoozed": + return null; + } +} + +export interface NotifyDelegatedIntakeInput { + /** The account the dose belongs to — the recipient of the notification. */ + ownerId: string; + /** The account that actually marked it. Equal to `ownerId` on a self-write. */ + actorId: string; + /** The medication whose dose was marked. Resolved for its name, owner-scoped. */ + medicationId: string; + /** What the dose was marked as. */ + status: DelegatedIntakeStatus; +} + +/** + * Tell the owner that somebody else marked one of their doses. + * + * Never throws: a notification that fails is not a reason to fail the write + * that produced it. Await it — the call sites reach this only on a delegated + * write, so the added latency lands on the caregiver's request and never on + * the owner's own hot path, and awaiting is what makes "one row in + * `push_attempts`" a thing a test can assert rather than a thing it can race. + */ +export async function notifyDelegatedIntake( + input: NotifyDelegatedIntakeInput, +): Promise { + const { ownerId, actorId, medicationId, status } = input; + + // The self refusal. First, cheap, and unconditional. + if (actorId === ownerId) return; + + const messageKey = delegatedIntakeMessageKey(status); + if (messageKey === null) return; + + try { + const [actor, medication] = await Promise.all([ + prisma.user.findUnique({ + where: { id: actorId }, + select: { username: true, displayName: true }, + }), + // Scoped to the owner: the name in this body goes onto the owner's lock + // screen, so it is read from the owner's own row set or not at all. + prisma.medication.findFirst({ + where: { id: medicationId, userId: ownerId }, + select: { name: true }, + }), + ]); + + // Both halves of the sentence or none of it. An acting account that no + // longer resolves is nobody we can name, and a medication that does not + // resolve under the OWNER is not one whose name belongs on their lock + // screen. Neither is reachable from the intake routes, which have both + // rows in hand before they call; a warning to ops beats a push that says + // "somebody marked something". + if (!actor || !medication) { + getEvent()?.addWarning( + `notifyDelegatedIntake — nothing to name (actor=${actor ? "ok" : "missing"} medication=${medication ? "ok" : "missing"}), no notification sent`, + ); + return; + } + + await dispatchLocalisedNotification({ + userId: ownerId, + eventType: "SHARED_RECORD_INTAKE", + titleKey: "notifications.sharedRecordIntake.title", + messageKey, + params: { + name: accountLabel(actor), + medication: medication.name, + }, + metadata: { medicationId }, + }); + } catch (err) { + getEvent()?.addWarning( + `notifyDelegatedIntake failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/tests/integration/delegated-intake-notification.test.ts b/tests/integration/delegated-intake-notification.test.ts new file mode 100644 index 000000000..c5eb0c47e --- /dev/null +++ b/tests/integration/delegated-intake-notification.test.ts @@ -0,0 +1,253 @@ +/** + * v1.36.x — the delegated-dose notification, end to end against Postgres. + * + * The helper is exercised through the real dispatcher, the real preference + * lookup and the real APNs sender (with the vendor client stubbed, the same + * shape `apns-dispatch.test.ts` uses), so what is asserted is the row the + * ledger actually holds rather than the call the helper meant to make. + * + * The three facts: + * + * 1. The owner gets exactly ONE `push_attempts` row for the event, and the + * delegate gets none. There is no delegate-directed push; the caregiver + * learns what they did from the response in their own session. + * 2. An owner marking their own dose produces no send and no row at all. + * 3. The owner's per-channel opt-out row suppresses it, which is what makes + * "default ON" a defaulted decision rather than an unconditional one. + * + * The ledger write is deliberately fire-and-forget in `recordPushAttempt`, so + * the assertions poll for the expected row instead of reading once and hoping. + * The poll is bounded and then asserted exactly: a helper that stops + * dispatching times out and fails on the count, it does not pass on an empty + * table. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const apnsSendMock = vi.fn(); + +vi.mock("@parse/node-apn", () => { + class Provider { + constructor(_opts: unknown) { + void _opts; + } + send(...args: unknown[]) { + return apnsSendMock(...args); + } + shutdown() {} + } + class Notification { + public topic = ""; + public alert: unknown; + public sound: string | undefined; + public badge: number | undefined; + public threadId: string | undefined; + public collapseId: string | undefined; + public payload: unknown; + } + return { default: { Provider, Notification }, Provider, Notification }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER_ID = "user-delegated-intake-owner"; +const DELEGATE_ID = "user-delegated-intake-delegate"; + +// Test-only EC P-256 key — `loadApnsConfig` parses it before the sender will +// talk to the stubbed provider. Same fixture shape as `apns-dispatch.test.ts`. +const TEST_EC_PEM_LINES = [ + "-----BEGIN PRIVATE KEY-----", + "MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN", + "pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU", + "1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ", + "bw81BG89", + "-----END PRIVATE KEY-----", +]; + +const APNS_ENV = { + APNS_KEY_ID: "ABCDE12345", + APNS_TEAM_ID: "TEAM123456", + APNS_BUNDLE_ID: "test.healthlog.ios", + APNS_KEY: TEST_EC_PEM_LINES.join("\\n"), +}; + +async function seedMedication(): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId: OWNER_ID, + name: "Ramipril", + dose: "5 mg", + }, + }); + return med.id; +} + +async function seedApnsChannel(): Promise { + const { encrypt } = await import("@/lib/crypto"); + await getPrismaClient().notificationChannel.create({ + data: { + userId: OWNER_ID, + type: "APNS", + enabled: true, + config: encrypt("{}"), + }, + }); + await getPrismaClient().device.create({ + data: { + userId: OWNER_ID, + platform: "ios", + token: "device-owner", + bundleId: APNS_ENV.APNS_BUNDLE_ID, + apnsToken: "owner-apns-token", + apnsEnvironment: "sandbox", + }, + }); +} + +/** Poll the ledger until it holds `expected` rows for the user, or give up. */ +async function pushAttemptsFor( + userId: string, + expected: number, +): Promise<{ channel: string; eventType: string; result: string }[]> { + for (let attempt = 0; attempt < 40; attempt += 1) { + const rows = await getPrismaClient().pushAttempt.findMany({ + where: { userId }, + select: { channel: true, eventType: true, result: true }, + }); + if (rows.length >= expected) return rows; + await new Promise((r) => setTimeout(r, 25)); + } + return getPrismaClient().pushAttempt.findMany({ + where: { userId }, + select: { channel: true, eventType: true, result: true }, + }); +} + +beforeEach(async () => { + vi.clearAllMocks(); + for (const [k, v] of Object.entries(APNS_ENV)) process.env[k] = v; + delete process.env.APNS_PRODUCTION; + const apns = await import("@/lib/notifications/senders/apns"); + apns.resetApnsForTesting(); + const { __resetDispatchLocaleCacheForTests } = + await import("@/lib/notifications/dispatch-localised"); + __resetDispatchLocaleCacheForTests(); + + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.createMany({ + data: [ + { + id: OWNER_ID, + username: "owner-delegated-intake", + email: "owner-delegated-intake@example.test", + locale: "en", + }, + { + id: DELEGATE_ID, + username: "wren", + displayName: "Wren", + email: "wren-delegated-intake@example.test", + locale: "de", + }, + ], + }); + + apnsSendMock.mockResolvedValue({ + sent: [{ device: "owner-apns-token" }], + failed: [], + }); +}); + +describe("delegated intake notification", () => { + it("notifies the owner once and the acting delegate not at all", async () => { + await seedApnsChannel(); + const medicationId = await seedMedication(); + + const { notifyDelegatedIntake } = + await import("@/lib/notifications/delegated-intake"); + await notifyDelegatedIntake({ + ownerId: OWNER_ID, + actorId: DELEGATE_ID, + medicationId, + status: "taken", + }); + + // The ledger first, deliberately: a dispatch that never landed and a + // dispatch nobody recorded look identical from the mock's side. + const ownerRows = await pushAttemptsFor(OWNER_ID, 1); + expect(ownerRows).toEqual([ + { channel: "APNS", eventType: "SHARED_RECORD_INTAKE", result: "ok" }, + ]); + + const delegateRows = await getPrismaClient().pushAttempt.findMany({ + where: { userId: DELEGATE_ID }, + }); + expect(delegateRows).toEqual([]); + + expect(apnsSendMock).toHaveBeenCalledTimes(1); + const note = apnsSendMock.mock.calls[0][0]; + // The owner's locale, not the delegate's, and the medication named the + // way MEDICATION_REMINDER names one. + expect(note.alert.title).toBe("Wren marked a dose"); + expect(note.alert.body).toBe("Ramipril: marked as taken by Wren."); + expect(note.payload).toMatchObject({ + eventType: "SHARED_RECORD_INTAKE", + medicationId, + }); + }); + + it("sends nothing when the owner marks their own dose", async () => { + await seedApnsChannel(); + const medicationId = await seedMedication(); + + const { notifyDelegatedIntake } = + await import("@/lib/notifications/delegated-intake"); + await notifyDelegatedIntake({ + ownerId: OWNER_ID, + actorId: OWNER_ID, + medicationId, + status: "taken", + }); + + expect(apnsSendMock).not.toHaveBeenCalled(); + // Wait out the ledger's fire-and-forget window before asserting absence, + // so "no row" means no row rather than "not yet". + await new Promise((r) => setTimeout(r, 250)); + expect(await getPrismaClient().pushAttempt.count()).toBe(0); + }); + + it("honours the owner's per-channel opt-out", async () => { + await seedApnsChannel(); + const medicationId = await seedMedication(); + const channel = + await getPrismaClient().notificationChannel.findFirstOrThrow({ + where: { userId: OWNER_ID, type: "APNS" }, + }); + await getPrismaClient().notificationPreference.create({ + data: { + channelId: channel.id, + eventType: "SHARED_RECORD_INTAKE", + enabled: false, + }, + }); + + const { notifyDelegatedIntake } = + await import("@/lib/notifications/delegated-intake"); + await notifyDelegatedIntake({ + ownerId: OWNER_ID, + actorId: DELEGATE_ID, + medicationId, + status: "taken", + }); + + expect(apnsSendMock).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 250)); + expect(await getPrismaClient().pushAttempt.count()).toBe(0); + }); +});