feat(vaccinations): publish the routes, classify them, gate the module

- four route files: list and create, read and edit and delete, restore,
  and the link pair; every delegable one is classified as health
  background, in each leg of the sharing guard, with its reason written
- link and unlink stay bare, consistent with the document and visit link
  routes; the three are revisited as one decision
- vaccinations becomes the nineteenth module key, default-on and
  surface-gated like medications: the data routes stay exempt so a
  restore keeps working and re-enabling finds every dose intact
- a delegate's dose entries get their own sentence in the owner's
  activity view rather than reading as a generic change
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:27:36 +02:00
1 parent 4dc3b1aa27
commit 7ac81f3eed
20 files changed
+853 -3

No files matched your search

+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+30 -2
View File
@@ -935,6 +935,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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<string, DelegableEntry> = {
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<string, string> = {
"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<string, ManageEntry> = {
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<string, string> = {
* 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 }) =>
@@ -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(
@@ -140,6 +140,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// 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<string> = [
// 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(";
@@ -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<Response> {
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"),
);
@@ -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));
},
);
+282
View File
@@ -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 });
},
);
+219
View File
@@ -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<Response> {
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,
);
}
@@ -53,6 +53,7 @@ import {
Pill,
Smile,
Sparkles,
Syringe,
TestTube,
Thermometer,
type LucideIcon,
@@ -110,6 +111,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
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() {
@@ -142,6 +142,7 @@ function moduleMap(
inboundDocuments: true,
mentalHealth: true,
nutrients: true,
vaccinations: true,
};
return { ...base, ...overrides };
}
+27
View File
@@ -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<Record<ModuleKey, ModuleDefinition>> =
// 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<string> = new Set(MODULE_KEYS);
+8
View File
@@ -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,
+33
View File
@@ -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",
@@ -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. */
@@ -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 () => {