diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 34d19567b..dabf15d07 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index afe1ea27a..ddbb5be4e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10145,7 +10145,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": { diff --git a/messages/en.json b/messages/en.json index 7014fc83e..96b50d64b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10145,7 +10145,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": { diff --git a/messages/es.json b/messages/es.json index f886c564b..6660cf137 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10145,7 +10145,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": { diff --git a/messages/fr.json b/messages/fr.json index 809ca5cec..00888167e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10145,7 +10145,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": { diff --git a/messages/it.json b/messages/it.json index 9efb8833e..94eb20435 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10145,7 +10145,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": { diff --git a/messages/pl.json b/messages/pl.json index 9ac705fa9..7713a3ca2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10145,7 +10145,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": { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 347983310..a9c42a979 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.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 ─────────────────────────────────────────────────────────────── { diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index d9a137b7b..57227ecca 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -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", () => { diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 6452d98b6..b9d3f9897 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -951,6 +951,14 @@ const DELEGABLE_ROUTES: Record = { 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 = { "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 = { * 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 }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index a21657ddb..97250c15b 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -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( diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index cf730b806..f7e72fbdf 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -303,6 +303,12 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "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("; diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index c442523cf..3e7b5185b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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, diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 414be182d..83535c8b4 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -193,7 +193,7 @@ describe(" — 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"); diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index 86e62b8a3..9fa6caa4e 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { toast } from "sonner"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { AlertDialog, AlertDialogAction, @@ -84,7 +85,7 @@ export function BoosterMintPrompt({ id: record.id, body: { intervalMonths: months, label: label.trim() || defaultLabel }, }); - toast.success(t("vaccinations.booster.planned")); + toastWrittenOutcome("success", t("vaccinations.booster.planned")); } catch (err) { toast.error( err instanceof Error ? err.message : t("vaccinations.booster.failed"), diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index c841cc278..ea07169aa 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -21,6 +21,7 @@ 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, @@ -68,7 +69,7 @@ export function VaccinationDocumentSuggestion({ { onSuccess: () => { setLinkedId(candidate.id); - toast.success(t("vaccinations.suggestion.linked")); + toastWrittenOutcome("success", t("vaccinations.suggestion.linked")); }, onError: () => toast.error(t("vaccinations.suggestion.failed")), }, diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 41e11e1cd..a7c6c2022 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -28,6 +28,7 @@ import { } 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 { @@ -73,11 +74,11 @@ export function VaccinationSheet({ try { if (isEdit) { await update.mutateAsync({ id: vaccination.id, body }); - toast.success(t("vaccinations.updated")); + toastWrittenOutcome("success", t("vaccinations.updated")); onOpenChange(false); } else { const created = await create.mutateAsync(body); - toast.success(t("vaccinations.created")); + toastWrittenOutcome("success", t("vaccinations.created")); onOpenChange(false); onCreated?.(created); } diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index fd279c055..61f2746bf 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -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, diff --git a/src/lib/vaccinations/__tests__/document-suggestion.test.ts b/src/lib/vaccinations/__tests__/document-suggestion.test.ts new file mode 100644 index 000000000..5b1e3290d --- /dev/null +++ b/src/lib/vaccinations/__tests__/document-suggestion.test.ts @@ -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 { + 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"); + }); +}); diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 2eeeeb2dd..37de10ffd 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -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",