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

@@ -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,
);
}