Files
HealthLog/src/lib/validations/encounters.ts
T
Marc-André Bombeck c47122ccfe feat(encounters): the lab moment, the condition side, and closing a checkup
A lab panel files itself against the visit it came from, and never has to. The
OCR review and the manual form both offer the suggestion through the one shared
field, anchored on the draw date rather than on now, so backdating a reading
moves the offer with it. The commit writes one link PER RESULT ROW, which is
why the join is m:n: a marker re-run on a different day belongs to its own
visit. Best-effort by design — the readings are the write that matters and must
not be lost to a link that could not be made.

The condition side gets a Visits about this card with an action that pre-links
the episode. From the episode side only, and that is a decision: an episode
spans weeks and a visit is a point, so matching the other way would be wrong
often enough to teach a person to ignore every suggestion in the product.

A due checkup gains a second action beside erledigt. Filing the visit as done
satisfies the checkup through the same forward-only, idempotent primitive the
checkbox uses, so doing both is one satisfaction and no error. The two actions
answer different questions — one says it happened, the other says where.

An appointment now appears where a person looks, and NEITHER surface relaxes
the reminder-table exclusion that keeps it out of the preventive-care lists.
Both read the visit table: a quiet line under the dashboard checkup block for
anything inside two weeks, and one rail item for today or tomorrow. A diff in
either that touches origin is the wrong fix.

A visit write now also refreshes the reminder reads. Closing a checkup moves
its next-due date on three surfaces that would otherwise keep showing it due
until their own poll.

OpenAPI regenerated in the same commit.
2026-08-08 14:08:09 +02:00

192 lines
6.5 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",
]);
export type EncounterStatusInput = z.infer<typeof encounterStatusEnum>;
export type EncounterKindInput = z.infer<typeof encounterKindEnum>;
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(),
/** 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(),
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(),
})
.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>;
/* ── 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>;