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:
Marc-André Bombeck committed 2026-08-08 14:09:52 +02:00
1 parent e22c7ddfcc
commit 4dc3b1aa27
4 files changed
+796

No files matched your search

+165
View File
@@ -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[];
}
+170
View File
@@ -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);
});
});
+211
View File
@@ -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>;