Files
HealthLog/src/lib/validations/illness.ts
T
Marc-André Bombeck 1de63f2b5c perf(illness): compute the recovery gap lazily and gate it on signal density
The illness list load triggered the cross-episode correlation fan-out on
every mount, so the page waited on up to 24 per-episode reads before it
painted. Move that work behind an explicit opt-in: the insights endpoint
takes an includeRecoveryGap query param (default off), the list reads the
count breakdown on a single fast query, and the recovery gap is computed
only when the viewer opens the Analyse expansion.

Tighten the typical-gap gate against coincidental data. A gap now counts
only from an episode that cleared a per-episode measurement floor
(MIN_GAP_MEASUREMENT_DAYS), at least MIN_EPISODES_FOR_TYPICAL_GAP such
episodes must qualify, and the median magnitude must reach
MIN_TYPICAL_GAP_MAGNITUDE_DAYS — a 0/+-1-day gap is the coincidental
baseline and is withheld as noise.
2026-06-20 11:58:39 +02:00

199 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Illness / condition-journal request/response validation (v1.18.1).
*
* Source of truth for the `/api/illness/*` wire contract. The Zod schemas
* here are reused by the OpenAPI registry so the spec stays single-source.
* `userId` is NEVER a body field — it is narrowed from the session/Bearer
* in every route and fed to the Prisma `where`.
*
* The journal is a CONDITION journal (any illness/condition kept for
* oneself), retrospective-only — NEVER a predictor/diagnoser. Day-log
* dates are `YYYY-MM-DD` strings (the CycleDayLog / MoodEntry tz-anchored
* convention); instants are ISO-8601 with offset. Free-text notes are
* encrypted at rest (`noteEncrypted` Bytes column); every other field is
* queryable plaintext.
*/
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
/**
* Plausible-instant bound shared with the measurement / mood / cycle
* paths: no future instant beyond the 5-min skew, no instant before 1900.
* Stays a string on the wire (iOS reads it back verbatim).
*/
const boundedInstant = z.iso
.datetime({ offset: true })
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
message: "must be a plausible instant (not future, not pre-1900)",
});
const dateString = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
export const illnessTypeEnum = z.enum([
"INFECTION",
"ALLERGY",
"INJURY",
"MENTAL_HEALTH",
"AUTOIMMUNE",
"CHRONIC",
"OTHER",
]);
export const illnessLifecycleEnum = z.enum([
"ACUTE",
"CHRONIC_ONGOING",
"RECURRING",
"FLARE",
]);
/* ── episode CRUD ─────────────────────────────────────────────────── */
/**
* Create an episode. `label` is the user-facing name; `note` is encrypted
* at rest. `parentConditionId` threads a FLARE/RECURRING bout under a
* parent condition. `onsetAt` defaults to "now" server-side when omitted.
*/
export const illnessEpisodeCreateSchema = z
.object({
label: z.string().min(1).max(120),
type: illnessTypeEnum,
lifecycle: illnessLifecycleEnum.optional().default("ACUTE"),
onsetAt: boundedInstant.optional(),
resolvedAt: boundedInstant.nullable().optional(),
parentConditionId: z.string().min(1).max(40).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
})
// An episode can never resolve before it began. `onsetAt` defaults to "now"
// server-side when omitted, so the invariant only bites when BOTH instants
// are supplied (the inverted-window case).
.superRefine((v, ctx) => {
if (v.resolvedAt && v.onsetAt && v.resolvedAt < v.onsetAt) {
ctx.addIssue({
code: "custom",
path: ["resolvedAt"],
message: "resolvedAt must be on or after onsetAt",
});
}
});
export type IllnessEpisodeCreate = z.infer<typeof illnessEpisodeCreateSchema>;
/**
* Edit an episode — every field optional; an omitted field is left
* untouched. A `null` `resolvedAt` re-opens a resolved episode.
*/
export const illnessEpisodeUpdateSchema = z
.object({
label: z.string().min(1).max(120).optional(),
type: illnessTypeEnum.optional(),
lifecycle: illnessLifecycleEnum.optional(),
onsetAt: boundedInstant.optional(),
resolvedAt: boundedInstant.nullable().optional(),
parentConditionId: z.string().min(1).max(40).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
})
.strict()
// When BOTH instants are in the same edit body, enforce the window order at
// parse time. A partial edit touching only one is validated against the
// stored value in the route (it must load the row anyway).
.superRefine((v, ctx) => {
if (v.resolvedAt && v.onsetAt && v.resolvedAt < v.onsetAt) {
ctx.addIssue({
code: "custom",
path: ["resolvedAt"],
message: "resolvedAt must be on or after onsetAt",
});
}
});
export type IllnessEpisodeUpdate = z.infer<typeof illnessEpisodeUpdateSchema>;
/** Resolve (mark recovered) — a dedicated PATCH `/resolve` endpoint. */
export const illnessEpisodeResolveSchema = z.object({
resolvedAt: boundedInstant.optional(),
});
export type IllnessEpisodeResolve = z.infer<typeof illnessEpisodeResolveSchema>;
/** History/list query — newest-first, bounded. */
export const illnessEpisodeListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional(),
includeResolved: z.union([z.literal("true"), z.literal("false")]).optional(),
});
/* ── day-log capture (one row per day of an episode) ─────────────── */
/**
* The canonical day-log input for an episode. Upserts on the
* `(episodeId, date)` key. `note` is encrypted at rest (`noteEncrypted`);
* `functionalImpact` (0–3) and `feverC` are queryable plaintext. Symptoms
* carry a 0–3 Jackson/WURSS severity on the link.
*/
export const illnessDayLogInputSchema = z.object({
date: dateString,
functionalImpact: z.number().int().min(0).max(3).nullable().optional(),
feverC: z.number().min(30).max(45).nullable().optional(),
symptoms: z
.array(
z.object({
key: z.string().min(1).max(80),
// 1–3 graded intensity (Jackson/WURSS). A link's mere PRESENCE already
// means "present"; `null`/omitted = a plain presence link, so 0 is not
// a distinct state and the selector offers 1–3 to match this contract.
severity: z.number().int().min(1).max(3).optional(),
}),
)
.max(40)
.optional(),
note: z.string().max(2000).nullable().optional(),
loggedAt: boundedInstant.optional(),
});
export type IllnessDayLogInput = z.infer<typeof illnessDayLogInputSchema>;
/** The single-day read query: `GET .../day-logs?date=YYYY-MM-DD`. */
export const illnessDayLogQuerySchema = z.object({ date: dateString });
/**
* The date-less LIST query: `GET .../day-logs` with NO `date` param returns
* the episode's day-logs newest-first, paginated. Mirrors the Labs
* limit/offset/sortDir + `meta.total` pattern so iOS (healthlog-iOS#30) can
* page a full history without anchoring on today. `date` and the list params
* are mutually exclusive at the route: a `date` triggers the single-day read,
* its absence triggers the list.
*/
export const illnessDayLogListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).default(60),
offset: z.coerce.number().int().min(0).default(0),
sortDir: z.enum(["asc", "desc"]).default("desc"),
});
export type IllnessDayLogListQuery = z.infer<
typeof illnessDayLogListQuerySchema
>;
/* ── P3 retrospective insight window ─────────────────────────────────── */
/**
* The retrospective-insight window query: `GET /api/illness/insights?
* windowDays=365`. Bounded 30..1095 days; defaults to a trailing year.
* Retrospective only — the engine summarises past episodes, never forecasts.
*
* `includeRecoveryGap` (default false) gates the EXPENSIVE per-episode
* correlation fan-out. The illness LIST loads with it off so the page paints
* on a single fast count query; only the explicit "Analyse" expansion sets it
* true and pays for the recovery-gap computation. Withholding it leaves the
* typical-gap null (the surface shows the calm "still learning" line).
*/
export const illnessInsightsQuerySchema = z.object({
windowDays: z.coerce.number().int().min(30).max(1095).optional(),
includeRecoveryGap: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional(),
});