Merge pull request #751 from MBombeck/feat/vaccinations-phase-2

Vaccinations: the immunization record, visible and reachable
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-09 01:14:58 +02:00
commit 2f7f522225
60 files changed
+4764 -36

No files matched your search

+3 -3
View File
@@ -932,7 +932,7 @@ paths:
type: number
const: 2
leaves:
maxItems: 92
maxItems: 93
type: array
items:
type: string
@@ -1037,7 +1037,7 @@ paths:
type: number
const: 2
leaves:
maxItems: 92
maxItems: 93
type: array
items:
type: string
@@ -16157,7 +16157,7 @@ components:
type: number
const: 2
leaves:
maxItems: 92
maxItems: 93
type: array
items:
type: string
+89
View File
@@ -0,0 +1,89 @@
/**
* The immunization-log spec's account hygiene.
*
* `vaccinations.spec.ts` asserts GROUPED verdicts — a combination dose appears
* in each of its three component groups, one antigen holds at most one minted
* booster reminder, a logged next dose moves the reminder's due date forward.
* Every one of those is a count over the seeded e2e user's live rows, so the
* spec has to own the rows it counts against: a dose left by an earlier run, a
* Playwright retry, or a second project sharing the account would leave a
* stale group on the page and a second reminder in the antigen bucket, and the
* grouped assertions would read the wrong count.
*
* The reset is delete-then-assert-clean, keyed by the seeded user's id. Links
* go first for their foreign key; the minted booster reminders go last and are
* scoped to the ones this feature writes — `vaccination_antigen IS NOT NULL` —
* so an unrelated Vorsorge reminder another spec relies on is never touched.
*/
import pg from "pg";
import { E2E_USER } from "./global-setup";
async function getUserId(pool: pg.Pool): Promise<string> {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_USER.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
"[vaccination-fixture] e2e user not seeded — global-setup must run first",
);
}
return id;
}
/**
* Remove the seeded e2e user's vaccination rows, their document links, and the
* booster reminders they minted. Safe to call from every test's `beforeEach`.
*/
export async function resetVaccinations(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vaccination-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
// Links first — they FK into the record rows.
await pool.query(
"DELETE FROM vaccination_document_links WHERE user_id = $1",
[userId],
);
await pool.query("DELETE FROM vaccination_records WHERE user_id = $1", [
userId,
]);
// Only the reminders this feature mints. A booster reminder is the only
// kind that carries an antigen; a checkup another spec created has none and
// stays.
await pool.query(
"DELETE FROM measurement_reminders WHERE user_id = $1 AND vaccination_antigen IS NOT NULL",
[userId],
);
} finally {
await pool.end();
}
}
/**
* Put the seeded e2e user's `vaccinations` module back to its default-on state.
*
* The module-off test flips the toggle through the real settings UI, and that
* write persists on the shared account past the test that made it. Every other
* flow needs the surface reachable, so this clears the `vaccinations` key from
* the preferences blob — default-on is absence — leaving any other module the
* account carries (`nutrients`) untouched. Called from `beforeEach` so no test
* order can strand the surface off.
*/
export async function ensureVaccinationsModuleOn(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vaccination-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
await pool.query(
"UPDATE users SET module_preferences_json = module_preferences_json - 'vaccinations' WHERE id = $1 AND module_preferences_json ? 'vaccinations'",
[userId],
);
} finally {
await pool.end();
}
}
+337
View File
@@ -0,0 +1,337 @@
/**
* The immunization log, proven end to end.
*
* Five flows, one script each, each assertion addressed to a stable
* `data-slot` / `data-*` attribute and never to viewport text — the copy is
* i18n-driven and the same slots paint at a different size on the mobile
* project, so a text assertion would break for the wrong reason.
*
* 1. Transcribe — a catalogue pick plus a lot number saves and reads back in
* its antigen group with a resolved series label.
* 2. Combination — one Tdap renders a row in each of its three component
* groups, the render-only duplication the series design exists for.
* 3. Mint + satisfy — confirming the booster prompt writes an ordinary
* Vorsorge reminder that shows on `/checkups`; logging the next dose moves
* its due date forward through the real satisfy matcher.
* 4. Module off — turning the module off in settings drops the nav entry and
* the direct visit lands on the module-off redirect, not a crash.
* 5. a11y — axe over the list and the capture form, serious/critical only.
*
* The flows mutate the one seeded account, so — like `visits.spec.ts` — this
* file runs in a single project (see `playwright.config.ts`) and serial, and
* clears its own rows before each test so a grouped verdict counts only what
* the test itself wrote.
*/
import AxeBuilder from "@axe-core/playwright";
import type { APIRequestContext, Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import {
ensureVaccinationsModuleOn,
resetVaccinations,
} from "./setup/vaccination-fixture";
test.beforeEach(async () => {
// Own the counts every grouped assertion reads, and undo whatever the
// module-off flow left behind.
await resetVaccinations();
await ensureVaccinationsModuleOn();
});
// Serial and single-account: the reset above would race a sibling worker, and
// two tests clearing the same rows would each undo the other's setup.
test.describe.configure({ mode: "serial" });
/** One reminder as the list route publishes it. */
interface ReminderDTO {
id: string;
origin: string;
nextDueAt: string | null;
}
async function listReminders(
request: APIRequestContext,
): Promise<ReminderDTO[]> {
const res = await request.get("/api/measurement-reminders");
expect(res.status()).toBe(200);
return ((await res.json()) as { data: ReminderDTO[] }).data;
}
async function listVaccinationIds(
request: APIRequestContext,
): Promise<string[]> {
const res = await request.get("/api/vaccinations");
expect(res.status()).toBe(200);
const body = (await res.json()) as {
data: { vaccinations: { id: string }[] };
};
return body.data.vaccinations.map((row) => row.id);
}
/**
* Drive the capture sheet: open it, pick a catalogue antigen by its slug, set
* the date and an optional lot, and save. `booster` says what to do with the
* mint prompt a booster-bearing antigen raises after the save.
*/
async function captureDose(
page: Page,
opts: {
catalogSlug: string;
/** `YYYY-MM-DD`. Defaults to the form's own today. */
date?: string;
lot?: string;
booster: "confirm" | "decline" | "none";
},
): Promise<void> {
// The header Add renders whether or not the list is empty, so clicking it
// works in both states. click() auto-waits for the button to be actionable,
// which avoids racing an instant visibility read against the client render
// (an empty-state-only branch times out once a record already exists).
await page.locator('[data-slot="vaccination-add"]').first().click();
await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible();
if (opts.date) {
await page.getByTestId("vaccination-occurred-at").fill(opts.date);
}
await page.locator('[data-slot="vaccination-catalog-trigger"]').click();
await page
.locator('[data-slot="vaccination-catalog-search"]')
.fill(opts.catalogSlug);
await page
.locator(
`[data-slot="vaccination-catalog-option"][data-catalog-slug="${opts.catalogSlug}"]`,
)
.click();
if (opts.lot) {
await page.locator("#vaccination-lot").fill(opts.lot);
}
await page.locator('[data-slot="vaccination-save"]').click();
if (opts.booster === "confirm") {
await page.locator('[data-slot="vaccination-booster-confirm"]').click();
} else if (opts.booster === "decline") {
await page.locator('[data-slot="vaccination-booster-decline"]').click();
}
// The prompt (when raised) and the save button both leave the tree once the
// flow settles; waiting on the save button's detachment keeps the next step
// off a stale sheet.
await expect(page.locator('[data-slot="vaccination-save"]')).toBeHidden();
}
test.describe("vaccinations", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("a dose transcribed with a catalogue pick and a lot reads back in its antigen group", async ({
page,
request,
}) => {
await page.goto("/vaccinations");
// Polio carries no booster interval, so the capture completes without a
// mint prompt — this flow is about the transcription, not the reminder.
await captureDose(page, {
catalogSlug: "polio",
lot: "LOT-E2E-001",
booster: "none",
});
const [id] = await listVaccinationIds(request);
expect(id, "the dose was written").toBeTruthy();
const group = page.locator(
'[data-slot="vaccination-group"][data-antigen="polio"]',
);
await expect(group).toBeVisible({ timeout: 15_000 });
const row = group.locator(`[data-vaccination-id="${id}"]`);
await expect(row).toBeVisible();
// The series label is resolved server-side and rendered as its own slot.
await expect(row.locator('[data-slot="vaccination-series"]')).toBeVisible();
// The lot rides the row's meta line under its own slot.
await expect(row.locator('[data-slot="vaccination-lot"]')).toBeVisible();
});
test("a combination dose appears in each of its three component groups", async ({
page,
request,
}) => {
await page.goto("/vaccinations");
// Tdap's primary component (tetanus) carries a booster interval, so the
// mint prompt is raised — decline it; this flow is about the grouping.
await captureDose(page, { catalogSlug: "tdap", booster: "decline" });
const [id] = await listVaccinationIds(request);
expect(id, "the Tdap dose was written").toBeTruthy();
// One record, three appearances — once under each component antigen, each
// its own group, each carrying the same record id.
for (const antigen of ["tetanus", "diphtheria", "pertussis"] as const) {
const row = page.locator(
`[data-slot="vaccination-group"][data-antigen="${antigen}"] [data-vaccination-id="${id}"]`,
);
await expect(row, `Tdap appears under ${antigen}`).toBeVisible({
timeout: 15_000,
});
}
});
test("confirming the booster prompt mints a reminder that a logged next dose re-anchors", async ({
page,
request,
}) => {
const before = new Set((await listReminders(request)).map((r) => r.id));
await page.goto("/vaccinations");
// The first dose sits years back, so the minted reminder's first due date
// (dose + interval) is anchored in the past relative to a dose logged now —
// which is what lets the re-anchor below prove a forward move.
await captureDose(page, {
catalogSlug: "tetanus",
date: "2016-03-15",
booster: "confirm",
});
// Confirming the prompt closes the sheet before the mint's write has
// necessarily propagated to the list route, so poll for the new reminder
// rather than reading once against an in-flight mutation.
await expect
.poll(
async () =>
(await listReminders(request)).filter((r) => !before.has(r.id))
.length,
{ timeout: 15_000 },
)
.toBe(1);
const minted = (await listReminders(request)).find(
(r) => !before.has(r.id),
);
expect(minted, "the mint wrote exactly one new reminder").toBeDefined();
expect(minted!.origin).toBe("VORSORGE");
expect(minted!.nextDueAt).toBeTruthy();
const dueBefore = new Date(minted!.nextDueAt!).getTime();
// It presents on /checkups like every other Vorsorge reminder — the file-a
// -visit affordance only paints on a rendered reminder card, so it standing
// in for "a reminder card is here" is a stable per-card slot.
await page.goto("/checkups");
await expect(
page.locator('[data-slot="vorsorge-file-visit"]').first(),
).toBeVisible({ timeout: 15_000 });
// Log the next dose today. The Phase 1 satisfy matcher re-anchors the
// minted reminder on this dose during the create, so its due date moves
// forward off the years-old anchor.
await page.goto("/vaccinations");
await captureDose(page, { catalogSlug: "tetanus", booster: "decline" });
// The satisfy re-anchor runs inside the second dose's create transaction;
// poll the reminder's due date forward rather than reading once, so a scan
// that beats the commit does not read the pre-anchor value.
await expect
.poll(
async () => {
const row = (await listReminders(request)).find(
(r) => r.id === minted!.id,
);
return row?.nextDueAt ? new Date(row.nextDueAt).getTime() : null;
},
{ timeout: 15_000 },
)
.toBeGreaterThan(dueBefore);
});
test("turning the module off drops the nav entry and the direct visit redirects", async ({
page,
}) => {
await page.goto("/vaccinations");
// The surface is present to begin with.
await expect(
page.locator('[data-tour-id="vaccinations-hero"]'),
).toBeVisible({ timeout: 15_000 });
// Flip the real settings switch off and wait for the resolved module map to
// repaint the shell.
await page.goto("/settings/modules");
const toggle = page.locator("#module-toggle-vaccinations");
await expect(toggle).toBeVisible({ timeout: 15_000 });
await expect(toggle).toBeChecked();
await toggle.click();
await expect(toggle).not.toBeChecked();
// The nav entry is gone.
await expect(page.locator('[data-tour-id="nav-vaccinations"]')).toHaveCount(
0,
);
// A direct visit no longer renders the surface: the page redirects home,
// so the hero never paints and the dashboard's own landmark does.
await page.goto("/vaccinations");
await expect(page).toHaveURL(/\/$|\/dashboard/, { timeout: 15_000 });
await expect(
page.locator('[data-tour-id="vaccinations-hero"]'),
).toHaveCount(0);
});
test("the list and the capture form carry no serious accessibility violations", async ({
page,
request,
}) => {
// Seed one dose so the list scans a populated surface rather than the empty
// state, then scan both the list and the open capture form.
const seeded = await request.post("/api/vaccinations", {
data: { occurredAt: "2019-05-01T00:00:00.000Z", antigenSlug: "polio" },
});
expect(seeded.status()).toBe(201);
await page.goto("/vaccinations");
await expect(
page.locator('[data-slot="vaccination-group"][data-antigen="polio"]'),
).toBeVisible({ timeout: 15_000 });
await expectNoSeriousAxe(page);
await page.locator('[data-slot="vaccination-add"]').first().click();
await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible();
await expectNoSeriousAxe(page);
});
});
/**
* Wait until every running CSS animation/transition on the page has settled.
*
* The capture sheet fades in, and axe reads computed colours: a scan that races
* the fade measures a half-opacity blend of the muted foreground over the card
* and reports phantom `color-contrast` failures that clear the instant the
* animation lands. Settling first makes the scan read the real, final surface —
* it never relaxes what axe asserts.
*/
async function waitForAnimationsSettled(page: Page): Promise<void> {
await page.evaluate(async () => {
const anims = document
.getAnimations()
.map((a) => a.finished.catch(() => undefined));
await Promise.all(anims);
});
}
/** Fail only on serious/critical WCAG violations, the suite's a11y floor. */
async function expectNoSeriousAxe(page: Page): Promise<void> {
await waitForAnimationsSettled(page);
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const blocking = results.violations.filter(
(v) => v.impact === "serious" || v.impact === "critical",
);
expect(
blocking.map((v) => v.id),
"serious/critical accessibility violations",
).toEqual([]);
}
+143 -3
View File
@@ -290,7 +290,12 @@
"visitsColKind": "Art",
"visitsColReason": "Anlass",
"visitsColOutcome": "Ergebnis",
"visitsColConditions": "Erkrankungen"
"visitsColConditions": "Erkrankungen",
"immunizationsTitle": "Impfungen",
"immunizationsColDate": "Datum",
"immunizationsColVaccine": "Impfstoff",
"immunizationsColDose": "Dosis",
"immunizationsColLot": "Charge"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -354,6 +359,7 @@
"vorsorge": "Vorsorge",
"illness": "Krankheit",
"documents": "Dokumente",
"vaccinations": "Impfungen",
"mentalWellbeing": "Seelisches Wohlbefinden"
},
"auth": {
@@ -8507,6 +8513,138 @@
"description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung."
}
},
"vaccinations": {
"title": "Impfungen",
"subtitle": "Dein Impfpass, Dosis für Dosis.",
"listLoadError": "Impfungen konnten nicht geladen werden.",
"series": {
"booster": "Auffrischung",
"ofTotal": "Dosis {position} von {total}",
"doseN": "{position}. Dosis"
},
"row": {
"lot": "Charge {lot}"
},
"form": {
"date": "Datum",
"catalogLabel": "Impfstoff (Katalog)",
"identityHint": "Katalog wählen oder unten frei eintragen — eins genügt.",
"catalogNone": "Impfstoff wählen",
"catalogSearch": "Impfstoff suchen",
"catalogNoMatch": "Kein Katalogeintrag gefunden",
"catalogClear": "Auswahl entfernen",
"freeTextLabel": "Freitext",
"freeTextPlaceholder": "wie im Impfpass angegeben",
"seriesToggle": "Serie angeben",
"doseNumber": "Dosis-Nr.",
"seriesDoses": "Dosen in Serie",
"lot": "Chargennummer",
"site": "Impfstelle",
"siteNone": "Keine Angabe",
"practitioner": "Praxis / Arzt",
"note": "Notiz",
"linkDocuments": "Dokument verknüpfen",
"linkNothingToOffer": "Noch keine Dokumente vorhanden."
},
"site": {
"LEFT_ARM": "Linker Oberarm",
"RIGHT_ARM": "Rechter Oberarm",
"LEFT_THIGH": "Linker Oberschenkel",
"RIGHT_THIGH": "Rechter Oberschenkel",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Andere"
},
"createTitle": "Impfung erfassen",
"editTitle": "Impfung bearbeiten",
"formDescription": "Datum und Impfstoff genügen; alles andere ist optional.",
"created": "Impfung gespeichert",
"updated": "Impfung aktualisiert",
"saveFailed": "Impfung konnte nicht gespeichert werden.",
"deleteTitle": "Impfung löschen?",
"deleteDescription": "Der Eintrag wird aus deinem Impfpass entfernt. Ein geplanter Auffrischungs-Reminder bleibt bestehen.",
"deleteFailed": "Impfung konnte nicht gelöscht werden.",
"info": {
"primarySeriesOne": "Grundimmunisierung: {count} Dosis",
"primarySeriesFew": "Grundimmunisierung: {count} Dosen",
"primarySeriesOther": "Grundimmunisierung: {count} Dosen",
"yearly": "Jährliche Impfung",
"boosterYearsOne": "Auffrischung üblicherweise alle {count} Jahr",
"boosterYearsFew": "Auffrischung üblicherweise alle {count} Jahre",
"boosterYearsOther": "Auffrischung üblicherweise alle {count} Jahre",
"boosterMonthsOne": "Auffrischung üblicherweise alle {count} Monat",
"boosterMonthsFew": "Auffrischung üblicherweise alle {count} Monate",
"boosterMonthsOther": "Auffrischung üblicherweise alle {count} Monate",
"standard60": "Standardimpfung ab 60",
"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"
},
"suggestion": {
"single": "Zu dieser Impfung hinzufügen?",
"choose": "Zu welcher Impfung gehört der Scan?",
"linked": "Mit Impfung verknüpft",
"failed": "Verknüpfung fehlgeschlagen.",
"unnamed": "Impfung"
},
"empty": {
"title": "Noch keine Impfung erfasst",
"description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen."
},
"catalog": {
"tetanus": "Tetanus (Wundstarrkrampf)",
"diphtheria": "Diphtherie",
"pertussis": "Keuchhusten (Pertussis)",
"polio": "Polio (Kinderlähmung)",
"measles": "Masern",
"mumps": "Mumps",
"rubella": "Röteln",
"varicella": "Windpocken (Varizellen)",
"hib": "Hib (Haemophilus influenzae b)",
"hepatitis-a": "Hepatitis A",
"hepatitis-b": "Hepatitis B",
"influenza": "Grippe (Influenza)",
"pneumococcal": "Pneumokokken",
"herpes-zoster": "Gürtelrose (Herpes zoster)",
"rsv": "RSV",
"covid-19": "COVID-19",
"fsme": "FSME (Frühsommer-Meningoenzephalitis)",
"hpv": "HPV (Humane Papillomviren)",
"meningococcal-acwy": "Meningokokken ACWY",
"meningococcal-b": "Meningokokken B",
"rotavirus": "Rotaviren",
"typhoid": "Typhus",
"rabies": "Tollwut",
"yellow-fever": "Gelbfieber",
"japanese-encephalitis": "Japanische Enzephalitis",
"cholera": "Cholera",
"td": "Td (Tetanus, Diphtherie)",
"tdap": "Tdap (Tetanus, Diphtherie, Pertussis)",
"tdap-ipv": "Tdap-IPV (Vierfachimpfung)",
"dtap-ipv": "DTaP-IPV (Vierfachimpfung, Kindesalter)",
"hexavalent": "Sechsfachimpfung (DTaP-IPV-Hib-HepB)",
"mmr": "MMR (Masern, Mumps, Röteln)",
"mmrv": "MMRV (Masern, Mumps, Röteln, Varizellen)",
"hepa-hepb": "Hepatitis A und B",
"hepa-typhoid": "Hepatitis A und Typhus"
}
},
"illness": {
"title": "Krankheitstagebuch",
"subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
@@ -9702,7 +9840,8 @@
"scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}",
"leafVisits": "Arztbesuche"
"leafVisits": "Arztbesuche",
"leafImmunizations": "Impfungen"
},
"recordSharing": {
"banner": {
@@ -10002,7 +10141,8 @@
"documentUpdate": "{name} hat ein Dokument geändert",
"documentDiscard": "{name} hat ein Dokument verworfen",
"medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig",
"medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen"
"medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen",
"vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant"
},
"section": {
"measurements": {
+143 -3
View File
@@ -290,7 +290,12 @@
"visitsColKind": "Kind",
"visitsColReason": "Reason",
"visitsColOutcome": "Outcome",
"visitsColConditions": "Conditions"
"visitsColConditions": "Conditions",
"immunizationsTitle": "Immunizations",
"immunizationsColDate": "Date",
"immunizationsColVaccine": "Vaccine",
"immunizationsColDose": "Dose",
"immunizationsColLot": "Lot"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -354,6 +359,7 @@
"vorsorge": "Checkups",
"illness": "Illness",
"documents": "Documents",
"vaccinations": "Vaccinations",
"mentalWellbeing": "Mental wellbeing"
},
"auth": {
@@ -8507,6 +8513,138 @@
"description": "The vaccination record, structured: dose, series, next booster."
}
},
"vaccinations": {
"title": "Vaccinations",
"subtitle": "Your immunization record, dose by dose.",
"listLoadError": "Vaccinations could not be loaded.",
"series": {
"booster": "Booster",
"ofTotal": "Dose {position} of {total}",
"doseN": "Dose {position}"
},
"row": {
"lot": "Lot {lot}"
},
"form": {
"date": "Date",
"catalogLabel": "Vaccine (catalogue)",
"identityHint": "Pick from the catalogue or type it in below — either is enough.",
"catalogNone": "Choose a vaccine",
"catalogSearch": "Search vaccines",
"catalogNoMatch": "No catalogue entry found",
"catalogClear": "Clear selection",
"freeTextLabel": "Free text",
"freeTextPlaceholder": "as written on the record",
"seriesToggle": "Add series details",
"doseNumber": "Dose no.",
"seriesDoses": "Doses in series",
"lot": "Lot number",
"site": "Injection site",
"siteNone": "Not specified",
"practitioner": "Practice / doctor",
"note": "Note",
"linkDocuments": "Link a document",
"linkNothingToOffer": "No documents yet."
},
"site": {
"LEFT_ARM": "Left upper arm",
"RIGHT_ARM": "Right upper arm",
"LEFT_THIGH": "Left thigh",
"RIGHT_THIGH": "Right thigh",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Other"
},
"createTitle": "Log a vaccination",
"editTitle": "Edit vaccination",
"formDescription": "A date and the vaccine are enough; everything else is optional.",
"created": "Vaccination saved",
"updated": "Vaccination updated",
"saveFailed": "The vaccination could not be saved.",
"deleteTitle": "Delete this vaccination?",
"deleteDescription": "The entry is removed from your record. A planned booster reminder stays.",
"deleteFailed": "The vaccination could not be deleted.",
"info": {
"primarySeriesOne": "Primary series: {count} dose",
"primarySeriesFew": "Primary series: {count} doses",
"primarySeriesOther": "Primary series: {count} doses",
"yearly": "Yearly vaccination",
"boosterYearsOne": "Booster typically every {count} year",
"boosterYearsFew": "Booster typically every {count} years",
"boosterYearsOther": "Booster typically every {count} years",
"boosterMonthsOne": "Booster typically every {count} month",
"boosterMonthsFew": "Booster typically every {count} months",
"boosterMonthsOther": "Booster typically every {count} months",
"standard60": "Standard vaccination from age 60",
"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"
},
"suggestion": {
"single": "File against this vaccination?",
"choose": "Which vaccination does this scan belong to?",
"linked": "Linked to vaccination",
"failed": "Linking failed.",
"unnamed": "Vaccination"
},
"empty": {
"title": "No vaccination logged yet",
"description": "Transcribe your vaccination card line by line — a date and the vaccine are enough."
},
"catalog": {
"tetanus": "Tetanus",
"diphtheria": "Diphtheria",
"pertussis": "Whooping cough (pertussis)",
"polio": "Polio",
"measles": "Measles",
"mumps": "Mumps",
"rubella": "Rubella",
"varicella": "Chickenpox (varicella)",
"hib": "Hib (Haemophilus influenzae b)",
"hepatitis-a": "Hepatitis A",
"hepatitis-b": "Hepatitis B",
"influenza": "Influenza (flu)",
"pneumococcal": "Pneumococcus",
"herpes-zoster": "Shingles (herpes zoster)",
"rsv": "RSV",
"covid-19": "COVID-19",
"fsme": "Tick-borne encephalitis (TBE)",
"hpv": "HPV (human papillomavirus)",
"meningococcal-acwy": "Meningococcus ACWY",
"meningococcal-b": "Meningococcus B",
"rotavirus": "Rotavirus",
"typhoid": "Typhoid",
"rabies": "Rabies",
"yellow-fever": "Yellow fever",
"japanese-encephalitis": "Japanese encephalitis",
"cholera": "Cholera",
"td": "Td (tetanus, diphtheria)",
"tdap": "Tdap (tetanus, diphtheria, pertussis)",
"tdap-ipv": "Tdap-IPV (four-in-one)",
"dtap-ipv": "DTaP-IPV (childhood four-in-one)",
"hexavalent": "Six-in-one (DTaP-IPV-Hib-HepB)",
"mmr": "MMR (measles, mumps, rubella)",
"mmrv": "MMRV (measles, mumps, rubella, varicella)",
"hepa-hepb": "Hepatitis A and B",
"hepa-typhoid": "Hepatitis A and typhoid"
}
},
"illness": {
"title": "Illness journal",
"subtitle": "A retrospective log of your illness and condition episodes.",
@@ -9702,7 +9840,8 @@
"scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.",
"scopeCount": "{count} entries from {groups} areas",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visits"
"leafVisits": "Visits",
"leafImmunizations": "Immunizations"
},
"recordSharing": {
"banner": {
@@ -10002,7 +10141,8 @@
"documentUpdate": "{name} changed a document",
"documentDiscard": "{name} discarded a document",
"medicationIntakeImportFinished": "The dose import {name} started has finished",
"medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded"
"medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded",
"vaccinationBoosterPlanned": "{name} planned a booster"
},
"section": {
"measurements": {
+143 -3
View File
@@ -290,7 +290,12 @@
"visitsColKind": "Tipo",
"visitsColReason": "Motivo",
"visitsColOutcome": "Resultado",
"visitsColConditions": "Enfermedades"
"visitsColConditions": "Enfermedades",
"immunizationsTitle": "Vacunas",
"immunizationsColDate": "Fecha",
"immunizationsColVaccine": "Vacuna",
"immunizationsColDose": "Dosis",
"immunizationsColLot": "Lote"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -354,6 +359,7 @@
"vorsorge": "Revisiones",
"illness": "Enfermedad",
"documents": "Documentos",
"vaccinations": "Vacunas",
"mentalWellbeing": "Bienestar mental"
},
"auth": {
@@ -8507,6 +8513,138 @@
"description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo."
}
},
"vaccinations": {
"title": "Vacunas",
"subtitle": "Tu registro de vacunación, dosis a dosis.",
"listLoadError": "No se pudieron cargar las vacunas.",
"series": {
"booster": "Refuerzo",
"ofTotal": "Dosis {position} de {total}",
"doseN": "Dosis {position}"
},
"row": {
"lot": "Lote {lot}"
},
"form": {
"date": "Fecha",
"catalogLabel": "Vacuna (catálogo)",
"identityHint": "Elige del catálogo o escríbela abajo: basta con una.",
"catalogNone": "Elegir una vacuna",
"catalogSearch": "Buscar vacunas",
"catalogNoMatch": "No se encontró ninguna entrada del catálogo",
"catalogClear": "Quitar selección",
"freeTextLabel": "Texto libre",
"freeTextPlaceholder": "tal como figura en la cartilla",
"seriesToggle": "Añadir datos de la serie",
"doseNumber": "N.º de dosis",
"seriesDoses": "Dosis en la serie",
"lot": "Número de lote",
"site": "Lugar de aplicación",
"siteNone": "Sin especificar",
"practitioner": "Consulta / médico",
"note": "Nota",
"linkDocuments": "Vincular un documento",
"linkNothingToOffer": "Aún no hay documentos."
},
"site": {
"LEFT_ARM": "Brazo izquierdo",
"RIGHT_ARM": "Brazo derecho",
"LEFT_THIGH": "Muslo izquierdo",
"RIGHT_THIGH": "Muslo derecho",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Otro"
},
"createTitle": "Registrar una vacuna",
"editTitle": "Editar vacuna",
"formDescription": "Bastan una fecha y la vacuna; todo lo demás es opcional.",
"created": "Vacuna guardada",
"updated": "Vacuna actualizada",
"saveFailed": "No se pudo guardar la vacuna.",
"deleteTitle": "¿Eliminar esta vacuna?",
"deleteDescription": "La entrada se elimina de tu registro. Un recordatorio de refuerzo programado se mantiene.",
"deleteFailed": "No se pudo eliminar la vacuna.",
"info": {
"primarySeriesOne": "Serie primaria: {count} dosis",
"primarySeriesFew": "Serie primaria: {count} dosis",
"primarySeriesOther": "Serie primaria: {count} dosis",
"yearly": "Vacunación anual",
"boosterYearsOne": "Refuerzo normalmente cada {count} año",
"boosterYearsFew": "Refuerzo normalmente cada {count} años",
"boosterYearsOther": "Refuerzo normalmente cada {count} años",
"boosterMonthsOne": "Refuerzo normalmente cada {count} mes",
"boosterMonthsFew": "Refuerzo normalmente cada {count} meses",
"boosterMonthsOther": "Refuerzo normalmente cada {count} meses",
"standard60": "Vacunación estándar a partir de los 60",
"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"
},
"suggestion": {
"single": "¿Asociar a esta vacuna?",
"choose": "¿A qué vacuna pertenece este escaneo?",
"linked": "Vinculado a la vacuna",
"failed": "No se pudo vincular.",
"unnamed": "Vacuna"
},
"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."
},
"catalog": {
"tetanus": "Tétanos",
"diphtheria": "Difteria",
"pertussis": "Tos ferina (pertussis)",
"polio": "Poliomielitis",
"measles": "Sarampión",
"mumps": "Paperas",
"rubella": "Rubéola",
"varicella": "Varicela",
"hib": "Hib (Haemophilus influenzae b)",
"hepatitis-a": "Hepatitis A",
"hepatitis-b": "Hepatitis B",
"influenza": "Gripe (influenza)",
"pneumococcal": "Neumococo",
"herpes-zoster": "Herpes zóster (culebrilla)",
"rsv": "VRS",
"covid-19": "COVID-19",
"fsme": "Encefalitis por garrapatas (TBE)",
"hpv": "VPH (virus del papiloma humano)",
"meningococcal-acwy": "Meningococo ACWY",
"meningococcal-b": "Meningococo B",
"rotavirus": "Rotavirus",
"typhoid": "Fiebre tifoidea",
"rabies": "Rabia",
"yellow-fever": "Fiebre amarilla",
"japanese-encephalitis": "Encefalitis japonesa",
"cholera": "Cólera",
"td": "Td (tétanos, difteria)",
"tdap": "Tdap (tétanos, difteria, tos ferina)",
"tdap-ipv": "Tdap-IPV (tetravalente)",
"dtap-ipv": "DTaP-IPV (tetravalente infantil)",
"hexavalent": "Hexavalente (DTaP-IPV-Hib-HepB)",
"mmr": "Triple vírica (sarampión, paperas, rubéola)",
"mmrv": "Tetravírica (sarampión, paperas, rubéola, varicela)",
"hepa-hepb": "Hepatitis A y B",
"hepa-typhoid": "Hepatitis A y fiebre tifoidea"
}
},
"illness": {
"title": "Diario de enfermedad",
"subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
@@ -9702,7 +9840,8 @@
"scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visitas médicas"
"leafVisits": "Visitas médicas",
"leafImmunizations": "Vacunas"
},
"recordSharing": {
"banner": {
@@ -10002,7 +10141,8 @@
"documentUpdate": "{name} cambió un documento",
"documentDiscard": "{name} descartó un documento",
"medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado",
"medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}"
"medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}",
"vaccinationBoosterPlanned": "{name} planificó un refuerzo"
},
"section": {
"measurements": {
+143 -3
View File
@@ -290,7 +290,12 @@
"visitsColKind": "Type",
"visitsColReason": "Motif",
"visitsColOutcome": "Résultat",
"visitsColConditions": "Maladies"
"visitsColConditions": "Maladies",
"immunizationsTitle": "Vaccinations",
"immunizationsColDate": "Date",
"immunizationsColVaccine": "Vaccin",
"immunizationsColDose": "Dose",
"immunizationsColLot": "Lot"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -354,6 +359,7 @@
"vorsorge": "Examens",
"illness": "Maladie",
"documents": "Documents",
"vaccinations": "Vaccinations",
"mentalWellbeing": "Bien-être mental"
},
"auth": {
@@ -8507,6 +8513,138 @@
"description": "Le carnet de vaccination structuré : dose, série, prochain rappel."
}
},
"vaccinations": {
"title": "Vaccinations",
"subtitle": "Votre carnet de vaccination, dose par dose.",
"listLoadError": "Impossible de charger les vaccinations.",
"series": {
"booster": "Rappel",
"ofTotal": "Dose {position} sur {total}",
"doseN": "Dose {position}"
},
"row": {
"lot": "Lot {lot}"
},
"form": {
"date": "Date",
"catalogLabel": "Vaccin (catalogue)",
"identityHint": "Choisissez dans le catalogue ou saisissez ci-dessous : l'un suffit.",
"catalogNone": "Choisir un vaccin",
"catalogSearch": "Rechercher des vaccins",
"catalogNoMatch": "Aucune entrée du catalogue trouvée",
"catalogClear": "Effacer la sélection",
"freeTextLabel": "Texte libre",
"freeTextPlaceholder": "comme indiqué sur le carnet",
"seriesToggle": "Ajouter les détails de la série",
"doseNumber": "N° de dose",
"seriesDoses": "Doses dans la série",
"lot": "Numéro de lot",
"site": "Site d'injection",
"siteNone": "Non précisé",
"practitioner": "Cabinet / médecin",
"note": "Note",
"linkDocuments": "Associer un document",
"linkNothingToOffer": "Aucun document pour l'instant."
},
"site": {
"LEFT_ARM": "Bras gauche",
"RIGHT_ARM": "Bras droit",
"LEFT_THIGH": "Cuisse gauche",
"RIGHT_THIGH": "Cuisse droite",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Autre"
},
"createTitle": "Enregistrer une vaccination",
"editTitle": "Modifier la vaccination",
"formDescription": "Une date et le vaccin suffisent ; tout le reste est facultatif.",
"created": "Vaccination enregistrée",
"updated": "Vaccination mise à jour",
"saveFailed": "La vaccination n'a pas pu être enregistrée.",
"deleteTitle": "Supprimer cette vaccination ?",
"deleteDescription": "L'entrée est retirée de votre carnet. Un rappel programmé est conservé.",
"deleteFailed": "La vaccination n'a pas pu être supprimée.",
"info": {
"primarySeriesOne": "Primovaccination : {count} dose",
"primarySeriesFew": "Primovaccination : {count} doses",
"primarySeriesOther": "Primovaccination : {count} doses",
"yearly": "Vaccination annuelle",
"boosterYearsOne": "Rappel généralement tous les {count} an",
"boosterYearsFew": "Rappel généralement tous les {count} ans",
"boosterYearsOther": "Rappel généralement tous les {count} ans",
"boosterMonthsOne": "Rappel généralement tous les {count} mois",
"boosterMonthsFew": "Rappel généralement tous les {count} mois",
"boosterMonthsOther": "Rappel généralement tous les {count} mois",
"standard60": "Vaccination standard à partir de 60 ans",
"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"
},
"suggestion": {
"single": "Associer à cette vaccination ?",
"choose": "À quelle vaccination appartient ce scan ?",
"linked": "Associé à la vaccination",
"failed": "L'association a échoué.",
"unnamed": "Vaccination"
},
"empty": {
"title": "Aucune vaccination enregistrée",
"description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent."
},
"catalog": {
"tetanus": "Tétanos",
"diphtheria": "Diphtérie",
"pertussis": "Coqueluche",
"polio": "Poliomyélite",
"measles": "Rougeole",
"mumps": "Oreillons",
"rubella": "Rubéole",
"varicella": "Varicelle",
"hib": "Hib (Haemophilus influenzae b)",
"hepatitis-a": "Hépatite A",
"hepatitis-b": "Hépatite B",
"influenza": "Grippe",
"pneumococcal": "Pneumocoque",
"herpes-zoster": "Zona (herpès zoster)",
"rsv": "VRS",
"covid-19": "COVID-19",
"fsme": "Encéphalite à tiques (TBE)",
"hpv": "HPV (papillomavirus humain)",
"meningococcal-acwy": "Méningocoque ACWY",
"meningococcal-b": "Méningocoque B",
"rotavirus": "Rotavirus",
"typhoid": "Typhoïde",
"rabies": "Rage",
"yellow-fever": "Fièvre jaune",
"japanese-encephalitis": "Encéphalite japonaise",
"cholera": "Choléra",
"td": "dT (tétanos, diphtérie)",
"tdap": "dTca (tétanos, diphtérie, coqueluche)",
"tdap-ipv": "dTca-VPI (quadruple)",
"dtap-ipv": "DTCa-VPI (quadruple, enfance)",
"hexavalent": "Hexavalent (DTCa-VPI-Hib-VHB)",
"mmr": "ROR (rougeole, oreillons, rubéole)",
"mmrv": "RORV (rougeole, oreillons, rubéole, varicelle)",
"hepa-hepb": "Hépatite A et B",
"hepa-typhoid": "Hépatite A et typhoïde"
}
},
"illness": {
"title": "Journal de maladie",
"subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
@@ -9702,7 +9840,8 @@
"scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.",
"scopeCount": "{count} éléments issus de {groups} domaines",
"scopeSensitive": "dont {names}",
"leafVisits": "Consultations"
"leafVisits": "Consultations",
"leafImmunizations": "Vaccinations"
},
"recordSharing": {
"banner": {
@@ -10002,7 +10141,8 @@
"documentUpdate": "{name} a modifié un document",
"documentDiscard": "{name} a écarté un document",
"medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé",
"medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}"
"medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}",
"vaccinationBoosterPlanned": "{name} a planifié un rappel"
},
"section": {
"measurements": {
+143 -3
View File
@@ -290,7 +290,12 @@
"visitsColKind": "Tipo",
"visitsColReason": "Motivo",
"visitsColOutcome": "Esito",
"visitsColConditions": "Malattie"
"visitsColConditions": "Malattie",
"immunizationsTitle": "Vaccinazioni",
"immunizationsColDate": "Data",
"immunizationsColVaccine": "Vaccino",
"immunizationsColDose": "Dose",
"immunizationsColLot": "Lotto"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -354,6 +359,7 @@
"vorsorge": "Controlli",
"illness": "Malattia",
"documents": "Documenti",
"vaccinations": "Vaccinazioni",
"mentalWellbeing": "Benessere mentale"
},
"auth": {
@@ -8507,6 +8513,138 @@
"description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo."
}
},
"vaccinations": {
"title": "Vaccinazioni",
"subtitle": "Il tuo libretto vaccinale, dose per dose.",
"listLoadError": "Impossibile caricare le vaccinazioni.",
"series": {
"booster": "Richiamo",
"ofTotal": "Dose {position} di {total}",
"doseN": "Dose {position}"
},
"row": {
"lot": "Lotto {lot}"
},
"form": {
"date": "Data",
"catalogLabel": "Vaccino (catalogo)",
"identityHint": "Scegli dal catalogo o scrivilo sotto: ne basta uno.",
"catalogNone": "Scegli un vaccino",
"catalogSearch": "Cerca vaccini",
"catalogNoMatch": "Nessuna voce del catalogo trovata",
"catalogClear": "Rimuovi selezione",
"freeTextLabel": "Testo libero",
"freeTextPlaceholder": "come indicato sul libretto",
"seriesToggle": "Aggiungi dettagli della serie",
"doseNumber": "N. dose",
"seriesDoses": "Dosi nella serie",
"lot": "Numero di lotto",
"site": "Sede di iniezione",
"siteNone": "Non specificato",
"practitioner": "Studio / medico",
"note": "Nota",
"linkDocuments": "Collega un documento",
"linkNothingToOffer": "Nessun documento ancora."
},
"site": {
"LEFT_ARM": "Braccio sinistro",
"RIGHT_ARM": "Braccio destro",
"LEFT_THIGH": "Coscia sinistra",
"RIGHT_THIGH": "Coscia destra",
"ORAL": "Orale",
"NASAL": "Nasale",
"OTHER": "Altro"
},
"createTitle": "Registra una vaccinazione",
"editTitle": "Modifica vaccinazione",
"formDescription": "Bastano una data e il vaccino; tutto il resto è facoltativo.",
"created": "Vaccinazione salvata",
"updated": "Vaccinazione aggiornata",
"saveFailed": "Impossibile salvare la vaccinazione.",
"deleteTitle": "Eliminare questa vaccinazione?",
"deleteDescription": "La voce viene rimossa dal tuo libretto. Un promemoria di richiamo programmato rimane.",
"deleteFailed": "Impossibile eliminare la vaccinazione.",
"info": {
"primarySeriesOne": "Ciclo primario: {count} dose",
"primarySeriesFew": "Ciclo primario: {count} dosi",
"primarySeriesOther": "Ciclo primario: {count} dosi",
"yearly": "Vaccinazione annuale",
"boosterYearsOne": "Richiamo di norma ogni {count} anno",
"boosterYearsFew": "Richiamo di norma ogni {count} anni",
"boosterYearsOther": "Richiamo di norma ogni {count} anni",
"boosterMonthsOne": "Richiamo di norma ogni {count} mese",
"boosterMonthsFew": "Richiamo di norma ogni {count} mesi",
"boosterMonthsOther": "Richiamo di norma ogni {count} mesi",
"standard60": "Vaccinazione standard dai 60 anni",
"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"
},
"suggestion": {
"single": "Collegare a questa vaccinazione?",
"choose": "A quale vaccinazione appartiene questa scansione?",
"linked": "Collegato alla vaccinazione",
"failed": "Collegamento non riuscito.",
"unnamed": "Vaccinazione"
},
"empty": {
"title": "Nessuna vaccinazione registrata",
"description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino."
},
"catalog": {
"tetanus": "Tetano",
"diphtheria": "Difterite",
"pertussis": "Pertosse",
"polio": "Poliomielite",
"measles": "Morbillo",
"mumps": "Parotite",
"rubella": "Rosolia",
"varicella": "Varicella",
"hib": "Hib (Haemophilus influenzae b)",
"hepatitis-a": "Epatite A",
"hepatitis-b": "Epatite B",
"influenza": "Influenza",
"pneumococcal": "Pneumococco",
"herpes-zoster": "Herpes zoster (fuoco di Sant'Antonio)",
"rsv": "RSV",
"covid-19": "COVID-19",
"fsme": "Encefalite da zecche (TBE)",
"hpv": "HPV (papillomavirus umano)",
"meningococcal-acwy": "Meningococco ACWY",
"meningococcal-b": "Meningococco B",
"rotavirus": "Rotavirus",
"typhoid": "Febbre tifoide",
"rabies": "Rabbia",
"yellow-fever": "Febbre gialla",
"japanese-encephalitis": "Encefalite giapponese",
"cholera": "Colera",
"td": "Td (tetano, difterite)",
"tdap": "Tdap (tetano, difterite, pertosse)",
"tdap-ipv": "Tdap-IPV (tetravalente)",
"dtap-ipv": "DTPa-IPV (tetravalente pediatrico)",
"hexavalent": "Esavalente (DTPa-IPV-Hib-HBV)",
"mmr": "MPR (morbillo, parotite, rosolia)",
"mmrv": "MPRV (morbillo, parotite, rosolia, varicella)",
"hepa-hepb": "Epatite A e B",
"hepa-typhoid": "Epatite A e febbre tifoide"
}
},
"illness": {
"title": "Diario delle malattie",
"subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
@@ -9702,7 +9840,8 @@
"scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.",
"scopeCount": "{count} voci da {groups} aree",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visite mediche"
"leafVisits": "Visite mediche",
"leafImmunizations": "Vaccinazioni"
},
"recordSharing": {
"banner": {
@@ -10002,7 +10141,8 @@
"documentUpdate": "{name} ha modificato un documento",
"documentDiscard": "{name} ha scartato un documento",
"medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata",
"medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}"
"medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}",
"vaccinationBoosterPlanned": "{name} ha pianificato un richiamo"
},
"section": {
"measurements": {
+143 -3
View File
@@ -290,7 +290,12 @@
"visitsColKind": "Rodzaj",
"visitsColReason": "Powód",
"visitsColOutcome": "Wynik",
"visitsColConditions": "Choroby"
"visitsColConditions": "Choroby",
"immunizationsTitle": "Szczepienia",
"immunizationsColDate": "Data",
"immunizationsColVaccine": "Szczepionka",
"immunizationsColDose": "Dawka",
"immunizationsColLot": "Seria"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -354,6 +359,7 @@
"vorsorge": "Badania",
"illness": "Choroba",
"documents": "Dokumenty",
"vaccinations": "Szczepienia",
"mentalWellbeing": "Dobrostan psychiczny"
},
"auth": {
@@ -8507,6 +8513,138 @@
"description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie."
}
},
"vaccinations": {
"title": "Szczepienia",
"subtitle": "Twoja karta szczepień, dawka po dawce.",
"listLoadError": "Nie udało się załadować szczepień.",
"series": {
"booster": "Dawka przypominająca",
"ofTotal": "Dawka {position} z {total}",
"doseN": "Dawka {position}"
},
"row": {
"lot": "Seria {lot}"
},
"form": {
"date": "Data",
"catalogLabel": "Szczepionka (katalog)",
"identityHint": "Wybierz z katalogu lub wpisz poniżej — wystarczy jedno.",
"catalogNone": "Wybierz szczepionkę",
"catalogSearch": "Szukaj szczepionek",
"catalogNoMatch": "Nie znaleziono wpisu w katalogu",
"catalogClear": "Wyczyść wybór",
"freeTextLabel": "Tekst dowolny",
"freeTextPlaceholder": "jak podano w karcie szczepień",
"seriesToggle": "Dodaj szczegóły serii",
"doseNumber": "Nr dawki",
"seriesDoses": "Dawki w serii",
"lot": "Numer serii",
"site": "Miejsce podania",
"siteNone": "Nie podano",
"practitioner": "Gabinet / lekarz",
"note": "Notatka",
"linkDocuments": "Powiąż dokument",
"linkNothingToOffer": "Brak dokumentów."
},
"site": {
"LEFT_ARM": "Lewe ramię",
"RIGHT_ARM": "Prawe ramię",
"LEFT_THIGH": "Lewe udo",
"RIGHT_THIGH": "Prawe udo",
"ORAL": "Doustnie",
"NASAL": "Donosowo",
"OTHER": "Inne"
},
"createTitle": "Zarejestruj szczepienie",
"editTitle": "Edytuj szczepienie",
"formDescription": "Wystarczą data i szczepionka; cała reszta jest opcjonalna.",
"created": "Szczepienie zapisane",
"updated": "Szczepienie zaktualizowane",
"saveFailed": "Nie udało się zapisać szczepienia.",
"deleteTitle": "Usunąć to szczepienie?",
"deleteDescription": "Wpis zostanie usunięty z Twojej karty. Zaplanowane przypomnienie o dawce przypominającej pozostaje.",
"deleteFailed": "Nie udało się usunąć szczepienia.",
"info": {
"primarySeriesOne": "Seria podstawowa: {count} dawka",
"primarySeriesFew": "Seria podstawowa: {count} dawki",
"primarySeriesOther": "Seria podstawowa: {count} dawek",
"yearly": "Szczepienie coroczne",
"boosterYearsOne": "Dawka przypominająca zwykle co {count} rok",
"boosterYearsFew": "Dawka przypominająca zwykle co {count} lata",
"boosterYearsOther": "Dawka przypominająca zwykle co {count} lat",
"boosterMonthsOne": "Dawka przypominająca zwykle co {count} miesiąc",
"boosterMonthsFew": "Dawka przypominająca zwykle co {count} miesiące",
"boosterMonthsOther": "Dawka przypominająca zwykle co {count} miesięcy",
"standard60": "Szczepienie standardowe od 60. roku życia",
"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"
},
"suggestion": {
"single": "Powiązać z tym szczepieniem?",
"choose": "Do którego szczepienia należy ten skan?",
"linked": "Powiązano ze szczepieniem",
"failed": "Powiązanie nie powiodło się.",
"unnamed": "Szczepienie"
},
"empty": {
"title": "Nie zarejestrowano jeszcze żadnego szczepienia",
"description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka."
},
"catalog": {
"tetanus": "Tężec",
"diphtheria": "Błonica",
"pertussis": "Krztusiec",
"polio": "Polio (choroba Heinego-Medina)",
"measles": "Odra",
"mumps": "Świnka",
"rubella": "Różyczka",
"varicella": "Ospa wietrzna",
"hib": "Hib (Haemophilus influenzae b)",
"hepatitis-a": "WZW typu A",
"hepatitis-b": "WZW typu B",
"influenza": "Grypa",
"pneumococcal": "Pneumokoki",
"herpes-zoster": "Półpasiec (herpes zoster)",
"rsv": "RSV",
"covid-19": "COVID-19",
"fsme": "KZM (kleszczowe zapalenie mózgu)",
"hpv": "HPV (wirus brodawczaka ludzkiego)",
"meningococcal-acwy": "Meningokoki ACWY",
"meningococcal-b": "Meningokoki B",
"rotavirus": "Rotawirusy",
"typhoid": "Dur brzuszny",
"rabies": "Wścieklizna",
"yellow-fever": "Żółta gorączka",
"japanese-encephalitis": "Japońskie zapalenie mózgu",
"cholera": "Cholera",
"td": "Td (tężec, błonica)",
"tdap": "Tdap (tężec, błonica, krztusiec)",
"tdap-ipv": "Tdap-IPV (poczwórna)",
"dtap-ipv": "DTaP-IPV (poczwórna dziecięca)",
"hexavalent": "Sześciowalentna (DTaP-IPV-Hib-WZWB)",
"mmr": "MMR (odra, świnka, różyczka)",
"mmrv": "MMRV (odra, świnka, różyczka, ospa wietrzna)",
"hepa-hepb": "WZW typu A i B",
"hepa-typhoid": "WZW typu A i dur brzuszny"
}
},
"illness": {
"title": "Dziennik chorób",
"subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
@@ -9702,7 +9840,8 @@
"scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.",
"scopeCount": "{count} pozycji z {groups} obszarów",
"scopeSensitive": "w tym {names}",
"leafVisits": "Wizyty lekarskie"
"leafVisits": "Wizyty lekarskie",
"leafImmunizations": "Szczepienia"
},
"recordSharing": {
"banner": {
@@ -10002,7 +10141,8 @@
"documentUpdate": "{name} zmienił dokument",
"documentDiscard": "{name} odrzucił dokument",
"medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się",
"medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}"
"medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}",
"vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą"
},
"section": {
"measurements": {
+8
View File
@@ -143,6 +143,14 @@ export default defineConfig({
// the single-candidate branch to the picker. It proves a flow through
// stable data-slots, not a mobile layout, so it runs in one project.
"visits.spec.ts",
// The immunization-log journey transcribes doses, mints a booster
// reminder and logs a next dose against the one shared account, then
// reads grouped counts back off it. Two projects mutating that account
// in parallel would leave a stale group on the page and a second row in
// an antigen bucket, flipping the grouped verdicts. It proves flows
// through stable data-slots, not a mobile layout, so it runs in one
// project.
"vaccinations.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
],
@@ -400,6 +400,16 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/vaccinations/use-vaccinations.ts",
destroys:
"one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound",
triggers: ["components/vaccinations/vaccination-sheet.tsx"],
// The route soft-deletes and a restore route exists, but no surface offers
// the undo yet, so from a person's side the dose is gone.
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
// ── Cycle ───────────────────────────────────────────────────────────────
{
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(92);
expect(ALL_LEAF_IDS.length).toBe(93);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(16);
});
it("places every leaf in exactly one group", () => {
@@ -75,6 +75,13 @@ function isMaskPlaceholder(words: string[]): boolean {
* reason. Prune an entry only when the value genuinely diverges in a locale.
*/
const LEGIT_IDENTICAL = new Set<string>([
// Vaccine catalogue: clinical proper nouns whose accepted written form is the
// same across these locales. "Hib" carries its Latin binomial verbatim, and
// "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue
// ships no trade names, so these are disease names, not brands.
"vaccinations.catalog.hib",
"vaccinations.catalog.hepatitis-a",
"vaccinations.catalog.hepatitis-b",
// The AM/PM marker label — the affix abbreviations themselves are the same
// 12-hour-clock notation across the shipped Latin-script locales.
"common.period",
@@ -47,9 +47,15 @@ describe("shared-record navigation", () => {
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([
"/profile",
"/checkups",
"/vaccinations",
]);
expect(navigation.destinationHrefs).toEqual([
"/profile",
"/checkups",
"/vaccinations",
]);
expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]);
expect(navigation.allowsPath("/profile")).toBe(true);
expect(navigation.allowsPath("/vaccinations")).toBe(true);
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
});
+12 -2
View File
@@ -951,6 +951,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "One dose of the record, fetch-then-guard against the resolved user. The pages it resolves are read through the link service, which narrows both ends to the same resolved id, and their names are withheld from a grant that does not reach the vault — the filename of a scanned page is itself the sensitive part.",
},
"app/api/vaccinations/[id]/booster/route.ts": {
domain: "profile",
why: "Arming the booster reminder a logged dose suggests. The antigen is read from the dose's own catalogue entry server-side, never from the body, so a delegate cannot key a reminder onto an antigen the dose does not contain, and the reminder is minted under the RECORD — it is the owner's booster plan and the owner's phone it rings, which is correct even when a helper transcribes the Impfpass.",
},
"app/api/vaccinations/suggest/route.ts": {
domain: "profile",
why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1401,6 +1409,8 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
"Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.",
"app/api/vaccinations/route.ts":
"Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.",
"app/api/vaccinations/[id]/booster/route.ts":
"Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.",
"app/api/medications/[id]/side-effects/route.ts":
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
"app/api/medications/route.ts":
@@ -1902,7 +1912,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* record list, one create on the write literal, and the edit/delete pair plus
* the restore on the manage literal. 215 -> 221.
*/
const FROZEN_ENTRY_COUNT = 221;
const FROZEN_ENTRY_COUNT = 224;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2663,7 +2673,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(74);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -303,6 +303,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/vaccinations/[id]/route.ts",
"src/app/api/vaccinations/[id]/restore/route.ts",
"src/app/api/vaccinations/[id]/links/route.ts",
// The booster mint and the upload suggestion are the same posture: a restore
// or an import that arms a booster, or a document review that files a scan,
// must keep working with the surface hidden, so the data routes stay exempt
// while the nav entry, the picker and the report leaf hide.
"src/app/api/vaccinations/[id]/booster/route.ts",
"src/app/api/vaccinations/suggest/route.ts",
];
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
illnessEpisode: { findMany: vi.fn() },
encounter: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
vaccinationRecord: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
@@ -168,6 +169,7 @@ beforeEach(() => {
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
@@ -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,
);
},
);
+56
View File
@@ -0,0 +1,56 @@
/**
* `GET /api/vaccinations/suggest?anchor=<iso>` — which dose does a document
* dated `anchor` most plausibly belong to?
*
* The document upload review asks this when a scan is classified `VACCINATION`.
* The rule — the ±7-day window (shared with the visit moment), and the
* one-pre-selects / two-offer-a-picker verdict — lives in
* `src/lib/vaccinations/document-suggestion.ts` and is answered here so the
* browser never re-derives it.
*
* A read of the record's own doses, so it declares the same `profile` domain
* the immunization list does. `userId` is narrowed from auth; the anchor is the
* only input and it is a date, not an id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { vaccinationSuggestQuerySchema } from "@/lib/validations/vaccinations";
import { suggestVaccinationForDate } from "@/lib/vaccinations/document-suggestion";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = vaccinationSuggestQuerySchema.safeParse({
anchor: params.get("anchor") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.invalid",
});
}
const result = await suggestVaccinationForDate(prisma, {
userId: user.id,
anchor: new Date(parsed.data.anchor),
});
annotate({
action: { name: "vaccination.dose.suggest", entity_type: "vaccination" },
meta: {
verdict: result.kind,
candidates:
result.kind === "many"
? result.vaccinations.length
: result.kind === "one"
? 1
: 0,
},
});
return apiSuccess(result);
});
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
/**
* v1.38.0 — the immunization log entry.
*
* Gated on the resolved `modules.vaccinations` flag from `GET /api/auth/me`.
* The module is default-on, so `=== false` is the only state that hides the
* surface — an account that never touched the toggle keeps it. An
* unauthenticated visitor is bounced to login; an authenticated account that
* turned the module off is bounced home (the nav entry is already gone for
* them, so this only catches a direct URL hit). The `/api/vaccinations*`
* routes stay reachable regardless — the module gate governs the surface, not
* the row store — so this is a UX redirect, never the security boundary.
*/
export default function VaccinationsPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Default-on: hidden only when the resolved map says `false` explicitly. A
// shared-record session always sees it — the delegate's own module map does
// not govern the owner's record.
const enabled = inSharedRecord || user?.modules?.vaccinations !== false;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/");
}
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) {
return <PageAuthGate />;
}
return <VaccinationsView />;
}
@@ -80,6 +80,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
@@ -1045,6 +1046,16 @@ export function DocumentDetailSheet({
onChange={setSuggestedEncounter}
/>
{/* A vaccination scan offers to file against the dose it records,
through the same ±7-day rule the visit suggestion uses. */}
{doc.kind === "VACCINATION" ? (
<VaccinationDocumentSuggestion
anchor={doc.reportDate ?? doc.documentDate}
documentId={doc.id}
disabled={!canManage}
/>
) : null}
{/* "Belongs to visit" — read-only here on purpose. The filing
happens at the moment the document arrives, or from the
visit's own sheet; a second editor for the same link would
@@ -67,6 +67,11 @@ export function PractitionerCombobox({
variant="outline"
role="combobox"
aria-expanded={open}
// `role="combobox"` is not a name-from-content role: the visible
// placeholder span does not give the trigger an accessible name,
// so name it from the field's own label. Applies wherever this
// combobox is embedded (the visit form and the immunization form).
aria-label={t("encounters.form.practitioner")}
disabled={disabled}
data-slot="encounter-practitioner-trigger"
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
+15
View File
@@ -12,6 +12,7 @@ import {
Pill,
Settings,
Stethoscope,
Syringe,
Thermometer,
Trophy,
Waves,
@@ -193,6 +194,20 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-illness",
requiresModule: "illness",
},
// v1.38.0 — the immunization log sits in the clinical spine beside Illness.
// Born-gated: `requiresModule: "vaccinations"` reads the resolved module map,
// so the entry is absent until the account keeps the module on (default-on —
// it drops only when a user turns it off). SURFACE-gated: the `/api/vaccinations*`
// data routes stay reachable so a restore / import keeps working and
// re-enabling finds every dose intact.
{
href: "/vaccinations",
sharedRecord: true,
tKey: "nav.vaccinations",
icon: Syringe,
tourId: "nav-vaccinations",
requiresModule: "vaccinations",
},
// v1.18.0 — Workouts and Recovery both left the left-nav: each already
// surfaces as an Insights tab-strip pill (`/insights/workouts` gated on
// a workout row, `/insights/recovery` always present), so neither is a
@@ -193,7 +193,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
expect(groupChip(html, "history")).toBe("0/3");
expect(groupChip(html, "history")).toBe("0/4");
expect(groupChip(html, "cardio")).toBe("0/14");
expect(groupChip(html, "activity")).toBe("0/8");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { CatalogInfo, catalogInfoAvailable } from "../catalog-info";
/**
* Rung 1 renders the catalogue's own numbers, cited, and nothing personal.
* The templates compose from `typicalSeriesDoses` / `boosterIntervalMonths` /
* `category`, so each null combination simply drops its line.
*/
function render(slug: string | null): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CatalogInfo slug={slug} />
</I18nProvider>,
);
}
describe("CatalogInfo rung-1 text", () => {
it("renders the primary series and a year-interval booster with the source", () => {
// tetanus: 3 doses, 120-month (10-year) booster, STIKO source.
const html = render("tetanus");
expect(html).toContain("Primary series: 3 doses");
expect(html).toContain("Booster typically every 10 years");
expect(html).toContain("Source: STIKO Epid Bull 4/2026");
});
it("renders a yearly vaccine as yearly, not as an interval", () => {
// influenza: no primary series, 12-month interval → yearly.
const html = render("influenza");
expect(html).toContain("Yearly vaccination");
expect(html).not.toContain("every 1 year");
// No primary-series line when the count is null.
expect(html).not.toContain("Primary series");
});
it("renders nothing for a dead slug", () => {
expect(render("no-such-antigen")).toBe("");
expect(catalogInfoAvailable("no-such-antigen")).toBe(false);
});
it("marks a standard60 entry available", () => {
// pneumococcal: 1 dose, no interval, standard60.
expect(catalogInfoAvailable("pneumococcal")).toBe(true);
const html = render("pneumococcal");
expect(html).toContain("Standard vaccination from age 60");
});
});
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import {
draftHasIdentity,
draftInstant,
draftToBody,
emptyDraft,
} from "../vaccination-form";
/**
* The form's one hard rule: a dose saves with a date and ONE identity arm — a
* catalogue pick OR the person's own wording — and nothing else. These cover
* the body derivation that the sheet's Save gate reads.
*/
describe("vaccination form draft", () => {
it("saves with a date and a catalogue pick alone", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "2020-03-04", antigenSlug: "tetanus" }),
);
expect(body).not.toBeNull();
expect(body!.antigenSlug).toBe("tetanus");
expect(body!.occurredAt).toBe("2020-03-04T00:00:00.000Z");
// Nothing else is required — the optional fields default to absences.
expect(body!.vaccineName).toBeNull();
expect(body!.doseNumber).toBeNull();
expect(body!.lotNumber).toBeNull();
expect(body!.site).toBeNull();
expect(body!.practitionerId).toBeNull();
});
it("saves with a date and free text alone", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "1987-06-01", vaccineName: " Old brand " }),
);
expect(body).not.toBeNull();
expect(body!.antigenSlug).toBeNull();
expect(body!.vaccineName).toBe("Old brand");
});
it("refuses a draft with neither identity arm", () => {
const draft = emptyDraft({ occurredAt: "2020-01-01" });
expect(draftHasIdentity(draft)).toBe(false);
expect(draftToBody(draft)).toBeNull();
});
it("refuses a draft with no usable date", () => {
expect(draftInstant("")).toBeNull();
expect(draftInstant("not-a-date")).toBeNull();
expect(
draftToBody(emptyDraft({ occurredAt: "", antigenSlug: "tetanus" })),
).toBeNull();
});
it("carries the optional fields through when present", () => {
const body = draftToBody(
emptyDraft({
occurredAt: "2021-09-09",
antigenSlug: "tdap",
doseNumber: "3",
seriesDoses: "3",
lotNumber: "AB123",
site: "LEFT_ARM",
note: "sore arm",
}),
);
expect(body).toMatchObject({
doseNumber: 3,
seriesDoses: 3,
lotNumber: "AB123",
site: "LEFT_ARM",
note: "sore arm",
});
});
});
@@ -0,0 +1,135 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { VaccinationDTO } from "@/lib/vaccinations/dto";
import type { SeriesPosition } from "@/lib/vaccinations/series";
import { VaccinationList } from "../vaccination-list";
/**
* The list answers "what have I had" and "where does each series stand": one
* group per component antigen, a combined dose rendered once in each of its
* component groups with that component's own resolved position, and a
* free-text or dead-slug record folded under its verbatim name. The numbers
* come from the DTO — this suite never asserts a re-derivation.
*/
function record(
over: Partial<VaccinationDTO> & { id: string; series: SeriesPosition[] },
): VaccinationDTO {
return {
occurredAt: "2020-01-01T00:00:00.000Z",
antigenSlug: null,
vaccineName: null,
doseNumber: null,
seriesDoses: null,
lotNumber: null,
site: null,
catalogEntry: null,
practitioner: null,
encounter: null,
reminderId: null,
note: null,
createdAt: "2020-01-01T00:00:00.000Z",
updatedAt: "2020-01-01T00:00:00.000Z",
...over,
};
}
function render(records: VaccinationDTO[]): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<VaccinationList records={records} />
</I18nProvider>,
);
}
describe("VaccinationList grouping", () => {
it("renders a combination dose in every component group", () => {
const tdap = record({
id: "tdap-1",
antigenSlug: "tdap",
catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" },
series: [
{ antigen: "tetanus", position: 3, total: 3, booster: false },
{ antigen: "diphtheria", position: 1, total: 3, booster: false },
{ antigen: "pertussis", position: 1, total: 3, booster: false },
],
});
const html = render([tdap]);
for (const antigen of ["tetanus", "diphtheria", "pertussis"]) {
expect(html).toContain(`data-antigen="${antigen}"`);
}
// Three appearances of the one record — one per component group.
const appearances = html.match(/data-vaccination-id="tdap-1"/g) ?? [];
expect(appearances).toHaveLength(3);
});
it("places a monovalent and a combo in the same antigen group with per-component positions", () => {
const tetanus = record({
id: "tet-1",
antigenSlug: "tetanus",
catalogEntry: { slug: "tetanus", atc: "J07AM01", category: "standard" },
occurredAt: "2010-05-05T00:00:00.000Z",
series: [{ antigen: "tetanus", position: 2, total: 3, booster: false }],
});
const tdap = record({
id: "tdap-2",
antigenSlug: "tdap",
catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" },
series: [
{ antigen: "tetanus", position: 3, total: 3, booster: false },
{ antigen: "pertussis", position: 1, total: 3, booster: false },
],
});
const html = render([tetanus, tdap]);
// The tetanus group holds both doses; the pertussis group only the Tdap.
const tetanusSection = html.slice(html.indexOf('data-antigen="tetanus"'));
expect(tetanusSection).toContain('data-vaccination-id="tet-1"');
expect(html).toContain('data-antigen="pertussis"');
// Dose 3 of 3 renders as resolved text, never recomputed.
expect(html).toContain("Dose 3 of 3");
});
it("renders a booster past the series end as a booster, not a position", () => {
const html = render([
record({
id: "boost-1",
antigenSlug: "tetanus",
catalogEntry: { slug: "tetanus", atc: "J07AM01", category: "standard" },
series: [{ antigen: "tetanus", position: 4, total: 3, booster: true }],
}),
]);
expect(html).toContain("Booster");
expect(html).not.toContain("Dose 4 of 3");
});
it("folds a free-text and a dead-slug record under their verbatim name", () => {
const free = record({
id: "free-1",
vaccineName: "Some old vaccine",
series: [],
});
const deadSlug = record({
id: "dead-1",
antigenSlug: "retired-antigen",
vaccineName: "Retired brand",
catalogEntry: null,
series: [],
});
const html = render([free, deadSlug]);
expect(html).toContain('data-antigen="free"');
expect(html).toContain("Some old vaccine");
expect(html).toContain("Retired brand");
// A free-text row shows no series sentence — nothing is guessed.
const freeSection = html.slice(
html.indexOf('data-vaccination-id="free-1"'),
);
expect(freeSection.slice(0, 400)).not.toContain(
'data-slot="vaccination-series"',
);
});
});
@@ -0,0 +1,169 @@
"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 { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
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 },
});
toastWrittenOutcome("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>
);
}
@@ -0,0 +1,99 @@
"use client";
/**
* Rung 1: the catalogue's own knowledge, rendered with its citation.
*
* This is information reproduction, never a personal recommendation. The
* sentences are composed from the seed's `typicalSeriesDoses`,
* `boosterIntervalMonths` and `category` over a handful of i18n templates —
* not hand-written per entry — so six locales carry ~6 templates rather than
* ~35 prose blocks. Every sentence is population-level and impersonal by
* construction: it states what the cited schedule says, with no "you should"
* and no conditioning on age, sex or history. That conditioning would be rung
* 3, and rung 3 is a different venture.
*
* The per-entry `source` renders verbatim as the footnote — a citation is a
* proper noun, like a unit, and is not translated.
*/
import { useTranslations } from "@/lib/i18n/context";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
type Translate = ReturnType<typeof useTranslations>["t"];
type TranslateCount = ReturnType<typeof useTranslations>["tCount"];
/** The impersonal sentences that apply to this entry, in reading order. */
function infoSentences(
slug: string | null,
t: Translate,
tCount: TranslateCount,
): { lines: string[]; source: string } | null {
const entry = resolveCatalogEntry(slug);
if (!entry) return null;
const lines: string[] = [];
if (entry.typicalSeriesDoses !== null) {
lines.push(
tCount("vaccinations.info.primarySeries", entry.typicalSeriesDoses, {
count: entry.typicalSeriesDoses,
}),
);
}
if (entry.boosterIntervalMonths !== null) {
const months = entry.boosterIntervalMonths;
if (months === 12) {
lines.push(t("vaccinations.info.yearly"));
} else if (months % 12 === 0) {
const years = months / 12;
lines.push(
tCount("vaccinations.info.boosterYears", years, { count: years }),
);
} else {
lines.push(
tCount("vaccinations.info.boosterMonths", months, { count: months }),
);
}
}
if (entry.category === "standard60") {
lines.push(t("vaccinations.info.standard60"));
}
if (lines.length === 0) return null;
return { lines, source: entry.source };
}
/**
* Whether {@link CatalogInfo} would render anything for this slug — so a
* caller can decide to mount an info affordance at all, rather than opening an
* empty popover for an entry the templates say nothing about.
*/
export function catalogInfoAvailable(slug: string | null): boolean {
const entry = resolveCatalogEntry(slug);
if (!entry) return false;
return (
entry.typicalSeriesDoses !== null ||
entry.boosterIntervalMonths !== null ||
entry.category === "standard60"
);
}
export function CatalogInfo({ slug }: { slug: string | null }) {
const { t, tCount } = useTranslations();
const info = infoSentences(slug, t, tCount);
if (!info) return null;
return (
<div className="space-y-1" data-slot="vaccination-catalog-info">
<ul className="text-foreground space-y-0.5 text-sm">
{info.lines.map((line, index) => (
<li key={index}>{line}</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("vaccinations.info.sourceLabel", { source: info.source })}
</p>
</div>
);
}
@@ -0,0 +1,166 @@
"use client";
/**
* The catalogue arm of the identity pair.
*
* Searches the static antigen catalogue by its localised display name and by
* the generic synonyms shipped with each entry — never a trade name, because
* none exist in the catalogue; a person who types a brand matches nothing here
* and falls back to the free-text arm, which is exactly where a Pass's own
* wording belongs. Picking an entry sets `antigenSlug`; it never touches what
* the person typed in the free-text field.
*
* The list is a static import, so the search is an in-memory filter with no
* round trip and no provider dependency. Grouped by category so a lifetime Pass
* (childhood through travel) reads in the order a person recognises.
*/
import { useMemo, useState } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
VACCINE_CATALOG,
type VaccineSeed,
} from "@/lib/vaccinations/vaccine-catalog";
import { CatalogInfo } from "./catalog-info";
export function CatalogPicker({
value,
onChange,
disabled,
}: {
/** The chosen catalogue slug, or null. */
value: string | null;
onChange: (slug: string | null) => void;
disabled?: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const name = (slug: string) => t(`vaccinations.catalog.${slug}`);
const matches = useMemo(() => {
const needle = term.trim().toLowerCase();
const scored = VACCINE_CATALOG.filter((entry) => {
if (!needle) return true;
if (name(entry.slug).toLowerCase().includes(needle)) return true;
if (entry.slug.includes(needle)) return true;
return (entry.synonyms ?? []).some((s) =>
s.toLowerCase().includes(needle),
);
});
return scored;
// `name` closes over `t`, which is stable per locale; term drives the recompute.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term, t]);
const selected: VaccineSeed | undefined = value
? VACCINE_CATALOG.find((entry) => entry.slug === value)
: undefined;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
// `role="combobox"` is not a name-from-content role, so the
// visible placeholder span does not name the control for a screen
// reader and the wrapping field label targets an id this button
// does not carry. Name it explicitly with the field's own label.
aria-label={t("vaccinations.form.catalogLabel")}
disabled={disabled}
data-slot="vaccination-catalog-trigger"
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{selected
? name(selected.slug)
: t("vaccinations.form.catalogNone")}
</span>
<ChevronsUpDown
className="size-4 shrink-0 opacity-50"
aria-hidden
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("vaccinations.form.catalogSearch")}
aria-label={t("vaccinations.form.catalogSearch")}
data-slot="vaccination-catalog-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{matches.length === 0 ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("vaccinations.form.catalogNoMatch")}
</p>
) : (
matches.map((entry) => (
<button
key={entry.slug}
type="button"
data-slot="vaccination-catalog-option"
data-catalog-slug={entry.slug}
onClick={() => {
onChange(entry.slug);
setOpen(false);
}}
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
>
<Check
className={cn(
"size-4 shrink-0",
entry.slug === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="text-foreground min-w-0 flex-1 truncate">
{name(entry.slug)}
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
{selected ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={t("vaccinations.form.catalogClear")}
onClick={() => onChange(null)}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
{/* Rung 1: the catalogue's sourced sentences for the chosen entry. */}
{selected ? <CatalogInfo slug={selected.slug} /> : null}
</div>
);
}
@@ -0,0 +1,190 @@
"use client";
/**
* v1.38.0 — immunization-log read hooks.
*
* Reads unwrap the envelope `data` (via `apiGet`) per the project rule, and
* every key is factory-routed through `queryKeys.vaccination*`. The list
* arrives with each dose's `series` already resolved per component antigen —
* this client renders text from those numbers and never re-derives "N von M".
*
* Writes and the booster mint land with the capture form; they invalidate
* `vaccinationDependentKeys`, which evicts the preventive-care root alongside
* the dose list because logging a dose re-anchors the booster it answers.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
invalidateKeys,
queryKeys,
vaccinationDependentKeys,
} from "@/lib/query-keys";
import type {
VaccinationDTO,
VaccinationListDTO,
} from "@/lib/vaccinations/dto";
import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion";
export type Vaccination = VaccinationDTO;
/**
* The write body — every field optional but the identity pair and the date.
* `userId` is never here; the route narrows it from the session.
*/
export interface VaccinationWriteBody {
occurredAt?: string;
antigenSlug?: string | null;
vaccineName?: string | null;
doseNumber?: number | null;
seriesDoses?: number | null;
lotNumber?: string | null;
site?: string | null;
practitionerId?: string | null;
encounterId?: string | null;
note?: string | null;
documentIds?: string[];
}
const BASE = "/api/vaccinations";
/**
* The account's immunization log, newest dose first.
*
* `antigenSlug` filters the server query to one antigen's history; the series
* numbers are still derived over the whole live set on the server, so a
* filtered view never reports the oldest visible dose as the first ever given.
*/
export function useVaccinations(antigenSlug?: string | null, enabled = true) {
return useQuery({
queryKey: queryKeys.vaccinationList(antigenSlug ?? null),
enabled,
queryFn: () =>
apiGet<VaccinationListDTO>(
antigenSlug
? `${BASE}?antigenSlug=${encodeURIComponent(antigenSlug)}`
: BASE,
),
});
}
/**
* Create / edit / soft-delete / restore a dose.
*
* Every write fans out through `vaccinationDependentKeys` AND the reminder
* reads: logging a dose runs the server's satisfy matcher, which re-anchors any
* booster the dose answers, so the preventive-care list must repaint in the
* same tick — the exact pairing the encounter mutations use, through the same
* helper rather than a second sweep that could drift from it.
*/
export function useVaccinationMutations() {
const qc = useQueryClient();
const invalidate = () =>
Promise.all([
invalidateKeys(qc, vaccinationDependentKeys),
invalidateReminderReads(qc),
]);
const create = useMutation({
mutationKey: queryKeys.vaccinationCreate(),
mutationFn: (body: VaccinationWriteBody) =>
apiPost<Vaccination>(BASE, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.vaccinationUpdate(),
mutationFn: ({ id, body }: { id: string; body: VaccinationWriteBody }) =>
apiPatch<Vaccination>(`${BASE}/${id}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.vaccinationDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
onSuccess: invalidate,
});
const restore = useMutation({
mutationKey: queryKeys.vaccinationRestore(),
mutationFn: (id: string) =>
apiPost<Vaccination>(`${BASE}/${id}/restore`, {}),
onSuccess: invalidate,
});
return { create, update, remove, restore };
}
/**
* Which dose a document dated `anchor` most plausibly belongs to.
*
* The verdict is server-resolved through the shared ±7-day window, so the
* browser never re-derives which dose a scan belongs to — and the "many"
* verdict carries no pre-selection, so a caller cannot pre-select one of two.
*/
export function useVaccinationSuggestion(
anchor: string | null,
enabled = true,
) {
return useQuery({
queryKey: queryKeys.vaccinationSuggestion(anchor ?? ""),
enabled: enabled && Boolean(anchor),
queryFn: () =>
apiGet<VaccinationSuggestionResult>(
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
),
});
}
/** Link a document to a dose from the document side (the upload suggestion). */
export function useLinkDocumentToVaccination() {
const qc = useQueryClient();
return useMutation({
mutationKey: queryKeys.vaccinationLink(),
mutationFn: ({
vaccinationId,
documentId,
}: {
vaccinationId: string;
documentId: string;
}) =>
apiPost(`${BASE}/${vaccinationId}/links`, {
targetKind: "document",
targetIds: [documentId],
}),
onSuccess: () => invalidateKeys(qc, vaccinationDependentKeys),
});
}
/** 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),
]),
});
}
@@ -0,0 +1,123 @@
"use client";
/**
* The from-the-record document link: attach the scanned page a dose was
* transcribed from.
*
* **The gate blanks the block, it does not post-filter it.** When the
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
* list — because an empty picker for a switched-off module advertises a feature
* that is not there. The same shape the visit form's link pickers use.
*
* Nothing here can block a save: the list starts empty and stays valid empty,
* and linking is optional, capped and idempotent behind the link facade.
*/
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
const OPTION_LIMIT = 25;
interface DocumentListPage {
documents: InboundDocumentDto[];
}
export function VaccinationDocumentPicker({
enabled,
documentIds,
onChange,
}: {
/** The `inboundDocuments` module flag — false blanks the block entirely. */
enabled: boolean;
documentIds: string[];
onChange: (documentIds: string[]) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const documents = useQuery({
queryKey: queryKeys.inboundDocumentPicker("vaccination-form"),
enabled,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
),
});
if (!enabled) return null;
const toggle = (id: string) =>
onChange(
documentIds.includes(id)
? documentIds.filter((entry) => entry !== id)
: [...documentIds, id],
);
const options = (documents.data?.documents ?? []).map((doc) => ({
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
}));
return (
<div
className="space-y-2 border-t pt-4"
data-slot="vaccination-document-picker"
>
<div className="flex items-center gap-2">
<FolderOpen className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">
{t("vaccinations.form.linkDocuments")}
</span>
{documentIds.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{documentIds.length}
</Badge>
) : null}
</div>
{documents.isPending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("vaccinations.form.linkNothingToOffer")}
</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
{options.map((option) => {
const on = documentIds.includes(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot="vaccination-document-option"
onClick={() => toggle(option.id)}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
{option.label}
</span>
{option.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(option.date)}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
/**
* The from-upload half of the document link: a scan classified `VACCINATION`
* offers to file itself against a dose recorded around the same date.
*
* The RULE is not here — it lives in `src/lib/vaccinations/document-suggestion.ts`
* and is resolved server-side, the same ±7-day window the visit moment uses, so
* the two cannot drift. What this owns is only how the three verdicts look:
*
* one candidate → pre-selected, visibly;
* two or more → a picker with NOTHING pre-selected;
* none → nothing at all.
*
* The middle branch is the point, and this file never picks one of a "many":
* that verdict carries no pre-selection. It never blocks the upload — linking is
* an offer, and the facade's link is idempotent, so a second tap is a no-op.
*/
import { useState } from "react";
import { Check, Syringe } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useLinkDocumentToVaccination,
useVaccinationSuggestion,
} from "./use-vaccinations";
import type { VaccinationSuggestion } from "@/lib/vaccinations/document-suggestion";
export function VaccinationDocumentSuggestion({
anchor,
documentId,
disabled,
}: {
/** `reportDate ?? documentDate`, date-only; null means nothing to ask. */
anchor: string | null;
documentId: string;
disabled?: boolean;
}) {
const { t } = useTranslations();
const format = useFormatters();
// Lift the date-only anchor to noon UTC — the rule is a ±7-day window, so the
// time never changes the verdict, and noon keeps a viewer's local midnight
// from rounding the day either way. The bare date would 422 the endpoint.
const instant = anchor ? `${anchor}T12:00:00.000Z` : null;
const suggestion = useVaccinationSuggestion(instant, !disabled);
const link = useLinkDocumentToVaccination();
const [linkedId, setLinkedId] = useState<string | null>(null);
if (disabled) return null;
const result = suggestion.data;
if (!result || result.kind === "none") return null;
const candidates =
result.kind === "one" ? [result.vaccination] : result.vaccinations;
const label = (candidate: VaccinationSuggestion) => {
const name = candidate.antigenSlug
? t(`vaccinations.catalog.${candidate.antigenSlug}`)
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
return `${name} · ${format.date(candidate.occurredAt)}`;
};
const onPick = (candidate: VaccinationSuggestion) => {
link.mutate(
{ vaccinationId: candidate.id, documentId },
{
onSuccess: () => {
setLinkedId(candidate.id);
toastWrittenOutcome("success", t("vaccinations.suggestion.linked"));
},
onError: () => toast.error(t("vaccinations.suggestion.failed")),
},
);
};
return (
<div
className="space-y-1.5"
data-slot="vaccination-document-suggestion"
data-verdict={result.kind}
data-candidates={candidates.length}
>
<p className="text-foreground flex items-center gap-2 text-sm">
<Syringe className="size-4 shrink-0" aria-hidden />
{t(
result.kind === "one"
? "vaccinations.suggestion.single"
: "vaccinations.suggestion.choose",
)}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{candidates.map((candidate) => {
const chosen = linkedId === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={chosen ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={chosen}
disabled={link.isPending || linkedId !== null}
data-slot="vaccination-document-suggestion-option"
onClick={() => onPick(candidate)}
>
{result.kind === "one" || chosen ? (
<Check
className="size-4"
aria-hidden
style={{ opacity: chosen ? 1 : 0.5 }}
/>
) : null}
{label(candidate)}
</Button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,313 @@
"use client";
/**
* The dose capture and edit form.
*
* **A dose saves with a date and ONE identity arm — a catalogue pick OR the
* person's own wording — and nothing else.** No required dose number, no
* required batch code, no required practice. A required catalogue pick would
* make a 1987 Impfpass line unloggable, which is the whole population this
* feature exists for. The only thing that can block Save is a missing date or
* neither identity arm.
*
* The two identity arms are independent: picking a catalogue entry never
* overwrites what the person typed, and typing never clears a pick. Either is
* sufficient; both are allowed.
*
* Follows the encounter form's controlled-draft shape rather than a
* react-hook-form instance — the sibling surface this plan cites as its
* precedent uses a draft, and the server's Zod schema is the validation
* authority either way. The client guard is for the two properties a person
* feels immediately: a date is present, and the vaccine is named somehow.
*/
import { useState } from "react";
import { DateField } from "@/components/ui/date-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { PractitionerCombobox } from "@/components/encounters/practitioner-combobox";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Practitioner } from "@/hooks/use-practitioners";
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
export const VACCINATION_SITES = [
"LEFT_ARM",
"RIGHT_ARM",
"LEFT_THIGH",
"RIGHT_THIGH",
"ORAL",
"NASAL",
"OTHER",
] as const;
export interface VaccinationDraft {
/** `YYYY-MM-DD`, the `DateField` contract. */
occurredAt: string;
antigenSlug: string | null;
vaccineName: string;
doseNumber: string;
seriesDoses: string;
lotNumber: string;
site: string;
practitioner: Practitioner | null;
encounterId: string | null;
note: string;
documentIds: string[];
}
/** Today as a local `YYYY-MM-DD`, the DateField default and max. */
export function todayLocal(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function emptyDraft(
overrides?: Partial<VaccinationDraft>,
): VaccinationDraft {
return {
occurredAt: todayLocal(),
antigenSlug: null,
vaccineName: "",
doseNumber: "",
seriesDoses: "",
lotNumber: "",
site: "",
practitioner: null,
encounterId: null,
note: "",
documentIds: [],
...overrides,
};
}
export function draftFromVaccination(row: Vaccination): VaccinationDraft {
return {
occurredAt: row.occurredAt.slice(0, 10),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName ?? "",
doseNumber: row.doseNumber?.toString() ?? "",
seriesDoses: row.seriesDoses?.toString() ?? "",
lotNumber: row.lotNumber ?? "",
site: row.site ?? "",
practitioner: row.practitioner,
encounterId: row.encounter?.id ?? null,
note: row.note ?? "",
documentIds: row.documents?.map((doc) => doc.id) ?? [],
};
}
/** The day, at UTC midnight — an Impfpass carries dates, never times. */
export function draftInstant(day: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
const parsed = new Date(`${day}T00:00:00.000Z`);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/** Does the draft carry at least one identity arm? */
export function draftHasIdentity(draft: VaccinationDraft): boolean {
return draft.antigenSlug !== null || draft.vaccineName.trim().length > 0;
}
function toInt(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number.parseInt(trimmed, 10);
return Number.isNaN(n) ? null : n;
}
/** The body a draft becomes, empties turned into absences. Null when unsavable. */
export function draftToBody(
draft: VaccinationDraft,
): VaccinationWriteBody | null {
const occurredAt = draftInstant(draft.occurredAt);
if (!occurredAt) return null;
if (!draftHasIdentity(draft)) return null;
return {
occurredAt,
antigenSlug: draft.antigenSlug,
vaccineName: draft.vaccineName.trim() || null,
doseNumber: toInt(draft.doseNumber),
seriesDoses: toInt(draft.seriesDoses),
lotNumber: draft.lotNumber.trim() || null,
site: draft.site || null,
practitionerId: draft.practitioner?.id ?? null,
encounterId: draft.encounterId,
note: draft.note.trim() || null,
documentIds: draft.documentIds,
};
}
export function VaccinationForm({
draft,
onChange,
}: {
draft: VaccinationDraft;
onChange: (next: VaccinationDraft) => void;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const patch = (part: Partial<VaccinationDraft>) =>
onChange({ ...draft, ...part });
const [seriesOpen, setSeriesOpen] = useState(
draft.doseNumber.length > 0 || draft.seriesDoses.length > 0,
);
const anchor = draftInstant(draft.occurredAt);
return (
<div className="space-y-4" data-slot="vaccination-form">
<FieldGroup
htmlFor="vaccination-occurred-at"
label={t("vaccinations.form.date")}
>
<DateField
id="vaccination-occurred-at"
value={draft.occurredAt}
onChange={(value) => patch({ occurredAt: value })}
max={todayLocal()}
required
data-testid="vaccination-occurred-at"
/>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-catalog-trigger"
label={t("vaccinations.form.catalogLabel")}
hint={t("vaccinations.form.identityHint")}
>
<CatalogPicker
value={draft.antigenSlug}
onChange={(slug) => patch({ antigenSlug: slug })}
/>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-free-text"
label={t("vaccinations.form.freeTextLabel")}
>
<Input
id="vaccination-free-text"
value={draft.vaccineName}
onChange={(event) => patch({ vaccineName: event.target.value })}
placeholder={t("vaccinations.form.freeTextPlaceholder")}
data-slot="vaccination-free-text"
/>
</FieldGroup>
{seriesOpen ? (
<div className="grid grid-cols-2 gap-3">
<FieldGroup
htmlFor="vaccination-dose-number"
label={t("vaccinations.form.doseNumber")}
>
<Input
id="vaccination-dose-number"
type="number"
inputMode="numeric"
min={1}
max={20}
value={draft.doseNumber}
onChange={(event) => patch({ doseNumber: event.target.value })}
/>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-series-doses"
label={t("vaccinations.form.seriesDoses")}
>
<Input
id="vaccination-series-doses"
type="number"
inputMode="numeric"
min={1}
max={10}
value={draft.seriesDoses}
onChange={(event) => patch({ seriesDoses: event.target.value })}
/>
</FieldGroup>
</div>
) : (
<button
type="button"
data-slot="vaccination-series-reveal"
className="text-muted-foreground hover:text-foreground text-sm underline-offset-2 hover:underline"
onClick={() => setSeriesOpen(true)}
>
{t("vaccinations.form.seriesToggle")}
</button>
)}
<FieldGroup htmlFor="vaccination-lot" label={t("vaccinations.form.lot")}>
<Input
id="vaccination-lot"
value={draft.lotNumber}
onChange={(event) => patch({ lotNumber: event.target.value })}
/>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-site"
label={t("vaccinations.form.site")}
>
<NativeSelect
id="vaccination-site"
value={draft.site}
onChange={(event) => patch({ site: event.target.value })}
>
<option value="">{t("vaccinations.form.siteNone")}</option>
{VACCINATION_SITES.map((site) => (
<option key={site} value={site}>
{t(`vaccinations.site.${site}`)}
</option>
))}
</NativeSelect>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-practitioner"
label={t("vaccinations.form.practitioner")}
>
<PractitionerCombobox
value={draft.practitioner?.id ?? null}
selected={draft.practitioner}
onChange={(next) => patch({ practitioner: next })}
/>
</FieldGroup>
<EncounterSuggestionField
anchor={anchor}
value={draft.encounterId}
onChange={(id) => patch({ encounterId: id })}
slot="vaccination-encounter-suggestion"
/>
<FieldGroup
htmlFor="vaccination-note"
label={t("vaccinations.form.note")}
>
<Textarea
id="vaccination-note"
rows={2}
value={draft.note}
onChange={(event) => patch({ note: event.target.value })}
/>
</FieldGroup>
<VaccinationDocumentPicker
enabled={user?.modules?.inboundDocuments === true}
documentIds={draft.documentIds}
onChange={(documentIds) => patch({ documentIds })}
/>
</div>
);
}
@@ -0,0 +1,254 @@
"use client";
/**
* The immunization log, grouped the way an Impfpass answers its two questions:
* "what have I had" and "where does each series stand".
*
* ── Grouping by component antigen ──────────────────────────────────────────
*
* A group is one antigen with at least one live dose. A combination record
* renders a row in EVERY component group — one Tdap shows up three times, once
* under tetanus, once under diphtheria, once under pertussis, each appearance
* carrying that component's own resolved series values. This is render-only
* duplication: one record, several appearances, and the numbers come straight
* from the DTO's `series` array, never recomputed here.
*
* A free-text-only record, and a record whose slug the catalogue no longer
* resolves, carries an empty `series` and groups under its verbatim
* `vaccineName` instead — the degrade the DTO's `catalogEntry: null` signals,
* now visible.
*
* ── Neutral, retrospective ─────────────────────────────────────────────────
*
* No due status anywhere on this page. A booster the user set shows on
* `/checkups` like every other reminder; this surface reproduces the record
* and does not adjudicate it. Cards are neutral — content in foreground, meta
* in muted, no tint.
*/
import { Syringe } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { InfoPopover } from "@/components/ui/info-popover";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { SeriesPosition } from "@/lib/vaccinations/series";
import type { Vaccination } from "./use-vaccinations";
import { CatalogInfo, catalogInfoAvailable } from "./catalog-info";
type Translate = ReturnType<typeof useTranslations>["t"];
/** One dose as it appears inside one antigen group. */
interface GroupRow {
record: Vaccination;
/** This group's resolved position, or null for a free-text group. */
position: SeriesPosition | null;
}
interface AntigenGroup {
/** Dedup / React key. */
key: string;
/** The catalogue antigen slug, or null for a free-text group. */
antigen: string | null;
/** The verbatim heading for a free-text group. */
freeName: string | null;
rows: GroupRow[];
}
/**
* The record's own identity, for the muted "· Tdap" marker that lets a reader
* recognise a combined dose inside a single-antigen group. A catalogue slug
* resolves through the dynamic name key; a free-text record shows its wording.
*/
function recordIdentity(t: Translate, record: Vaccination): string | null {
if (record.catalogEntry)
return t(`vaccinations.catalog.${record.catalogEntry.slug}`);
return record.vaccineName;
}
/** The series sentence for one appearance, composed from resolved numbers. */
function seriesLabel(
t: Translate,
position: SeriesPosition | null,
): string | null {
if (!position) return null;
if (position.booster) return t("vaccinations.series.booster");
if (position.total !== null) {
return t("vaccinations.series.ofTotal", {
position: position.position,
total: position.total,
});
}
return t("vaccinations.series.doseN", { position: position.position });
}
/**
* Fold the flat, newest-first list into antigen groups.
*
* The server already sorts the list `occurredAt desc`, so pushing in order
* keeps every group newest-first without a second sort. A record with at least
* one `series` entry lands in each of its component groups; one with none folds
* into a free-text group keyed by its verbatim name.
*/
function groupByAntigen(records: readonly Vaccination[]): AntigenGroup[] {
const groups = new Map<string, AntigenGroup>();
const ensure = (
key: string,
seed: Omit<AntigenGroup, "rows">,
): AntigenGroup => {
let group = groups.get(key);
if (!group) {
group = { ...seed, rows: [] };
groups.set(key, group);
}
return group;
};
for (const record of records) {
if (record.series.length > 0) {
for (const position of record.series) {
ensure(`antigen:${position.antigen}`, {
key: `antigen:${position.antigen}`,
antigen: position.antigen,
freeName: null,
}).rows.push({ record, position });
}
continue;
}
// No resolvable antigen: a free-text row or a dead slug. Its verbatim name
// is the heading; the slug is the last-resort key so a nameless dead-slug
// row still lands somewhere rather than colliding with another.
const name = record.vaccineName ?? record.antigenSlug ?? "—";
const key = `free:${name}`;
ensure(key, { key, antigen: null, freeName: name }).rows.push({
record,
position: null,
});
}
// Antigen groups first (alphabetical by resolved-independent slug for a
// stable order), then free-text groups after, also stable.
return [...groups.values()].sort((a, b) => {
if ((a.antigen === null) !== (b.antigen === null)) {
return a.antigen === null ? 1 : -1;
}
return a.key.localeCompare(b.key);
});
}
function DoseRow({
row,
onEdit,
}: {
row: GroupRow;
onEdit?: (record: Vaccination) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const { record, position } = row;
const series = seriesLabel(t, position);
const identity = recordIdentity(t, record);
// Inside an antigen group, mark a dose whose own preparation is not this
// antigen — a combined shot — so the reader sees it was a Tdap, not a
// monovalent tetanus. In a free-text group the heading already is the name.
const showIdentity =
position !== null && record.catalogEntry?.slug !== position.antigen;
return (
<Card
className="hover:bg-muted/40 gap-0 transition-colors"
data-slot="vaccination-row"
data-vaccination-id={record.id}
onClick={onEdit ? () => onEdit(record) : undefined}
role={onEdit ? "button" : undefined}
tabIndex={onEdit ? 0 : undefined}
onKeyDown={
onEdit
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onEdit(record);
}
}
: undefined
}
>
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 text-sm">
{series ? (
<span
className="text-foreground font-medium"
data-slot="vaccination-series"
>
{series}
</span>
) : null}
{showIdentity && identity ? (
<span className="text-muted-foreground">· {identity}</span>
) : null}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
<span>{format.date(record.occurredAt)}</span>
{record.lotNumber ? (
<span data-slot="vaccination-lot">
{t("vaccinations.row.lot", { lot: record.lotNumber })}
</span>
) : null}
{record.practitioner?.name ? (
<span>{record.practitioner.name}</span>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}
export function VaccinationList({
records,
onEdit,
}: {
records: readonly Vaccination[];
onEdit?: (record: Vaccination) => void;
}) {
const { t } = useTranslations();
const groups = groupByAntigen(records);
return (
<div className="space-y-6">
{groups.map((group) => (
<section
key={group.key}
className="space-y-2"
data-slot="vaccination-group"
data-antigen={group.antigen ?? "free"}
>
<h2 className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
<Syringe className="size-3.5" aria-hidden />
{group.antigen
? t(`vaccinations.catalog.${group.antigen}`)
: group.freeName}
<span className="normal-case">({group.rows.length})</span>
{group.antigen && catalogInfoAvailable(group.antigen) ? (
<InfoPopover
label={t("vaccinations.info.affordanceLabel")}
content={<CatalogInfo slug={group.antigen} />}
iconClassName="h-3 w-3"
triggerDataSlot="vaccination-info-trigger"
/>
) : null}
</h2>
<div className="space-y-2">
{group.rows.map((row, index) => (
<DoseRow
key={`${row.record.id}:${index}`}
row={row}
onEdit={onEdit}
/>
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,175 @@
"use client";
/**
* The sheet the dose form lives in — create and edit, one surface.
*
* Save is disabled only when the date cannot be parsed or neither identity arm
* is present. Nothing else on the form can block it — the property the whole
* feature is checked against.
*
* On a successful create the caller may raise the booster-mint prompt; this
* sheet completes and closes regardless of what the person does with it. The
* mint is an offer, never a step.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { useVaccinationMutations, type Vaccination } from "./use-vaccinations";
import {
VaccinationForm,
draftFromVaccination,
draftHasIdentity,
draftInstant,
draftToBody,
emptyDraft,
type VaccinationDraft,
} from "./vaccination-form";
export function VaccinationSheet({
open,
onOpenChange,
vaccination,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates a dose; a row edits it. */
vaccination: Vaccination | null;
/** Raised after a fresh create, for the booster-mint offer. */
onCreated?: (created: Vaccination) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useVaccinationMutations();
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Seeded once at mount; the caller remounts on every open by varying `key`.
const [draft, setDraft] = useState<VaccinationDraft>(() =>
vaccination ? draftFromVaccination(vaccination) : emptyDraft(),
);
const [error, setError] = useState<string | null>(null);
const isEdit = vaccination !== null;
const pending = create.isPending || update.isPending;
const savable =
draftInstant(draft.occurredAt) !== null && draftHasIdentity(draft);
const submit = async () => {
const body = draftToBody(draft);
if (!body) return;
try {
if (isEdit) {
await update.mutateAsync({ id: vaccination.id, body });
toastWrittenOutcome("success", t("vaccinations.updated"));
onOpenChange(false);
} else {
const created = await create.mutateAsync(body);
toastWrittenOutcome("success", t("vaccinations.created"));
onOpenChange(false);
onCreated?.(created);
}
} catch (err) {
const message =
err instanceof Error ? err.message : t("vaccinations.saveFailed");
setError(message);
toast.error(message);
}
};
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(isEdit ? "vaccinations.editTitle" : "vaccinations.createTitle")}
description={t("vaccinations.formDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="min-h-11"
data-slot="vaccination-save"
disabled={!savable || pending}
onClick={() => void submit()}
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<VaccinationForm draft={draft} onChange={setDraft} />
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
{isEdit ? (
<AlertDialog
open={confirmingDelete}
onOpenChange={setConfirmingDelete}
>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11"
data-slot="vaccination-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("vaccinations.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("vaccinations.deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(vaccination.id, {
onSuccess: () => onOpenChange(false),
onError: () =>
toast.error(t("vaccinations.deleteFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -0,0 +1,125 @@
"use client";
/**
* v1.38.0 — the immunization log surface.
*
* A calm, retrospective transcription of a lifetime Impfpass. The list groups
* doses by their component antigen and shows where each series stands, with the
* numbers resolved server-side (`src/lib/vaccinations/series.ts`) so this
* client never re-derives "N von M". Neutral cards, status through a discreet
* badge only — no red card, no overdue tint. It reproduces the record; it does
* not adjudicate what is due.
*/
import { useState } from "react";
import { Plus, Syringe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
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() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useVaccinations();
const records = data?.vaccinations ?? [];
// One sheet for create and edit. Bumped `session` remounts it so a second
// open never shows the first's values.
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);
setSession((n) => n + 1);
setSheetOpen(true);
};
const openEdit = (record: Vaccination) => {
setEditing(record);
setSession((n) => n + 1);
setSheetOpen(true);
};
return (
<div className="space-y-6">
<PageHeader
title={
<span data-tour-id="vaccinations-hero">
{t("vaccinations.title")}
</span>
}
description={t("vaccinations.subtitle")}
actions={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
/>
<VaccinationSheet
key={session}
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 ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : isError ? (
// A read failure is not an empty Impfpass — surface the error + Retry so
// an outage never reads as "nothing logged yet".
<QueryErrorCard
title={t("vaccinations.listLoadError")}
onRetry={() => void refetch()}
/>
) : records.length > 0 ? (
<VaccinationList records={records} onEdit={openEdit} />
) : (
<EmptyState
icon={<Syringe className="size-6" />}
title={t("vaccinations.empty.title")}
description={t("vaccinations.empty.description")}
action={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
/>
)}
</div>
);
}
@@ -284,6 +284,82 @@ export function buildClinicalRecordsNotesSection(
.finalY + 8;
}
// The immunization history. Reference data (not time-windowed), populated
// when the `IMMUNIZATIONS` leaf is selected AND the `vaccinations` module is
// on AND live doses exist. A compact chronological table — date, vaccine
// (catalogue name or verbatim free text), dose display, lot — in the same
// calm factual register. No due-status, no gap analysis: the report
// reproduces the record, it does not adjudicate it. The dose display is
// composed from the server-resolved series numbers; nothing is re-derived.
if (data.immunizations && data.immunizations.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.immunizationsTitle"), margin, y);
y += 6;
const doseDisplay = (
series: (typeof data.immunizations)[number]["series"],
): string => {
const primary = series[0];
if (!primary) return "—";
if (primary.booster) return t("vaccinations.series.booster");
if (primary.total !== null) {
return t("vaccinations.series.ofTotal", {
position: primary.position,
total: primary.total,
});
}
return t("vaccinations.series.doseN", { position: primary.position });
};
const immunizationRows = data.immunizations.map((row) => [
fmtDate(row.occurredAt),
row.antigenSlug
? t(`vaccinations.catalog.${row.antigenSlug}`)
: (row.vaccineName ?? "—"),
doseDisplay(row.series),
row.lotNumber ?? "—",
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.immunizationsColDate"),
t("doctorReport.immunizationsColVaccine"),
t("doctorReport.immunizationsColDose"),
t("doctorReport.immunizationsColLot"),
],
],
body: immunizationRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
// v1.27.x — structured allergy / intolerance records. Reference data
// (not time-windowed) the aggregator populates when the `allergies`
// toggle is ON (default) and rows exist. Stored fields only — substance,
+25
View File
@@ -296,6 +296,31 @@ export interface DoctorReportData {
/** Labels of the conditions this visit was filed against. */
conditionLabels: string[];
}> | null;
/**
* The immunization history. Reference data, not time-windowed — an Impfpass
* is a lifetime document and a doctor wants the whole of it, so this mirrors
* the allergies stance rather than the visit window. Populated when the
* `IMMUNIZATIONS` leaf is selected AND the `vaccinations` module is on AND at
* least one live dose exists; null/absent otherwise, and the PDF then skips
* the section. `series` carries the server-resolved position per component
* antigen so the renderer composes "N of M" without re-deriving it; the
* catalogue name resolves from `antigenSlug` at render, with `vaccineName`
* the free-text fallback.
*/
immunizations?: Array<{
occurredAt: string;
antigenSlug: string | null;
vaccineName: string | null;
lotNumber: string | null;
site: string | null;
practitionerName: string | null;
series: Array<{
antigen: string;
position: number;
total: number | null;
booster: boolean;
}>;
}> | null;
illnessEpisodes?: Array<{
/** User-facing condition label (e.g. "Erkältung"). */
label: string;
@@ -237,6 +237,19 @@ function seed() {
reactionEncrypted: null,
},
];
rows.vaccinationRecord = [
{
id: "vacc-1",
occurredAt: new Date("2019-03-04T00:00:00.000Z"),
antigenSlug: "tetanus",
vaccineName: null,
lotNumber: "AB123",
seriesDoses: null,
doseNumber: 3,
site: "LEFT_ARM",
practitioner: { name: "Sample practice" },
},
];
rows.familyHistoryEntry = [
{ relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
];
@@ -428,6 +441,7 @@ describe("zero read for unchosen leaves", () => {
["LAB_RESULTS", "labResult"],
["ILLNESS_EPISODES", "illnessEpisode"],
["VISITS", "encounter"],
["IMMUNIZATIONS", "vaccinationRecord"],
["ANAMNESIS", "userHealthProfile"],
["ANAMNESIS", "healthProfileFactRevision"],
];
+57
View File
@@ -11,6 +11,7 @@
* Split out of the aggregator with the selection rework.
*/
import { prisma } from "@/lib/db";
import { deriveSeries } from "@/lib/vaccinations/series";
import { resolveEffectiveReferenceRange } from "@/lib/labs/reference-range";
import { getEvent } from "@/lib/logging/context";
import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
@@ -194,6 +195,62 @@ export async function loadVisits(
return mapped.length > 0 ? mapped : null;
}
/**
* The immunization history. Reference data, not time-windowed — an Impfpass is
* a lifetime document, so like allergies it ignores the report window.
*
* The series positions are derived server-side over the whole live set (the
* same derivation the list and the API use), so the report reproduces exactly
* the numbers the person sees on screen and no client re-derives them. No note
* column is read on this path — an Impfreaktion is the person's private note,
* not part of a handed-over record.
*/
export async function loadImmunizations(
userId: string,
): Promise<DoctorReportData["immunizations"]> {
const rows = await prisma.vaccinationRecord.findMany({
where: { userId, deletedAt: null },
orderBy: { occurredAt: "asc" },
select: {
id: true,
occurredAt: true,
antigenSlug: true,
vaccineName: true,
lotNumber: true,
seriesDoses: true,
doseNumber: true,
site: true,
practitioner: { select: { name: true } },
},
});
if (rows.length === 0) return null;
const series = deriveSeries(
rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt,
antigenSlug: row.antigenSlug,
doseNumber: row.doseNumber,
seriesDoses: row.seriesDoses,
})),
);
return rows.map((row) => ({
occurredAt: row.occurredAt.toISOString(),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName,
lotNumber: row.lotNumber,
site: row.site,
practitionerName: row.practitioner?.name ?? null,
series: (series.get(row.id) ?? []).map((position) => ({
antigen: position.antigen,
position: position.position,
total: position.total,
booster: position.booster,
})),
}));
}
/**
* Structured allergy records. Reference data, not time-windowed — a
* penicillin allergy does not expire with the report window.
+8
View File
@@ -64,6 +64,7 @@ import {
loadAnamnesis,
loadFamilyHistory,
loadIllnessEpisodes,
loadImmunizations,
loadVisits,
loadLabResults,
} from "./clinical-records";
@@ -500,6 +501,7 @@ export async function collectDoctorReportData(
labResults,
illnessEpisodes,
visits,
immunizations,
allergies,
familyHistory,
anamnesis,
@@ -516,6 +518,11 @@ export async function collectDoctorReportData(
gate.admits("VISITS")
? loadVisits(userId, start, end)
: Promise.resolve(null),
// Reference data, not windowed — the immunization history is a lifetime
// document, so the whole live set rides when the leaf and module admit it.
gate.admits("IMMUNIZATIONS")
? loadImmunizations(userId)
: Promise.resolve(null),
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
gate.admits("FAMILY_HISTORY")
? loadFamilyHistory(userId)
@@ -585,6 +592,7 @@ export async function collectDoctorReportData(
labResults,
illnessEpisodes,
visits,
immunizations,
allergies,
familyHistory,
anamnesis,
@@ -128,6 +128,32 @@ describe("computeReminderNextDueAt", () => {
// 19:00 Berlin == 17:00Z (DST).
expect(evening!.toISOString()).toBe("2026-06-10T17:00:00.000Z");
});
// A ten-year booster (tetanus, FSME, …) uses the engine's maximum rolling
// interval — 3650 days. When such a reminder is satisfied, the satisfy path
// computes the next due with `after === lastSatisfiedAt`, so the single
// rolling slot lands exactly `intervalDays` out — right on the engine's
// ten-year forward search horizon. The boundary slot must still be found;
// a decade booster whose next dose is logged must roll forward, not vanish.
it("rolling at the maximum interval (3650 days) still re-anchors on satisfy", () => {
// Mirrors `satisfyReminder`: lastSatisfiedAt and `after` are the same
// instant (the dose's day-granular occurredAt, at day start).
const satisfiedAt = new Date("2026-08-08T00:00:00Z");
const reminder = base({
intervalDays: 3650,
// Anchor from the original 2016 dose + interval — irrelevant once
// satisfied, but this is what the mint wrote.
anchorDate: new Date("2026-03-13T00:00:00Z"),
lastSatisfiedAt: satisfiedAt,
notifyHour: 9,
createdAt: new Date("2026-08-08T00:00:00Z"),
});
const due = computeReminderNextDueAt(reminder, TZ, satisfiedAt);
expect(due).not.toBeNull();
// 3650 days after 2026-08-08 → 2036-08-05, slot at 09:00 Berlin (07:00Z).
expect(due!.getUTCFullYear()).toBe(2036);
expect(due!.getTime()).toBeGreaterThan(satisfiedAt.getTime());
});
});
describe("byHourTimesOfDay", () => {
+24 -1
View File
@@ -316,7 +316,30 @@ export function nextOccurrenceAfter(
const floor = new Date(
Math.min(dayFloor.getTime(), rollingDoseDayFloor(schedule, ctx)),
);
const slots = expandRolling(schedule, ctx, floor, limit);
// The rolling slot is a single deterministic instant — `lastIntakeAt + N`
// (or the anchor) at the notify-hour — not an open-ended walk, so the
// 10-year anti-spin `hardCap` must never gate it. It collides at the
// fencepost: a reminder at the maximum ten-year rolling interval, satisfied
// so that `after === lastIntakeAt`, produces a next slot exactly `after +
// 3650 days` — equal to `hardCap` to the millisecond, so the notify-hour
// slot spills past the raw boundary and `expandRolling` clips it to nothing
// (a ten-year booster silently lost its next due on the very dose that
// should re-anchor it). Bound the search by the deterministic slot's OWN
// end-of-day. The bound must key off the applied notify-hour instant, not
// the raw anchor: when the anchor sits in the late-UTC evening its local
// notify-hour slot falls on the NEXT UTC day, so an anchor-day ceiling is a
// day short and re-clips the very slot it meant to admit. `endsOn` stays
// enforced inside `expandRolling`, so a course-window reminder still
// self-expires.
const rollingSlotInstant = applyTimeOfDayToDate(
rollingDoseAnchor(schedule, ctx),
schedule.timesOfDay[0] ?? schedule.windowStart,
ctx.timeZone,
);
const rollingCeil = new Date(
Math.max(limit.getTime(), endOfUtcDay(rollingSlotInstant).getTime()),
);
const slots = expandRolling(schedule, ctx, floor, rollingCeil);
for (const s of slots) {
if (s.at.getTime() < floor.getTime()) continue;
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
+6 -1
View File
@@ -30,7 +30,12 @@ export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
measurements: ["/measurements", "/checkups"],
medications: ["/medications"],
labs: ["/labs"],
profile: ["/profile", "/checkups"],
// `/vaccinations` joins `profile`: an immunization history is health
// background in the same sense as the visits already listed here, so a
// delegate scoped to `profile` must be able to present the page the API
// already lets them read — without this line the grant reads the data and
// the chrome hides the only page that shows it.
profile: ["/profile", "/checkups", "/vaccinations"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
+19
View File
@@ -259,6 +259,25 @@ export const encounterDependentKeys = [
queryKeys.practitioners(),
];
/**
* Keys invalidated when a dose is logged, edited, deleted, restored, linked or
* a booster is minted.
*
* The immunization root and the preventive-care root ride together, and that
* pairing is the whole reason the bundle exists rather than a bare
* `queryKeys.vaccinations()` eviction: logging a dose runs the satisfy matcher,
* which re-anchors any booster reminder the dose answers. The checkups list
* reads that reminder from `["measurement-reminders"]`, so evicting only the
* dose list would leave a booster on screen the server had already moved a
* decade out. Both surfaces change on one write; both roots evict on one call.
*
* `useVaccinations`' mutations and the booster mint are its consumers.
*/
export const vaccinationDependentKeys = [
queryKeys.vaccinations(),
queryKeys.measurementReminders(),
];
/**
* Invalidate every key in the bundle in parallel. Use this from mutation
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
+10
View File
@@ -42,4 +42,14 @@ export const vaccinationKeys = {
vaccinationList: (antigenSlug?: string | null) =>
["vaccinations", "list", antigenSlug ?? null] as const,
vaccination: (id: string) => ["vaccinations", "detail", id] as const,
/** The upload suggestion for one anchor date; the anchor is part of the key. */
vaccinationSuggestion: (anchor: string) =>
["vaccinations", "suggest", anchor] as const,
/** Mutation keys — the factory is the only legal source of these too. */
vaccinationCreate: () => ["vaccinations", "create"] as const,
vaccinationUpdate: () => ["vaccinations", "update"] as const,
vaccinationDelete: () => ["vaccinations", "delete"] as const,
vaccinationRestore: () => ["vaccinations", "restore"] as const,
vaccinationBooster: () => ["vaccinations", "booster"] as const,
vaccinationLink: () => ["vaccinations", "link"] as const,
} as const;
+4
View File
@@ -195,6 +195,10 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.vaccinationRecordDelete", { name });
case "vaccination.record.restore":
return t("recordSharing.activityVerb.vaccinationRecordRestore", { name });
case "vaccination.booster.planned":
return t("recordSharing.activityVerb.vaccinationBoosterPlanned", {
name,
});
case "practitioner.contact.update":
return t("recordSharing.activityVerb.practitionerContactUpdate", {
name,
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
expect(STRUCTURED_LEAF_IDS).toHaveLength(15);
expect(ALL_LEAF_IDS).toHaveLength(92);
expect(STRUCTURED_LEAF_IDS).toHaveLength(16);
expect(ALL_LEAF_IDS).toHaveLength(93);
});
it("resolves a label for every leaf", () => {
+8 -1
View File
@@ -47,6 +47,7 @@ export type StructuredLeafId =
| "ALLERGIES"
| "ILLNESS_EPISODES"
| "VISITS"
| "IMMUNIZATIONS"
| "FAMILY_HISTORY"
| "MOOD"
| "CYCLE"
@@ -201,13 +202,14 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
ALLERGIES: "history",
ILLNESS_EPISODES: "history",
VISITS: "history",
IMMUNIZATIONS: "history",
FAMILY_HISTORY: "sensitive",
MOOD: "sensitive",
CYCLE: "sensitive",
ANAMNESIS: "sensitive",
};
/** i18n label keys for the 15 structured leaves. */
/** i18n label keys for the 16 structured leaves. */
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
INSURANCE: "reportSelection.leafInsurance",
@@ -220,6 +222,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
ALLERGIES: "reportSelection.leafAllergies",
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
VISITS: "reportSelection.leafVisits",
IMMUNIZATIONS: "reportSelection.leafImmunizations",
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
MOOD: "reportSelection.leafMood",
CYCLE: "reportSelection.leafCycle",
@@ -309,6 +312,10 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
MEDICATION_ADMINISTRATIONS: "medications",
MEDICATION_COMPLIANCE: "medications",
GLP1_THERAPY: "medications",
// The immunization log is its own opt-out module: the leaf hides when the
// `vaccinations` module is off, and the deselected leaf hides regardless of
// the module — the two exclude independently, like `ILLNESS_EPISODES`.
IMMUNIZATIONS: "vaccinations",
// VISITS is deliberately absent, like ALLERGIES beside it. A visit carries
// no module of its own — it is core, like the checkups page it lives on —
// and the whole report is already gated by `doctorReport`. Reading the
+1
View File
@@ -38,6 +38,7 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"MEDICATION_COMPLIANCE",
"ALLERGIES",
"ILLNESS_EPISODES",
"IMMUNIZATIONS",
];
/** The template's i18n label — it is named on the button, never anonymous. */
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
import {
suggestVaccinationForDate,
vaccinationSuggestWindow,
type VaccinationSuggestionResult,
} from "../document-suggestion";
/**
* The upload suggestion must NEVER pre-select one of two candidates. That
* middle branch is the whole reason the rule returns a shape rather than a best
* guess, and it is the property WR-8 breaks. Dates are relative — a fixed date
* about "within seven days" rots the moment it slides out of the window.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
function daysAgo(days: number): Date {
return new Date(Date.now() - days * DAY_MS);
}
/** A tx whose only used member is `vaccinationRecord.findMany`, returning `rows`. */
function fakeTx(rows: Array<{ id: string; occurredAt: Date }>) {
return {
vaccinationRecord: {
findMany: async () =>
rows.map((r) => ({
id: r.id,
occurredAt: r.occurredAt,
antigenSlug: "tetanus",
vaccineName: null,
})),
},
} as never;
}
async function suggest(
rows: Array<{ id: string; occurredAt: Date }>,
): Promise<VaccinationSuggestionResult> {
return suggestVaccinationForDate(fakeTx(rows), {
userId: "u1",
anchor: daysAgo(0),
});
}
describe("the vaccination upload suggestion", () => {
it("shares the visit moment's window rather than forking it", () => {
const anchor = new Date("2024-06-15T12:00:00.000Z");
const { from, to } = vaccinationSuggestWindow(anchor);
expect((anchor.getTime() - from.getTime()) / DAY_MS).toBe(
ENCOUNTER_SUGGEST_WINDOW_DAYS,
);
expect((to.getTime() - anchor.getTime()) / DAY_MS).toBe(
ENCOUNTER_SUGGEST_WINDOW_DAYS,
);
});
it("pre-selects a single candidate", async () => {
const result = await suggest([{ id: "v1", occurredAt: daysAgo(1) }]);
expect(result.kind).toBe("one");
});
it("offers a picker with NOTHING pre-selected for two candidates (WR-8)", async () => {
const result = await suggest([
{ id: "v1", occurredAt: daysAgo(1) },
{ id: "v2", occurredAt: daysAgo(2) },
]);
// The rule returns "many", carrying no pre-selection. A verdict of "one"
// here is the pre-select-the-first-of-two failure this assertion catches.
expect(result.kind).toBe("many");
if (result.kind === "many") {
expect(result.vaccinations).toHaveLength(2);
}
});
it("offers nothing when there is no candidate", async () => {
const result = await suggest([]);
expect(result.kind).toBe("none");
});
});
+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 };
}
+107
View File
@@ -0,0 +1,107 @@
/**
* "Gehört dieser Scan zu einer Impfung?" — the same rule the visit suggestion
* uses, applied to the dose direction.
*
* A document classified `VACCINATION` arriving in the vault offers to file
* itself against a dose recorded around the same date. The rule is NOT
* re-invented here: the window is the encounter suggestion's exported constant
* (`ENCOUNTER_SUGGEST_WINDOW_DAYS`), so the two moments cannot drift to
* different windows, and the verdict shape is the same discriminated union:
*
* exactly one → pre-select it, visibly, with one undo
* two or more → offer a picker with NOTHING pre-selected
* zero → offer nothing at all
*
* The middle branch is the whole point. Pre-selecting the first of two is the
* failure that teaches a person to stop reading suggestions; this module
* returns the SHAPE of the answer, and a caller cannot collapse "many" into
* "one" without writing the collapse itself. Breaking that is the watched-red
* this moment owes (WR-8).
*
* It never blocks an upload: every caller treats the verdict as an offer, and
* the filing works ignored, dismissed, or never rendered.
*
* When the shared filing-moment module from the visits milestone lands on
* `main`, this moment registers with it and this file collapses into that
* registration; until then it holds the same spec locally, and the window
* import is what keeps the two honest.
*/
import type { Prisma } from "@/generated/prisma/client";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Above one already means "offer a picker", so the exact count past this cap changes nothing. */
const MAX_CANDIDATES = 20;
/** One candidate dose, resolved enough to render without a second round trip. */
export interface VaccinationSuggestion {
id: string;
occurredAt: string;
/** The catalogue slug when the dose carries one, for the client to name it. */
antigenSlug: string | null;
/** The person's own wording, when that is all the dose has. */
vaccineName: string | null;
}
export type VaccinationSuggestionResult =
| { kind: "one"; vaccination: VaccinationSuggestion }
| { kind: "many"; vaccinations: VaccinationSuggestion[] }
| { kind: "none" };
/** The window around an anchor, inclusive on both edges — same width as the visit moment. */
export function vaccinationSuggestWindow(anchor: Date): {
from: Date;
to: Date;
} {
const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
return {
from: new Date(anchor.getTime() - half),
to: new Date(anchor.getTime() + half),
};
}
/**
* Resolve the dose a document dated `anchor` most plausibly belongs to.
*
* `userId` is the caller's resolved record id, fed straight to the `where`;
* this module never accepts one from a request body.
*/
export async function suggestVaccinationForDate(
tx: Prisma.TransactionClient,
input: { userId: string; anchor: Date },
): Promise<VaccinationSuggestionResult> {
if (Number.isNaN(input.anchor.getTime())) return { kind: "none" };
const { from, to } = vaccinationSuggestWindow(input.anchor);
const rows = await tx.vaccinationRecord.findMany({
where: {
userId: input.userId,
deletedAt: null,
occurredAt: { gte: from, lte: to },
},
orderBy: { occurredAt: "asc" },
take: MAX_CANDIDATES,
select: {
id: true,
occurredAt: true,
antigenSlug: true,
vaccineName: true,
},
});
const vaccinations: VaccinationSuggestion[] = rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName,
}));
if (vaccinations.length === 0) return { kind: "none" };
if (vaccinations.length === 1) {
return { kind: "one", vaccination: vaccinations[0]! };
}
return { kind: "many", vaccinations };
}
+39
View File
@@ -209,3 +209,42 @@ 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>;
/* ── upload suggestion ────────────────────────────────────────────── */
/**
* The single input to the upload suggestion: the document's anchor date. No
* ids — the candidate doses come from the caller's own record, narrowed from
* the session.
*/
export const vaccinationSuggestQuerySchema = z
.object({
anchor: z.iso.datetime({ offset: true }),
})
.strict();
export type VaccinationSuggestQuery = z.infer<
typeof vaccinationSuggestQuerySchema
>;
+8
View File
@@ -433,6 +433,14 @@ export const ADMITTED_MUTATING_HANDLERS = [
"manage",
"restore",
),
handler(
"app/api/vaccinations/[id]/booster/route.ts",
"/api/vaccinations/[id]/booster",
"POST",
"profile",
"write",
"upsert",
),
handler(
"app/api/illness/episodes/[id]/day-logs/route.ts",
"/api/illness/episodes/[id]/day-logs",
@@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
describe("the complete MANAGE handler matrix", () => {
it("starts with a non-empty, exact handler inventory", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75);
});
});
@@ -1556,6 +1556,25 @@ manageContract("POST /api/vaccinations/[id]/restore", {
)?.deletedAt === null,
});
proveWriteRoute("POST /api/vaccinations/[id]/booster", {
route: "/api/vaccinations/[id]/booster",
// The dose the booster is planned against. `makeVaccination` records a
// monovalent tetanus dose, so the mint keys the reminder on the tetanus
// antigen and `applied` can prove exactly one reminder now carries it.
prepare: async (ownerId) => ({
ownerId,
vaccination: await makeVaccination(ownerId),
}),
params: ({ vaccination }) => ({ id: vaccination.id }),
body: () => ({ intervalMonths: 120, label: "Tetanus booster" }),
ok: 201,
auditAction: "vaccination.booster.planned",
applied: async ({ ownerId }) =>
(await getPrismaClient().measurementReminder.count({
where: { userId: ownerId, vaccinationAntigen: "tetanus" },
})) === 1,
});
manageContract("POST /api/illness/episodes/[id]/day-logs", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) =>
@@ -2496,13 +2515,13 @@ describe("the conditions the admissions were granted on", () => {
describe("the complete MANAGE handler matrix", () => {
it("registers one strict actor-and-effect driver for every admission", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(STRICT_DRIVER_KEYS.size).toBe(74);
expect(STRICT_DRIVER_KEYS.size).toBe(75);
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
});
it("executes every registered driver through its owned effect and actor audit", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(REAL_EFFECT_KEYS.size).toBe(74);
expect(REAL_EFFECT_KEYS.size).toBe(75);
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
});
});
@@ -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(),
);
});
});
@@ -0,0 +1,246 @@
/**
* The two record-scope properties the immunization log must PROVE rather than
* assume, through the real routes and a real Postgres.
*
* Decision 4's whole rationale is a parent keeping a child's Impfpass, so the
* managed-profile write cannot be left to inference from the route's
* `requireRecordAuth("write", "profile")` call:
*
* 1. A Guardian standing inside a managed profile writes a dose against the
* RESOLVED record — the profile's rows, not the Guardian's own — and reads
* it straight back. A positive round trip through the same handlers the UI
* calls.
* 2. A delegate holding `profile` READ on an ordinary record sees the list
* and cannot escalate to a write: the PATCH is `manage`, and a READ grant
* is refused at the door with a 403, before the record is even looked up.
*
* Both are exercised through the record-session machinery
* (`switchSessionTo`) the sibling sharing suites use, because the property is
* about the resolver's answer, not about a fixture's shape.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { POST as createVaccination } from "@/app/api/vaccinations/route";
import { GET as listVaccinations } from "@/app/api/vaccinations/route";
import {
GET as readVaccination,
PATCH as patchVaccination,
} from "@/app/api/vaccinations/[id]/route";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, 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 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);
}
let sequence = 0;
interface Person {
id: string;
sessionId: string;
}
/** An account with a session, ready to be signed in or switched. */
async function person(label: string): Promise<Person> {
const suffix = sequence++;
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `${label}-${suffix}`,
email: `${label}-${suffix}@example.test`,
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: daysFromNow(1) },
});
return { id: user.id, sessionId: session.id };
}
function signIn(who: Person): void {
headerJar.delete("authorization");
cookieJar.set("healthlog_session", who.sessionId);
}
async function postDose(body: unknown): Promise<Response> {
return createVaccination(
new Request("http://localhost/api/vaccinations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
);
}
async function getList(): Promise<Response> {
return listVaccinations(
new Request("http://localhost/api/vaccinations") as never,
);
}
async function getOne(id: string): Promise<Response> {
return readVaccination(
new Request(`http://localhost/api/vaccinations/${id}`) as never,
{ params: Promise.resolve({ id }) } as never,
);
}
async function patchOne(id: string, body: unknown): Promise<Response> {
return patchVaccination(
new Request(`http://localhost/api/vaccinations/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
{ params: Promise.resolve({ id }) } as never,
);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("a Guardian captures against the managed profile's own record", () => {
it("writes the dose to the profile, not to the Guardian, and reads it back", async () => {
const { createManagedProfile } =
await import("@/lib/managed-profiles/create");
const guardian = await person("guardian");
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Child record",
dateOfBirth: null,
locale: "en",
timezone: "Europe/Berlin",
});
// Sign in as the Guardian and step into the child's record — the exact
// state the `/vaccinations` surface runs in for a managed profile.
signIn(guardian);
await switchSessionTo(guardian.sessionId, profile.id);
const created = await postDose({
occurredAt: daysAgo(30).toISOString(),
antigenSlug: "tetanus",
lotNumber: "CHILD-LOT-1",
});
expect(created.status, JSON.stringify(await created.clone().json())).toBe(
201,
);
const doseId = ((await created.json()) as { data: { id: string } }).data.id;
const prisma = getPrismaClient();
// The row belongs to the resolved record.
const stored = await prisma.vaccinationRecord.findUniqueOrThrow({
where: { id: doseId },
});
expect(stored.userId).toBe(profile.id);
// And not to the Guardian's own account — the resolution is real, not a
// fallback to the caller.
expect(
await prisma.vaccinationRecord.count({ where: { userId: guardian.id } }),
).toBe(0);
// The list the surface renders answers with the profile's dose.
const list = await getList();
expect(list.status).toBe(200);
const rows = (
(await list.json()) as { data: { vaccinations: { id: string }[] } }
).data.vaccinations;
expect(rows.map((r) => r.id)).toContain(doseId);
});
});
describe("a profile-scoped READ delegate reads the list but cannot write", () => {
async function ownerWithDose() {
const owner = await person("owner");
const dose = await getPrismaClient().vaccinationRecord.create({
data: {
userId: owner.id,
occurredAt: daysAgo(60),
antigenSlug: "tetanus",
lotNumber: "OWNER-LOT-1",
},
});
return { owner, dose };
}
/** Sign a delegate in and switch them into `ownerId` with a READ profile grant. */
async function switchInAsReadDelegate(ownerId: string): Promise<Person> {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const delegate = await person("delegate");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegate.id,
access: "READ",
scope: ["profile"],
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
signIn(delegate);
await switchSessionTo(delegate.sessionId, ownerId);
return delegate;
}
it("lists and reads the owner's doses", async () => {
const { owner, dose } = await ownerWithDose();
await switchInAsReadDelegate(owner.id);
const list = await getList();
expect(list.status).toBe(200);
const rows = (
(await list.json()) as { data: { vaccinations: { id: string }[] } }
).data.vaccinations;
expect(rows.map((r) => r.id)).toContain(dose.id);
// The single-record read is admitted for a reader too.
expect((await getOne(dose.id)).status).toBe(200);
});
it("is refused the PATCH with a 403, and the row is untouched", async () => {
const { owner, dose } = await ownerWithDose();
await switchInAsReadDelegate(owner.id);
const res = await patchOne(dose.id, { lotNumber: "TAMPERED" });
// The escalation is refused at `requireRecordAuth("manage")`, before the
// record is even looked up — so it is a 403, not the 404 a missing row gets.
expect(res.status).toBe(403);
expect((await res.json()).meta?.errorCode).toBe("sharing.access.denied");
// The write never landed.
const after = await getPrismaClient().vaccinationRecord.findUniqueOrThrow({
where: { id: dose.id },
});
expect(after.lotNumber).toBe("OWNER-LOT-1");
});
});