diff --git a/messages/de.json b/messages/de.json index cb3bb119c..2fb451b33 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 cd9607368..2dafb2086 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 bf879ea85..ba7a5108d 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 f62db527a..d8ec7de9d 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 524a67662..68706b562 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 b9d428617..7485f242c 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 (
({ + 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/__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/__tests__/event-labels.test.ts b/src/lib/notifications/__tests__/event-labels.test.ts new file mode 100644 index 000000000..ff2464768 --- /dev/null +++ b/src/lib/notifications/__tests__/event-labels.test.ts @@ -0,0 +1,114 @@ +/** + * 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 + * 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 + * `notifications.eventSecurityAlert` in the matrix until v1.36.x, because + * nothing anywhere asserted the pairing — the forward i18n coverage test only + * sees literal `t("…")` call sites, and this key is assembled from a runtime + * value. + * + * So this test walks the event list the page walks, computes the keys the page + * computes, and asserts both resolve in every locale bundle. + * + * Mutation check: remove `notifications.eventSharedRecordIntakeDesc` from + * `messages/pl.json` and this file reports + * `pl is missing notifications.eventSharedRecordIntakeDesc`. + */ +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 { + eventDescriptionTranslationKey, + eventTranslationKey, +} from "@/lib/notifications/event-labels"; + +const MESSAGES_DIR = join(__dirname, "../../../../messages"); + +function bundle(locale: string): Record { + 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/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/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`; +} 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 = { 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); + }); +});