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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 09:52:02 +02:00
1 parent 696b4d7d1c
commit bf7fba4af0
3 files changed
+587

No files matched your search

@@ -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"),
);
});
});
+136
View File
@@ -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<void> {
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)}`,
);
}
}
@@ -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<string> {
const med = await getPrismaClient().medication.create({
data: {
userId: OWNER_ID,
name: "Ramipril",
dose: "5 mg",
},
});
return med.id;
}
async function seedApnsChannel(): Promise<void> {
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);
});
});