feat(vaccinations): mint the booster reminder a logged dose suggests

- 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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 21:01:25 +02:00
1 parent f540298521
commit b5ce44628a
14 files changed
+849

No files matched your search

@@ -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,
);
},
);
@@ -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<number>(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 (
<AlertDialog open onOpenChange={(open) => (!open ? onClose() : undefined)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("vaccinations.booster.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("vaccinations.booster.body")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
<FieldGroup
htmlFor="booster-interval"
label={t("vaccinations.booster.interval")}
>
<NativeSelect
id="booster-interval"
value={String(months)}
onChange={(event) => setMonths(Number(event.target.value))}
>
{intervalOptions.map((value) => (
<option key={value} value={value}>
{value % 12 === 0
? tCount("vaccinations.booster.everyYears", value / 12, {
count: value / 12,
})
: tCount("vaccinations.booster.everyMonths", value, {
count: value,
})}
</option>
))}
</NativeSelect>
</FieldGroup>
<FieldGroup
htmlFor="booster-label"
label={t("vaccinations.booster.label")}
>
<Input
id="booster-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
/>
</FieldGroup>
</div>
<AlertDialogFooter>
<AlertDialogCancel data-slot="vaccination-booster-decline">
{t("vaccinations.booster.decline")}
</AlertDialogCancel>
<AlertDialogAction
data-slot="vaccination-booster-confirm"
disabled={mint.isPending}
onClick={(event) => {
// Keep the dialog until the write settles, then close it.
event.preventDefault();
void confirm();
}}
>
{t("vaccinations.booster.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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),
]),
});
}
@@ -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<Vaccination | null>(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<Vaccination | null>(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);
}}
/>
<BoosterMintPrompt
key={boosterFor?.id ?? "none"}
record={boosterFor}
onClose={() => setBoosterFor(null)}
/>
{isLoading ? (
+1
View File
@@ -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;
+166
View File
@@ -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<BoosterMintResult> {
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 };
}
+22
View File
@@ -209,3 +209,25 @@ export const vaccinationLinkSchema = z
.strict();
export type VaccinationLinkInput = z.infer<typeof vaccinationLinkSchema>;
/* ── 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<typeof vaccinationBoosterSchema>;