mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(vaccinations): add the wire schemas and the resolved DTO
- a dose needs a day and one way of naming what it was, and nothing else; a required catalogue pick would make an old Pass line unloggable - the write path refuses a future date and a slug the catalogue does not offer, while storage stays tolerant so an older backup restores whole - the DTO publishes the practitioner, the visit, the linked pages, the catalogue entry and the derived series already resolved, in numbers rather than localised text - a linked page's name and date are withheld from a delegate whose grant does not reach the vault, and the withholding says so rather than looking like a page with no name
This commit is contained in:
1 parent
e22c7ddfcc
commit
4dc3b1aa27
4 files changed
+796
No files matched your search
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Vaccination row → wire DTO.
|
||||
*
|
||||
* Everything is published resolved. The practitioner comes back as an object
|
||||
* and never as a bare id, the visit as a stub with its date and kind, the
|
||||
* document links with a label and a date, the catalogue entry as an object or
|
||||
* an explicit `null`, and — the one that matters — the SERIES already derived.
|
||||
*
|
||||
* "Dose 3 of 3" is a property of the whole history, not of the row, and it is
|
||||
* computed once on the server (`src/lib/vaccinations/series.ts`) so no client
|
||||
* re-derives it. The DTO carries numbers and a boolean, never a localised
|
||||
* string: six locales must not ride the API.
|
||||
*
|
||||
* `catalogEntry: null` is the degrade signal. It means the slug the row
|
||||
* carries is not one this release knows — a slug a past version wrote, or one
|
||||
* restored from an older backup — and the client falls back to `vaccineName`.
|
||||
* It is not an error and it is not empty data.
|
||||
*/
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type {
|
||||
Encounter,
|
||||
Practitioner,
|
||||
VaccinationRecord,
|
||||
VaccinationSite,
|
||||
} from "@/generated/prisma/client";
|
||||
import {
|
||||
toPractitionerDTO,
|
||||
type PractitionerDTO,
|
||||
} from "@/lib/practitioners/dto";
|
||||
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
|
||||
import type { SeriesPosition } from "@/lib/vaccinations/series";
|
||||
|
||||
/**
|
||||
* One page a dose was transcribed from, as the caller is allowed to see it.
|
||||
*
|
||||
* `label` and `date` go null together when the caller's grant does not cover
|
||||
* the vault. A dose lives in the health background and points into the
|
||||
* documents domain, and a grant that opened the background was never consent
|
||||
* for what is on the other side of that seam — the FILENAME of a scanned page
|
||||
* is itself the sensitive part. `redacted` says which null is a withholding
|
||||
* and which is simply a page with no date, so a client never has to guess.
|
||||
*/
|
||||
export interface VaccinationDocumentDTO {
|
||||
id: string;
|
||||
label: string | null;
|
||||
date: string | null;
|
||||
redacted: boolean;
|
||||
}
|
||||
|
||||
/** What the catalogue knows about the pick, when it still knows it. */
|
||||
export interface VaccinationCatalogDTO {
|
||||
slug: string;
|
||||
atc: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/** The visit this dose was given at, enough to recognise it by. */
|
||||
export interface VaccinationEncounterDTO {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface VaccinationDTO {
|
||||
id: string;
|
||||
/** The day, at UTC midnight. An Impfpass carries dates, never times. */
|
||||
occurredAt: string;
|
||||
/** The stored slug, verbatim — resolvable or not. */
|
||||
antigenSlug: string | null;
|
||||
/** What the person wrote. May be a trade name; it is their transcription. */
|
||||
vaccineName: string | null;
|
||||
doseNumber: number | null;
|
||||
seriesDoses: number | null;
|
||||
lotNumber: string | null;
|
||||
site: VaccinationSite | null;
|
||||
/**
|
||||
* The catalogue's view of `antigenSlug`, or `null` when this release does
|
||||
* not know the slug. Null is the signal to render `vaccineName` instead.
|
||||
*/
|
||||
catalogEntry: VaccinationCatalogDTO | null;
|
||||
/**
|
||||
* Where this dose sits in each of its antigen series, resolved server-side.
|
||||
*
|
||||
* One entry per component antigen, so a combined dose carries several. Empty
|
||||
* for a free-text-only row and for a slug the catalogue cannot resolve — no
|
||||
* antigen is guessed from a name string.
|
||||
*
|
||||
* The client renders text from these numbers: both `position` and `total` →
|
||||
* "N of M", only `position` → "dose N", `booster` → "booster". It never
|
||||
* recomputes them.
|
||||
*/
|
||||
series: SeriesPosition[];
|
||||
/** Resolved, never just an id. `null` when the dose names no practice. */
|
||||
practitioner: PractitionerDTO | null;
|
||||
/** Resolved stub, never just an id. `null` when no visit is linked. */
|
||||
encounter: VaccinationEncounterDTO | null;
|
||||
/** The booster reminder this dose satisfied, when one matched. */
|
||||
reminderId: string | null;
|
||||
note: string | null;
|
||||
/** Present on the detail response; omitted from the list. */
|
||||
documents?: VaccinationDocumentDTO[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Decrypt the dose note fail-soft: a rotation gap reads as no note, not 500. */
|
||||
function decryptNote(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(value);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`vaccination note decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type VaccinationWithRelations = VaccinationRecord & {
|
||||
practitioner?: Practitioner | null;
|
||||
encounter?: Pick<Encounter, "id" | "occurredAt" | "kind"> | null;
|
||||
};
|
||||
|
||||
export function toVaccinationDTO(
|
||||
row: VaccinationWithRelations,
|
||||
series: SeriesPosition[],
|
||||
documents?: VaccinationDocumentDTO[],
|
||||
): VaccinationDTO {
|
||||
const entry = resolveCatalogEntry(row.antigenSlug);
|
||||
return {
|
||||
id: row.id,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
antigenSlug: row.antigenSlug,
|
||||
vaccineName: row.vaccineName,
|
||||
doseNumber: row.doseNumber,
|
||||
seriesDoses: row.seriesDoses,
|
||||
lotNumber: row.lotNumber,
|
||||
site: row.site,
|
||||
catalogEntry: entry
|
||||
? { slug: entry.slug, atc: entry.atc, category: entry.category }
|
||||
: null,
|
||||
series,
|
||||
practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null,
|
||||
encounter: row.encounter
|
||||
? {
|
||||
id: row.encounter.id,
|
||||
occurredAt: row.encounter.occurredAt.toISOString(),
|
||||
kind: row.encounter.kind,
|
||||
}
|
||||
: null,
|
||||
reminderId: row.reminderId,
|
||||
note: decryptNote(row.noteEncrypted),
|
||||
...(documents ? { documents } : {}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The list response: one array, newest dose first. */
|
||||
export interface VaccinationListDTO {
|
||||
vaccinations: VaccinationDTO[];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* What the vaccination routes share: the query shapes, the ownership probes,
|
||||
* the domain redaction, and the link application.
|
||||
*
|
||||
* Extracted for the same reason `src/lib/encounters/service.ts` is: four route
|
||||
* files need the same include, the same "does the caller own this practice"
|
||||
* question and the same seam handling, and four copies of a redaction rule is
|
||||
* three chances to get it wrong.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { listTargets, replaceTargets } from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
deriveSeries,
|
||||
type SeriesInputRecord,
|
||||
} from "@/lib/vaccinations/series";
|
||||
|
||||
/** The relations every vaccination response resolves. */
|
||||
export const VACCINATION_INCLUDE = {
|
||||
practitioner: true,
|
||||
encounter: { select: { id: true, occurredAt: true, kind: true } },
|
||||
} as const satisfies Prisma.VaccinationRecordInclude;
|
||||
|
||||
/** What the series derivation needs, and nothing more. */
|
||||
export const SERIES_SELECT = {
|
||||
id: true,
|
||||
occurredAt: true,
|
||||
antigenSlug: true,
|
||||
doseNumber: true,
|
||||
seriesDoses: true,
|
||||
} as const satisfies Prisma.VaccinationRecordSelect;
|
||||
|
||||
/**
|
||||
* Which sections of the record the caller is actually inside.
|
||||
*
|
||||
* Returns a predicate rather than a set so the owner path costs nothing: with
|
||||
* no grant there is nothing to look up and every domain is open. A grant that
|
||||
* vanished between the auth check and here answers "no" to everything, which
|
||||
* is the fail-closed direction. Same shape as the visits service, deliberately
|
||||
* — the two features sit on the same seam and a second spelling of this would
|
||||
* be a second thing to keep in step.
|
||||
*/
|
||||
export async function actingDomainVisibility(
|
||||
tx: Prisma.TransactionClient,
|
||||
grantId: string | null,
|
||||
): Promise<(domain: ShareDomain) => boolean> {
|
||||
if (grantId === null) return () => true;
|
||||
const grant = await tx.accountGrant.findUnique({
|
||||
where: { id: grantId },
|
||||
select: { scopeJson: true },
|
||||
});
|
||||
if (!grant) return () => false;
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole live history for one account, in the shape the derivation wants.
|
||||
*
|
||||
* The series is a property of the set, so every read that publishes a position
|
||||
* loads the set — including the detail route, which holds one row and cannot
|
||||
* answer the question from it. The rows are small (five scalar columns) and an
|
||||
* Impfpass is a short document even after a long life, so this is cheaper than
|
||||
* the cache that would keep it consistent.
|
||||
*/
|
||||
export async function loadSeriesHistory(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<SeriesInputRecord[]> {
|
||||
return tx.vaccinationRecord.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: SERIES_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/** Every record's resolved series positions, keyed by record id. */
|
||||
export async function resolveSeriesFor(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
) {
|
||||
return deriveSeries(await loadSeriesHistory(tx, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked pages, resolved and redacted against the caller's reach.
|
||||
*
|
||||
* A dose lives in the health background and points into the document vault. A
|
||||
* grant that opened the background was never consent for the vault, and while
|
||||
* no document CONTENT crosses the seam here, the filename is itself the
|
||||
* sensitive part: a page called "oncology discharge letter" says most of what
|
||||
* the person was worried about.
|
||||
*
|
||||
* So the label and the date are withheld and the LINK is not: the caller may
|
||||
* know the dose was transcribed from a page, because the dose is theirs to
|
||||
* read. `label: null` rather than an invented placeholder, so an absence reads
|
||||
* as an absence, and `redacted` says which of the two nulls this is.
|
||||
*/
|
||||
export async function loadVaccinationDocuments(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
vaccinationId: string,
|
||||
visible: (domain: ShareDomain) => boolean,
|
||||
): Promise<VaccinationDocumentDTO[]> {
|
||||
const rows = await listTargets(tx, {
|
||||
userId,
|
||||
sourceKind: "vaccination",
|
||||
sourceId: vaccinationId,
|
||||
targetKind: "document",
|
||||
});
|
||||
if (visible("documents")) {
|
||||
return rows.map((row) => ({ ...row, redacted: false }));
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
label: null,
|
||||
date: null,
|
||||
redacted: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the document array, when the request named one.
|
||||
*
|
||||
* An absent array leaves the links alone; a present one replaces them, empty
|
||||
* array included. Unknown ids are dropped by the link service and never block
|
||||
* the save — a dose is never refused because a page it pointed at has since
|
||||
* been deleted.
|
||||
*/
|
||||
export async function applyVaccinationLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
vaccinationId: string,
|
||||
documentIds: string[] | undefined,
|
||||
): Promise<void> {
|
||||
if (documentIds === undefined) return;
|
||||
await replaceTargets(tx, {
|
||||
userId,
|
||||
sourceKind: "vaccination",
|
||||
sourceId: vaccinationId,
|
||||
targetKind: "document",
|
||||
targetIds: documentIds,
|
||||
});
|
||||
}
|
||||
|
||||
/** Does the caller own a live practitioner with this id? */
|
||||
export async function resolveOwnedPractitioner(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
practitionerId: string,
|
||||
): Promise<{ id: string } | null> {
|
||||
return tx.practitioner.findFirst({
|
||||
where: { id: practitionerId, userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Does the caller own a live visit with this id? */
|
||||
export async function resolveOwnedEncounter(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
encounterId: string,
|
||||
): Promise<{ id: string } | null> {
|
||||
return tx.encounter.findFirst({
|
||||
where: { id: encounterId, userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* The vaccination wire contract, at the boundary.
|
||||
*
|
||||
* Four properties are worth holding here rather than only at the route: the
|
||||
* either-arm rule (the one thing that IS mandatory beyond the date), the
|
||||
* future-date refusal (the split from the visit schema next door), the
|
||||
* catalogue check on write (the split from what storage accepts), and the
|
||||
* absence of `userId` (which is narrowed from auth in every route and must
|
||||
* never be something a body can name).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
vaccinationCreateSchema,
|
||||
vaccinationLinkSchema,
|
||||
vaccinationListQuerySchema,
|
||||
vaccinationUpdateSchema,
|
||||
} from "@/lib/validations/vaccinations";
|
||||
|
||||
const YESTERDAY = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const NEXT_YEAR = new Date(
|
||||
Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
function pathsOf(result: { success: false; error: { issues: unknown[] } }) {
|
||||
return (result.error.issues as Array<{ path: (string | number)[] }>).map(
|
||||
(issue) => issue.path.join("."),
|
||||
);
|
||||
}
|
||||
|
||||
describe("a dose needs a date and one way of naming what it was", () => {
|
||||
it("accepts a catalogue pick with nothing else", () => {
|
||||
const parsed = vaccinationCreateSchema.safeParse({
|
||||
occurredAt: YESTERDAY,
|
||||
antigenSlug: "tdap",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts free text with nothing else, so a 1987 Pass line is loggable", () => {
|
||||
const parsed = vaccinationCreateSchema.safeParse({
|
||||
occurredAt: YESTERDAY,
|
||||
vaccineName: "Tetanol, as the Pass wrote it",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a body with neither, naming BOTH fields", () => {
|
||||
const parsed = vaccinationCreateSchema.safeParse({
|
||||
occurredAt: YESTERDAY,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
// Both, so a client highlighting one field per issue highlights the pick
|
||||
// and the text box rather than leaving the person to guess which was
|
||||
// meant.
|
||||
expect(pathsOf(parsed).sort()).toEqual(["antigenSlug", "vaccineName"]);
|
||||
});
|
||||
|
||||
it("returns every issue at once rather than the first", () => {
|
||||
const parsed = vaccinationCreateSchema.safeParse({
|
||||
occurredAt: "not-a-date",
|
||||
doseNumber: 99,
|
||||
lotNumber: "x".repeat(200),
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a dose is logged after the fact", () => {
|
||||
it("refuses a date in the future", () => {
|
||||
const parsed = vaccinationCreateSchema.safeParse({
|
||||
occurredAt: NEXT_YEAR,
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(pathsOf(parsed)).toContain("occurredAt");
|
||||
expect(JSON.stringify(parsed.error.issues)).toMatch(
|
||||
/reminder, not a record/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a day's worth of clock skew, so a client east of here works", () => {
|
||||
const inSixHours = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString();
|
||||
expect(
|
||||
vaccinationCreateSchema.safeParse({
|
||||
occurredAt: inSixHours,
|
||||
antigenSlug: "tetanus",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a date before there were records to keep", () => {
|
||||
expect(
|
||||
vaccinationCreateSchema.safeParse({
|
||||
occurredAt: "1802-04-01T00:00:00.000Z",
|
||||
antigenSlug: "tetanus",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the write path is stricter than the column", () => {
|
||||
it("refuses a slug the catalogue does not offer", () => {
|
||||
const parsed = vaccinationCreateSchema.safeParse({
|
||||
occurredAt: YESTERDAY,
|
||||
antigenSlug: "not-a-real-antigen",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(pathsOf(parsed)).toContain("antigenSlug");
|
||||
});
|
||||
|
||||
it("accepts every slug the catalogue does offer", () => {
|
||||
for (const slug of ["tetanus", "tdap", "hexavalent", "mmrv", "cholera"]) {
|
||||
expect(
|
||||
vaccinationCreateSchema.safeParse({
|
||||
occurredAt: YESTERDAY,
|
||||
antigenSlug: slug,
|
||||
}).success,
|
||||
slug,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("no body may name the account it writes to", () => {
|
||||
it("refuses a userId field on create", () => {
|
||||
const parsed = vaccinationCreateSchema.safeParse({
|
||||
occurredAt: YESTERDAY,
|
||||
antigenSlug: "tetanus",
|
||||
userId: "somebody-else",
|
||||
});
|
||||
expect(
|
||||
parsed.success,
|
||||
"userId is narrowed from auth; a body that can name it is a body that " +
|
||||
"can write into another account",
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a userId field on update", () => {
|
||||
expect(
|
||||
vaccinationUpdateSchema.safeParse({
|
||||
lotNumber: "AB-1",
|
||||
userId: "somebody-else",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses an unknown field rather than ignoring it", () => {
|
||||
expect(
|
||||
vaccinationCreateSchema.safeParse({
|
||||
occurredAt: YESTERDAY,
|
||||
antigenSlug: "tetanus",
|
||||
manufacturer: "a trade name",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an edit names what it changes", () => {
|
||||
it("refuses a body that names nothing", () => {
|
||||
const parsed = vaccinationUpdateSchema.safeParse({});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(JSON.stringify(parsed.error.issues)).toMatch(
|
||||
/name at least one field/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows one field on its own", () => {
|
||||
expect(
|
||||
vaccinationUpdateSchema.safeParse({ lotNumber: "AB-1" }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the date to be corrected", () => {
|
||||
expect(
|
||||
vaccinationUpdateSchema.safeParse({ occurredAt: YESTERDAY }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an edit that clears both identity arms at once", () => {
|
||||
expect(
|
||||
vaccinationUpdateSchema.safeParse({
|
||||
antigenSlug: null,
|
||||
vaccineName: null,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows clearing one arm, because the other may still be there", () => {
|
||||
expect(
|
||||
vaccinationUpdateSchema.safeParse({ antigenSlug: null }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list query and the link body", () => {
|
||||
it("refuses a window that ends before it starts", () => {
|
||||
const parsed = vaccinationListQuerySchema.safeParse({
|
||||
from: "2026-07-01T00:00:00.000Z",
|
||||
to: "2026-06-01T00:00:00.000Z",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(pathsOf(parsed)).toContain("to");
|
||||
});
|
||||
|
||||
it("coerces a limit from the query string and bounds it", () => {
|
||||
expect(vaccinationListQuerySchema.safeParse({ limit: "50" })).toMatchObject(
|
||||
{
|
||||
success: true,
|
||||
data: { limit: 50 },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
vaccinationListQuerySchema.safeParse({ limit: "5000" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds a link call at the facade's own cap", () => {
|
||||
const ids = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `doc-${index}`);
|
||||
expect(
|
||||
vaccinationLinkSchema.safeParse({
|
||||
targetKind: "document",
|
||||
targetIds: ids(100),
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
vaccinationLinkSchema.safeParse({
|
||||
targetKind: "document",
|
||||
targetIds: ids(101),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("knows exactly one link family", () => {
|
||||
expect(
|
||||
vaccinationLinkSchema.safeParse({
|
||||
targetKind: "labResult",
|
||||
targetIds: ["lab-1"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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 { 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 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(),
|
||||
/** Becomes `noteEncrypted`. Impfreaktionen live here. */
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
documentIds: linkIds,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((body, ctx) => {
|
||||
if (body.antigenSlug || body.vaccineName) 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(),
|
||||
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 BOTH arms in one call. Clearing one while
|
||||
// the other stays is legal and the route checks the merged row.
|
||||
!(body.antigenSlug === null && body.vaccineName === 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>;
|
||||
|
||||
/* ── links ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The one link family a dose has.
|
||||
*
|
||||
* A single-member enum rather than a bare constant, so the wire shape matches
|
||||
* the encounter link route's and a second family — if this venture ever loses
|
||||
* the argument against one — is an added member rather than a changed shape.
|
||||
*/
|
||||
export const vaccinationLinkTargetEnum = z.enum(["document"]);
|
||||
|
||||
export const vaccinationLinkSchema = z
|
||||
.object({
|
||||
targetKind: vaccinationLinkTargetEnum,
|
||||
targetIds: z.array(id).min(1).max(100),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type VaccinationLinkInput = z.infer<typeof vaccinationLinkSchema>;
|
||||
Reference in new issue
Block a user