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); + }); +});