Files
HealthLog/src/lib/validations/encounters.ts
T
Marc-André Bombeck 73c4dda224 fix(visits): reach every lab result, condition, document and visit from the link pickers
The visit form's lab picker offered the newest 200 results, the condition
picker the newest 100 conditions, the document pickers the newest thousand
documents and the document sheet's visit picker the newest 100 visits.
On a long record everything older could not be linked.

Each picker now opens on the records around the date of the thing being
linked, pages older and newer from there, and searches through the list
route itself. Records already linked always show, from the labels the
record's own read carries and from what was picked in the sheet.

The lab list takes a q filter over analyte and panel, the condition list
from/to/q/offset/sortDir, and the visit list an offset.
2026-10-15 11:24:40 +02:00

230 lines
7.9 KiB
TypeScript

/**
* Visit (encounter) request/response validation.
*
* Source of truth for the `/api/encounters*` wire contract; the OpenAPI
* registry reuses these schemas so the spec stays single-source. `userId` is
* NEVER a body field — it is narrowed from the session or Bearer in every
* route and fed to the Prisma `where`.
*
* Nothing here is mandatory except the date. A visit saves with an instant and
* nothing else: no practitioner, no kind, no reason, no link. That is a
* deliberate product constraint and not an oversight — a required field on a
* form somebody fills after a doctor's appointment is the bureaucracy this
* whole feature exists to avoid.
*
* The one exception to "no future instants" that the rest of the record keeps:
* `occurredAt` may be in the future, because a booked appointment IS a visit
* with a future date. It is bounded on both sides regardless, so a typo cannot
* park a row a century out.
*/
import { z } from "zod/v4";
/** Ten years out. Long enough for any real appointment, short enough that a
* mistyped year lands as a 422 rather than as a row nobody sees again. */
const MAX_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000;
/**
* An instant a visit can plausibly carry.
*
* Deliberately NOT `isPlausibleEntryInstant`, which refuses anything beyond a
* five-minute clock skew. That bound is right for a measurement, which can only
* describe something that already happened, and wrong here.
*/
const visitInstant = z.iso.datetime({ offset: true }).refine(
(value) => {
const at = new Date(value).getTime();
if (Number.isNaN(at)) return false;
if (at < Date.UTC(1900, 0, 1)) return false;
return at <= Date.now() + MAX_FUTURE_MS;
},
{
message:
"must be a plausible visit instant (not before 1900, not more than ten years ahead)",
},
);
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
export const encounterStatusEnum = z.enum([
"PLANNED",
"DONE",
"CANCELLED",
"NO_SHOW",
]);
export const encounterKindEnum = z.enum([
"ROUTINE",
"ACUTE",
"SPECIALIST",
"PREVENTIVE",
"EMERGENCY",
"HOSPITAL",
"THERAPY",
"OTHER",
"PROCEDURE",
]);
/** The side a procedure's body site is on. Absent or null: not stated. */
export const lateralityEnum = z.enum(["LEFT", "RIGHT", "BOTH"]);
export type EncounterStatusInput = z.infer<typeof encounterStatusEnum>;
export type EncounterKindInput = z.infer<typeof encounterKindEnum>;
export type LateralityInput = z.infer<typeof lateralityEnum>;
/**
* Where on the body, in the person's own words. Becomes `bodySiteEncrypted`.
* Bounded like a label rather than like prose: "left knee, medial meniscus" is
* the long end of what belongs here, and the reason field holds the rest.
*/
const bodySite = z.string().max(200).nullable().optional();
const id = z.string().min(1).max(40);
/**
* The three optional pre-link arrays.
*
* Bounded at the link service's own cap so an over-long list is a 422 with a
* field path rather than a thrown error from two layers down.
*/
const linkIds = z.array(id).max(100).optional();
/* ── encounter CRUD ───────────────────────────────────────────────── */
export const encounterCreateSchema = z
.object({
occurredAt: visitInstant,
status: encounterStatusEnum.optional().default("DONE"),
kind: encounterKindEnum.optional().default("OTHER"),
practitionerId: id.nullable().optional(),
/** Becomes `reasonEncrypted`. */
reason: z.string().max(2000).nullable().optional(),
/** Becomes `outcomeEncrypted`. */
outcome: z.string().max(4000).nullable().optional(),
bodySite,
laterality: lateralityEnum.nullable().optional(),
/** The checkup this visit closes, when it was filed from a due one. */
reminderId: id.nullable().optional(),
documentIds: linkIds,
labResultIds: linkIds,
episodeIds: linkIds,
})
.strict();
export type EncounterCreate = z.infer<typeof encounterCreateSchema>;
/**
* Edit a visit. Every field optional; an omitted field is left untouched.
*
* A body naming nothing is a 422 rather than a silent success: a PATCH that
* changed nothing and answered 200 is indistinguishable from one that worked,
* which is how a client bug survives a release.
*/
export const encounterUpdateSchema = z
.object({
occurredAt: visitInstant.optional(),
status: encounterStatusEnum.optional(),
kind: encounterKindEnum.optional(),
practitionerId: id.nullable().optional(),
reason: z.string().max(2000).nullable().optional(),
outcome: z.string().max(4000).nullable().optional(),
bodySite,
laterality: lateralityEnum.nullable().optional(),
reminderId: id.nullable().optional(),
documentIds: linkIds,
labResultIds: linkIds,
episodeIds: linkIds,
})
.strict()
.refine((body) => Object.keys(body).length > 0, {
message: "name at least one field to change",
});
export type EncounterUpdate = z.infer<typeof encounterUpdateSchema>;
/**
* List query.
*
* Windowed and bounded. The response splits past from upcoming server-side, so
* there is no `order` parameter: the client renders two lists rather than
* sorting one.
*/
export const encounterListQuerySchema = z
.object({
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
status: encounterStatusEnum.optional(),
practitionerId: id.optional(),
/**
* Only visits filed against this condition episode.
*
* The condition side of the link, and the only direction it is offered in:
* an episode spans weeks and a visit is a point, so matching from the visit
* side would produce false positives at a rate that trains a person to
* ignore every suggestion in the product.
*/
episodeId: id.optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
/**
* Rows to skip in each arm, for a picker that pages a long history in
* `limit`-sized steps inside a date window.
*/
offset: z.coerce.number().int().min(0).max(100_000).optional(),
})
.strict()
.refine((q) => !(q.from && q.to) || q.from <= q.to, {
path: ["to"],
message: "to must be on or after from",
});
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>;
/**
* The procedure history query.
*
* `q` is matched after the decrypt, over the body site, the side and the
* reason, every word required (see `src/lib/encounters/procedures.ts`). It is
* named `q` and not `bodySite` because it also searches the reason: a knee
* operation recorded before the site field existed says "knee" only there.
*/
export const procedureListQuerySchema = z
.object({
q: z.string().max(200).optional(),
laterality: lateralityEnum.optional(),
})
.strict();
export type ProcedureListQuery = z.infer<typeof procedureListQuerySchema>;
/* ── links ────────────────────────────────────────────────────────── */
export const encounterLinkTargetEnum = z.enum([
"document",
"labResult",
"conditionEpisode",
]);
export const encounterLinkSchema = z
.object({
targetKind: encounterLinkTargetEnum,
targetIds: z.array(id).min(1).max(100),
})
.strict();
export type EncounterLinkInput = z.infer<typeof encounterLinkSchema>;