feat(encounters): one suggestion rule for every filing moment

A document arriving in the vault and a lab panel being committed both ask the
same question — does this belong to a visit? — and the answer has to be the
same question asked once. The rule lives in one module with one exported
window constant: the caller's live DONE or PLANNED visits within seven days of
an anchor date, resolved into a verdict rather than a list.

One candidate pre-selects, visibly, with one undo. Two or more offer a picker
with nothing pre-selected: pre-selecting the first of two is what teaches a
person to stop reading suggestions, and the return type has no field on that
arm a caller could mistake for a choice. Zero offers nothing.

Cancelled visits and no-shows are not candidates. Neither happened, so neither
produced the letter or the panel being filed, and admitting them would widen
the set with rows that are wrong most of the time — which pushes the honest
single match into the picker branch.

The verdict is served over HTTP because all three moments live in the browser.
Handing the client a windowed list and letting it decide would put the rule in
two places, and the copy in the browser is the one nobody re-reads.

- suggest-window.ts with the window as an exported constant, not a literal
- GET /api/encounters/suggest, profile domain, reviewed into the frozen literal
- OpenAPI regenerated
- fixtures are relative to the run instant; a fixed date slides out of a
  relative window and rots the test into a false green
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 12:43:51 +02:00
1 parent 46dc31d10e
commit 467e83d639
8 files changed
+667 -1

No files matched your search

+123
View File
@@ -9825,6 +9825,36 @@ paths:
"403": *a6
"422": *a3
"429": *a4
/api/encounters/suggest:
get:
tags:
- Records
summary: Which visit does a record dated around this day belong to?
description: "Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three
capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab
panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means
offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop
reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates:
neither happened, so neither produced the record being filed. No AI provider is involved and none is required."
parameters:
- in: query
name: anchor
schema:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required: true
responses:
"200":
description: The verdict.
content:
application/json:
schema:
$ref: "#/components/schemas/SuggestEncounterEnvelope"
"401": *a1
"403": *a6
"422": *a3
"429": *a4
/api/encounters/{id}:
get:
tags:
@@ -34923,6 +34953,99 @@ components:
- data
- error
additionalProperties: false
SuggestEncounterEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/EncounterSuggestion"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
EncounterSuggestion:
oneOf:
- type: object
properties:
kind:
type: string
const: one
encounter:
$ref: "#/components/schemas/EncounterSuggestionCandidate"
required:
- kind
- encounter
additionalProperties: false
- type: object
properties:
kind:
type: string
const: many
encounters:
type: array
items:
$ref: "#/components/schemas/EncounterSuggestionCandidate"
required:
- kind
- encounters
additionalProperties: false
- type: object
properties:
kind:
type: string
const: none
required:
- kind
additionalProperties: false
description: "The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a
client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to
make impossible."
type: object
EncounterSuggestionCandidate:
type: object
properties:
id:
type: string
occurredAt:
type: string
status:
type: string
enum:
- PLANNED
- DONE
- CANCELLED
- NO_SHOW
kind:
type: string
enum:
- ROUTINE
- ACUTE
- SPECIALIST
- PREVENTIVE
- EMERGENCY
- HOSPITAL
- THERAPY
- OTHER
practitionerName:
anyOf:
- type: string
- type: "null"
required:
- id
- occurredAt
- status
- kind
- practitionerName
additionalProperties: false
description: One visit a record might belong to, resolved far enough to render a row without a second round trip.
`practitionerName` is null when the visit names no practice, which is an absence rather than a gap.
GetEncounterEnvelope:
type: object
properties:
+7 -1
View File
@@ -935,6 +935,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
},
"app/api/encounters/suggest/route.ts": {
domain: "profile",
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1861,8 +1865,10 @@ const ACTOR_ROUTES: Record<string, string> = {
* read-only profile summary makes the next explicit admission: 197 → 198.
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
* The mood day's linked figures, read-only, add one: 200 → 201.
* The visit-suggestion read, which serves the three incidental-linking
* moments from the same rule, adds one: 213 → 214.
*/
const FROZEN_ENTRY_COUNT = 213;
const FROZEN_ENTRY_COUNT = 214;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+59
View File
@@ -0,0 +1,59 @@
/**
* `GET /api/encounters/suggest?anchor=<iso>` — which visit does a record dated
* `anchor` most plausibly belong to?
*
* The three capture moments that ask this question live in the browser: the
* document upload review, the lab extraction review and the manual lab form.
* The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and
* the one-pre-selects / two-offer-a-picker verdict — lives in
* `src/lib/encounters/suggest-window.ts` and is answered here so all three read
* the same answer. Handing the client a windowed list and letting it decide
* would put the rule in two places, and the copy in the browser is the one
* nobody re-reads when the rule changes.
*
* A read of the record's own visits, so it declares the same domain the visit
* 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 { encounterSuggestQuerySchema } from "@/lib/validations/encounters";
import { suggestEncounterForDate } from "@/lib/encounters/suggest-window";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = encounterSuggestQuerySchema.safeParse({
anchor: params.get("anchor") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const result = await suggestEncounterForDate(prisma, {
userId: user.id,
anchor: new Date(parsed.data.anchor),
});
annotate({
action: { name: "encounter.visit.suggest", entity_type: "encounter" },
meta: {
verdict: result.kind,
candidates:
result.kind === "many"
? result.encounters.length
: result.kind === "one"
? 1
: 0,
},
});
return apiSuccess(result);
});
@@ -0,0 +1,244 @@
/**
* The suggestion rule, pinned.
*
* Two properties carry the whole feature and both are easy to break without
* noticing:
*
* - the window is ±7 days and not "recently";
* - two candidates offer a picker with nothing pre-selected, and never the
* first one.
*
* Every fixture is RELATIVE to the moment the test runs (`now − 3 days`, never
* `2026-03-14`). A fixed-date fixture slides out of a relative window as the
* calendar moves and the test rots into a false green; this repository has
* shipped that bug before.
*
* The fake below honours the `where` it is given. A fake that ignores it would
* return every seeded row for every query, so the window assertions would pass
* against a module with no window at all — a check that cannot fail is worse
* than none.
*
* Mutation checks (both run, both seen red):
* - widen `ENCOUNTER_SUGGEST_WINDOW_DAYS` to 90 → the out-of-window cases go
* red naming the 30-day visit that started matching;
* - make the two-candidate branch return `{ kind: "one" }` with the first row
* → "offers a picker with nothing pre-selected" goes red.
*/
import { describe, it, expect } from "vitest";
import {
ENCOUNTER_SUGGEST_WINDOW_DAYS,
suggestEncounterForDate,
} from "../suggest-window";
import type { Prisma } from "@/generated/prisma/client";
const DAY_MS = 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
interface Row {
id: string;
userId: string;
occurredAt: Date;
status: string;
kind: string;
deletedAt: Date | null;
practitioner: { name: string } | null;
}
function row(over: Partial<Row> & { id: string; occurredAt: Date }): Row {
return {
userId: "u1",
status: "DONE",
kind: "ROUTINE",
deletedAt: null,
practitioner: null,
...over,
};
}
/**
* An in-memory `encounter.findMany` that actually applies the filter.
*
* It evaluates every clause the module writes — the record id, the tombstone,
* the status set and both window edges — so a module that dropped one of them
* returns rows this fake would have excluded, and the assertion below fails.
*/
function fakeTx(rows: Row[]): Prisma.TransactionClient {
return {
encounter: {
findMany: async (args: {
where: {
userId: string;
deletedAt: null;
status: { in: string[] };
occurredAt: { gte: Date; lte: Date };
};
take?: number;
}) => {
const { where } = args;
const matched = rows
.filter((r) => r.userId === where.userId)
.filter((r) =>
where.deletedAt === null ? r.deletedAt === null : true,
)
.filter((r) => where.status.in.includes(r.status))
.filter(
(r) =>
r.occurredAt.getTime() >= where.occurredAt.gte.getTime() &&
r.occurredAt.getTime() <= where.occurredAt.lte.getTime(),
)
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
return matched.slice(0, args.take ?? matched.length);
},
},
} as unknown as Prisma.TransactionClient;
}
const anchor = new Date();
const ago = (days: number, extraMs = 0) =>
new Date(anchor.getTime() - days * DAY_MS - extraMs);
const ahead = (days: number, extraMs = 0) =>
new Date(anchor.getTime() + days * DAY_MS + extraMs);
describe("the window", () => {
it("is seven days wide on each side", () => {
expect(ENCOUNTER_SUGGEST_WINDOW_DAYS).toBe(7);
});
it("admits a visit exactly seven days before the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(7) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: expect.objectContaining({ id: "e1" }),
});
});
it("admits a visit exactly seven days after the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ahead(7) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: expect.objectContaining({ id: "e1" }),
});
});
it("refuses a visit seven days and one minute before the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(7, MINUTE_MS) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("refuses a visit seven days and one minute after the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ahead(7, MINUTE_MS) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("refuses a visit thirty days out", async () => {
// The widened-window mutation lands here first: at ±90 days this row
// starts matching and the verdict stops being "none".
const result = await suggestEncounterForDate(
fakeTx([row({ id: "far", occurredAt: ahead(30) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
});
describe("the verdict", () => {
it("offers nothing when there is no candidate", async () => {
const result = await suggestEncounterForDate(fakeTx([]), {
userId: "u1",
anchor,
});
expect(result).toEqual({ kind: "none" });
});
it("pre-selects the single candidate, resolved", async () => {
const result = await suggestEncounterForDate(
fakeTx([
row({
id: "e1",
occurredAt: ago(3),
kind: "SPECIALIST",
practitioner: { name: "Praxis am Park" },
}),
]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: {
id: "e1",
occurredAt: ago(3).toISOString(),
status: "DONE",
kind: "SPECIALIST",
practitionerName: "Praxis am Park",
},
});
});
it("offers a picker with nothing pre-selected for two candidates", async () => {
const result = await suggestEncounterForDate(
fakeTx([
row({ id: "e1", occurredAt: ago(3) }),
row({ id: "e2", occurredAt: ago(1) }),
]),
{ userId: "u1", anchor },
);
// The shape is the assertion: there is no field on a "many" verdict a
// caller could read as a pre-selection.
expect(result.kind).toBe("many");
expect(result).not.toHaveProperty("encounter");
if (result.kind !== "many") throw new Error("unreachable");
expect(result.encounters.map((e) => e.id)).toEqual(["e1", "e2"]);
});
});
describe("what is never a candidate", () => {
it("a soft-deleted visit", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1), deletedAt: new Date() })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("a cancelled visit, and a no-show", async () => {
// Neither happened, so neither produced the record being filed. Admitting
// them would widen the set with rows that are wrong most of the time.
const result = await suggestEncounterForDate(
fakeTx([
row({ id: "e1", occurredAt: ago(1), status: "CANCELLED" }),
row({ id: "e2", occurredAt: ago(2), status: "NO_SHOW" }),
]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("another account's visit", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1), userId: "u2" })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("an unusable anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1) })]),
{ userId: "u1", anchor: new Date("not a date") },
);
expect(result).toEqual({ kind: "none" });
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* "Gehört das zu einem Termin?" — one rule, one constant, one module.
*
* Three moments ask it: a document arriving in the vault, a lab panel being
* committed from an extraction, and a lab panel typed by hand. They must ask
* the same question, so none of them owns the answer.
*
* ── The rule ────────────────────────────────────────────────────────────────
*
* candidates = the caller's own live visits, DONE or PLANNED, whose instant
* is within ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days of the
* anchor
*
* 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, which costs more
* than the suggestion ever saved. This module therefore returns the SHAPE of
* the answer rather than a best guess, and a caller cannot collapse "many" into
* "one" without writing the collapse itself.
*
* ── What it is not ──────────────────────────────────────────────────────────
*
* It is not an AI call. The match is a date-window query over the person's own
* rows: it costs one index scan, cannot hallucinate, and resolves on an account
* with no provider configured — which matters, because the document assist
* itself refuses without one.
*
* It never blocks. Every caller treats the verdict as an offer; a save works
* with it ignored, dismissed, or never rendered.
*
* ── Why CANCELLED and NO_SHOW are not candidates ────────────────────────────
*
* Both name an appointment that did not happen, so neither produced the letter
* or the panel being filed. Admitting them would widen the candidate set with
* rows that are wrong most of the time, which pushes the honest single-match
* case into the picker branch and trains exactly the distrust the two-candidate
* rule exists to prevent.
*/
import type { Prisma } from "@/generated/prisma/client";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
/**
* The half-width of the match window, in days, exported so the three call
* sites and the tests read the same number. A literal at three call sites is
* three numbers that agree until one of them is edited.
*/
export const ENCOUNTER_SUGGEST_WINDOW_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* A bound on how many candidates are read. Anything above one already means
* "offer a picker", so the exact count past this cap changes no decision — and
* an unbounded read on a busy account would load a page of rows to answer a
* yes/no question.
*/
const MAX_CANDIDATES = 20;
/**
* One candidate, resolved. Enough to render a row without a second round trip
* and without the client re-deriving a label the server already holds.
*/
export interface EncounterSuggestion {
id: string;
occurredAt: string;
status: EncounterStatus;
kind: EncounterKind;
/** The practice, when the visit names one. `null` is an absence, not a gap. */
practitionerName: string | null;
}
/**
* The verdict. A discriminated union rather than an array plus a boolean,
* because "one" and "many" are different offers and a caller that receives an
* array will eventually take `[0]`.
*/
export type EncounterSuggestionResult =
| { kind: "one"; encounter: EncounterSuggestion }
| { kind: "many"; encounters: EncounterSuggestion[] }
| { kind: "none" };
/** The statuses a visit can carry and still be the source of a filed record. */
const CANDIDATE_STATUSES: EncounterStatus[] = ["DONE", "PLANNED"];
/**
* The window around an anchor instant, inclusive on both edges.
*
* Exported so a caller that needs the bounds for a different query — the
* report offer reads the same idea forward-only — states them from here rather
* than re-deriving the arithmetic.
*/
export function encounterSuggestWindow(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 visit a record dated `anchor` most plausibly belongs to.
*
* `userId` is the caller's resolved record id and is fed straight to the
* `where`; this module never accepts one from a request body.
*/
export async function suggestEncounterForDate(
tx: Prisma.TransactionClient,
input: { userId: string; anchor: Date },
): Promise<EncounterSuggestionResult> {
if (Number.isNaN(input.anchor.getTime())) return { kind: "none" };
const { from, to } = encounterSuggestWindow(input.anchor);
const rows = await tx.encounter.findMany({
where: {
userId: input.userId,
deletedAt: null,
status: { in: CANDIDATE_STATUSES },
occurredAt: { gte: from, lte: to },
},
// Nearest to the anchor would be the kinder order, but Prisma cannot sort
// by a computed distance; the caller renders a picker in chronological
// order, which is how a person reads their own week anyway.
orderBy: { occurredAt: "asc" },
take: MAX_CANDIDATES,
select: {
id: true,
occurredAt: true,
status: true,
kind: true,
practitioner: { select: { name: true } },
},
});
const encounters: EncounterSuggestion[] = rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
status: row.status,
kind: row.kind,
practitionerName: row.practitioner?.name ?? null,
}));
if (encounters.length === 0) return { kind: "none" };
if (encounters.length === 1) return { kind: "one", encounter: encounters[0] };
return { kind: "many", encounters };
}
+60
View File
@@ -21,6 +21,7 @@ import {
encounterCreateSchema,
encounterUpdateSchema,
encounterListQuerySchema,
encounterSuggestQuerySchema,
encounterLinkSchema,
encounterStatusEnum,
encounterKindEnum,
@@ -56,6 +57,12 @@ encounterListQuerySchema.meta({
"Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).",
});
encounterSuggestQuerySchema.meta({
id: "SuggestEncounterQuery",
description:
"The date to resolve around, as an ISO-8601 instant with an offset. A document uses `reportDate ?? documentDate`; a lab panel uses `takenAt`.",
});
encounterLinkSchema.meta({
id: "EncounterLinkRequest",
description:
@@ -172,6 +179,35 @@ const encounterList = z
"Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.",
});
const suggestionCandidate = z
.object({
id: z.string(),
occurredAt: z.string(),
status: encounterStatusEnum,
kind: encounterKindEnum,
practitionerName: z.string().nullable(),
})
.meta({
id: "EncounterSuggestionCandidate",
description:
"One visit a record might belong to, resolved far enough to render a row without a second round trip. `practitionerName` is null when the visit names no practice, which is an absence rather than a gap.",
});
const encounterSuggestion = z
.discriminatedUnion("kind", [
z.object({ kind: z.literal("one"), encounter: suggestionCandidate }),
z.object({
kind: z.literal("many"),
encounters: z.array(suggestionCandidate),
}),
z.object({ kind: z.literal("none") }),
])
.meta({
id: "EncounterSuggestion",
description:
"The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to make impossible.",
});
const encounterNotFound = {
"404": {
description: "Visit not found (or owned by another user).",
@@ -232,6 +268,30 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/encounters/suggest": {
get: {
tags: ["Records"],
summary: "Which visit does a record dated around this day belong to?",
description:
"Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: neither happened, so neither produced the record being filed. No AI provider is involved and none is required.",
requestParams: { query: encounterSuggestQuerySchema },
responses: {
...recordRefusal(),
"200": {
description: "The verdict.",
content: {
"application/json": {
schema: dataEnvelope(
encounterSuggestion,
"SuggestEncounterEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/encounters/{id}": {
get: {
tags: ["Records"],
+8
View File
@@ -34,6 +34,14 @@ export const encounterKeys = {
encounterList: (from: string | null, to: string | null, status?: string) =>
["encounters", "list", from, to, status ?? null] as const,
encounter: (id: string) => ["encounters", "detail", id] as const,
/**
* The "which visit does this belong to" verdict for one anchor date. The
* anchor is part of the key because it IS the question: a document dated the
* 3rd and one dated the 20th get different answers, and one overwriting the
* other would pre-select the wrong visit on the second review step.
*/
encounterSuggestion: (anchor: string) =>
["encounters", "suggest", anchor] as const,
/**
* The address book. Under its own root rather than nested beneath
* `["encounters"]`: a practitioner outlives the visits that name it, and a
+16
View File
@@ -148,6 +148,22 @@ export const encounterListQuerySchema = z
export type EncounterListQuery = z.infer<typeof encounterListQuerySchema>;
/**
* The suggestion query.
*
* One input, and it is a date rather than an id: the answer is derived from the
* caller's own visits and the anchor only says where to look. Bounded by the
* same instant rule the visit itself carries, so an anchor a client mis-parsed
* into the year 12000 is a 422 rather than a full-table scan.
*/
export const encounterSuggestQuerySchema = z
.object({
anchor: visitInstant,
})
.strict();
export type EncounterSuggestQuery = z.infer<typeof encounterSuggestQuerySchema>;
/* ── links ────────────────────────────────────────────────────────── */
export const encounterLinkTargetEnum = z.enum([