From c1d804809d30cac8fbc405af37cb863efe124982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:56:54 +0200 Subject: [PATCH] docs(openapi): publish the illness episode + day-log CRUD contract Register /api/illness/episodes (list + create), the {id} resource (read + edit + soft-delete), the {id}/resolve verb, and the {id}/day-logs upsert in the OpenAPI route table, and add the Illness tag. Request bodies + queries reuse the runtime Zod schemas from validations/illness so the wire contract stays single-source; the response shapes mirror the server-authoritative episode + day-log DTOs. Regenerated docs/api/openapi.yaml; openapi:check is in sync. --- docs/api/openapi.yaml | 622 ++++++++++++++++++++++++++++++ src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/illness.ts | 329 ++++++++++++++++ src/lib/openapi/routes/index.ts | 2 + 4 files changed, 954 insertions(+) create mode 100644 src/lib/openapi/routes/illness.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb1029861..f66bd3171 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41,6 +41,7 @@ tags: - name: Cycle - name: MeasurementReminders - name: Labs + - name: Illness - name: Admin - name: Meta paths: @@ -6167,6 +6168,373 @@ paths: "404": *a9 "422": *a3 "429": *a4 + /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. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + - in: query + name: includeResolved + schema: + anyOf: + - type: string + const: "true" + - type: string + const: "false" + responses: + "200": + description: The caller's illness episodes. + content: + application/json: + schema: + $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + 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: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 120 + type: + type: string + enum: + - INFECTION + - ALLERGY + - INJURY + - MENTAL_HEALTH + - AUTOIMMUNE + - CHRONIC + - OTHER + lifecycle: + default: ACUTE + type: string + enum: + - ACUTE + - CHRONIC_ONGOING + - RECURRING + - FLARE + onsetAt: + 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)))$ + resolvedAt: + anyOf: + - 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)))$ + - type: "null" + parentConditionId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - label + - type + responses: + "201": + description: Episode created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /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. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The episode. + content: + application/json: + schema: + $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" + "401": *a1 + "404": &a11 + description: Illness episode not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + 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 + born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 120 + type: + type: string + enum: + - INFECTION + - ALLERGY + - INJURY + - MENTAL_HEALTH + - AUTOIMMUNE + - CHRONIC + - OTHER + lifecycle: + type: string + enum: + - ACUTE + - CHRONIC_ONGOING + - RECURRING + - FLARE + onsetAt: + 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)))$ + resolvedAt: + anyOf: + - 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)))$ + - type: "null" + parentConditionId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: Episode updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + 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 204 No Content. Audits as + `illness.episode.delete`. Owner-scoped + born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + /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 + born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + resolvedAt: + 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)))$ + responses: + "200": + description: Episode resolved. + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + /api/illness/episodes/{id}/day-logs: + get: + tags: + - Illness + summary: Read one day-log for an episode (v1.18.1) + description: Returns the day-log for the episode + `date`, or `null` when nothing is logged that day (lets the log-day + sheet pre-fill). The parent episode must be owned + live. Born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: date + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + required: true + responses: + "200": + description: The day-log, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/GetIllnessDayLogEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + 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." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + functionalImpact: + anyOf: + - type: integer + minimum: 0 + maximum: 3 + - type: "null" + feverC: + anyOf: + - type: number + minimum: 30 + maximum: 45 + - type: "null" + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 0 + maximum: 3 + required: + - key + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + loggedAt: + 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: + - date + responses: + "200": + description: Existing day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertIllnessDayLogUpdatedEnvelope" + "201": + description: New day-log created. + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -17822,6 +18190,260 @@ components: - data - error additionalProperties: false + ListIllnessEpisodesEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + IllnessEpisode: + type: object + properties: + id: + type: string + label: + type: string + type: + type: string + enum: + - INFECTION + - ALLERGY + - INJURY + - MENTAL_HEALTH + - AUTOIMMUNE + - CHRONIC + - OTHER + lifecycle: + type: string + enum: + - ACUTE + - CHRONIC_ONGOING + - RECURRING + - FLARE + onsetAt: + type: string + resolvedAt: + anyOf: + - type: string + - type: "null" + parentConditionId: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - label + - type + - lifecycle + - onsetAt + - resolvedAt + - parentConditionId + - note + - createdAt + - updatedAt + additionalProperties: false + 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. + CreateIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ResolveIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetIllnessDayLogEnvelope: + type: object + properties: + data: + anyOf: + - $ref: "#/components/schemas/IllnessDayLog" + - type: "null" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + IllnessDayLog: + type: object + properties: + id: + type: string + episodeId: + type: string + date: + type: string + functionalImpact: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + feverC: + anyOf: + - type: number + - type: "null" + symptoms: + type: array + items: + $ref: "#/components/schemas/IllnessSymptom" + note: + anyOf: + - type: string + - type: "null" + updatedAt: + type: string + required: + - id + - episodeId + - date + - functionalImpact + - feverC + - symptoms + - note + - updatedAt + additionalProperties: false + 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). + IllnessSymptom: + type: object + properties: + key: + type: string + severity: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - key + - severity + additionalProperties: false + description: "A symptom link on a day-log: the catalog `key` plus an optional 0–3 severity (`null` = a plain presence link)." + UpsertIllnessDayLogUpdatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessDayLog" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpsertIllnessDayLogCreatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessDayLog" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 1b25db586..147d50b24 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -74,6 +74,7 @@ const openApiBase: Pick< { name: "Cycle" }, { name: "MeasurementReminders" }, { name: "Labs" }, + { name: "Illness" }, { name: "Admin" }, { name: "Meta" }, ], diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts new file mode 100644 index 000000000..ef28e76ca --- /dev/null +++ b/src/lib/openapi/routes/illness.ts @@ -0,0 +1,329 @@ +/** + * 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 born-gated: a non-opted-in account (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, + 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.", +}); + +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 0–3 severity (`null` = a plain presence link).", + }); + +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 episodeNotFound = { + "404": { + description: "Illness episode not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +export const illnessPaths: NonNullable = { + "/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 + born-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 204 No Content. Audits as `illness.episode.delete`. Owner-scoped + born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "204": { description: "Soft-deleted (no body)." }, + ...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 + born-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 one day-log for an episode (v1.18.1)", + description: + "Returns the day-log for the episode + `date`, or `null` when nothing is logged that day (lets the log-day sheet pre-fill). The parent episode must be owned + live. Born-gated.", + requestParams: { + path: z.object({ id: z.string() }), + query: illnessDayLogQuerySchema, + }, + responses: { + "200": { + description: "The day-log, or null.", + content: { + "application/json": { + schema: dataEnvelope( + illnessDayLog.nullable(), + "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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 87cfc3e92..7799b7c5d 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -29,6 +29,7 @@ import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; import { healthRecordPaths } from "./health-record"; +import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; import { labsPaths } from "./labs"; @@ -65,6 +66,7 @@ export const openApiPaths: NonNullable = { ...measurementReminderPaths, ...labsPaths, ...biomarkerPaths, + ...illnessPaths, }; export const openApiComponents: NonNullable = {