Merge tickets part 2 into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 13:07:02 +02:00
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.
+31 -3
View File
@@ -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));
+185 -17
View File
@@ -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 });
},
);
+132 -13
View File
@@ -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);
});
+28 -3
View File
@@ -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"),
},
});
}