mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
merge: extract worker lifecycle handlers + register illness CRUD in OpenAPI
This commit is contained in:
commit
800e799d48
6 files changed
+1082
-77
No files matched your search
@@ -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:
|
||||
@@ -17828,6 +18196,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:
|
||||
|
||||
@@ -124,7 +124,6 @@ import {
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_CONCURRENCY,
|
||||
handleAppleHealthImport,
|
||||
reconcileOrphanImportJobs,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import {
|
||||
@@ -183,8 +182,6 @@ import {
|
||||
enqueueBootTimeIntakeSlotDedup,
|
||||
type IntakeSlotDedupPayload,
|
||||
} from "@/lib/medications/intake-slot-dedup";
|
||||
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
|
||||
import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { assertSubsystemEnabled } from "@/lib/process-type";
|
||||
import {
|
||||
@@ -193,6 +190,12 @@ import {
|
||||
RESTORE_DRILL_QUEUE,
|
||||
} from "@/lib/jobs/restore-drill";
|
||||
import { DATABASE_URL, getWorkerPrisma, workerLog } from "./reminder/shared";
|
||||
import {
|
||||
registerShutdownHandlers,
|
||||
rotateLegacyMoodLogSecretsAtBoot,
|
||||
reconcileImportJobsAtBoot,
|
||||
probeIntegrationStatusAtBoot,
|
||||
} from "./reminder/worker-lifecycle";
|
||||
import {
|
||||
ReminderCheckPayload,
|
||||
handleReminderCheck,
|
||||
@@ -563,56 +566,12 @@ export async function startReminderWorker() {
|
||||
setGlobalBoss(boss);
|
||||
markWorkerStarted();
|
||||
|
||||
// V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration. Rotates
|
||||
// any rows that still hold a plaintext mood_log_webhook_secret to the
|
||||
// AES-256-GCM envelope. Idempotent — encrypted rows are skipped.
|
||||
try {
|
||||
const p = getWorkerPrisma();
|
||||
const rotated = await rotateLegacyMoodLogSecrets({
|
||||
findLegacy: () =>
|
||||
p.user.findMany({
|
||||
where: { moodLogWebhookSecret: { not: null } },
|
||||
select: { id: true, moodLogWebhookSecret: true },
|
||||
}),
|
||||
rotate: async (id, encryptedSecret) => {
|
||||
await p.user.update({
|
||||
where: { id },
|
||||
data: { moodLogWebhookSecret: encryptedSecret },
|
||||
});
|
||||
},
|
||||
});
|
||||
if (rotated > 0) {
|
||||
workerLog(
|
||||
"error",
|
||||
`moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog("error", `moodlog-secret-migration failed: ${err}`);
|
||||
}
|
||||
// V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration for any
|
||||
// plaintext mood_log_webhook_secret rows. Idempotent.
|
||||
await rotateLegacyMoodLogSecretsAtBoot();
|
||||
|
||||
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by
|
||||
// Docker Compose `docker stop`, Kubernetes pod termination, Coolify
|
||||
// redeploys). Without this, pending handlers were force-killed and could
|
||||
// either be lost or replayed on next start. We only register the listeners
|
||||
// once — re-entering startReminderWorker (e.g. on hot-reload in dev) is
|
||||
// a no-op for the handlers because they capture `boss` by closure and the
|
||||
// first signal stops everything.
|
||||
let shutdownInProgress = false;
|
||||
const onSignal = async (signal: "SIGTERM" | "SIGINT") => {
|
||||
if (shutdownInProgress) return;
|
||||
shutdownInProgress = true;
|
||||
workerLog("error", `received ${signal}, draining boss`);
|
||||
try {
|
||||
// graceful=true waits for active handlers to finish, then closes the
|
||||
// pg connection pool. timeout cap so a stuck handler can't block deploys.
|
||||
await boss.stop({ graceful: true, timeout: 30_000 });
|
||||
} catch (err) {
|
||||
workerLog("error", "boss.stop failed during shutdown", err);
|
||||
}
|
||||
};
|
||||
process.once("SIGTERM", () => void onSignal("SIGTERM"));
|
||||
process.once("SIGINT", () => void onSignal("SIGINT"));
|
||||
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT.
|
||||
registerShutdownHandlers(boss);
|
||||
|
||||
// pg-boss v12 requires explicit queue creation before scheduling
|
||||
const allQueues = [
|
||||
@@ -834,32 +793,13 @@ export async function startReminderWorker() {
|
||||
await boss.createQueue(q);
|
||||
}
|
||||
|
||||
// v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when
|
||||
// the worker last shut down. Flips orphaned rows to `failed` so the
|
||||
// operator can re-upload without leaving the polling endpoint
|
||||
// stuck on `parsing` / `upserting`.
|
||||
try {
|
||||
await reconcileOrphanImportJobs();
|
||||
} catch (err) {
|
||||
workerLog("error", "Failed to reconcile orphan ImportJob rows", err);
|
||||
}
|
||||
// v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the
|
||||
// worker last shut down, flipping orphaned rows to `failed`.
|
||||
await reconcileImportJobsAtBoot();
|
||||
|
||||
// v1.4.48 M1 — boot probe for legacy `integration_statuses` rows
|
||||
// that still carry `consecutive_failures_by_kind = NULL`. After
|
||||
// v1.4.47 dropped the single-column fallback, such rows alert two
|
||||
// strikes later than they did pre-upgrade. The probe is a single
|
||||
// count query + Wide-Event warning if any survive; fire-and-forget
|
||||
// so a probe failure never blocks worker boot.
|
||||
try {
|
||||
await withBackgroundEvent(
|
||||
"worker.boot.integration_status_null_probe",
|
||||
async () => {
|
||||
await probeIntegrationStatusNullBuckets(getWorkerPrisma());
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
workerLog("error", "integration-status-null-probe failed", err);
|
||||
}
|
||||
// v1.4.48 M1 — boot probe for legacy `integration_statuses` rows that still
|
||||
// carry `consecutive_failures_by_kind = NULL`.
|
||||
await probeIntegrationStatusAtBoot();
|
||||
|
||||
// v1.15.20 — retry policy for the LLM-bound insight queues. A transient
|
||||
// failure (provider hiccup, pool exhaustion) used to fail the nightly tick
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Reminder-worker boot lifecycle helpers.
|
||||
*
|
||||
* These run once per worker process around the queue/schedule/handler wiring
|
||||
* in `reminder-worker.ts`: a graceful-shutdown signal binding plus three
|
||||
* fire-and-forget boot maintenance passes. None of them touch the pg-boss
|
||||
* queue declarations, the `allQueues` array, the `schedules` table, or the
|
||||
* `boss.work` bindings — those stay co-located in `reminder-worker.ts` so the
|
||||
* source-text registration guards (the v1.4.37 dead-queue catch) keep working.
|
||||
*/
|
||||
import type { PgBoss } from "pg-boss";
|
||||
|
||||
import { reconcileOrphanImportJobs } from "@/lib/jobs/apple-health-import-worker";
|
||||
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
|
||||
import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
|
||||
import { getWorkerPrisma, workerLog } from "./shared";
|
||||
|
||||
/**
|
||||
* Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by Docker
|
||||
* Compose `docker stop`, Kubernetes pod termination, Coolify redeploys).
|
||||
* Without this, pending handlers were force-killed and could either be lost
|
||||
* or replayed on next start. The listeners register once — re-entering the
|
||||
* worker boot (e.g. on hot-reload in dev) is a no-op for the handlers because
|
||||
* they capture `boss` by closure and the first signal stops everything.
|
||||
*/
|
||||
export function registerShutdownHandlers(boss: PgBoss): void {
|
||||
let shutdownInProgress = false;
|
||||
const onSignal = async (signal: "SIGTERM" | "SIGINT") => {
|
||||
if (shutdownInProgress) return;
|
||||
shutdownInProgress = true;
|
||||
workerLog("error", `received ${signal}, draining boss`);
|
||||
try {
|
||||
// graceful=true waits for active handlers to finish, then closes the
|
||||
// pg connection pool. timeout cap so a stuck handler can't block deploys.
|
||||
await boss.stop({ graceful: true, timeout: 30_000 });
|
||||
} catch (err) {
|
||||
workerLog("error", "boss.stop failed during shutdown", err);
|
||||
}
|
||||
};
|
||||
process.once("SIGTERM", () => void onSignal("SIGTERM"));
|
||||
process.once("SIGINT", () => void onSignal("SIGINT"));
|
||||
}
|
||||
|
||||
/**
|
||||
* V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration. Rotates any rows
|
||||
* that still hold a plaintext mood_log_webhook_secret to the AES-256-GCM
|
||||
* envelope. Idempotent — encrypted rows are skipped. Never throws; a failure
|
||||
* is logged and boot continues.
|
||||
*/
|
||||
export async function rotateLegacyMoodLogSecretsAtBoot(): Promise<void> {
|
||||
try {
|
||||
const p = getWorkerPrisma();
|
||||
const rotated = await rotateLegacyMoodLogSecrets({
|
||||
findLegacy: () =>
|
||||
p.user.findMany({
|
||||
where: { moodLogWebhookSecret: { not: null } },
|
||||
select: { id: true, moodLogWebhookSecret: true },
|
||||
}),
|
||||
rotate: async (id, encryptedSecret) => {
|
||||
await p.user.update({
|
||||
where: { id },
|
||||
data: { moodLogWebhookSecret: encryptedSecret },
|
||||
});
|
||||
},
|
||||
});
|
||||
if (rotated > 0) {
|
||||
workerLog(
|
||||
"error",
|
||||
`moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog("error", `moodlog-secret-migration failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the worker
|
||||
* last shut down. Flips orphaned rows to `failed` so the operator can
|
||||
* re-upload without leaving the polling endpoint stuck on `parsing` /
|
||||
* `upserting`. Never throws.
|
||||
*/
|
||||
export async function reconcileImportJobsAtBoot(): Promise<void> {
|
||||
try {
|
||||
await reconcileOrphanImportJobs();
|
||||
} catch (err) {
|
||||
workerLog("error", "Failed to reconcile orphan ImportJob rows", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.48 M1 — boot probe for legacy `integration_statuses` rows that still
|
||||
* carry `consecutive_failures_by_kind = NULL`. After v1.4.47 dropped the
|
||||
* single-column fallback, such rows alert two strikes later than they did
|
||||
* pre-upgrade. The probe is a single count query + Wide-Event warning if any
|
||||
* survive; fire-and-forget so a probe failure never blocks worker boot.
|
||||
*/
|
||||
export async function probeIntegrationStatusAtBoot(): Promise<void> {
|
||||
try {
|
||||
await withBackgroundEvent(
|
||||
"worker.boot.integration_status_null_probe",
|
||||
async () => {
|
||||
await probeIntegrationStatusNullBuckets(getWorkerPrisma());
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
workerLog("error", "integration-status-null-probe failed", err);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ const openApiBase: Pick<
|
||||
{ name: "Cycle" },
|
||||
{ name: "MeasurementReminders" },
|
||||
{ name: "Labs" },
|
||||
{ name: "Illness" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
|
||||
@@ -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<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 + 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
...measurementReminderPaths,
|
||||
...labsPaths,
|
||||
...biomarkerPaths,
|
||||
...illnessPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
Reference in new issue
Block a user