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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:36:08 +02:00
1 parent 7ac81f3eed
commit 82982038cb
4 files changed
+572

No files matched your search

+38
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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) ?? []),
+153
View File
@@ -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<BoosterSatisfyResult> {
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,
};
}
+19
View File
@@ -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<string> {
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,