From 82982038cbe6a089c45b0ffcfc868be114d435a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:36:08 +0200 Subject: [PATCH] feat(vaccinations): let a logged dose clear the boosters it answers - the dose is expanded to its component antigens first, so one Tdap entry settles a tetanus booster and a pertussis booster at once; matching the preparation's own slug would leave both standing - it runs inside the create transaction and nowhere else: no cron, no ingest hook, and an edit never re-runs it, so correcting a typo in a date cannot move a reminder - forward-only, which is what makes transcribing an old record safe: a page from decades ago never clears a booster that is due now - a free-text-only dose matches nothing; a name string is not evidence about what was in the syringe - an ordinary preventive-care reminder carries no antigen key and is structurally out of reach --- src/app/api/vaccinations/route.ts | 38 ++ src/lib/vaccinations/satisfy-matching.ts | 153 ++++++++ src/lib/vaccinations/service.ts | 19 + .../vaccination-booster-satisfy.test.ts | 362 ++++++++++++++++++ 4 files changed, 572 insertions(+) create mode 100644 src/lib/vaccinations/satisfy-matching.ts create mode 100644 tests/integration/vaccination-booster-satisfy.test.ts diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 46b207de1..16d2be8b7 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -44,8 +44,10 @@ import { applyVaccinationLinks, resolveOwnedEncounter, resolveOwnedPractitioner, + resolveOwnerTimezone, resolveSeriesFor, } from "@/lib/vaccinations/service"; +import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "profile"); @@ -130,6 +132,11 @@ async function postVaccination(request: NextRequest): Promise { const entry = parsed.data; const occurredAt = new Date(entry.occurredAt); + const timezone = await resolveOwnerTimezone(user.id); + + // Filled inside the transaction, read after it for the wide event: an + // annotation is not worth holding a transaction open for. + let satisfiedReminderIds: string[] = []; const created = await prisma.$transaction(async (tx) => { // Every id the body carries is re-narrowed to the resolved account before @@ -169,6 +176,26 @@ async function postVaccination(request: NextRequest): Promise { await applyVaccinationLinks(tx, user.id, row.id, entry.documentIds); + // The booster arm, inside the same transaction so a dose that failed to + // save cannot leave a reminder looking settled. It runs on the OWNER's + // reminders, which is correct even when a delegate is transcribing: it is + // the owner's booster plan, and filing their Impfpass is exactly the work + // that should settle it. + const boosters = await satisfyBoostersForDose( + tx, + user.id, + entry.antigenSlug ?? null, + occurredAt, + timezone, + ); + satisfiedReminderIds = boosters.satisfiedReminderIds; + if (boosters.primaryReminderId) { + await tx.vaccinationRecord.update({ + where: { id: row.id }, + data: { reminderId: boosters.primaryReminderId }, + }); + } + return tx.vaccinationRecord.findUniqueOrThrow({ where: { id: row.id }, include: VACCINATION_INCLUDE, @@ -209,8 +236,19 @@ async function postVaccination(request: NextRequest): Promise { meta: { antigen_slug: created.antigenSlug ?? "free-text", linked_documents: entry.documentIds?.length ?? 0, + satisfied_boosters: satisfiedReminderIds.length, }, }); + for (const reminderId of satisfiedReminderIds) { + annotate({ + action: { + name: "vaccination.booster.satisfied", + entity_type: "measurement-reminder", + entity_id: reminderId, + }, + meta: { antigen_slug: created.antigenSlug ?? "free-text" }, + }); + } return apiSuccess( toVaccinationDTO(created, series.get(created.id) ?? []), diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts new file mode 100644 index 000000000..8518b622c --- /dev/null +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -0,0 +1,153 @@ +/** + * Logging a dose clears the booster reminders it answers. + * + * This is the reader half of `MeasurementReminder.vaccinationAntigen`, and the + * whole of it. The writer — the booster mint, which is rung 2: prefilled, + * user-confirmed, editable, declinable — lands with the capture surface. The + * column is a match key and nothing else: no cron query changes, no reminder + * DTO field, no new queue, no new notification event type, no new preference, + * and the published `origin` enum still reads `["VORSORGE","COACH"]`. A + * booster reminder is an ordinary preventive-care row that happens to say + * which antigen would settle it. + * + * ── Why components, not the slug ─────────────────────────────────────────── + * + * A reminder is keyed by an ANTIGEN. A dose is recorded as whatever + * preparation the person was given, which is usually a combination. Matching + * the record's own slug against the reminder's key would mean a Tdap dose left + * a tetanus booster standing, which is the named failure mode of this whole + * design. So the dose is expanded to its component antigens first, and every + * reminder any of them matches is satisfied. + * + * A free-text-only dose matches nothing. Nothing here reads `vaccineName`: a + * name string is not evidence about which antigens were in the syringe, and a + * booster cleared by a guess is worse than a booster not cleared at all. + * + * ── Cold path only ───────────────────────────────────────────────────────── + * + * It runs inside the create transaction of `POST /api/vaccinations` and + * nowhere else. Never on a cron, never on an ingest hook, never on an edit — + * the never-nag query in `src/lib/jobs/measurement-reminder.ts` is untouched + * by this feature, and PATCH deliberately does not re-run this, because + * correcting a transcription typo is not a second clinical event. + * + * ── The two properties that come from `satisfyReminder` ──────────────────── + * + * Both are the design rather than an accident, and both have a fixture: + * + * - **Forward-only.** A dose older than the reminder's `lastSatisfiedAt` is + * a no-op. Transcribing a 1987 Pass page can never clear a booster that is + * due today, which is the property that makes bulk back-entry safe. + * - **Re-anchored.** `nextDueAt` recomputes from the satisfy instant, so + * logging a tetanus dose from 2019 against a ten-year reminder moves the + * due date to 2029 — and logging one old enough that the recomputed date + * is already past leaves the reminder due, which is correct. An old dose + * does not silence a real gap. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { + satisfyReminder, + type SatisfiableReminder, +} from "@/lib/measurement-reminders/satisfy"; +import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog"; + +/** What the create route needs back: which reminders moved, and the first. */ +export interface BoosterSatisfyResult { + /** Ids of every reminder this dose advanced, in catalogue component order. */ + satisfiedReminderIds: string[]; + /** + * The id stamped onto `VaccinationRecord.reminderId`. + * + * The first satisfied reminder in catalogue component order, so a Tdap dose + * that clears a tetanus and a pertussis booster always records the same one + * rather than whichever the database returned first. The column records ONE + * act; the engine records them all, in each row's own `lastSatisfiedAt`. + */ + primaryReminderId: string | null; +} + +const EMPTY: BoosterSatisfyResult = { + satisfiedReminderIds: [], + primaryReminderId: null, +}; + +/** + * Satisfy every live booster reminder this dose answers. + * + * Takes the transaction client the create runs in, so the reminder advance and + * the record insert commit or roll back together: a dose that failed to save + * must not leave a booster looking settled. + */ +export async function satisfyBoostersForDose( + tx: Prisma.TransactionClient, + userId: string, + antigenSlug: string | null, + occurredAt: Date, + timezone: string, +): Promise { + const components = componentsForSlug(antigenSlug); + if (components.length === 0) return EMPTY; + + const reminders = await tx.measurementReminder.findMany({ + where: { + userId, + deletedAt: null, + // An ordinary Vorsorge reminder carries NULL here and is never touched: + // the `in` filter cannot match a null, so the fence is structural rather + // than a condition somebody has to remember. + vaccinationAntigen: { in: [...components] }, + }, + select: { + id: true, + vaccinationAntigen: true, + intervalDays: true, + rrule: true, + anchorDate: true, + notifyHour: true, + lastSatisfiedAt: true, + createdAt: true, + }, + }); + if (reminders.length === 0) return EMPTY; + + // Deterministic: catalogue component order, then id, so two reminders for + // the same antigen still resolve to a stable primary. + const rank = new Map(components.map((antigen, index) => [antigen, index])); + const ordered = [...reminders].sort((a, b) => { + const byAntigen = + (rank.get(a.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER); + return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id); + }); + + const satisfiedReminderIds: string[] = []; + for (const reminder of ordered) { + const row: SatisfiableReminder = { + id: reminder.id, + intervalDays: reminder.intervalDays, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: reminder.lastSatisfiedAt, + createdAt: reminder.createdAt, + }; + // `satisfyReminder` types its client as the full `PrismaClient` because + // every other caller hands it one; the only member it touches is + // `measurementReminder.updateMany`, which a transaction client has. The + // cast is confined to this line so the advance stays inside the create + // transaction rather than committing on its own. + const result = await satisfyReminder( + tx as unknown as PrismaClient, + row, + timezone, + occurredAt, + ); + if (result.satisfied) satisfiedReminderIds.push(reminder.id); + } + + return { + satisfiedReminderIds, + primaryReminderId: satisfiedReminderIds[0] ?? null, + }; +} diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index b07c0de3f..8d929ff5f 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -9,6 +9,7 @@ */ import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; import { listTargets, replaceTargets } from "@/lib/links"; import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; @@ -18,6 +19,9 @@ import { type SeriesInputRecord, } from "@/lib/vaccinations/series"; +/** Matches the visits service: the fallback when an account never set one. */ +const DEFAULT_TIMEZONE = "Europe/Berlin"; + /** The relations every vaccination response resolves. */ export const VACCINATION_INCLUDE = { practitioner: true, @@ -157,6 +161,21 @@ export async function resolveOwnedPractitioner( }); } +/** + * The RECORD owner's timezone, for the booster reschedule. + * + * The owner's, never the acting delegate's: a reminder fires on the owner's + * phone at the owner's local hour, and a helper transcribing from another + * timezone must not shift when it rings. + */ +export async function resolveOwnerTimezone(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return row?.timezone || DEFAULT_TIMEZONE; +} + /** Does the caller own a live visit with this id? */ export async function resolveOwnedEncounter( tx: Prisma.TransactionClient, diff --git a/tests/integration/vaccination-booster-satisfy.test.ts b/tests/integration/vaccination-booster-satisfy.test.ts new file mode 100644 index 000000000..8de0be3ef --- /dev/null +++ b/tests/integration/vaccination-booster-satisfy.test.ts @@ -0,0 +1,362 @@ +/** + * Logging a dose clears the boosters it answers — through the real route, real + * Postgres, real reminder engine. + * + * Every property here is about ROWS: which `lastSatisfiedAt` moved, which did + * not, and whether a reminder nobody keyed to an antigen was touched. None of + * them can be answered by a mocked client, and the one that matters most is a + * negative: a fake that ignored the `where` would report the ordinary Vorsorge + * reminder as untouched while having satisfied it. + * + * The combination case is the reason this file exists. A Tdap dose has to + * clear a tetanus booster AND a pertussis booster in one call, because a + * reminder is keyed by an antigen and a dose is recorded as whatever + * preparation the person was given. Matching the dose's own slug would leave + * the tetanus booster standing. + * + * Fixture dates are relative. A fixed calendar date in a test about "older + * than" and "moves forward" rots the moment it slides out of the window it + * was written inside. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { POST as createVaccination } from "@/app/api/vaccinations/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "booster-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; + +function daysAgo(days: number): Date { + return new Date(Date.now() - days * DAY_MS); +} + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * DAY_MS); +} + +async function seedSession() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "booster-owner", + email: "booster-owner@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: daysFromNow(7), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** + * A booster reminder as the mint will write it: an ordinary ten-year VORSORGE + * row that names the antigen a dose would settle. + */ +async function seedBooster( + antigen: string | null, + overrides: { lastSatisfiedAt?: Date | null; label?: string } = {}, +) { + return getPrismaClient().measurementReminder.create({ + data: { + userId: OWNER_ID, + label: overrides.label ?? `${antigen ?? "generic"} booster`, + intervalDays: 3650, + notifyHour: 9, + vaccinationAntigen: antigen, + lastSatisfiedAt: overrides.lastSatisfiedAt ?? null, + nextDueAt: daysAgo(1), + }, + }); +} + +function post(body: unknown): Promise { + return createVaccination( + new Request("http://localhost/api/vaccinations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + ); +} + +async function reminderById(id: string) { + return getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedSession(); +}); + +describe("a dose satisfies the boosters it answers", () => { + it("clears a monovalent antigen's own booster and re-anchors it", async () => { + const reminder = await seedBooster("tetanus"); + const occurredAt = daysAgo(2); + + const res = await post({ + occurredAt: occurredAt.toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status, JSON.stringify(await res.clone().json())).toBe(201); + + const after = await reminderById(reminder.id); + expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString()); + // Re-anchored from the satisfy instant, so the next one lands roughly ten + // years out rather than staying on the date it was already overdue on. + expect(after.nextDueAt).not.toBeNull(); + expect(after.nextDueAt!.getTime()).toBeGreaterThan(Date.now()); + }); + + it("records the satisfied reminder on the dose", async () => { + const reminder = await seedBooster("tetanus"); + const res = await post({ + occurredAt: daysAgo(2).toISOString(), + antigenSlug: "tetanus", + }); + const payload = (await res.json()) as { + data: { id: string; reminderId: string | null }; + }; + expect(payload.data.reminderId).toBe(reminder.id); + }); + + it("clears a tetanus AND a pertussis booster from one Tdap dose", async () => { + // The property the whole component axis exists for. A matcher keyed on the + // record's own slug would find neither of these. + const tetanus = await seedBooster("tetanus"); + const pertussis = await seedBooster("pertussis"); + const occurredAt = daysAgo(3); + + const res = await post({ + occurredAt: occurredAt.toISOString(), + antigenSlug: "tdap", + }); + expect(res.status).toBe(201); + + expect((await reminderById(tetanus.id)).lastSatisfiedAt).not.toBeNull(); + expect((await reminderById(pertussis.id)).lastSatisfiedAt).not.toBeNull(); + }); + + it("clears every component of a six-antigen infant dose", async () => { + const reminders = await Promise.all( + ["diphtheria", "tetanus", "pertussis", "polio", "hib", "hepatitis-b"].map( + (antigen) => seedBooster(antigen), + ), + ); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "hexavalent", + }); + expect(res.status).toBe(201); + + const after = await Promise.all( + reminders.map((reminder) => reminderById(reminder.id)), + ); + expect(after.filter((row) => row.lastSatisfiedAt !== null)).toHaveLength(6); + }); +}); + +describe("what a dose must never touch", () => { + it("leaves an ordinary Vorsorge reminder alone", async () => { + // No antigen key at all — the blood-panel reminder somebody set years ago. + // A matcher whose `where` did not hold would clear this and nothing in the + // response would say so. + const ordinary = await seedBooster(null, { label: "Blood panel" }); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + + const after = await reminderById(ordinary.id); + expect(after.lastSatisfiedAt).toBeNull(); + expect(after.nextDueAt?.toISOString()).toBe( + ordinary.nextDueAt?.toISOString(), + ); + }); + + it("leaves a booster for an antigen the dose does not contain", async () => { + const measles = await seedBooster("measles"); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tdap", + }); + expect(res.status).toBe(201); + expect((await reminderById(measles.id)).lastSatisfiedAt).toBeNull(); + }); + + it("matches nothing at all for a free-text-only dose", async () => { + // A name string is not evidence about which antigens were in the syringe. + const tetanus = await seedBooster("tetanus"); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + vaccineName: "Tetanus, as the 1987 Pass wrote it", + }); + expect(res.status).toBe(201); + + const payload = (await res.json()) as { + data: { reminderId: string | null }; + }; + expect(payload.data.reminderId).toBeNull(); + expect((await reminderById(tetanus.id)).lastSatisfiedAt).toBeNull(); + }); + + it("leaves another account's booster alone", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: "booster-stranger", + username: "booster-stranger", + email: "booster-stranger@example.test", + }, + }); + const foreign = await prisma.measurementReminder.create({ + data: { + userId: "booster-stranger", + label: "tetanus booster", + intervalDays: 3650, + vaccinationAntigen: "tetanus", + nextDueAt: daysAgo(1), + }, + }); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + expect((await reminderById(foreign.id)).lastSatisfiedAt).toBeNull(); + }); + + it("leaves a soft-deleted booster alone", async () => { + const deleted = await seedBooster("tetanus"); + await getPrismaClient().measurementReminder.update({ + where: { id: deleted.id }, + data: { deletedAt: new Date() }, + }); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + expect((await reminderById(deleted.id)).lastSatisfiedAt).toBeNull(); + }); +}); + +describe("the two properties the engine brings with it", () => { + it("is forward-only: a backdated transcription never clears a due booster", async () => { + // The property that makes bulk back-entry of an old Pass safe. The + // reminder was satisfied a month ago; a page from 1987 must not move it. + const satisfiedAt = daysAgo(30); + const reminder = await seedBooster("tetanus", { + lastSatisfiedAt: satisfiedAt, + }); + + const res = await post({ + occurredAt: daysAgo(14000).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + + const after = await reminderById(reminder.id); + expect(after.lastSatisfiedAt?.toISOString()).toBe( + satisfiedAt.toISOString(), + ); + // And the record says so: no reminder was satisfied, so none is recorded. + const payload = (await res.json()) as { + data: { reminderId: string | null }; + }; + expect(payload.data.reminderId).toBeNull(); + }); + + it("is idempotent: logging the same dose twice moves nothing the second time", async () => { + const reminder = await seedBooster("tetanus"); + const occurredAt = daysAgo(5); + const bodyIn = { + occurredAt: occurredAt.toISOString(), + antigenSlug: "tetanus", + }; + + expect((await post(bodyIn)).status).toBe(201); + const afterFirst = await reminderById(reminder.id); + + // A duplicate entry is still a valid dose — two doses on one day happen — + // so it returns 201. What must not happen is the reminder advancing again. + const second = await post(bodyIn); + expect(second.status).toBe(201); + + const afterSecond = await reminderById(reminder.id); + expect(afterSecond.lastSatisfiedAt?.toISOString()).toBe( + afterFirst.lastSatisfiedAt?.toISOString(), + ); + expect(afterSecond.nextDueAt?.toISOString()).toBe( + afterFirst.nextDueAt?.toISOString(), + ); + const payload = (await second.json()) as { + data: { reminderId: string | null }; + }; + expect( + payload.data.reminderId, + "the second dose satisfied nothing, so it records nothing", + ).toBeNull(); + }); + + it("leaves a reminder due when the dose is too old to have settled it", async () => { + // Re-anchoring from an old instant can land the next due date in the past, + // and that is correct: an old dose does not silence a real gap. + const reminder = await seedBooster("tetanus"); + + const res = await post({ + occurredAt: daysAgo(5000).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + + const after = await reminderById(reminder.id); + expect(after.lastSatisfiedAt).not.toBeNull(); + expect( + after.nextDueAt!.getTime(), + "a dose from fourteen years ago on a ten-year cadence is still overdue", + ).toBeLessThan(Date.now()); + }); +});