diff --git a/messages/de.json b/messages/de.json index 710817775..3c61e9416 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." diff --git a/messages/en.json b/messages/en.json index c6cd99c51..36f7d3bc3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index c290bc2c9..b44149579 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." diff --git a/messages/fr.json b/messages/fr.json index 9183755d0..23131d60b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index 1c0aec1f7..6dd96ed24 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/pl.json b/messages/pl.json index 39df4b3d6..a02bacdf2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." diff --git a/src/app/api/vaccinations/suggest/route.ts b/src/app/api/vaccinations/suggest/route.ts new file mode 100644 index 000000000..fd6288465 --- /dev/null +++ b/src/app/api/vaccinations/suggest/route.ts @@ -0,0 +1,56 @@ +/** + * `GET /api/vaccinations/suggest?anchor=` — 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); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index b2459a76f..2d947998b 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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" ? ( + + ) : 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 diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 9641a3479..947a97536 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -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( + `${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; diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx new file mode 100644 index 000000000..0246172a7 --- /dev/null +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -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( + `/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 ( +
+
+ + + {t("vaccinations.form.linkDocuments")} + + {documentIds.length > 0 ? ( + + {documentIds.length} + + ) : null} +
+ {documents.isPending ? ( + + ) : options.length === 0 ? ( +

+ {t("vaccinations.form.linkNothingToOffer")} +

+ ) : ( +
    + {options.map((option) => { + const on = documentIds.includes(option.id); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx new file mode 100644 index 000000000..c841cc278 --- /dev/null +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -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(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 ( +
+

+ + {t( + result.kind === "one" + ? "vaccinations.suggestion.single" + : "vaccinations.suggestion.choose", + )} +

+
+ {candidates.map((candidate) => { + const chosen = linkedId === candidate.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index df1880a4e..c9b5885d9 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -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) => onChange({ ...draft, ...part }); @@ -299,6 +302,12 @@ export function VaccinationForm({ onChange={(event) => patch({ note: event.target.value })} /> + + patch({ documentIds })} + /> ); } diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts index 9d399db02..6271cc3fc 100644 --- a/src/lib/query-keys/vaccinations.ts +++ b/src/lib/query-keys/vaccinations.ts @@ -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; diff --git a/src/lib/vaccinations/document-suggestion.ts b/src/lib/vaccinations/document-suggestion.ts new file mode 100644 index 000000000..e123b83c4 --- /dev/null +++ b/src/lib/vaccinations/document-suggestion.ts @@ -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 { + 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 }; +} diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 51ca6fce4..4de5d2ee4 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -231,3 +231,20 @@ export const vaccinationBoosterSchema = z .strict(); export type VaccinationBoosterInput = z.infer; + +/* ── 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 +>;