mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
- the dose is expanded to its component antigens first, so one Tdap entry settles a tetanus booster and a pertussis booster at once; matching the preparation's own slug would leave both standing - it runs inside the create transaction and nowhere else: no cron, no ingest hook, and an edit never re-runs it, so correcting a typo in a date cannot move a reminder - forward-only, which is what makes transcribing an old record safe: a page from decades ago never clears a booster that is due now - a free-text-only dose matches nothing; a name string is not evidence about what was in the syringe - an ordinary preventive-care reminder carries no antigen key and is structurally out of reach
258 lines
8.5 KiB
TypeScript
258 lines
8.5 KiB
TypeScript
/**
|
|
* `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,
|
|
resolveOwnerTimezone,
|
|
resolveSeriesFor,
|
|
} from "@/lib/vaccinations/service";
|
|
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
|
|
|
|
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 timezone = await resolveOwnerTimezone(user.id);
|
|
|
|
// Filled inside the transaction, read after it for the wide event: an
|
|
// annotation is not worth holding a transaction open for.
|
|
let satisfiedReminderIds: string[] = [];
|
|
|
|
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);
|
|
|
|
// The booster arm, inside the same transaction so a dose that failed to
|
|
// save cannot leave a reminder looking settled. It runs on the OWNER's
|
|
// reminders, which is correct even when a delegate is transcribing: it is
|
|
// the owner's booster plan, and filing their Impfpass is exactly the work
|
|
// that should settle it.
|
|
const boosters = await satisfyBoostersForDose(
|
|
tx,
|
|
user.id,
|
|
entry.antigenSlug ?? null,
|
|
occurredAt,
|
|
timezone,
|
|
);
|
|
satisfiedReminderIds = boosters.satisfiedReminderIds;
|
|
if (boosters.primaryReminderId) {
|
|
await tx.vaccinationRecord.update({
|
|
where: { id: row.id },
|
|
data: { reminderId: boosters.primaryReminderId },
|
|
});
|
|
}
|
|
|
|
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,
|
|
satisfied_boosters: satisfiedReminderIds.length,
|
|
},
|
|
});
|
|
for (const reminderId of satisfiedReminderIds) {
|
|
annotate({
|
|
action: {
|
|
name: "vaccination.booster.satisfied",
|
|
entity_type: "measurement-reminder",
|
|
entity_id: reminderId,
|
|
},
|
|
meta: { antigen_slug: created.antigenSlug ?? "free-text" },
|
|
});
|
|
}
|
|
|
|
return apiSuccess(
|
|
toVaccinationDTO(created, series.get(created.id) ?? []),
|
|
201,
|
|
);
|
|
}
|