feat(vaccinations): link documents to doses, with the upload suggestion

- from the record: a document link picker on the capture form, gated on the
  documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
  within the shared ±7-day window; a single candidate pre-selects, two offer a
  picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
  reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
  forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 21:11:21 +02:00
1 parent b5ce44628a
commit 9b58394724
15 files changed
+551 -6

No files matched your search

+10 -1
View File
@@ -8544,7 +8544,9 @@
"site": "Impfstelle",
"siteNone": "Keine Angabe",
"practitioner": "Praxis / Arzt",
"note": "Notiz"
"note": "Notiz",
"linkDocuments": "Dokument verknüpfen",
"linkNothingToOffer": "Noch keine Dokumente vorhanden."
},
"site": {
"LEFT_ARM": "Linker Oberarm",
@@ -8596,6 +8598,13 @@
"everyMonthsFew": "alle {count} Monate",
"everyMonthsOther": "alle {count} Monate"
},
"suggestion": {
"single": "Zu dieser Impfung hinzufügen?",
"choose": "Zu welcher Impfung gehört der Scan?",
"linked": "Mit Impfung verknüpft",
"failed": "Verknüpfung fehlgeschlagen.",
"unnamed": "Impfung"
},
"empty": {
"title": "Noch keine Impfung erfasst",
"description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen."
+10 -1
View File
@@ -8544,7 +8544,9 @@
"site": "Injection site",
"siteNone": "Not specified",
"practitioner": "Practice / doctor",
"note": "Note"
"note": "Note",
"linkDocuments": "Link a document",
"linkNothingToOffer": "No documents yet."
},
"site": {
"LEFT_ARM": "Left upper arm",
@@ -8596,6 +8598,13 @@
"everyMonthsFew": "every {count} months",
"everyMonthsOther": "every {count} months"
},
"suggestion": {
"single": "File against this vaccination?",
"choose": "Which vaccination does this scan belong to?",
"linked": "Linked to vaccination",
"failed": "Linking failed.",
"unnamed": "Vaccination"
},
"empty": {
"title": "No vaccination logged yet",
"description": "Transcribe your vaccination card line by line — a date and the vaccine are enough."
+10 -1
View File
@@ -8544,7 +8544,9 @@
"site": "Lugar de aplicación",
"siteNone": "Sin especificar",
"practitioner": "Consulta / médico",
"note": "Nota"
"note": "Nota",
"linkDocuments": "Vincular un documento",
"linkNothingToOffer": "Aún no hay documentos."
},
"site": {
"LEFT_ARM": "Brazo izquierdo",
@@ -8596,6 +8598,13 @@
"everyMonthsFew": "cada {count} meses",
"everyMonthsOther": "cada {count} meses"
},
"suggestion": {
"single": "¿Asociar a esta vacuna?",
"choose": "¿A qué vacuna pertenece este escaneo?",
"linked": "Vinculado a la vacuna",
"failed": "No se pudo vincular.",
"unnamed": "Vacuna"
},
"empty": {
"title": "Aún no hay ninguna vacuna registrada",
"description": "Transcribe tu cartilla de vacunación línea por línea: bastan una fecha y la vacuna."
+10 -1
View File
@@ -8544,7 +8544,9 @@
"site": "Site d'injection",
"siteNone": "Non précisé",
"practitioner": "Cabinet / médecin",
"note": "Note"
"note": "Note",
"linkDocuments": "Associer un document",
"linkNothingToOffer": "Aucun document pour l'instant."
},
"site": {
"LEFT_ARM": "Bras gauche",
@@ -8596,6 +8598,13 @@
"everyMonthsFew": "tous les {count} mois",
"everyMonthsOther": "tous les {count} mois"
},
"suggestion": {
"single": "Associer à cette vaccination ?",
"choose": "À quelle vaccination appartient ce scan ?",
"linked": "Associé à la vaccination",
"failed": "L'association a échoué.",
"unnamed": "Vaccination"
},
"empty": {
"title": "Aucune vaccination enregistrée",
"description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent."
+10 -1
View File
@@ -8544,7 +8544,9 @@
"site": "Sede di iniezione",
"siteNone": "Non specificato",
"practitioner": "Studio / medico",
"note": "Nota"
"note": "Nota",
"linkDocuments": "Collega un documento",
"linkNothingToOffer": "Nessun documento ancora."
},
"site": {
"LEFT_ARM": "Braccio sinistro",
@@ -8596,6 +8598,13 @@
"everyMonthsFew": "ogni {count} mesi",
"everyMonthsOther": "ogni {count} mesi"
},
"suggestion": {
"single": "Collegare a questa vaccinazione?",
"choose": "A quale vaccinazione appartiene questa scansione?",
"linked": "Collegato alla vaccinazione",
"failed": "Collegamento non riuscito.",
"unnamed": "Vaccinazione"
},
"empty": {
"title": "Nessuna vaccinazione registrata",
"description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino."
+10 -1
View File
@@ -8544,7 +8544,9 @@
"site": "Miejsce podania",
"siteNone": "Nie podano",
"practitioner": "Gabinet / lekarz",
"note": "Notatka"
"note": "Notatka",
"linkDocuments": "Powiąż dokument",
"linkNothingToOffer": "Brak dokumentów."
},
"site": {
"LEFT_ARM": "Lewe ramię",
@@ -8596,6 +8598,13 @@
"everyMonthsFew": "co {count} miesiące",
"everyMonthsOther": "co {count} miesięcy"
},
"suggestion": {
"single": "Powiązać z tym szczepieniem?",
"choose": "Do którego szczepienia należy ten skan?",
"linked": "Powiązano ze szczepieniem",
"failed": "Powiązanie nie powiodło się.",
"unnamed": "Szczepienie"
},
"empty": {
"title": "Nie zarejestrowano jeszcze żadnego szczepienia",
"description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka."
+56
View File
@@ -0,0 +1,56 @@
/**
* `GET /api/vaccinations/suggest?anchor=<iso>` — which dose does a document
* dated `anchor` most plausibly belong to?
*
* The document upload review asks this when a scan is classified `VACCINATION`.
* The rule — the ±7-day window (shared with the visit moment), and the
* one-pre-selects / two-offer-a-picker verdict — lives in
* `src/lib/vaccinations/document-suggestion.ts` and is answered here so the
* browser never re-derives it.
*
* A read of the record's own doses, so it declares the same `profile` domain
* the immunization list does. `userId` is narrowed from auth; the anchor is the
* only input and it is a date, not an id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { vaccinationSuggestQuerySchema } from "@/lib/validations/vaccinations";
import { suggestVaccinationForDate } from "@/lib/vaccinations/document-suggestion";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = vaccinationSuggestQuerySchema.safeParse({
anchor: params.get("anchor") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.invalid",
});
}
const result = await suggestVaccinationForDate(prisma, {
userId: user.id,
anchor: new Date(parsed.data.anchor),
});
annotate({
action: { name: "vaccination.dose.suggest", entity_type: "vaccination" },
meta: {
verdict: result.kind,
candidates:
result.kind === "many"
? result.vaccinations.length
: result.kind === "one"
? 1
: 0,
},
});
return apiSuccess(result);
});
@@ -80,6 +80,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
@@ -1045,6 +1046,16 @@ export function DocumentDetailSheet({
onChange={setSuggestedEncounter}
/>
{/* A vaccination scan offers to file against the dose it records,
through the same ±7-day rule the visit suggestion uses. */}
{doc.kind === "VACCINATION" ? (
<VaccinationDocumentSuggestion
anchor={doc.reportDate ?? doc.documentDate}
documentId={doc.id}
disabled={!canManage}
/>
) : null}
{/* "Belongs to visit" — read-only here on purpose. The filing
happens at the moment the document arrives, or from the
visit's own sheet; a second editor for the same link would
@@ -25,6 +25,7 @@ import type {
VaccinationDTO,
VaccinationListDTO,
} from "@/lib/vaccinations/dto";
import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion";
export type Vaccination = VaccinationDTO;
@@ -116,6 +117,47 @@ export function useVaccinationMutations() {
return { create, update, remove, restore };
}
/**
* Which dose a document dated `anchor` most plausibly belongs to.
*
* The verdict is server-resolved through the shared ±7-day window, so the
* browser never re-derives which dose a scan belongs to — and the "many"
* verdict carries no pre-selection, so a caller cannot pre-select one of two.
*/
export function useVaccinationSuggestion(
anchor: string | null,
enabled = true,
) {
return useQuery({
queryKey: queryKeys.vaccinationSuggestion(anchor ?? ""),
enabled: enabled && Boolean(anchor),
queryFn: () =>
apiGet<VaccinationSuggestionResult>(
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
),
});
}
/** Link a document to a dose from the document side (the upload suggestion). */
export function useLinkDocumentToVaccination() {
const qc = useQueryClient();
return useMutation({
mutationKey: queryKeys.vaccinationLink(),
mutationFn: ({
vaccinationId,
documentId,
}: {
vaccinationId: string;
documentId: string;
}) =>
apiPost(`${BASE}/${vaccinationId}/links`, {
targetKind: "document",
targetIds: [documentId],
}),
onSuccess: () => invalidateKeys(qc, vaccinationDependentKeys),
});
}
/** What the booster confirm carries — the user's accepted-or-edited values. */
export interface BoosterMintBody {
intervalMonths: number;
@@ -0,0 +1,123 @@
"use client";
/**
* The from-the-record document link: attach the scanned page a dose was
* transcribed from.
*
* **The gate blanks the block, it does not post-filter it.** When the
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
* list — because an empty picker for a switched-off module advertises a feature
* that is not there. The same shape the visit form's link pickers use.
*
* Nothing here can block a save: the list starts empty and stays valid empty,
* and linking is optional, capped and idempotent behind the link facade.
*/
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
const OPTION_LIMIT = 25;
interface DocumentListPage {
documents: InboundDocumentDto[];
}
export function VaccinationDocumentPicker({
enabled,
documentIds,
onChange,
}: {
/** The `inboundDocuments` module flag — false blanks the block entirely. */
enabled: boolean;
documentIds: string[];
onChange: (documentIds: string[]) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const documents = useQuery({
queryKey: queryKeys.inboundDocumentPicker("vaccination-form"),
enabled,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
),
});
if (!enabled) return null;
const toggle = (id: string) =>
onChange(
documentIds.includes(id)
? documentIds.filter((entry) => entry !== id)
: [...documentIds, id],
);
const options = (documents.data?.documents ?? []).map((doc) => ({
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
}));
return (
<div
className="space-y-2 border-t pt-4"
data-slot="vaccination-document-picker"
>
<div className="flex items-center gap-2">
<FolderOpen className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">
{t("vaccinations.form.linkDocuments")}
</span>
{documentIds.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{documentIds.length}
</Badge>
) : null}
</div>
{documents.isPending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("vaccinations.form.linkNothingToOffer")}
</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
{options.map((option) => {
const on = documentIds.includes(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot="vaccination-document-option"
onClick={() => toggle(option.id)}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
{option.label}
</span>
{option.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(option.date)}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
/**
* The from-upload half of the document link: a scan classified `VACCINATION`
* offers to file itself against a dose recorded around the same date.
*
* The RULE is not here — it lives in `src/lib/vaccinations/document-suggestion.ts`
* and is resolved server-side, the same ±7-day window the visit moment uses, so
* the two cannot drift. What this owns is only how the three verdicts look:
*
* one candidate → pre-selected, visibly;
* two or more → a picker with NOTHING pre-selected;
* none → nothing at all.
*
* The middle branch is the point, and this file never picks one of a "many":
* that verdict carries no pre-selection. It never blocks the upload — linking is
* an offer, and the facade's link is idempotent, so a second tap is a no-op.
*/
import { useState } from "react";
import { Check, Syringe } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useLinkDocumentToVaccination,
useVaccinationSuggestion,
} from "./use-vaccinations";
import type { VaccinationSuggestion } from "@/lib/vaccinations/document-suggestion";
export function VaccinationDocumentSuggestion({
anchor,
documentId,
disabled,
}: {
/** `reportDate ?? documentDate`, date-only; null means nothing to ask. */
anchor: string | null;
documentId: string;
disabled?: boolean;
}) {
const { t } = useTranslations();
const format = useFormatters();
// Lift the date-only anchor to noon UTC — the rule is a ±7-day window, so the
// time never changes the verdict, and noon keeps a viewer's local midnight
// from rounding the day either way. The bare date would 422 the endpoint.
const instant = anchor ? `${anchor}T12:00:00.000Z` : null;
const suggestion = useVaccinationSuggestion(instant, !disabled);
const link = useLinkDocumentToVaccination();
const [linkedId, setLinkedId] = useState<string | null>(null);
if (disabled) return null;
const result = suggestion.data;
if (!result || result.kind === "none") return null;
const candidates =
result.kind === "one" ? [result.vaccination] : result.vaccinations;
const label = (candidate: VaccinationSuggestion) => {
const name = candidate.antigenSlug
? t(`vaccinations.catalog.${candidate.antigenSlug}`)
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
return `${name} · ${format.date(candidate.occurredAt)}`;
};
const onPick = (candidate: VaccinationSuggestion) => {
link.mutate(
{ vaccinationId: candidate.id, documentId },
{
onSuccess: () => {
setLinkedId(candidate.id);
toast.success(t("vaccinations.suggestion.linked"));
},
onError: () => toast.error(t("vaccinations.suggestion.failed")),
},
);
};
return (
<div
className="space-y-1.5"
data-slot="vaccination-document-suggestion"
data-verdict={result.kind}
data-candidates={candidates.length}
>
<p className="text-foreground flex items-center gap-2 text-sm">
<Syringe className="size-4 shrink-0" aria-hidden />
{t(
result.kind === "one"
? "vaccinations.suggestion.single"
: "vaccinations.suggestion.choose",
)}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{candidates.map((candidate) => {
const chosen = linkedId === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={chosen ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={chosen}
disabled={link.isPending || linkedId !== null}
data-slot="vaccination-document-suggestion-option"
onClick={() => onPick(candidate)}
>
{result.kind === "one" || chosen ? (
<Check
className="size-4"
aria-hidden
style={{ opacity: chosen ? 1 : 0.5 }}
/>
) : null}
{label(candidate)}
</Button>
);
})}
</div>
</div>
);
}
@@ -29,10 +29,12 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { PractitionerCombobox } from "@/components/encounters/practitioner-combobox";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Practitioner } from "@/hooks/use-practitioners";
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
export const VACCINATION_SITES = [
@@ -153,6 +155,7 @@ export function VaccinationForm({
onChange: (next: VaccinationDraft) => void;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const patch = (part: Partial<VaccinationDraft>) =>
onChange({ ...draft, ...part });
@@ -299,6 +302,12 @@ export function VaccinationForm({
onChange={(event) => patch({ note: event.target.value })}
/>
</FieldGroup>
<VaccinationDocumentPicker
enabled={user?.modules?.inboundDocuments === true}
documentIds={draft.documentIds}
onChange={(documentIds) => patch({ documentIds })}
/>
</div>
);
}
+4
View File
@@ -42,10 +42,14 @@ export const vaccinationKeys = {
vaccinationList: (antigenSlug?: string | null) =>
["vaccinations", "list", antigenSlug ?? null] as const,
vaccination: (id: string) => ["vaccinations", "detail", id] as const,
/** The upload suggestion for one anchor date; the anchor is part of the key. */
vaccinationSuggestion: (anchor: string) =>
["vaccinations", "suggest", anchor] as const,
/** Mutation keys — the factory is the only legal source of these too. */
vaccinationCreate: () => ["vaccinations", "create"] as const,
vaccinationUpdate: () => ["vaccinations", "update"] as const,
vaccinationDelete: () => ["vaccinations", "delete"] as const,
vaccinationRestore: () => ["vaccinations", "restore"] as const,
vaccinationBooster: () => ["vaccinations", "booster"] as const,
vaccinationLink: () => ["vaccinations", "link"] as const,
} as const;
+107
View File
@@ -0,0 +1,107 @@
/**
* "Gehört dieser Scan zu einer Impfung?" — the same rule the visit suggestion
* uses, applied to the dose direction.
*
* A document classified `VACCINATION` arriving in the vault offers to file
* itself against a dose recorded around the same date. The rule is NOT
* re-invented here: the window is the encounter suggestion's exported constant
* (`ENCOUNTER_SUGGEST_WINDOW_DAYS`), so the two moments cannot drift to
* different windows, and the verdict shape is the same discriminated union:
*
* exactly one → pre-select it, visibly, with one undo
* two or more → offer a picker with NOTHING pre-selected
* zero → offer nothing at all
*
* The middle branch is the whole point. Pre-selecting the first of two is the
* failure that teaches a person to stop reading suggestions; this module
* returns the SHAPE of the answer, and a caller cannot collapse "many" into
* "one" without writing the collapse itself. Breaking that is the watched-red
* this moment owes (WR-8).
*
* It never blocks an upload: every caller treats the verdict as an offer, and
* the filing works ignored, dismissed, or never rendered.
*
* When the shared filing-moment module from the visits milestone lands on
* `main`, this moment registers with it and this file collapses into that
* registration; until then it holds the same spec locally, and the window
* import is what keeps the two honest.
*/
import type { Prisma } from "@/generated/prisma/client";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Above one already means "offer a picker", so the exact count past this cap changes nothing. */
const MAX_CANDIDATES = 20;
/** One candidate dose, resolved enough to render without a second round trip. */
export interface VaccinationSuggestion {
id: string;
occurredAt: string;
/** The catalogue slug when the dose carries one, for the client to name it. */
antigenSlug: string | null;
/** The person's own wording, when that is all the dose has. */
vaccineName: string | null;
}
export type VaccinationSuggestionResult =
| { kind: "one"; vaccination: VaccinationSuggestion }
| { kind: "many"; vaccinations: VaccinationSuggestion[] }
| { kind: "none" };
/** The window around an anchor, inclusive on both edges — same width as the visit moment. */
export function vaccinationSuggestWindow(anchor: Date): {
from: Date;
to: Date;
} {
const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
return {
from: new Date(anchor.getTime() - half),
to: new Date(anchor.getTime() + half),
};
}
/**
* Resolve the dose a document dated `anchor` most plausibly belongs to.
*
* `userId` is the caller's resolved record id, fed straight to the `where`;
* this module never accepts one from a request body.
*/
export async function suggestVaccinationForDate(
tx: Prisma.TransactionClient,
input: { userId: string; anchor: Date },
): Promise<VaccinationSuggestionResult> {
if (Number.isNaN(input.anchor.getTime())) return { kind: "none" };
const { from, to } = vaccinationSuggestWindow(input.anchor);
const rows = await tx.vaccinationRecord.findMany({
where: {
userId: input.userId,
deletedAt: null,
occurredAt: { gte: from, lte: to },
},
orderBy: { occurredAt: "asc" },
take: MAX_CANDIDATES,
select: {
id: true,
occurredAt: true,
antigenSlug: true,
vaccineName: true,
},
});
const vaccinations: VaccinationSuggestion[] = rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName,
}));
if (vaccinations.length === 0) return { kind: "none" };
if (vaccinations.length === 1) {
return { kind: "one", vaccination: vaccinations[0]! };
}
return { kind: "many", vaccinations };
}
+17
View File
@@ -231,3 +231,20 @@ export const vaccinationBoosterSchema = z
.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
>;