Files
HealthLog/src/lib/validations/vaccinations.ts
T
Marc-André Bombeck b4bc039d16 feat(links): close the relation matrix and publish related[] on six details
The link service gains medication and lab result sources and the five new
tables, each readable from both ends. POST/DELETE /api/medications/{id}/links
and /api/labs/{id}/links replace their 501 stubs; the visit links route also
files medications and doses, the dose links route its visit. A scan commit
with a documentId files each written result against its report.

GET on a lab result, visit, document, condition, medication and dose now
carries related[]: stored links, the condition a result shares a visit with,
and the medication whose efficacy target is the result's marker, each with
its web path. A kind is listed only when its module is on and the caller's
grant covers its section. relation-matrix.test.ts holds every one of the
fifteen pairs to a table, a reader or a written exception.
2026-10-15 23:57:34 +02:00

309 lines
12 KiB
TypeScript

/**
* Vaccination request/response validation.
*
* Source of truth for the `/api/vaccinations*` wire contract; the OpenAPI
* registry reuses these schemas so the spec stays single-source. `userId` is
* NEVER a body field — it is narrowed from the session or Bearer in every
* route and fed to the Prisma `where`.
*
* Nothing is mandatory except the date and ONE identity arm. A dose saves with
* a day and either a catalogue pick or the person's own wording, and nothing
* else: no dose number, no batch code, no arm, no practice, no document. A
* required catalogue pick would make a 1987 Pass line unloggable, which is the
* whole population this feature exists for.
*
* Two places where the API is deliberately stricter than the column:
*
* - `occurredAt` refuses a future instant. A vaccination is logged after the
* fact; a planned booster is a reminder, not a record. The visit schema
* next door allows the future because a booked appointment genuinely is
* one; a dose that has not been given is not a dose.
* - `antigenSlug` must resolve in the catalogue this release ships. Storage
* stays tolerant so a backup written by an older version restores whole
* and its slug degrades to the free-text arm on screen; a write is the one
* moment the catalogue is authoritative, because the picker produced it.
*/
import { z } from "zod/v4";
import {
ANTIGEN_SLUGS,
VACCINE_CATALOG_SLUGS,
} from "@/lib/vaccinations/vaccine-catalog";
const KNOWN_SLUGS: ReadonlySet<string> = new Set(VACCINE_CATALOG_SLUGS);
/**
* A day a dose can plausibly carry.
*
* Bounded on both sides: not before 1900, and not in the future beyond the
* clock skew a client's own "today" can plausibly show. The skew allowance is
* a day rather than minutes because the value is a DAY — a client in a
* timezone ahead of the server sends its own local date, and refusing that
* would make the form unusable east of here.
*/
const DAY_SKEW_MS = 24 * 60 * 60 * 1000;
const doseInstant = z.iso.datetime({ offset: true }).refine(
(value) => {
const at = new Date(value).getTime();
if (Number.isNaN(at)) return false;
if (at < Date.UTC(1900, 0, 1)) return false;
return at <= Date.now() + DAY_SKEW_MS;
},
{
message:
"must be a day a dose was actually given (not before 1900, not in the future — a planned booster is a reminder, not a record)",
},
);
/* ── enum (mirrors the Prisma enum) ───────────────────────────────── */
export const vaccinationSiteEnum = z.enum([
"LEFT_ARM",
"RIGHT_ARM",
"LEFT_THIGH",
"RIGHT_THIGH",
"ORAL",
"NASAL",
"OTHER",
]);
export type VaccinationSiteInput = z.infer<typeof vaccinationSiteEnum>;
const id = z.string().min(1).max(40);
/**
* A slug the catalogue this release ships actually has.
*
* The message names the field rather than listing thirty-five slugs: the
* picker is the source of a legitimate value, so a slug arriving here that is
* not in the set is a client defect or a hand-written request, and neither is
* helped by an enumeration.
*/
const antigenSlug = z
.string()
.min(1)
.max(64)
.refine((value) => KNOWN_SLUGS.has(value), {
message: "must be a slug the vaccine catalogue offers",
});
/** Bounded at the link service's own cap, so an over-long list is a 422. */
const linkIds = z.array(id).max(100).optional();
/* ── vaccination CRUD ─────────────────────────────────────────────── */
/**
* The either-arm rule, applied to whichever schema needs it.
*
* Written as a helper because create and update both need it and they need it
* to REPORT the same way: the issue is raised against both fields, so a client
* highlighting one field per issue highlights the pick and the free-text box
* rather than leaving the person guessing which of the two the server meant.
*/
const IDENTITY_MESSAGE =
"name the vaccine: pick one from the catalogue or your own vaccines, or type what the record says";
export const vaccinationCreateSchema = z
.object({
occurredAt: doseInstant,
antigenSlug: antigenSlug.nullable().optional(),
/** The Pass's own wording, verbatim. May be a trade name — user data. */
vaccineName: z.string().min(1).max(200).nullable().optional(),
doseNumber: z.number().int().min(1).max(20).nullable().optional(),
seriesDoses: z.number().int().min(1).max(10).nullable().optional(),
/** Chargenbezeichnung. A batch code, not a secret. */
lotNumber: z.string().min(1).max(64).nullable().optional(),
site: vaccinationSiteEnum.nullable().optional(),
practitionerId: id.nullable().optional(),
encounterId: id.nullable().optional(),
/**
* v1.42 (#1005) — one of the record's own vaccine definitions, the third
* identity arm. Re-narrowed to the resolved record in the route.
*/
customVaccineId: id.nullable().optional(),
/** Becomes `noteEncrypted`. Impfreaktionen live here. */
note: z.string().max(2000).nullable().optional(),
documentIds: linkIds,
})
.strict()
.superRefine((body, ctx) => {
if (body.antigenSlug || body.vaccineName || body.customVaccineId) return;
for (const path of ["antigenSlug", "vaccineName"] as const) {
ctx.addIssue({ code: "custom", path: [path], message: IDENTITY_MESSAGE });
}
});
export type VaccinationCreate = z.infer<typeof vaccinationCreateSchema>;
/**
* Edit a dose. Every field optional; an omitted field is left untouched.
*
* A body naming nothing is a 422 rather than a silent success: a PATCH that
* changed nothing and answered 200 is indistinguishable from one that worked.
*
* `occurredAt` is editable — a transcription typo is the common case, and the
* date is the field most likely to carry one. The identity rule is checked
* against the merged row in the route rather than here, because a PATCH that
* names only `lotNumber` says nothing about the identity and must not be
* refused for it.
*/
export const vaccinationUpdateSchema = z
.object({
occurredAt: doseInstant.optional(),
antigenSlug: antigenSlug.nullable().optional(),
vaccineName: z.string().min(1).max(200).nullable().optional(),
doseNumber: z.number().int().min(1).max(20).nullable().optional(),
seriesDoses: z.number().int().min(1).max(10).nullable().optional(),
lotNumber: z.string().min(1).max(64).nullable().optional(),
site: vaccinationSiteEnum.nullable().optional(),
practitionerId: id.nullable().optional(),
encounterId: id.nullable().optional(),
customVaccineId: id.nullable().optional(),
note: z.string().max(2000).nullable().optional(),
documentIds: linkIds,
})
.strict()
.refine((body) => Object.keys(body).length > 0, {
message: "name at least one field to change",
})
.refine(
(body) =>
// Only when the edit clears EVERY arm in one call. Clearing one while
// another stays is legal and the route checks the merged row.
!(
body.antigenSlug === null &&
body.vaccineName === null &&
body.customVaccineId === null
),
{ path: ["antigenSlug"], message: IDENTITY_MESSAGE },
);
export type VaccinationUpdate = z.infer<typeof vaccinationUpdateSchema>;
/**
* List query.
*
* An Impfpass is a lifetime document but a small one, so the list is full and
* bounded rather than paged: the default `take` already covers a long life,
* and a client that wants less says so.
*/
export const vaccinationListQuerySchema = z
.object({
antigenSlug: z.string().min(1).max(64).optional(),
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
})
.strict()
.refine((q) => !(q.from && q.to) || q.from <= q.to, {
path: ["to"],
message: "to must be on or after from",
});
export type VaccinationListQuery = z.infer<typeof vaccinationListQuerySchema>;
/* ── the record's own vaccines (v1.42, #1005) ─────────────────────── */
/**
* The antigens a definition protects against: only from the catalogue's
* closed antigen list, because the series derivation and the booster match
* both key on those slugs. A disease the list does not carry is a free-text
* dose, not a definition.
*/
export const customVaccineComponentsSchema = z
.array(z.enum(ANTIGEN_SLUGS))
.min(1)
.max(ANTIGEN_SLUGS.length)
.refine((list) => new Set(list).size === list.length, {
message: "name each antigen once",
});
/** The definition's name: what the person calls the product. */
const customVaccineName = z.string().trim().min(1).max(100);
export const customVaccineCreateSchema = z
.object({
name: customVaccineName,
components: customVaccineComponentsSchema,
typicalSeriesDoses: z.number().int().min(1).max(10).nullable().optional(),
/** Months; a decade booster is 120, bounded like the booster confirm. */
boosterIntervalMonths: z
.number()
.int()
.min(1)
.max(600)
.nullable()
.optional(),
})
.strict();
export type CustomVaccineCreate = z.infer<typeof customVaccineCreateSchema>;
/** Edit a definition; an omitted field is left untouched, an empty body 422s. */
export const customVaccineUpdateSchema = customVaccineCreateSchema
.partial()
.strict()
.refine((body) => Object.keys(body).length > 0, {
message: "name at least one field to change",
});
export type CustomVaccineUpdate = z.infer<typeof customVaccineUpdateSchema>;
/* ── links ────────────────────────────────────────────────────────── */
/**
* The link families a dose has: the page it was transcribed from and, since
* v1.43, the visit it was given at (`encounter_vaccination_links`, the same
* table the visit's own links route writes from its end).
*/
export const vaccinationLinkTargetEnum = z.enum(["document", "encounter"]);
export const vaccinationLinkSchema = z
.object({
targetKind: vaccinationLinkTargetEnum,
targetIds: z.array(id).min(1).max(100),
})
.strict();
export type VaccinationLinkInput = z.infer<typeof vaccinationLinkSchema>;
/* ── booster mint (rung 2) ────────────────────────────────────────── */
/**
* The confirm body for a booster reminder.
*
* Every value is the user's — accepted or edited from the catalogue's
* prefill. `intervalMonths` is bounded generously (a decade booster is 120)
* and the label is composed client-side so its locale is the person's own. No
* `vaccinationAntigen` field: the server reads the antigen from the dose's
* catalogue entry, never from the request, so a client cannot key a reminder
* onto an antigen the dose does not contain.
*/
export const vaccinationBoosterSchema = z
.object({
intervalMonths: z.number().int().min(1).max(600),
label: z.string().trim().min(1).max(120),
notifyHour: z.number().int().min(0).max(23).optional(),
})
.strict();
export type VaccinationBoosterInput = z.infer<typeof vaccinationBoosterSchema>;
/* ── upload suggestion ────────────────────────────────────────────── */
/**
* The single input to the upload suggestion: the document's anchor date. No
* ids — the candidate doses come from the caller's own record, narrowed from
* the session.
*/
export const vaccinationSuggestQuerySchema = z
.object({
anchor: z.iso.datetime({ offset: true }),
})
.strict();
export type VaccinationSuggestQuery = z.infer<
typeof vaccinationSuggestQuerySchema
>;