Files
HealthLog/src/lib/openapi/routes/illness.ts
T
Marc-André Bombeck 8d6d63cd1a feat(illness): surface sleep as a context line on the recovery card
Adds a neutral sleep observation alongside the per-episode recovery gap: the
user's usual asleep minutes over their well baseline vs the mean over the
episode window, narrated as 'you slept more/less than usual' when the two
diverge. It is an observation only — never a recovery claim, never a return
track, and never part of the headline recovery-gap number.

Per-night asleep totals come from the canonical night reconstruction over the
baseline and episode windows, so a multi-source night (two writers for one
sleep) is counted once, matching every other sleep surface. The observation
withholds entirely on thin data — too few baseline or episode nights, or a
delta below a 30-minute floor — so day-to-day jitter is never narrated as a
finding.

Additive optional DTO field; no schema, no migration. Two locale keys across
the six bundles. Regenerate the OpenAPI contract from the Zod source.
2026-06-22 07:06:22 +02:00

577 lines
22 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.
/**
* OpenAPI route table for the illness / condition journal (`/api/illness`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies + queries reuse the runtime Zod schemas from `@/lib/validations/illness`
* so the wire contract stays single-source. Response shapes are declared here
* to mirror the server-authoritative DTOs in `@/lib/illness/dto.ts` (the routes
* serialise a decrypted `note` + flattened `symptoms` the input schemas don't
* carry; iOS renders the DTO, it never recomputes it).
*
* The journal is a CONDITION journal, retrospective-only — never a
* predictor/diagnoser. Every route is module-gated: an account that turned
* the module off (or an operator-disabled instance) 403s with
* `errorCode:"illness.disabled"` even with a valid Bearer token.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
illnessEpisodeCreateSchema,
illnessEpisodeUpdateSchema,
illnessEpisodeResolveSchema,
illnessEpisodeListQuerySchema,
illnessDayLogInputSchema,
illnessDayLogQuerySchema,
illnessDayLogListQuerySchema,
illnessInsightsQuerySchema,
illnessTypeEnum,
illnessLifecycleEnum,
} from "@/lib/validations/illness";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
illnessEpisodeCreateSchema.meta({
id: "CreateIllnessEpisodeRequest",
description:
"Open an illness/condition episode. `label` is the user-facing name; `type` + `lifecycle` classify it. `onsetAt` defaults to 'now' server-side when omitted. `parentConditionId` threads a FLARE/RECURRING bout under a parent condition (must be an owned, live episode). The optional `note` is encrypted at rest.",
});
illnessEpisodeUpdateSchema.meta({
id: "UpdateIllnessEpisodeRequest",
description:
"Partial edit of an episode; an omitted key leaves the column untouched. A `null` `resolvedAt` re-opens a resolved episode; a `null` `parentConditionId` detaches it from its parent. Rejects unknown keys.",
});
illnessEpisodeResolveSchema.meta({
id: "ResolveIllnessEpisodeRequest",
description:
"Mark an episode recovered. `resolvedAt` defaults to 'now' when omitted, so the one-tap 'mark recovered' affordance can send an empty body. A CHRONIC_ONGOING episode has no recovery date by design and 422s.",
});
illnessEpisodeListQuerySchema.meta({
id: "ListIllnessEpisodesQuery",
description:
"Query params for the episode history: optional `limit` (1–100, default 50) and `includeResolved` ('true' | 'false'); 'false' hides episodes that already carry a `resolvedAt`. Newest-first by `onsetAt`.",
});
illnessDayLogInputSchema.meta({
id: "UpsertIllnessDayLogRequest",
description:
"Upsert one day's symptom / functional-impact / fever row for an episode (keyed on `(episodeId, date)`). `date` is a `YYYY-MM-DD` tz-anchored day. `functionalImpact` (0–3) + `feverC` are queryable plaintext; `symptoms` carry an optional 0–3 Jackson/WURSS severity per link; the optional `note` is encrypted at rest.",
});
illnessDayLogQuerySchema.meta({
id: "GetIllnessDayLogQuery",
description:
"Single-day read query: `date` is a `YYYY-MM-DD` day. Returns the matching day-log or `null` when nothing is logged for that day.",
});
illnessDayLogListQuerySchema.meta({
id: "ListIllnessDayLogsQuery",
description:
"Date-less LIST query (v1.18.3): omit `date` to page the episode's whole day-log history. `limit` (1–200, default 60) + `offset` (default 0) + `sortDir` ('asc' | 'desc', default 'desc') with `meta.total`. Mutually exclusive with the single-day `date` read — presence of `date` selects the single-day mode.",
});
/**
* The documented query for `GET .../day-logs`: a `date` (single-day read) OR
* the list pagination params (date-less list). Both are optional on the wire;
* the route picks the mode by whether `date` is present.
*/
const illnessDayLogGetQuery = z
.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
offset: z.coerce.number().int().min(0).optional(),
sortDir: z.enum(["asc", "desc"]).optional(),
})
.meta({ id: "GetIllnessDayLogsQuery" });
illnessInsightsQuerySchema.meta({
id: "IllnessInsightsQuery",
description:
"Cross-episode retrospective window query: optional `windowDays` (30–1095, default 365) and optional `includeRecoveryGap` (default false). With `includeRecoveryGap` off the response carries the count breakdown and a null typical recovery gap on a single fast query; set it true to pay for the per-episode correlation that computes the recovery gap. Retrospective only — the engine summarises past episodes, never forecasts.",
});
const illnessSymptom = z
.object({
key: z.string(),
severity: z.number().int().nullable(),
})
.meta({
id: "IllnessSymptom",
description:
"A symptom link on a day-log: the catalog `key` plus an optional 1–3 graded severity (`null` = a plain presence link; the link's presence already means 'present', so 0 is not a distinct state).",
});
const illnessEpisode = z
.object({
id: z.string(),
label: z.string(),
type: illnessTypeEnum,
lifecycle: illnessLifecycleEnum,
onsetAt: z.string(),
resolvedAt: z.string().nullable(),
parentConditionId: z.string().nullable(),
note: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "IllnessEpisode",
description:
"A stored illness/condition episode. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `resolvedAt` is null while the episode is open. `parentConditionId` links a FLARE/RECURRING bout to its parent condition.",
});
const illnessDayLog = z
.object({
id: z.string(),
episodeId: z.string(),
date: z.string(),
functionalImpact: z.number().int().nullable(),
feverC: z.number().nullable(),
symptoms: z.array(illnessSymptom),
note: z.string().nullable(),
updatedAt: z.string(),
})
.meta({
id: "IllnessDayLog",
description:
"A stored day-log for an episode. `date` is the `YYYY-MM-DD` it covers. `functionalImpact` (0–3) and `feverC` are plaintext; `symptoms` flattens the link rows; `note` is the decrypted free-text (or null).",
});
const illnessDayLogList = z
.object({
dayLogs: z.array(illnessDayLog),
meta: z.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
}),
})
.meta({
id: "IllnessDayLogList",
description:
"The date-less day-log LIST (v1.18.3): the episode's day-logs newest-first (or oldest-first under `sortDir:'asc'`), with `meta.total` for paging the full history. Powers the detail timeline scroll + iOS healthlog-iOS#30.",
});
/* ── P3 retrospective correlation + cross-episode insights DTOs ───────── */
const illnessVitalDeviation = z
.object({
type: z.string(),
day: z.string(),
value: z.number(),
baselineCenter: z.number(),
deviationSd: z.number(),
direction: z.enum(["above", "below"]),
adverse: z.boolean(),
})
.meta({
id: "IllnessVitalDeviation",
description:
"One vital's deviation finding on a day: signed deviation in robust-SD units from the user's OWN baseline (median ± MAD), the direction, and whether the move is illness-adverse for that metric.",
});
const illnessVitalReturn = z
.object({
type: z.string(),
returnedDay: z.string().nullable(),
gapDays: z.number().nullable(),
adverse: z.boolean(),
})
.meta({
id: "IllnessVitalReturn",
description:
"A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0).",
});
const illnessRedFlag = z
.object({
type: z.string(),
reason: z.enum(["sustained_low_spo2", "sustained_fever"]),
worstValue: z.number(),
days: z.number().int(),
})
.meta({
id: "IllnessRedFlag",
description:
"A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical floors. Copy must escalate ('seek care if this recurs'), never reassure.",
});
const illnessSleepContext = z
.object({
baselineMeanMinutes: z.number(),
episodeMeanMinutes: z.number(),
deltaMinutes: z.number(),
nightsCounted: z.number().int(),
})
.meta({
id: "IllnessSleepContext",
description:
"A neutral sleep-as-context observation: the user's median asleep minutes over the well baseline vs the mean over the episode window, and their signed delta (positive = slept more than usual). A pure OBSERVATION surfaced alongside the gap — NEVER a recovery return and NEVER part of the headline recovery-gap. Withheld (the field is null) on thin sleep data or a sub-floor delta.",
});
const illnessCorrelationValue = z
.object({
episodeId: z.string(),
preOnset: z.array(illnessVitalDeviation),
nadir: z.array(illnessVitalDeviation),
returns: z.array(illnessVitalReturn),
recoveryGapDays: z.number().nullable(),
adverseCoverageDays: z.number().int(),
feltBetterDay: z.string().nullable(),
gapDriverType: z.string().nullable(),
redFlags: z.array(illnessRedFlag),
sleepContext: illnessSleepContext.nullable(),
})
.meta({
id: "IllnessCorrelationValue",
description:
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), any red flags, and an optional sleep-as-context observation (`sleepContext`, never in the gap).",
});
const illnessCorrelationResponse = z
.object({
episodeId: z.string(),
status: z.enum(["ok", "insufficient"]),
value: illnessCorrelationValue.nullable(),
coverage: z.object({
requiredInputs: z.number().int(),
presentInputs: z.number().int(),
historyDays: z.number().int(),
missing: z.array(z.string()),
}),
confidence: z.object({ score: z.number(), band: z.string() }).nullable(),
provenance: z.object({
inputs: z.array(z.string()),
source: z.string(),
windowDays: z.number().int(),
computedAt: z.string(),
}),
reason: z.string().nullable(),
})
.meta({
id: "IllnessCorrelationResponse",
description:
"Coverage-gated `Derived<T>` wire shape for the per-episode correlation. `status:\"insufficient\"` carries coverage + a reason and a null `value` — the surface renders 'still learning', never a fabricated number. Server-authoritative; iOS pattern-matches `status`, never recomputes.",
});
const illnessInsightsResponse = z
.object({
windowDays: z.number().int(),
episodeCount: z.number().int(),
resolvedCount: z.number().int(),
typicalRecoveryGapDays: z.number().nullable(),
gapSampleSize: z.number().int(),
byMonth: z.record(z.string(), z.number().int()),
byType: z.record(z.string(), z.number().int()),
gapDriverType: z.string().nullable(),
})
.meta({
id: "IllnessInsightsResponse",
description:
"Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). Retrospective only.",
});
const episodeNotFound = {
"404": {
description: "Illness episode not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/illness/episodes": {
get: {
tags: ["Illness"],
summary: "List illness episodes (v1.18.1)",
description:
"Returns the caller's live (non-deleted) illness/condition episodes, newest-first by onset. `includeResolved=false` hides episodes that already carry a `resolvedAt`. Born-gated.",
requestParams: { query: illnessEpisodeListQuerySchema },
responses: {
"200": {
description: "The caller's illness episodes.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(illnessEpisode),
"ListIllnessEpisodesEnvelope",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Illness"],
summary: "Open an illness episode (v1.18.1)",
description:
"Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated.",
requestBody: {
required: true,
content: {
"application/json": { schema: illnessEpisodeCreateSchema },
},
},
responses: {
"201": {
description: "Episode created.",
content: {
"application/json": {
schema: dataEnvelope(
illnessEpisode,
"CreateIllnessEpisodeEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/illness/episodes/{id}": {
get: {
tags: ["Illness"],
summary: "Read a single illness episode (v1.18.1)",
description:
"Returns the episode including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. Born-gated.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "The episode.",
content: {
"application/json": {
schema: dataEnvelope(illnessEpisode, "GetIllnessEpisodeEnvelope"),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
patch: {
tags: ["Illness"],
summary: "Edit an illness episode (v1.18.1)",
description:
"Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: illnessEpisodeUpdateSchema },
},
},
responses: {
"200": {
description: "Episode updated.",
content: {
"application/json": {
schema: dataEnvelope(
illnessEpisode,
"UpdateIllnessEpisodeEnvelope",
),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
delete: {
tags: ["Illness"],
summary: "Soft-delete an illness episode (v1.18.1)",
description:
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Soft-deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteIllnessEpisodeEnvelope",
),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
},
"/api/illness/episodes/{id}/restore": {
post: {
tags: ["Illness"],
summary: "Restore a soft-deleted illness episode (v1.18.3)",
description:
"Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Episode restored.",
content: {
"application/json": {
schema: dataEnvelope(
illnessEpisode,
"RestoreIllnessEpisodeEnvelope",
),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
},
"/api/illness/episodes/{id}/resolve": {
patch: {
tags: ["Illness"],
summary: "Mark an illness episode recovered (v1.18.1)",
description:
"Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + module-gated.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: false,
content: {
"application/json": { schema: illnessEpisodeResolveSchema },
},
},
responses: {
"200": {
description: "Episode resolved.",
content: {
"application/json": {
schema: dataEnvelope(
illnessEpisode,
"ResolveIllnessEpisodeEnvelope",
),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
},
"/api/illness/episodes/{id}/day-logs": {
get: {
tags: ["Illness"],
summary: "Read an episode's day-log(s) (v1.18.1, list v1.18.3)",
description:
"Two modes on one path, picked by the `date` param. With `date=YYYY-MM-DD`: the single day-log for that day, or `null` when nothing is logged (lets the log-day sheet pre-fill). Without `date`: the date-less LIST — the episode's day-logs newest-first (override with `sortDir`), paged via `limit`/`offset` with `meta.total`. The parent episode must be owned + live. Born-gated.",
requestParams: {
path: z.object({ id: z.string() }),
query: illnessDayLogGetQuery,
},
responses: {
"200": {
description:
"With `date`: the day-log or null. Without `date`: the paged day-log list.",
content: {
"application/json": {
schema: dataEnvelope(
z.union([illnessDayLog.nullable(), illnessDayLogList]),
"GetIllnessDayLogEnvelope",
),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
post: {
tags: ["Illness"],
summary: "Upsert an episode day-log (v1.18.1)",
description:
"Upserts the day's symptom / functional-impact / fever timeline row on `(episodeId, date)`: 201 on insert, 200 on update. The parent episode must be owned + live; the optional note is encrypted at rest. Audits as `illness.day-log.upsert`. Born-gated.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: illnessDayLogInputSchema },
},
},
responses: {
"200": {
description: "Existing day-log updated.",
content: {
"application/json": {
schema: dataEnvelope(
illnessDayLog,
"UpsertIllnessDayLogUpdatedEnvelope",
),
},
},
},
"201": {
description: "New day-log created.",
content: {
"application/json": {
schema: dataEnvelope(
illnessDayLog,
"UpsertIllnessDayLogCreatedEnvelope",
),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
},
"/api/illness/episodes/{id}/correlation": {
get: {
tags: ["Illness"],
summary: "Per-episode retrospective correlation (v1.18.1)",
description:
'Returns the coverage-gated `Derived<T>` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user\'s OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:"insufficient"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + module-gated.',
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "The correlation Derived DTO (ok or insufficient).",
content: {
"application/json": {
schema: dataEnvelope(
illnessCorrelationResponse,
"IllnessCorrelationEnvelope",
),
},
},
},
...episodeNotFound,
...stdResponses,
},
},
},
"/api/illness/insights": {
get: {
tags: ["Illness"],
summary: "Cross-episode retrospective insights (v1.18.1)",
description:
"Returns the cross-episode retrospective summary over a trailing window: 'sick N times · typical recovery gap X days', a recurrence-by-month tally, and a per-type breakdown. The typical gap is withheld (null) below the min-sample floor (asserts nothing thin). Retrospective ONLY — the recurrence figure is a count of the past, never a forecast. Born-gated + owner-scoped.",
requestParams: { query: illnessInsightsQuerySchema },
responses: {
"200": {
description: "The cross-episode retrospective summary.",
content: {
"application/json": {
schema: dataEnvelope(
illnessInsightsResponse,
"IllnessInsightsEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};