mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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));
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user