diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33fb729bd..6a3dbc7d4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4af6c2557..7a7b2570d 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -935,6 +935,10 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/app/api/encounters/suggest/route.ts b/src/app/api/encounters/suggest/route.ts new file mode 100644 index 000000000..84914950c --- /dev/null +++ b/src/app/api/encounters/suggest/route.ts @@ -0,0 +1,59 @@ +/** + * `GET /api/encounters/suggest?anchor=` — 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); +}); diff --git a/src/lib/encounters/__tests__/suggest-window.test.ts b/src/lib/encounters/__tests__/suggest-window.test.ts new file mode 100644 index 000000000..ec2ea3c61 --- /dev/null +++ b/src/lib/encounters/__tests__/suggest-window.test.ts @@ -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 & { 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" }); + }); +}); diff --git a/src/lib/encounters/suggest-window.ts b/src/lib/encounters/suggest-window.ts new file mode 100644 index 000000000..cc8a974b3 --- /dev/null +++ b/src/lib/encounters/suggest-window.ts @@ -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 { + 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 }; +} diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 15a860b41..d46141083 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -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 = { }, }, }, + "/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"], diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index d59d9d591..a5ea82533 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -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 diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 2217b53be..ce6a1d54c 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -148,6 +148,22 @@ export const encounterListQuerySchema = z export type EncounterListQuery = z.infer; +/** + * 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; + /* ── links ────────────────────────────────────────────────────────── */ export const encounterLinkTargetEnum = z.enum([