From b5ce44628aa7f66d2f53a56bd78e7e007a8e0b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 21:01:25 +0200 Subject: [PATCH] feat(vaccinations): mint the booster reminder a logged dose suggests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an ordinary VORSORGE reminder keyed on the dose's primary antigen; the published origin enum and reminder DTO are unchanged - idempotent by antigen — a second confirmation re-anchors the existing reminder rather than minting a second (WR-9 seen red on the reminder count, reverted by content hash) - rolling day cadence so logging the next dose re-anchors the due date through the Phase 1 satisfy matcher; the whole loop proven through real routes - booster prompt copy in six locales, plural-aware --- messages/de.json | 17 ++ messages/en.json | 17 ++ messages/es.json | 17 ++ messages/fr.json | 17 ++ messages/it.json | 17 ++ messages/pl.json | 17 ++ .../api/vaccinations/[id]/booster/route.ts | 111 +++++++++ .../vaccinations/booster-mint-prompt.tsx | 168 +++++++++++++ .../vaccinations/use-vaccinations.ts | 31 +++ .../vaccinations/vaccinations-view.tsx | 15 ++ src/lib/query-keys/vaccinations.ts | 1 + src/lib/vaccinations/booster-mint.ts | 166 +++++++++++++ src/lib/validations/vaccinations.ts | 22 ++ .../vaccination-booster-mint.test.ts | 233 ++++++++++++++++++ 14 files changed, 849 insertions(+) create mode 100644 src/app/api/vaccinations/[id]/booster/route.ts create mode 100644 src/components/vaccinations/booster-mint-prompt.tsx create mode 100644 src/lib/vaccinations/booster-mint.ts create mode 100644 tests/integration/vaccination-booster-mint.test.ts diff --git a/messages/de.json b/messages/de.json index a99a83959..710817775 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8579,6 +8579,23 @@ "sourceLabel": "Quelle: {source}", "affordanceLabel": "Impfschema-Info" }, + "booster": { + "title": "Auffrischung planen?", + "body": "Aus dieser Impfung lässt sich eine Erinnerung für die nächste Auffrischung anlegen. Du kannst Intervall und Bezeichnung ändern oder ablehnen.", + "interval": "Intervall", + "label": "Bezeichnung", + "labelSuffix": "Auffrischung", + "decline": "Nicht jetzt", + "confirm": "Erinnerung anlegen", + "planned": "Auffrischung geplant", + "failed": "Erinnerung konnte nicht angelegt werden.", + "everyYearsOne": "alle {count} Jahr", + "everyYearsFew": "alle {count} Jahre", + "everyYearsOther": "alle {count} Jahre", + "everyMonthsOne": "alle {count} Monat", + "everyMonthsFew": "alle {count} Monate", + "everyMonthsOther": "alle {count} Monate" + }, "empty": { "title": "Noch keine Impfung erfasst", "description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen." diff --git a/messages/en.json b/messages/en.json index d672d79b8..c6cd99c51 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8579,6 +8579,23 @@ "sourceLabel": "Source: {source}", "affordanceLabel": "Schedule info" }, + "booster": { + "title": "Plan a booster?", + "body": "You can set a reminder for the next booster from this dose. Change the interval or the label, or decline.", + "interval": "Interval", + "label": "Label", + "labelSuffix": "booster", + "decline": "Not now", + "confirm": "Set reminder", + "planned": "Booster planned", + "failed": "The reminder could not be created.", + "everyYearsOne": "every {count} year", + "everyYearsFew": "every {count} years", + "everyYearsOther": "every {count} years", + "everyMonthsOne": "every {count} month", + "everyMonthsFew": "every {count} months", + "everyMonthsOther": "every {count} months" + }, "empty": { "title": "No vaccination logged yet", "description": "Transcribe your vaccination card line by line — a date and the vaccine are enough." diff --git a/messages/es.json b/messages/es.json index a716ed2db..c290bc2c9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8579,6 +8579,23 @@ "sourceLabel": "Fuente: {source}", "affordanceLabel": "Información del esquema" }, + "booster": { + "title": "¿Planificar un refuerzo?", + "body": "Puedes crear un recordatorio para el próximo refuerzo a partir de esta dosis. Cambia el intervalo o la etiqueta, o recházalo.", + "interval": "Intervalo", + "label": "Etiqueta", + "labelSuffix": "refuerzo", + "decline": "Ahora no", + "confirm": "Crear recordatorio", + "planned": "Refuerzo planificado", + "failed": "No se pudo crear el recordatorio.", + "everyYearsOne": "cada {count} año", + "everyYearsFew": "cada {count} años", + "everyYearsOther": "cada {count} años", + "everyMonthsOne": "cada {count} mes", + "everyMonthsFew": "cada {count} meses", + "everyMonthsOther": "cada {count} meses" + }, "empty": { "title": "Aún no hay ninguna vacuna registrada", "description": "Transcribe tu cartilla de vacunación línea por línea: bastan una fecha y la vacuna." diff --git a/messages/fr.json b/messages/fr.json index dc0ef08a5..9183755d0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8579,6 +8579,23 @@ "sourceLabel": "Source : {source}", "affordanceLabel": "Infos sur le schéma" }, + "booster": { + "title": "Planifier un rappel ?", + "body": "Vous pouvez créer un rappel pour le prochain rappel à partir de cette dose. Modifiez l'intervalle ou le libellé, ou refusez.", + "interval": "Intervalle", + "label": "Libellé", + "labelSuffix": "rappel", + "decline": "Pas maintenant", + "confirm": "Créer le rappel", + "planned": "Rappel planifié", + "failed": "Le rappel n'a pas pu être créé.", + "everyYearsOne": "tous les {count} an", + "everyYearsFew": "tous les {count} ans", + "everyYearsOther": "tous les {count} ans", + "everyMonthsOne": "tous les {count} mois", + "everyMonthsFew": "tous les {count} mois", + "everyMonthsOther": "tous les {count} mois" + }, "empty": { "title": "Aucune vaccination enregistrée", "description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent." diff --git a/messages/it.json b/messages/it.json index 5f0a8f0d8..1c0aec1f7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8579,6 +8579,23 @@ "sourceLabel": "Fonte: {source}", "affordanceLabel": "Informazioni sullo schema" }, + "booster": { + "title": "Pianificare un richiamo?", + "body": "Da questa dose puoi creare un promemoria per il prossimo richiamo. Cambia l'intervallo o l'etichetta, oppure rifiuta.", + "interval": "Intervallo", + "label": "Etichetta", + "labelSuffix": "richiamo", + "decline": "Non ora", + "confirm": "Crea promemoria", + "planned": "Richiamo pianificato", + "failed": "Impossibile creare il promemoria.", + "everyYearsOne": "ogni {count} anno", + "everyYearsFew": "ogni {count} anni", + "everyYearsOther": "ogni {count} anni", + "everyMonthsOne": "ogni {count} mese", + "everyMonthsFew": "ogni {count} mesi", + "everyMonthsOther": "ogni {count} mesi" + }, "empty": { "title": "Nessuna vaccinazione registrata", "description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino." diff --git a/messages/pl.json b/messages/pl.json index ff8c0304d..39df4b3d6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8579,6 +8579,23 @@ "sourceLabel": "Źródło: {source}", "affordanceLabel": "Informacje o schemacie" }, + "booster": { + "title": "Zaplanować dawkę przypominającą?", + "body": "Z tego szczepienia możesz utworzyć przypomnienie o następnej dawce przypominającej. Zmień odstęp lub etykietę albo odrzuć.", + "interval": "Odstęp", + "label": "Etykieta", + "labelSuffix": "przypomnienie", + "decline": "Nie teraz", + "confirm": "Utwórz przypomnienie", + "planned": "Zaplanowano dawkę przypominającą", + "failed": "Nie udało się utworzyć przypomnienia.", + "everyYearsOne": "co {count} rok", + "everyYearsFew": "co {count} lata", + "everyYearsOther": "co {count} lat", + "everyMonthsOne": "co {count} miesiąc", + "everyMonthsFew": "co {count} miesiące", + "everyMonthsOther": "co {count} miesięcy" + }, "empty": { "title": "Nie zarejestrowano jeszcze żadnego szczepienia", "description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka." diff --git a/src/app/api/vaccinations/[id]/booster/route.ts b/src/app/api/vaccinations/[id]/booster/route.ts new file mode 100644 index 000000000..da406db47 --- /dev/null +++ b/src/app/api/vaccinations/[id]/booster/route.ts @@ -0,0 +1,111 @@ +/** + * `POST /api/vaccinations/{id}/booster` — mint (or re-anchor) the booster + * reminder a logged dose suggests. + * + * Rung 2: the reminder only exists because the person confirmed it, prefilled + * from the catalogue's interval and fully editable before this call. The minted + * row is an ordinary `origin: VORSORGE` reminder that carries the dose's + * primary antigen as a match key; it lists on `/checkups` and rings through the + * same engine as every other checkup, with no new code. + * + * WRITE, classified `profile`: filing the owner's Impfpass and arming the + * owner's booster plan are the same act, and both run on the owner's reminders + * even when a delegate is transcribing. The antigen is read from the dose's + * catalogue entry server-side, never from the body — a client cannot key a + * reminder onto an antigen the dose does not contain. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { vaccinationBoosterSchema } from "@/lib/validations/vaccinations"; +import { mintOrReanchorBooster } from "@/lib/vaccinations/booster-mint"; +import { resolveOwnerTimezone } from "@/lib/vaccinations/service"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("write", "profile"); + const { id } = await params; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationBoosterSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.booster-invalid", + }); + } + + const timezone = await resolveOwnerTimezone(user.id); + const result = await prisma.$transaction((tx) => + mintOrReanchorBooster( + tx, + user.id, + { vaccinationId: id, ...parsed.data }, + timezone, + ), + ); + + if (result.outcome === "unknown-record") { + return apiError("Vaccination not found", 404, { + errorCode: "vaccination.not-found", + }); + } + if (result.outcome === "no-antigen") { + // A free-text-only dose has no antigen to key a booster on. The prompt is + // never offered for one; this is the defence if a request arrives anyway. + return apiError("This dose has no catalogue antigen to remind on", 422, { + errorCode: "vaccination.booster-no-antigen", + }); + } + + const reminder = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: result.reminderId }, + }); + + await auditLog("vaccination.booster.planned", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: id, + reminderId: result.reminderId, + minted: result.outcome === "minted", + }, + }); + + annotate({ + action: { + name: "vaccination.booster.planned", + entity_type: "measurement-reminder", + entity_id: result.reminderId, + }, + meta: { + antigen_slug: result.antigen, + minted: result.outcome === "minted", + }, + }); + + return apiSuccess( + { + reminder: toMeasurementReminderDto(reminder), + minted: result.outcome === "minted", + }, + result.outcome === "minted" ? 201 : 200, + ); + }, +); diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx new file mode 100644 index 000000000..86e62b8a3 --- /dev/null +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -0,0 +1,168 @@ +"use client"; + +/** + * Rung 2, at the point of offer: after a dose whose catalogue entry carries a + * booster interval, a prefilled — never imposed — prompt to plan the next one. + * + * The interval and the label are prefilled from the catalogue and the person's + * own record, and both are editable before they confirm; declining is one tap + * and leaves no state behind. Nothing here computes what a person is due: the + * catalogue's typical interval seeds a reminder the person owns, and that is + * the whole of the "recommendation". Confirming writes an ordinary Vorsorge + * reminder that keys on the dose's antigen; the satisfy matcher usually + * re-anchored one already during the create, so the common path simply confirms + * the new due date. + * + * The prompt never blocks: the create has already completed and the sheet has + * already closed by the time this appears. + */ +import { useState } from "react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { FieldGroup } from "@/components/ui/field-group"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useTranslations } from "@/lib/i18n/context"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { useBoosterMint, type Vaccination } from "./use-vaccinations"; + +/** + * Whether a freshly created dose should raise the offer at all. Only a + * catalogue entry that carries a booster interval does; a free-text row and a + * one-off vaccine never prompt. + */ +export function boosterOfferFor( + record: Vaccination, +): { intervalMonths: number; catalogName: string } | null { + const entry = resolveCatalogEntry(record.antigenSlug); + if (!entry || entry.boosterIntervalMonths === null) return null; + return { + intervalMonths: entry.boosterIntervalMonths, + catalogName: entry.slug, + }; +} + +const MONTH_OPTIONS = [6, 12, 24, 36, 60, 120] as const; + +export function BoosterMintPrompt({ + record, + onClose, +}: { + /** The just-created dose, or null when there is nothing to offer. */ + record: Vaccination | null; + onClose: () => void; +}) { + const { t, tCount } = useTranslations(); + const mint = useBoosterMint(); + const offer = record ? boosterOfferFor(record) : null; + + // The default label is the catalogue name plus the booster word, composed in + // the person's own locale — the server never resolves an i18n key. + const defaultLabel = + record && offer + ? `${t(`vaccinations.catalog.${record.antigenSlug}`)} ${t("vaccinations.booster.labelSuffix")}` + : ""; + const [label, setLabel] = useState(defaultLabel); + const [months, setMonths] = useState(offer?.intervalMonths ?? 12); + + // The prompt is only mounted when there is an offer; guard for the type. + if (!record || !offer) return null; + + const confirm = async () => { + try { + await mint.mutateAsync({ + id: record.id, + body: { intervalMonths: months, label: label.trim() || defaultLabel }, + }); + toast.success(t("vaccinations.booster.planned")); + } catch (err) { + toast.error( + err instanceof Error ? err.message : t("vaccinations.booster.failed"), + ); + } finally { + onClose(); + } + }; + + const intervalOptions = MONTH_OPTIONS.includes( + offer.intervalMonths as (typeof MONTH_OPTIONS)[number], + ) + ? MONTH_OPTIONS + : ([offer.intervalMonths, ...MONTH_OPTIONS] as readonly number[]); + + return ( + (!open ? onClose() : undefined)}> + + + {t("vaccinations.booster.title")} + + {t("vaccinations.booster.body")} + + + +
+ + setMonths(Number(event.target.value))} + > + {intervalOptions.map((value) => ( + + ))} + + + + + setLabel(event.target.value)} + /> + +
+ + + + {t("vaccinations.booster.decline")} + + { + // Keep the dialog until the write settles, then close it. + event.preventDefault(); + void confirm(); + }} + > + {t("vaccinations.booster.confirm")} + + +
+
+ ); +} diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index d83422355..9641a3479 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -115,3 +115,34 @@ export function useVaccinationMutations() { return { create, update, remove, restore }; } + +/** What the booster confirm carries — the user's accepted-or-edited values. */ +export interface BoosterMintBody { + intervalMonths: number; + label: string; + notifyHour?: number; +} + +/** + * Mint (or re-anchor) the booster reminder a dose suggests. + * + * The response is an ordinary Vorsorge reminder; the write fans out through the + * dose dependent-keys and the reminder reads so the new checkup appears on + * `/checkups` the instant it is confirmed. + */ +export function useBoosterMint() { + const qc = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.vaccinationBooster(), + mutationFn: ({ id, body }: { id: string; body: BoosterMintBody }) => + apiPost<{ reminder: unknown; minted: boolean }>( + `${BASE}/${id}/booster`, + body, + ), + onSuccess: () => + Promise.all([ + invalidateKeys(qc, vaccinationDependentKeys), + invalidateReminderReads(qc), + ]), + }); +} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index bae793733..4bc8e5882 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { VaccinationList } from "./vaccination-list"; import { VaccinationSheet } from "./vaccination-sheet"; +import { BoosterMintPrompt, boosterOfferFor } from "./booster-mint-prompt"; import { useVaccinations, type Vaccination } from "./use-vaccinations"; export function VaccinationsView() { @@ -34,6 +35,9 @@ export function VaccinationsView() { const [sheetOpen, setSheetOpen] = useState(false); const [editing, setEditing] = useState(null); const [session, setSession] = useState(0); + // The just-created dose whose catalogue entry carries a booster interval; + // drives the one-time mint offer, cleared on confirm or decline. + const [boosterFor, setBoosterFor] = useState(null); const openCreate = () => { setEditing(null); @@ -72,6 +76,17 @@ export function VaccinationsView() { open={sheetOpen} onOpenChange={setSheetOpen} vaccination={editing} + onCreated={(created) => { + // Offer the booster only when the catalogue entry carries an + // interval; a free-text or one-off dose never prompts. + if (boosterOfferFor(created)) setBoosterFor(created); + }} + /> + + setBoosterFor(null)} /> {isLoading ? ( diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts index c01c6e4bd..9d399db02 100644 --- a/src/lib/query-keys/vaccinations.ts +++ b/src/lib/query-keys/vaccinations.ts @@ -47,4 +47,5 @@ export const vaccinationKeys = { vaccinationUpdate: () => ["vaccinations", "update"] as const, vaccinationDelete: () => ["vaccinations", "delete"] as const, vaccinationRestore: () => ["vaccinations", "restore"] as const, + vaccinationBooster: () => ["vaccinations", "booster"] as const, } as const; diff --git a/src/lib/vaccinations/booster-mint.ts b/src/lib/vaccinations/booster-mint.ts new file mode 100644 index 000000000..ccd0504ae --- /dev/null +++ b/src/lib/vaccinations/booster-mint.ts @@ -0,0 +1,166 @@ +/** + * Rung 2: mint the booster reminder a logged dose suggests — the writer half of + * `MeasurementReminder.vaccinationAntigen`, whose reader is the Phase 1 satisfy + * matcher. + * + * This is rung 2 exactly: the reminder is prefilled and only exists because the + * person confirmed it. The catalogue's booster interval seeds a due date the + * user can edit or decline; this module writes what they confirmed. It never + * computes what a person is due — that would be rung 3, a different venture. + * + * ── An ordinary Vorsorge reminder ────────────────────────────────────────── + * + * The minted row is `origin: VORSORGE` and carries `vaccinationAntigen` as a + * match key. Everything else — the cron, the digest, `/checkups`, the insights + * block, `get_preventive_care` — reads it with zero new code, because a booster + * reminder is the engine's native case. That nativeness is the whole Option-A + * argument; the column is a key, not a behaviour change, and the published + * `origin` enum stays two-armed. + * + * ── One antigen, at most one minted reminder ─────────────────────────────── + * + * The idempotence is by `vaccinationAntigen`: if a live reminder already keys + * on this antigen, a second confirmation RE-ANCHORS it rather than minting a + * second. The satisfy matcher has usually already re-anchored it during the + * create, so the common path simply confirms the new due date. Breaking this + * branch mints a duplicate, which is the watched-red the mint owes (WR-9). + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; +import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog"; + +/** What the confirm carries. All three are the user's edited-or-accepted values. */ +export interface BoosterMintInput { + vaccinationId: string; + /** Months between doses, as confirmed. Converted to a rolling day interval. */ + intervalMonths: number; + /** The reminder label, composed and editable client-side. */ + label: string; + notifyHour?: number; +} + +export type BoosterMintResult = + | { outcome: "unknown-record" } + | { outcome: "no-antigen" } + | { outcome: "minted" | "reanchored"; reminderId: string; antigen: string }; + +/** The reminder engine's hard ceiling on a rolling interval (ten years). */ +const MAX_INTERVAL_DAYS = 3650; + +const DAYS_PER_MONTH = 30.4375; + +/** + * A booster reminder is a ROLLING cadence, not an absolute calendar rule. + * + * This is the whole reason it uses `intervalDays` rather than an rrule: a + * rolling interval re-anchors on `lastSatisfiedAt`, so logging the next dose + * moves the due date forward — the activity-aware loop the satisfy matcher + * closes. An rrule's occurrences are fixed dates, so satisfying one would + * leave the same due date standing. The months are converted to days and + * capped at the engine's ten-year ceiling, which is exactly a decade booster. + */ +function cadenceDays(intervalMonths: number): number { + return Math.min( + MAX_INTERVAL_DAYS, + Math.round(intervalMonths * DAYS_PER_MONTH), + ); +} + +/** + * Mint the booster reminder for a dose, or re-anchor the one that already keys + * on its antigen. Runs in the caller's transaction so the reminder and the + * `reminderId` stamp commit together. + */ +export async function mintOrReanchorBooster( + tx: Prisma.TransactionClient, + userId: string, + input: BoosterMintInput, + timezone: string, +): Promise { + const record = await tx.vaccinationRecord.findFirst({ + where: { id: input.vaccinationId, userId, deletedAt: null }, + select: { id: true, antigenSlug: true, occurredAt: true }, + }); + if (!record) return { outcome: "unknown-record" }; + + // The reminder keys on the dose's PRIMARY component antigen — for a Tdap that + // is tetanus. A person who tracks the components separately can mint further + // reminders by hand; the offer covers the one the interval belongs to. + const [antigen] = componentsForSlug(record.antigenSlug); + if (!antigen) return { outcome: "no-antigen" }; + + const notifyHour = input.notifyHour ?? 9; + const intervalDays = cadenceDays(input.intervalMonths); + const now = new Date(); + // The FIRST booster is due one interval after the dose, so the anchor is the + // dose date plus the interval — a never-satisfied rolling reminder is due at + // its anchor, and the dose date itself is not when the next shot is due. Once + // the person logs that next dose, `satisfyReminder` re-anchors on the satisfy + // instant and this anchor no longer matters. + const DAY_MS = 24 * 60 * 60 * 1000; + const anchorDate = new Date( + record.occurredAt.getTime() + intervalDays * DAY_MS, + ); + const nextDueAt = computeReminderNextDueAt( + { + intervalDays, + rrule: null, + anchorDate, + notifyHour, + lastSatisfiedAt: null, + createdAt: now, + }, + timezone, + now, + ); + + // One antigen, at most one minted reminder. A live row keyed on this antigen + // is re-anchored, never duplicated. + const existing = await tx.measurementReminder.findFirst({ + where: { userId, deletedAt: null, vaccinationAntigen: antigen }, + select: { id: true }, + }); + + if (existing) { + await tx.measurementReminder.update({ + where: { id: existing.id }, + data: { + label: input.label, + intervalDays, + rrule: null, + anchorDate, + notifyHour, + nextDueAt, + }, + }); + await tx.vaccinationRecord.update({ + where: { id: record.id }, + data: { reminderId: existing.id }, + }); + return { outcome: "reanchored", reminderId: existing.id, antigen }; + } + + const created = await tx.measurementReminder.create({ + data: { + userId, + label: input.label, + origin: "VORSORGE", + vaccinationAntigen: antigen, + measurementType: null, + intervalDays, + rrule: null, + anchorDate, + notifyHour, + location: null, + enabled: true, + nextDueAt, + }, + select: { id: true }, + }); + await tx.vaccinationRecord.update({ + where: { id: record.id }, + data: { reminderId: created.id }, + }); + return { outcome: "minted", reminderId: created.id, antigen }; +} diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 0ae186dee..51ca6fce4 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -209,3 +209,25 @@ export const vaccinationLinkSchema = z .strict(); export type VaccinationLinkInput = z.infer; + +/* ── booster mint (rung 2) ────────────────────────────────────────── */ + +/** + * The confirm body for a booster reminder. + * + * Every value is the user's — accepted or edited from the catalogue's + * prefill. `intervalMonths` is bounded generously (a decade booster is 120) + * and the label is composed client-side so its locale is the person's own. No + * `vaccinationAntigen` field: the server reads the antigen from the dose's + * catalogue entry, never from the request, so a client cannot key a reminder + * onto an antigen the dose does not contain. + */ +export const vaccinationBoosterSchema = z + .object({ + intervalMonths: z.number().int().min(1).max(600), + label: z.string().trim().min(1).max(120), + notifyHour: z.number().int().min(0).max(23).optional(), + }) + .strict(); + +export type VaccinationBoosterInput = z.infer; diff --git a/tests/integration/vaccination-booster-mint.test.ts b/tests/integration/vaccination-booster-mint.test.ts new file mode 100644 index 000000000..1e89514a5 --- /dev/null +++ b/tests/integration/vaccination-booster-mint.test.ts @@ -0,0 +1,233 @@ +/** + * The booster mint — rung 2 — through the real routes, real Postgres, real + * reminder engine. + * + * Every property here is about ROWS: how many reminders exist after a second + * confirmation, whether the minted row lists as an ordinary Vorsorge reminder, + * and whether logging the next dose moves the due date the mint set. None can + * be answered from a fixture. + * + * The idempotence case is why the file exists: one antigen holds at most one + * minted booster reminder, so a second confirmation re-anchors rather than + * mints a second (WR-9). Fixture dates are relative — a test about "moves + * forward" rots against a fixed calendar date. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { POST as createVaccination } from "@/app/api/vaccinations/route"; +import { POST as mintBooster } from "@/app/api/vaccinations/[id]/booster/route"; +import { GET as listReminders } from "@/app/api/measurement-reminders/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 = "mint-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: "mint-owner", + email: "mint-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); +} + +async function createDose(body: unknown): Promise<{ id: string }> { + const res = await createVaccination( + new Request("http://localhost/api/vaccinations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + ); + expect(res.status, JSON.stringify(await res.clone().json())).toBe(201); + return ((await res.json()) as { data: { id: string } }).data; +} + +function mint(vaccinationId: string, body: unknown): Promise { + return mintBooster( + new Request(`http://localhost/api/vaccinations/${vaccinationId}/booster`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + { params: Promise.resolve({ id: vaccinationId }) }, + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedSession(); +}); + +describe("the booster mint writes an ordinary Vorsorge reminder", () => { + it("mints one VORSORGE reminder keyed on the dose's primary antigen", async () => { + const dose = await createDose({ + occurredAt: daysAgo(2).toISOString(), + antigenSlug: "tetanus", + }); + + const res = await mint(dose.id, { + intervalMonths: 120, + label: "Tetanus booster", + }); + expect(res.status, JSON.stringify(await res.clone().json())).toBe(201); + + const prisma = getPrismaClient(); + const reminders = await prisma.measurementReminder.findMany({ + where: { userId: OWNER_ID, deletedAt: null }, + }); + expect(reminders).toHaveLength(1); + expect(reminders[0]!.origin).toBe("VORSORGE"); + expect(reminders[0]!.vaccinationAntigen).toBe("tetanus"); + expect(reminders[0]!.nextDueAt!.getTime()).toBeGreaterThan(Date.now()); + + const stamped = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: dose.id }, + }); + expect(stamped.reminderId).toBe(reminders[0]!.id); + }); + + it("lists as an ordinary Vorsorge row with no new DTO field", async () => { + const dose = await createDose({ + occurredAt: daysAgo(2).toISOString(), + antigenSlug: "tetanus", + }); + await mint(dose.id, { intervalMonths: 120, label: "Tetanus booster" }); + + const res = await listReminders(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array & { origin: string }>; + }; + const minted = body.data.find((r) => r.origin === "VORSORGE"); + expect(minted).toBeDefined(); + expect(minted).not.toHaveProperty("vaccinationAntigen"); + }); +}); + +describe("one antigen, at most one minted reminder (WR-9)", () => { + it("re-anchors on a second confirmation rather than minting a second", async () => { + const dose = await createDose({ + occurredAt: daysAgo(2).toISOString(), + antigenSlug: "tetanus", + }); + + const first = await mint(dose.id, { + intervalMonths: 120, + label: "Tetanus booster", + }); + expect(first.status).toBe(201); + + const second = await mint(dose.id, { + intervalMonths: 60, + label: "Tetanus booster (sooner)", + }); + + // The count is the property WR-9 breaks: a broken idempotence branch mints + // a second row, and this is where it must go red. + const reminders = await getPrismaClient().measurementReminder.findMany({ + where: { + userId: OWNER_ID, + deletedAt: null, + vaccinationAntigen: "tetanus", + }, + }); + expect( + reminders, + "one antigen holds at most one minted booster reminder", + ).toHaveLength(1); + // A re-anchor answers 200 and carries the second confirmation's edits. + expect(second.status).toBe(200); + expect(reminders[0]!.label).toBe("Tetanus booster (sooner)"); + }); +}); + +describe("the whole activity-aware loop, through real routes", () => { + it("mint → log the next dose → the matcher re-anchors the minted reminder", async () => { + const first = await createDose({ + occurredAt: daysAgo(730).toISOString(), + antigenSlug: "tetanus", + }); + const mintRes = await mint(first.id, { + intervalMonths: 120, + label: "Tetanus booster", + }); + expect(mintRes.status).toBe(201); + const reminderId = ( + (await mintRes.json()) as { data: { reminder: { id: string } } } + ).data.reminder.id; + + const prisma = getPrismaClient(); + const before = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: reminderId }, + }); + expect(before.lastSatisfiedAt).toBeNull(); + + const occurredAt = daysAgo(1); + const second = await createVaccination( + new Request("http://localhost/api/vaccinations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: occurredAt.toISOString(), + antigenSlug: "tetanus", + }), + }) as never, + ); + expect(second.status).toBe(201); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: reminderId }, + }); + expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString()); + expect(after.nextDueAt!.getTime()).toBeGreaterThan( + before.nextDueAt!.getTime(), + ); + }); +});