diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 201096698..f994da49e 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: @@ -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: diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 120f934fc..7bfd591b9 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -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 diff --git a/src/lib/jobs/reminder/worker-lifecycle.ts b/src/lib/jobs/reminder/worker-lifecycle.ts new file mode 100644 index 000000000..9b54fb85b --- /dev/null +++ b/src/lib/jobs/reminder/worker-lifecycle.ts @@ -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 { + 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 { + 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 { + try { + await withBackgroundEvent( + "worker.boot.integration_status_null_probe", + async () => { + await probeIntegrationStatusNullBuckets(getWorkerPrisma()); + }, + ); + } catch (err) { + workerLog("error", "integration-status-null-probe failed", err); + } +} 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 = {