diff --git a/messages/de.json b/messages/de.json index 3b3b32bb6..13b0a7861 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Nährstoffzufuhr", "description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus." + }, + "vaccinations": { + "label": "Impfungen", + "description": "Der Impfpass in strukturierter Form: welche Dosis wann gegeben wurde, in welcher Serie sie steht und wann die nächste Auffrischung ansteht." } }, "illness": { @@ -9944,6 +9948,10 @@ "encounterVisitUpdate": "{name} hat einen Arztbesuch bearbeitet", "encounterVisitDelete": "{name} hat einen Arztbesuch gelöscht", "encounterVisitRestore": "{name} hat einen Arztbesuch wiederhergestellt", + "vaccinationRecordCreate": "{name} hat eine Impfung eingetragen", + "vaccinationRecordUpdate": "{name} hat eine Impfung bearbeitet", + "vaccinationRecordDelete": "{name} hat eine Impfung gelöscht", + "vaccinationRecordRestore": "{name} hat eine Impfung wiederhergestellt", "practitionerContactCreate": "{name} hat eine Praxis zum Adressbuch hinzugefügt", "practitionerContactUpdate": "{name} hat einen Adressbucheintrag bearbeitet", "practitionerContactDelete": "{name} hat einen Adressbucheintrag gelöscht", diff --git a/messages/en.json b/messages/en.json index e4dd15b3e..585d15d14 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Nutrient intake", "description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default." + }, + "vaccinations": { + "label": "Vaccinations", + "description": "The vaccination record in structured form: which dose was given when, where it sits in its series, and when the next booster is due." } }, "illness": { @@ -9944,6 +9948,10 @@ "encounterVisitUpdate": "{name} edited a visit", "encounterVisitDelete": "{name} deleted a visit", "encounterVisitRestore": "{name} restored a visit", + "vaccinationRecordCreate": "{name} added a vaccination", + "vaccinationRecordUpdate": "{name} edited a vaccination", + "vaccinationRecordDelete": "{name} deleted a vaccination", + "vaccinationRecordRestore": "{name} restored a vaccination", "practitionerContactCreate": "{name} added a practice to the address book", "practitionerContactUpdate": "{name} edited an address book entry", "practitionerContactDelete": "{name} deleted an address book entry", diff --git a/messages/es.json b/messages/es.json index f59d0bfaa..a966a48d7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Ingesta de nutrientes", "description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto." + }, + "vaccinations": { + "label": "Vacunas", + "description": "La cartilla de vacunación en forma estructurada: qué dosis se administró y cuándo, en qué lugar de la serie está y cuándo toca el próximo refuerzo." } }, "illness": { @@ -9944,6 +9948,10 @@ "encounterVisitUpdate": "{name} editó una visita médica", "encounterVisitDelete": "{name} eliminó una visita médica", "encounterVisitRestore": "{name} restauró una visita médica", + "vaccinationRecordCreate": "{name} añadió una vacuna", + "vaccinationRecordUpdate": "{name} editó una vacuna", + "vaccinationRecordDelete": "{name} eliminó una vacuna", + "vaccinationRecordRestore": "{name} restauró una vacuna", "practitionerContactCreate": "{name} añadió una consulta a la agenda", "practitionerContactUpdate": "{name} editó una entrada de la agenda", "practitionerContactDelete": "{name} eliminó una entrada de la agenda", diff --git a/messages/fr.json b/messages/fr.json index c385161f5..e2c24c74b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Apports en nutriments", "description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut." + }, + "vaccinations": { + "label": "Vaccinations", + "description": "Le carnet de vaccination sous forme structurée : quelle dose a été administrée et quand, sa place dans la série, et la date du prochain rappel." } }, "illness": { @@ -9944,6 +9948,10 @@ "encounterVisitUpdate": "{name} a modifié une consultation", "encounterVisitDelete": "{name} a supprimé une consultation", "encounterVisitRestore": "{name} a restauré une consultation", + "vaccinationRecordCreate": "{name} a ajouté une vaccination", + "vaccinationRecordUpdate": "{name} a modifié une vaccination", + "vaccinationRecordDelete": "{name} a supprimé une vaccination", + "vaccinationRecordRestore": "{name} a restauré une vaccination", "practitionerContactCreate": "{name} a ajouté un cabinet au carnet d'adresses", "practitionerContactUpdate": "{name} a modifié une entrée du carnet d'adresses", "practitionerContactDelete": "{name} a supprimé une entrée du carnet d'adresses", diff --git a/messages/it.json b/messages/it.json index b5e7ea5db..527bda760 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Assunzione di nutrienti", "description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita." + }, + "vaccinations": { + "label": "Vaccinazioni", + "description": "Il libretto vaccinale in forma strutturata: quale dose è stata somministrata e quando, in che punto della serie si trova e quando è previsto il prossimo richiamo." } }, "illness": { @@ -9944,6 +9948,10 @@ "encounterVisitUpdate": "{name} ha modificato una visita medica", "encounterVisitDelete": "{name} ha eliminato una visita medica", "encounterVisitRestore": "{name} ha ripristinato una visita medica", + "vaccinationRecordCreate": "{name} ha aggiunto una vaccinazione", + "vaccinationRecordUpdate": "{name} ha modificato una vaccinazione", + "vaccinationRecordDelete": "{name} ha eliminato una vaccinazione", + "vaccinationRecordRestore": "{name} ha ripristinato una vaccinazione", "practitionerContactCreate": "{name} ha aggiunto uno studio alla rubrica", "practitionerContactUpdate": "{name} ha modificato una voce della rubrica", "practitionerContactDelete": "{name} ha eliminato una voce della rubrica", diff --git a/messages/pl.json b/messages/pl.json index c754c729f..f1b6534d1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Spożycie składników odżywczych", "description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone." + }, + "vaccinations": { + "label": "Szczepienia", + "description": "Książeczka szczepień w formie uporządkowanej: która dawka i kiedy została podana, w którym miejscu serii się znajduje i kiedy przypada następne przypomnienie." } }, "illness": { @@ -9944,6 +9948,10 @@ "encounterVisitUpdate": "{name} zmienił wizytę lekarską", "encounterVisitDelete": "{name} usunął wizytę lekarską", "encounterVisitRestore": "{name} przywrócił wizytę lekarską", + "vaccinationRecordCreate": "{name} dodał szczepienie", + "vaccinationRecordUpdate": "{name} zmienił szczepienie", + "vaccinationRecordDelete": "{name} usunął szczepienie", + "vaccinationRecordRestore": "{name} przywrócił szczepienie", "practitionerContactCreate": "{name} dodał gabinet do książki adresowej", "practitionerContactUpdate": "{name} zmienił wpis w książce adresowej", "practitionerContactDelete": "{name} usunął wpis w książce adresowej", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4af6c2557..529716083 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -935,6 +935,14 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.", }, + "app/api/vaccinations/route.ts": { + domain: "profile", + why: "The record's immunization history. Health background in the same sense as the allergies and the visits already under this domain: it names no credential, no integration and no notification channel. It carries the same honest cost as those two, which is that a delegate granted `profile` to read an allergy list also sees every dose the person has had; the consent copy names the immunization history for exactly this reason. The create arm is a delegable write.", + }, + "app/api/vaccinations/[id]/route.ts": { + 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/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.", @@ -1136,6 +1144,10 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "Reopening a deleted address-book entry. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/vaccinations/[id]/restore/route.ts": { + domain: "profile", + why: "Reopening a deleted dose with the page it was transcribed from still filed against it. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/mood-entries/bulk-delete/route.ts": { domain: "mind", why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1379,6 +1391,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.", "app/api/practitioners/route.ts": "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/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": @@ -1505,6 +1519,16 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Reopening a deleted address-book entry. It does not re-attach the visits whose reference was nulled, because which visits meant this practice is not recoverable.", }, + "app/api/vaccinations/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing one dose; the delete soft-deletes and deliberately does not rewind the booster reminder the dose once cleared, because tidying a transcription is not evidence the dose never happened. C4 on the edit: the date, the antigen, the dose number and the two references are filed before and after, and the encrypted note deliberately is not. The edit never re-runs the satisfy matcher, so a delegate cannot move a booster's due date by correcting a typo.", + }, + "app/api/vaccinations/[id]/restore/route.ts": { + domain: "profile", + conditions: [], + why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.", + }, "app/api/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -1861,8 +1885,12 @@ const ACTOR_ROUTES: Record = { * read-only profile summary makes the next explicit admission: 197 → 198. * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. * The mood day's linked figures, read-only, add one: 200 → 201. + * + * v1.38.0 — the immunization log adds six: two reads and one restore on the + * record list, one create on the write literal, and the edit/delete pair plus + * the restore on the manage literal. 213 → 219. */ -const FROZEN_ENTRY_COUNT = 213; +const FROZEN_ENTRY_COUNT = 219; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2623,7 +2651,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(70); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74); 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 f18f282b5..a21657ddb 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(70); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(74); 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 b60133b32..cf730b806 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -140,6 +140,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // phone whose user never opted in cannot land rows server-side. Walking // the tree means a NEW ungated nutrients route fails BY NAME. "src/app/api/nutrients", + // v1.38.0 — the immunization log (`vaccinations` module). SURFACE-gated + // like medications, not data-layer-gated: every `/api/vaccinations/*` route + // is raw CRUD over the person's own doses, so all four are EXEMPT below + // under the same reasoning. Walking the tree means a NEW vaccination route + // must justify itself — gate or documented exemption — rather than + // silently appearing. + "src/app/api/vaccinations", ]; /** @@ -283,6 +290,19 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // rationale as glp1-timeline above: a deterministic detector over weight + // dose history, no AI narrative, medications is core/always-on. "src/app/api/insights/glp1-plateau/route.ts", + // ── DATA LAYER (vaccinations) ───────────────────────────────────── + // v1.38.0 — the immunization log is SURFACE-gated (nav entry, dashboard + // and report leaves, the picker), not data-layer-gated. Every + // `/api/vaccinations/*` route is raw CRUD over the person's own doses and + // the pages they were transcribed from — the same reasoning as mood, labs + // and medications above: the module toggle governs whether vaccinations + // SURFACE, not whether the row store accepts rows. A restore or an import + // must keep working while the module is off, and re-enabling must find + // every dose intact rather than a gap in an Impfpass nobody can rebuild. + "src/app/api/vaccinations/route.ts", + "src/app/api/vaccinations/[id]/route.ts", + "src/app/api/vaccinations/[id]/restore/route.ts", + "src/app/api/vaccinations/[id]/links/route.ts", ]; const MODULE_GATE_NEEDLE = "requireModuleEnabled("; diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts new file mode 100644 index 000000000..7a9b10442 --- /dev/null +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -0,0 +1,104 @@ +/** + * `POST /api/vaccinations/{id}/links` — file a scanned page against a dose. + * `DELETE /api/vaccinations/{id}/links` — unfile it. + * + * Deliberately bare `requireAuth()` rather than a delegable declaration, and + * the reason is consistency rather than oversight: document link and unlink + * are not delegable today, and neither is the visit link beside it. Making a + * third answer to the same question would be the drift. The set is revisited + * as one decision when the polymorphic-linking work lands. + * + * Both verbs are idempotent, both are capped, and neither ever blocks a save: + * an id that names nothing this account owns comes back in `unknown` rather + * than failing the request, because a dose must never be un-saveable because + * one page it pointed at has since been deleted. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { vaccinationLinkSchema } from "@/lib/validations/vaccinations"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { + actingDomainVisibility, + loadVaccinationDocuments, +} from "@/lib/vaccinations/service"; + +type RouteParams = { params: Promise<{ id: string }> }; + +async function handle( + request: NextRequest, + { params }: RouteParams, + verb: "link" | "unlink", +): Promise { + const { user } = await requireAuth(); + + const { id } = await params; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationLinkSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.link.invalid", + }); + } + + const apply = verb === "link" ? linkTargets : unlinkTargets; + const result = await apply(prisma, { + userId: user.id, + sourceKind: "vaccination", + sourceId: id, + targetKind: parsed.data.targetKind, + targetIds: parsed.data.targetIds, + }); + + if (result.unknownSource) { + return apiError("Vaccination not found", 404); + } + + annotate({ + action: { + name: `vaccination.link.${verb}`, + entity_type: "vaccination", + entity_id: id, + }, + meta: { + target_kind: parsed.data.targetKind, + changed: result.changed, + unknown: result.unknownTargetIds.length, + }, + }); + + // Bare `requireAuth()` resolves the caller's OWN record — this route is not + // delegable — so there is no narrowed grant to honour and every domain is + // the caller's own. + const visible = await actingDomainVisibility(prisma, null); + const documents = await loadVaccinationDocuments( + prisma, + user.id, + id, + visible, + ); + return apiSuccess({ documents, unknown: result.unknownTargetIds }); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "link"), +); + +export const DELETE = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "unlink"), +); diff --git a/src/app/api/vaccinations/[id]/restore/route.ts b/src/app/api/vaccinations/[id]/restore/route.ts new file mode 100644 index 000000000..685e61657 --- /dev/null +++ b/src/app/api/vaccinations/[id]/restore/route.ts @@ -0,0 +1,75 @@ +/** + * `POST /api/vaccinations/{id}/restore` — clear a dose's tombstone. + * + * MANAGE rather than WRITE: undoing a deletion is a management act when the + * manager is the one who holds delete. + * + * The document links survived the soft delete — they cascade only on a hard + * one — so a restored dose comes back with the page it was transcribed from + * still filed against it. The booster satisfy matcher does not run: restoring + * is not logging a dose, and the reminder it once satisfied was never rewound + * by the deletion either. + */ +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 } from "@/lib/api-response"; +import { toVaccinationDTO } from "@/lib/vaccinations/dto"; +import { + VACCINATION_INCLUDE, + actingDomainVisibility, + loadVaccinationDocuments, + resolveSeriesFor, +} from "@/lib/vaccinations/service"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.vaccinationRecord.findUnique({ + where: { id }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Vaccination not found", 404); + } + + if (existing.deletedAt !== null) { + await prisma.vaccinationRecord.update({ + where: { id }, + data: { deletedAt: null }, + }); + } + + const row = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id }, + include: VACCINATION_INCLUDE, + }); + const visible = await actingDomainVisibility(prisma, grantId); + const [documents, series] = await Promise.all([ + loadVaccinationDocuments(prisma, user.id, id, visible), + resolveSeriesFor(prisma, user.id), + ]); + + await auditLog("vaccination.record.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { vaccinationId: id, occurredAt: row.occurredAt.toISOString() }, + }); + + annotate({ + action: { + name: "vaccination.record.restore", + entity_type: "vaccination", + entity_id: id, + }, + }); + + return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + }, +); diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts new file mode 100644 index 000000000..8a7c77bf2 --- /dev/null +++ b/src/app/api/vaccinations/[id]/route.ts @@ -0,0 +1,282 @@ +/** + * `GET /api/vaccinations/{id}` — one owned, live dose with its links. + * `PATCH /api/vaccinations/{id}` — edit it (partial; field-by-field). + * `DELETE /api/vaccinations/{id}` — soft-delete it. + * + * Owner-scoped by fetch-then-guard. `userId` is narrowed from auth; the body + * never carries it. + * + * PATCH deliberately does NOT re-run the booster satisfy matcher. An edit is a + * correction of a transcription, not a new clinical event, and re-satisfying + * on edit would make `occurredAt` edits side-effectful in a way no other + * clinical model here is: fixing a typo in a date would silently push a + * booster reminder's next due date out. The side effect belongs to the act of + * logging a dose, which happens once. + */ +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 { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations"; +import { toVaccinationDTO } from "@/lib/vaccinations/dto"; +import { + VACCINATION_INCLUDE, + actingDomainVisibility, + applyVaccinationLinks, + loadVaccinationDocuments, + resolveOwnedEncounter, + resolveOwnedPractitioner, + resolveSeriesFor, +} from "@/lib/vaccinations/service"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("read", "profile"); + + const { id } = await params; + const row = await prisma.vaccinationRecord.findUnique({ + where: { id }, + include: VACCINATION_INCLUDE, + }); + if (!row || row.userId !== user.id || row.deletedAt !== null) { + return apiError("Vaccination not found", 404); + } + + const visible = await actingDomainVisibility(prisma, grantId); + const [documents, series] = await Promise.all([ + loadVaccinationDocuments(prisma, user.id, id, visible), + resolveSeriesFor(prisma, user.id), + ]); + + annotate({ + action: { + name: "vaccination.record.read", + entity_type: "vaccination", + entity_id: id, + }, + }); + + return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.vaccinationRecord.findUnique({ + where: { id }, + }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Vaccination not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 32 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.invalid", + }); + } + + const entry = parsed.data; + + // The identity rule is checked against the MERGED row rather than the + // body, because a PATCH naming only the batch code says nothing about the + // identity and must not be refused for it — while a PATCH that clears the + // one arm a record was saved with would leave a dose with no name at all. + const nextSlug = + entry.antigenSlug !== undefined + ? entry.antigenSlug + : existing.antigenSlug; + const nextName = + entry.vaccineName !== undefined + ? entry.vaccineName + : existing.vaccineName; + if (!nextSlug && !nextName) { + return apiError( + "A dose keeps a name: pick one from the catalogue or type what the record says", + 422, + { errorCode: "vaccination.identity-required" }, + ); + } + + const outcome = await prisma.$transaction(async (tx) => { + if (entry.practitionerId) { + const owned = await resolveOwnedPractitioner( + tx, + user.id, + entry.practitionerId, + ); + if (!owned) return "unknown-practitioner" as const; + } + if (entry.encounterId) { + const owned = await resolveOwnedEncounter( + tx, + user.id, + entry.encounterId, + ); + if (!owned) return "unknown-encounter" as const; + } + + const data: Prisma.VaccinationRecordUpdateInput = {}; + if (entry.occurredAt !== undefined) { + data.occurredAt = new Date(entry.occurredAt); + } + if (entry.antigenSlug !== undefined) data.antigenSlug = entry.antigenSlug; + if (entry.vaccineName !== undefined) data.vaccineName = entry.vaccineName; + if (entry.doseNumber !== undefined) data.doseNumber = entry.doseNumber; + if (entry.seriesDoses !== undefined) data.seriesDoses = entry.seriesDoses; + if (entry.lotNumber !== undefined) data.lotNumber = entry.lotNumber; + if (entry.site !== undefined) data.site = entry.site; + if (entry.note !== undefined) { + data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null; + } + if (entry.practitionerId !== undefined) { + data.practitioner = entry.practitionerId + ? { connect: { id: entry.practitionerId } } + : { disconnect: true }; + } + if (entry.encounterId !== undefined) { + data.encounter = entry.encounterId + ? { connect: { id: entry.encounterId } } + : { disconnect: true }; + } + + const updated = await tx.vaccinationRecord.update({ + where: { id }, + data, + }); + await applyVaccinationLinks(tx, user.id, id, entry.documentIds); + return { updated }; + }); + + if (outcome === "unknown-practitioner") { + return apiError("Practitioner not found", 404, { + errorCode: "vaccination.practitioner-not-found", + }); + } + if (outcome === "unknown-encounter") { + return apiError("Visit not found", 404, { + errorCode: "vaccination.encounter-not-found", + }); + } + + const row = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id }, + include: VACCINATION_INCLUDE, + }); + const visible = await actingDomainVisibility(prisma, grantId); + const [documents, series] = await Promise.all([ + loadVaccinationDocuments(prisma, user.id, id, visible), + resolveSeriesFor(prisma, user.id), + ]); + + await auditLog("vaccination.record.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: id, + // The note is deliberately absent: encrypted PHI, and the audit table + // is not a second copy of the record. + ...overwriteDetails({ + before: { + occurredAt: existing.occurredAt, + antigenSlug: existing.antigenSlug, + doseNumber: existing.doseNumber, + practitionerId: existing.practitionerId, + encounterId: existing.encounterId, + }, + after: { + occurredAt: outcome.updated.occurredAt, + antigenSlug: outcome.updated.antigenSlug, + doseNumber: outcome.updated.doseNumber, + practitionerId: outcome.updated.practitionerId, + encounterId: outcome.updated.encounterId, + }, + }), + }, + }); + + annotate({ + action: { + name: "vaccination.record.update", + entity_type: "vaccination", + entity_id: id, + }, + meta: { antigen_slug: row.antigenSlug ?? "free-text" }, + }); + + return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.vaccinationRecord.findUnique({ + where: { id }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Vaccination not found", 404); + } + + // Already tombstoned: deleting twice is a no-op that still succeeds. + // + // A booster reminder this dose satisfied is deliberately NOT re-opened. + // The reminder belongs to the person's preventive-care list and outlives + // the record; rewinding its due date because a transcription was tidied up + // would turn a correction into a nudge. + if (existing.deletedAt === null) { + await prisma.vaccinationRecord.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + } + + await auditLog("vaccination.record.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: id, + occurredAt: existing.occurredAt.toISOString(), + antigenSlug: existing.antigenSlug, + }, + }); + + annotate({ + action: { + name: "vaccination.record.delete", + entity_type: "vaccination", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts new file mode 100644 index 000000000..46b207de1 --- /dev/null +++ b/src/app/api/vaccinations/route.ts @@ -0,0 +1,219 @@ +/** + * `GET /api/vaccinations` — the account's immunization log, series resolved. + * `POST /api/vaccinations` — log one dose, optionally pre-linked to the page + * it was transcribed from. + * + * Classified `profile`: an immunization history is health background in the + * same sense as the allergy list and the visit record already under that + * domain. No credential, no integration, no outbound channel. + * + * The module gate governs the SURFACES, not this route. A disabled + * `vaccinations` module hides the nav entry, the dashboard and report leaves + * and the picker; the data routes stay reachable so a restore or an import + * keeps working and re-enabling the module finds every row intact. That is the + * `medications` posture, and the route-gate inventory records it. + * + * `userId` is narrowed from auth and fed to the Prisma `where`; it is never a + * body field. The `data` object is built field-by-field. + */ +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 { withIdempotency } from "@/lib/idempotency"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + vaccinationCreateSchema, + vaccinationListQuerySchema, +} from "@/lib/validations/vaccinations"; +import { + toVaccinationDTO, + type VaccinationListDTO, +} from "@/lib/vaccinations/dto"; +import { + VACCINATION_INCLUDE, + applyVaccinationLinks, + resolveOwnedEncounter, + resolveOwnedPractitioner, + resolveSeriesFor, +} from "@/lib/vaccinations/service"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = vaccinationListQuerySchema.safeParse({ + antigenSlug: params.get("antigenSlug") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.invalid", + }); + } + + const query = parsed.data; + // Full and bounded rather than paged: an Impfpass is a lifetime document but + // a small one, and the default already covers a long life. + const take = query.limit ?? 300; + + const [rows, series] = await Promise.all([ + prisma.vaccinationRecord.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}), + ...(query.from || query.to + ? { + occurredAt: { + ...(query.from ? { gte: new Date(query.from) } : {}), + ...(query.to ? { lte: new Date(query.to) } : {}), + }, + } + : {}), + }, + orderBy: { occurredAt: "desc" }, + take, + include: VACCINATION_INCLUDE, + }), + // Over the WHOLE live set, never over the filtered page: a position is a + // count of what came before it, so deriving it from a window would report + // the oldest dose in that window as the first one ever given. + resolveSeriesFor(prisma, user.id), + ]); + + annotate({ + action: { name: "vaccination.record.list", entity_type: "vaccination" }, + meta: { count: rows.length, filtered: Boolean(query.antigenSlug) }, + }); + + const body: VaccinationListDTO = { + vaccinations: rows.map((row) => + toVaccinationDTO(row, series.get(row.id) ?? []), + ), + }; + return apiSuccess(body); +}); + +// A double-tap on "save" must not log the same dose twice. +export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination)); + +async function postVaccination(request: NextRequest): Promise { + const { user } = await requireRecordAuth("write", "profile"); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 32 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationCreateSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "vaccination.record.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.invalid", + }); + } + + const entry = parsed.data; + const occurredAt = new Date(entry.occurredAt); + + const created = await prisma.$transaction(async (tx) => { + // Every id the body carries is re-narrowed to the resolved account before + // anything is written. A practice or a visit the caller does not own is a + // refusal rather than a silently dropped field: the person named one and + // would otherwise watch the dose save without it. + if (entry.practitionerId) { + const owned = await resolveOwnedPractitioner( + tx, + user.id, + entry.practitionerId, + ); + if (!owned) return "unknown-practitioner" as const; + } + if (entry.encounterId) { + const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId); + if (!owned) return "unknown-encounter" as const; + } + + // Field-by-field — never spread the parsed object whole. + const row = await tx.vaccinationRecord.create({ + data: { + userId: user.id, + occurredAt, + antigenSlug: entry.antigenSlug ?? null, + vaccineName: entry.vaccineName ?? null, + doseNumber: entry.doseNumber ?? null, + seriesDoses: entry.seriesDoses ?? null, + lotNumber: entry.lotNumber ?? null, + site: entry.site ?? null, + practitionerId: entry.practitionerId ?? null, + encounterId: entry.encounterId ?? null, + noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, + }, + select: { id: true }, + }); + + await applyVaccinationLinks(tx, user.id, row.id, entry.documentIds); + + return tx.vaccinationRecord.findUniqueOrThrow({ + where: { id: row.id }, + include: VACCINATION_INCLUDE, + }); + }); + + if (created === "unknown-practitioner") { + return apiError("Practitioner not found", 404, { + errorCode: "vaccination.practitioner-not-found", + }); + } + if (created === "unknown-encounter") { + return apiError("Visit not found", 404, { + errorCode: "vaccination.encounter-not-found", + }); + } + + const series = await resolveSeriesFor(prisma, user.id); + + await auditLog("vaccination.record.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: created.id, + occurredAt: created.occurredAt.toISOString(), + antigenSlug: created.antigenSlug, + // The note is deliberately absent: it is encrypted PHI and the audit + // table is not a second copy of the record. + }, + }); + + annotate({ + action: { + name: "vaccination.record.create", + entity_type: "vaccination", + entity_id: created.id, + }, + meta: { + antigen_slug: created.antigenSlug ?? "free-text", + linked_documents: entry.documentIds?.length ?? 0, + }, + }); + + return apiSuccess( + toVaccinationDTO(created, series.get(created.id) ?? []), + 201, + ); +} diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 606444432..f42a0b95f 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -53,6 +53,7 @@ import { Pill, Smile, Sparkles, + Syringe, TestTube, Thermometer, type LucideIcon, @@ -110,6 +111,8 @@ const MODULE_ICONS: Record = { mentalHealth: Brain, // v1.28 — opt-in micronutrient-intake sync (Apple Health day totals). nutrients: Leaf, + // v1.38.0 — the immunization log. + vaccinations: Syringe, }; export function ModulesSection() { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index fb6dacf08..73ca46678 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,6 +142,7 @@ function moduleMap( inboundDocuments: true, mentalHealth: true, nutrients: true, + vaccinations: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 16546d4c3..a7edcfc44 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -154,6 +154,25 @@ export const MODULE_KEYS = [ // ingest route refuses (403 `module.disabled`) and the settings card // does not render. The `optIn` marker inverts the per-user default. "nutrients", + // v1.38.0 — the immunization log (`/vaccinations`). A tracked clinical + // vertical a person may not use at all, which is exactly the shape the + // existing opt-out verticals have (`labs`, `illness`, `medications`), so it + // gets a key rather than riding somebody else's. Default-on, no `optIn` + // marker: no egress, no new attack surface — the `mentalHealth` / + // `inboundDocuments` posture. + // + // SURFACE-gated like `medications`: the nav entry, the dashboard and report + // surfaces and the picker hide, and the `/api/vaccinations*` data routes + // stay exempt so a restore / import keeps working and re-enabling finds + // every row intact. + // + // The one subtlety, stated so nobody reads it as a leak: a booster reminder + // the user CONFIRMED does not follow this off-switch. It is an ordinary + // preventive-care row on an engine that is never gated, and a reminder + // somebody agreed to keeps its promise. Turning the module off hides + // surfaces the person made content for; it does not silently retract a + // commitment the app made to them. + "vaccinations", ] as const; export type ModuleKey = (typeof MODULE_KEYS)[number]; @@ -344,6 +363,14 @@ export const MODULE_REGISTRY: Readonly> = // ingest refuses while off (refuse-ingest posture, not surface-only). optIn: true, }, + vaccinations: { + key: "vaccinations", + labelKey: "modules.vaccinations.label", + descriptionKey: "modules.vaccinations.description", + category: "tracking", + // No `optIn`: default-on. Nothing leaves the instance and the surfaces + // are inert until the person logs a dose. + }, }); const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index e64890544..fd279c055 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -74,6 +74,8 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.illnessEpisodeCreate", { name }); case "encounter.visit.create": return t("recordSharing.activityVerb.encounterVisitCreate", { name }); + case "vaccination.record.create": + return t("recordSharing.activityVerb.vaccinationRecordCreate", { name }); case "practitioner.contact.create": return t("recordSharing.activityVerb.practitionerContactCreate", { name, @@ -187,6 +189,12 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.encounterVisitDelete", { name }); case "encounter.visit.restore": return t("recordSharing.activityVerb.encounterVisitRestore", { name }); + case "vaccination.record.update": + return t("recordSharing.activityVerb.vaccinationRecordUpdate", { name }); + case "vaccination.record.delete": + return t("recordSharing.activityVerb.vaccinationRecordDelete", { name }); + case "vaccination.record.restore": + return t("recordSharing.activityVerb.vaccinationRecordRestore", { name }); case "practitioner.contact.update": return t("recordSharing.activityVerb.practitionerContactUpdate", { name, diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 929e2f76e..2eeeeb2dd 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -400,6 +400,39 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "restore", ), + // v1.38.0 — the immunization log. + handler( + "app/api/vaccinations/route.ts", + "/api/vaccinations", + "POST", + "profile", + "write", + "create", + ), + handler( + "app/api/vaccinations/[id]/route.ts", + "/api/vaccinations/[id]", + "PATCH", + "profile", + "manage", + "update", + ), + handler( + "app/api/vaccinations/[id]/route.ts", + "/api/vaccinations/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), + handler( + "app/api/vaccinations/[id]/restore/route.ts", + "/api/vaccinations/[id]/restore", + "POST", + "profile", + "manage", + "restore", + ), handler( "app/api/illness/episodes/[id]/day-logs/route.ts", "/api/illness/episodes/[id]/day-logs", diff --git a/tests/integration/doctor-report-dense-data.test.ts b/tests/integration/doctor-report-dense-data.test.ts index 6e52a4563..fac0f7a13 100644 --- a/tests/integration/doctor-report-dense-data.test.ts +++ b/tests/integration/doctor-report-dense-data.test.ts @@ -31,6 +31,7 @@ const MODULES = { inboundDocuments: true, mentalHealth: true, nutrients: true, + vaccinations: true, } as const; /** The dense path is about pulse and glucose; nothing else is in scope here. */ diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 7529ad80a..a034efd03 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -335,6 +335,7 @@ describe("doctor-report — module enable/disable gating", () => { inboundDocuments: true, mentalHealth: true, nutrients: true, + vaccinations: true, } as const; it("includes every module's data when all modules are enabled", async () => {