mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #751 from MBombeck/feat/vaccinations-phase-2
Vaccinations: the immunization record, visible and reachable
This commit is contained in:
60 files changed
+4764
-36
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user