mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge tickets part 2 into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
9d4a50282e
80 files changed
+3975
-818
No files matched your search
@@ -50,6 +50,7 @@ vi.mock("@/lib/db", () => ({
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
|
||||
// same reason as the visit tables above.
|
||||
@@ -158,6 +159,7 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.customVaccine.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -88,6 +88,7 @@ vi.mock("@/lib/db", () => ({
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock("@/lib/db", () => ({
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
|
||||
// same reason as the visit tables above.
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
VACCINATION_INCLUDE,
|
||||
applyVaccinationLinks,
|
||||
loadVaccinationDocuments,
|
||||
resolveOwnedCustomVaccine,
|
||||
resolveOwnedEncounter,
|
||||
resolveOwnedPractitioner,
|
||||
resolveSeriesFor,
|
||||
@@ -116,9 +117,13 @@ export const PATCH = apiHandler(
|
||||
entry.vaccineName !== undefined
|
||||
? entry.vaccineName
|
||||
: existing.vaccineName;
|
||||
if (!nextSlug && !nextName) {
|
||||
const nextCustom =
|
||||
entry.customVaccineId !== undefined
|
||||
? entry.customVaccineId
|
||||
: existing.customVaccineId;
|
||||
if (!nextSlug && !nextName && !nextCustom) {
|
||||
return apiError(
|
||||
"A dose keeps a name: pick one from the catalogue or type what the record says",
|
||||
"A dose keeps a name: pick one from the catalogue or your own vaccines, or type what the record says",
|
||||
422,
|
||||
{ errorCode: "vaccination.identity-required" },
|
||||
);
|
||||
@@ -141,6 +146,14 @@ export const PATCH = apiHandler(
|
||||
);
|
||||
if (!owned) return "unknown-encounter" as const;
|
||||
}
|
||||
if (entry.customVaccineId) {
|
||||
const owned = await resolveOwnedCustomVaccine(
|
||||
tx,
|
||||
user.id,
|
||||
entry.customVaccineId,
|
||||
);
|
||||
if (!owned) return "unknown-custom-vaccine" as const;
|
||||
}
|
||||
|
||||
const data: Prisma.VaccinationRecordUpdateInput = {};
|
||||
if (entry.occurredAt !== undefined) {
|
||||
@@ -165,6 +178,11 @@ export const PATCH = apiHandler(
|
||||
? { connect: { id: entry.encounterId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (entry.customVaccineId !== undefined) {
|
||||
data.customVaccine = entry.customVaccineId
|
||||
? { connect: { id: entry.customVaccineId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
const updated = await tx.vaccinationRecord.update({
|
||||
where: { id },
|
||||
@@ -184,6 +202,11 @@ export const PATCH = apiHandler(
|
||||
errorCode: "vaccination.encounter-not-found",
|
||||
});
|
||||
}
|
||||
if (outcome === "unknown-custom-vaccine") {
|
||||
return apiError("Vaccine not found", 404, {
|
||||
errorCode: "vaccination.custom-vaccine-not-found",
|
||||
});
|
||||
}
|
||||
|
||||
const row = await prisma.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id },
|
||||
@@ -209,6 +232,7 @@ export const PATCH = apiHandler(
|
||||
doseNumber: existing.doseNumber,
|
||||
practitionerId: existing.practitionerId,
|
||||
encounterId: existing.encounterId,
|
||||
customVaccineId: existing.customVaccineId,
|
||||
},
|
||||
after: {
|
||||
occurredAt: outcome.updated.occurredAt,
|
||||
@@ -216,6 +240,7 @@ export const PATCH = apiHandler(
|
||||
doseNumber: outcome.updated.doseNumber,
|
||||
practitionerId: outcome.updated.practitionerId,
|
||||
encounterId: outcome.updated.encounterId,
|
||||
customVaccineId: outcome.updated.customVaccineId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -227,7 +252,10 @@ export const PATCH = apiHandler(
|
||||
entity_type: "vaccination",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { antigen_slug: row.antigenSlug ?? "free-text" },
|
||||
meta: {
|
||||
antigen_slug:
|
||||
row.antigenSlug ?? (row.customVaccineId ? "custom" : "free-text"),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
|
||||
|
||||
@@ -1,22 +1,190 @@
|
||||
/**
|
||||
* `PATCH /api/vaccinations/custom/[id]` edits one of the record's own vaccine
|
||||
* definitions; `DELETE` soft-deletes it, leaving every dose logged against it
|
||||
* on its `vaccineName` (v1.42, #1005). Record-scoped like the dose log.
|
||||
* `PATCH /api/vaccinations/custom/{id}` — edit one of the record's own
|
||||
* vaccine definitions.
|
||||
* `DELETE /api/vaccinations/custom/{id}` — remove it (v1.42, #1005).
|
||||
*
|
||||
* Contract stub: owner-only until implemented, then answers 501. The
|
||||
* finished routes resolve the record through `requireRecordAuth` like the
|
||||
* dose log and join the sharing guard's delegable lists with their audit
|
||||
* rows; a stub cannot carry those, so it does not claim the admission.
|
||||
* MANAGE, classified `profile`, the same split as the dose edit and delete.
|
||||
*
|
||||
* An edit is a correction of the definition and applies to every dose logged
|
||||
* against it: the series those doses sit in is derived on read, so changing
|
||||
* the antigens or the series length re-reads them, exactly as a corrected
|
||||
* catalogue entry would. It deliberately does not re-run the booster satisfy
|
||||
* matcher — that belongs to the act of logging a dose, which happened once.
|
||||
*
|
||||
* The removal soft-deletes the definition and lets go of every dose that
|
||||
* named it; each dose keeps a name (`removeCustomVaccine`). Owner-scoped by
|
||||
* fetch-then-guard; `userId` is narrowed from auth and never a body field.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const PATCH = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
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 {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { customVaccineUpdateSchema } from "@/lib/validations/vaccinations";
|
||||
import { toCustomVaccineDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
CUSTOM_VACCINE_SELECT,
|
||||
findLiveNameClash,
|
||||
removeCustomVaccine,
|
||||
} from "@/lib/vaccinations/custom-vaccines";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.customVaccine.findUnique({ where: { id } });
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Vaccine not found", 404, {
|
||||
errorCode: "vaccination.custom.not-found",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = customVaccineUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.custom.invalid",
|
||||
});
|
||||
}
|
||||
const entry = parsed.data;
|
||||
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
if (
|
||||
entry.name !== undefined &&
|
||||
entry.name !== existing.name &&
|
||||
(await findLiveNameClash(tx, user.id, entry.name, id))
|
||||
) {
|
||||
return "name-taken" as const;
|
||||
}
|
||||
// A removed definition may still hold the new name on the unique
|
||||
// index. It is gone from the person's view, so it gives the name up
|
||||
// rather than blocking the rename.
|
||||
if (entry.name !== undefined && entry.name !== existing.name) {
|
||||
await tx.customVaccine.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
name: entry.name,
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
});
|
||||
}
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const data: Prisma.CustomVaccineUpdateInput = {};
|
||||
if (entry.name !== undefined) data.name = entry.name;
|
||||
if (entry.components !== undefined) data.components = entry.components;
|
||||
if (entry.typicalSeriesDoses !== undefined) {
|
||||
data.typicalSeriesDoses = entry.typicalSeriesDoses;
|
||||
}
|
||||
if (entry.boosterIntervalMonths !== undefined) {
|
||||
data.boosterIntervalMonths = entry.boosterIntervalMonths;
|
||||
}
|
||||
return tx.customVaccine.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: CUSTOM_VACCINE_SELECT,
|
||||
});
|
||||
});
|
||||
|
||||
if (outcome === "name-taken") {
|
||||
return apiError("A vaccine with this name already exists", 409, {
|
||||
errorCode: "vaccination.custom.name-taken",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("vaccination.custom.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
customVaccineId: id,
|
||||
// The name is the person's own wording: a rename is named in
|
||||
// `fields`, never quoted.
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
components: existing.components.join(","),
|
||||
typicalSeriesDoses: existing.typicalSeriesDoses,
|
||||
boosterIntervalMonths: existing.boosterIntervalMonths,
|
||||
},
|
||||
after: {
|
||||
components: outcome.components.join(","),
|
||||
typicalSeriesDoses: outcome.typicalSeriesDoses,
|
||||
boosterIntervalMonths: outcome.boosterIntervalMonths,
|
||||
},
|
||||
redacted: outcome.name !== existing.name ? ["name"] : [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.custom.update",
|
||||
entity_type: "custom-vaccine",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toCustomVaccineDTO(outcome));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.customVaccine.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, name: true, deletedAt: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Vaccine not found", 404, {
|
||||
errorCode: "vaccination.custom.not-found",
|
||||
});
|
||||
}
|
||||
|
||||
// Already removed: removing twice is a no-op that still succeeds.
|
||||
let dosesUnlinked = 0;
|
||||
if (existing.deletedAt === null) {
|
||||
dosesUnlinked = await prisma.$transaction((tx) =>
|
||||
removeCustomVaccine(tx, user.id, existing),
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("vaccination.custom.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { customVaccineId: id, dosesUnlinked },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.custom.delete",
|
||||
entity_type: "custom-vaccine",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { doses_unlinked: dosesUnlinked },
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -1,21 +1,140 @@
|
||||
/**
|
||||
* `GET /api/vaccinations/custom` lists the record's own vaccine definitions;
|
||||
* `POST` adds one (v1.42, #1005). Record-scoped like the dose log.
|
||||
* `GET /api/vaccinations/custom` — the record's own vaccine definitions.
|
||||
* `POST /api/vaccinations/custom` — add one (v1.42, #1005).
|
||||
*
|
||||
* Contract stub: owner-only until implemented, then answers 501. The
|
||||
* finished routes resolve the record through `requireRecordAuth` like the
|
||||
* dose log and join the sharing guard's delegable lists with their audit
|
||||
* rows; a stub cannot carry those, so it does not claim the admission.
|
||||
* A definition is a product the shipped catalogue does not list, described in
|
||||
* the shape a catalogue entry resolves to: a name, the antigens it protects
|
||||
* against (from the catalogue's closed antigen list), a typical series length
|
||||
* and a booster interval. A dose logged against it counts into a series,
|
||||
* clears a booster and offers one exactly the way a catalogue pick does
|
||||
* (`src/lib/vaccinations/resolve-vaccine-entry.ts`).
|
||||
*
|
||||
* Record-scoped like the dose log and classified `profile` with it: a
|
||||
* definition is part of the immunization history a delegate granted `profile`
|
||||
* already sees, names no credential, no integration and no notification
|
||||
* channel. The list is READ, the create WRITE, the same split as the doses.
|
||||
*
|
||||
* `userId` is narrowed from auth and fed to every `where`; it is never a body
|
||||
* field. The `data` object is built field-by-field.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
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 {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { customVaccineCreateSchema } from "@/lib/validations/vaccinations";
|
||||
import { toCustomVaccineDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
CUSTOM_VACCINE_SELECT,
|
||||
findLiveNameClash,
|
||||
} from "@/lib/vaccinations/custom-vaccines";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const rows = await prisma.customVaccine.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
select: CUSTOM_VACCINE_SELECT,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "vaccination.custom.list", entity_type: "custom-vaccine" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toCustomVaccineDTO));
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user, actor } = await requireRecordAuth("write", "profile");
|
||||
|
||||
// The shared per-account write ceiling, keyed on the ACTOR so a delegate
|
||||
// spends their own allowance (`checkRecordWriteRateLimit`).
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = customVaccineCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.custom.invalid",
|
||||
});
|
||||
}
|
||||
const entry = parsed.data;
|
||||
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
if (await findLiveNameClash(tx, user.id, entry.name)) {
|
||||
return "name-taken" as const;
|
||||
}
|
||||
// Names are unique per record, removed definitions included, because the
|
||||
// index is. A name the person removed and now adds again brings that row
|
||||
// back with the new values rather than failing on the index: deleting
|
||||
// unlinked every dose, so nothing re-attaches on the way back.
|
||||
const removed = await tx.customVaccine.findFirst({
|
||||
where: { userId: user.id, name: entry.name, deletedAt: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
const data = {
|
||||
name: entry.name,
|
||||
components: entry.components,
|
||||
typicalSeriesDoses: entry.typicalSeriesDoses ?? null,
|
||||
boosterIntervalMonths: entry.boosterIntervalMonths ?? null,
|
||||
};
|
||||
if (removed) {
|
||||
return tx.customVaccine.update({
|
||||
where: { id: removed.id },
|
||||
data: { ...data, deletedAt: null },
|
||||
select: CUSTOM_VACCINE_SELECT,
|
||||
});
|
||||
}
|
||||
return tx.customVaccine.create({
|
||||
data: { userId: user.id, ...data },
|
||||
select: CUSTOM_VACCINE_SELECT,
|
||||
});
|
||||
});
|
||||
|
||||
if (outcome === "name-taken") {
|
||||
return apiError("A vaccine with this name already exists", 409, {
|
||||
errorCode: "vaccination.custom.name-taken",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("vaccination.custom.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
customVaccineId: outcome.id,
|
||||
components: outcome.components,
|
||||
boosterIntervalMonths: outcome.boosterIntervalMonths,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.custom.create",
|
||||
entity_type: "custom-vaccine",
|
||||
entity_id: outcome.id,
|
||||
},
|
||||
meta: { components: outcome.components.length },
|
||||
});
|
||||
|
||||
return apiSuccess(toCustomVaccineDTO(outcome), 201);
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
applyVaccinationLinks,
|
||||
resolveOwnedCustomVaccine,
|
||||
resolveOwnedEncounter,
|
||||
resolveOwnedPractitioner,
|
||||
resolveOwnerTimezone,
|
||||
@@ -194,6 +195,14 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId);
|
||||
if (!owned) return "unknown-encounter" as const;
|
||||
}
|
||||
if (entry.customVaccineId) {
|
||||
const owned = await resolveOwnedCustomVaccine(
|
||||
tx,
|
||||
user.id,
|
||||
entry.customVaccineId,
|
||||
);
|
||||
if (!owned) return "unknown-custom-vaccine" as const;
|
||||
}
|
||||
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const row = await tx.vaccinationRecord.create({
|
||||
@@ -208,6 +217,7 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
site: entry.site ?? null,
|
||||
practitionerId: entry.practitionerId ?? null,
|
||||
encounterId: entry.encounterId ?? null,
|
||||
customVaccineId: entry.customVaccineId ?? null,
|
||||
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -223,7 +233,10 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
const boosters = await satisfyBoostersForDose(
|
||||
tx,
|
||||
user.id,
|
||||
entry.antigenSlug ?? null,
|
||||
{
|
||||
antigenSlug: entry.antigenSlug ?? null,
|
||||
customVaccineId: entry.customVaccineId ?? null,
|
||||
},
|
||||
occurredAt,
|
||||
timezone,
|
||||
);
|
||||
@@ -251,6 +264,11 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
errorCode: "vaccination.encounter-not-found",
|
||||
});
|
||||
}
|
||||
if (created === "unknown-custom-vaccine") {
|
||||
return apiError("Vaccine not found", 404, {
|
||||
errorCode: "vaccination.custom-vaccine-not-found",
|
||||
});
|
||||
}
|
||||
|
||||
const series = await resolveSeriesFor(prisma, user.id);
|
||||
|
||||
@@ -261,6 +279,7 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
vaccinationId: created.id,
|
||||
occurredAt: created.occurredAt.toISOString(),
|
||||
antigenSlug: created.antigenSlug,
|
||||
customVaccineId: created.customVaccineId,
|
||||
// The note is deliberately absent: it is encrypted PHI and the audit
|
||||
// table is not a second copy of the record.
|
||||
},
|
||||
@@ -273,7 +292,9 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: {
|
||||
antigen_slug: created.antigenSlug ?? "free-text",
|
||||
antigen_slug:
|
||||
created.antigenSlug ??
|
||||
(created.customVaccineId ? "custom" : "free-text"),
|
||||
linked_documents: entry.documentIds?.length ?? 0,
|
||||
satisfied_boosters: satisfiedReminderIds.length,
|
||||
},
|
||||
@@ -285,7 +306,11 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
entity_type: "measurement-reminder",
|
||||
entity_id: reminderId,
|
||||
},
|
||||
meta: { antigen_slug: created.antigenSlug ?? "free-text" },
|
||||
meta: {
|
||||
antigen_slug:
|
||||
created.antigenSlug ??
|
||||
(created.customVaccineId ? "custom" : "free-text"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user