mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
f540298521
commit
b5ce44628a
14 files changed
+849
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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<Response> {
|
||||
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<Record<string, unknown> & { 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user