From d6571c4e9c4e7db8a035b33f520d29936fcf8d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:13:45 +0200 Subject: [PATCH 1/8] feat(symptoms): add person-defined symptoms and timestamped occurrences A symptom of one's own ("aura", "headache") is a definition with an encrypted name and an icon from the cycle custom-symptom allowlist; each occurrence carries a time, a 0-10 intensity (the pain score's scale), an optional encrypted note and an optional link to one of the record's open illness episodes. Both ride the illness module: every route answers illness.disabled when it is off. - Migration 0369 (idempotent): symptom_definitions and symptom_events, intensity CHECK 0-10, episode link ON DELETE SET NULL. - /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}: eight active definitions per record (a hidden one frees its slot, showing it again re-checks), hide or purge, events create/list/edit/ delete, a new link must name an open episode of the same record (422 symptoms.episode.notOpen). Error group symptoms.*. - Sharing: reads at READ, logging an occurrence at WRITE, defining and editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler matrix and the activity verbs updated. - Both encrypted columns registered and rotated; both models in the wipe plan; both carried at both backup ends with occurrences nested in their definition, plaintext in a portable file and ciphertext in a recovery file, and a link to an episode the file lacks dropped and named (symptomEpisodeReference). - OpenAPI regenerated. --- docs/api/openapi.yaml | 746 +++++++++++++++++- .../migration.sql | 73 ++ prisma/schema.prisma | 66 ++ scripts/rotate-encryption-key.ts | 15 + src/__tests__/sharing-surface-guard.test.ts | 42 +- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../api/symptoms/definitions/[id]/route.ts | 232 ++++++ src/app/api/symptoms/definitions/route.ts | 139 ++++ src/app/api/symptoms/events/[id]/route.ts | 192 +++++ src/app/api/symptoms/events/route.ts | 166 ++++ src/components/admin/backups-section.tsx | 3 + src/lib/crypto/encrypted-columns.ts | 3 + src/lib/data-wipe/wipe-plan.ts | 3 + .../__tests__/full-backup-payload.test.ts | 1 + .../restore-backup-round-trip.test.ts | 1 + src/lib/export/backup-plan.ts | 11 + src/lib/export/full-backup-payload.ts | 18 +- src/lib/export/restore-backup.ts | 15 + src/lib/export/restore-skips.ts | 9 +- src/lib/export/symptoms-backup.ts | 289 +++++++ src/lib/openapi/error-codes.ts | 7 + src/lib/openapi/routes/index.ts | 4 + src/lib/openapi/routes/symptoms.ts | 371 +++++++++ src/lib/record-activity/activity-verb.ts | 12 + src/lib/symptoms/server.ts | 154 ++++ src/lib/symptoms/shared.ts | 154 ++++ src/lib/validations/backup.ts | 40 + tests/fixtures/v137/sharing-matrix.ts | 48 ++ tests/integration/backup-round-trip.test.ts | 112 +++ .../sharing-manage-handler-matrix.test.ts | 179 ++++- 30 files changed, 3091 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/0369_symptom_definitions_and_events/migration.sql create mode 100644 src/app/api/symptoms/definitions/[id]/route.ts create mode 100644 src/app/api/symptoms/definitions/route.ts create mode 100644 src/app/api/symptoms/events/[id]/route.ts create mode 100644 src/app/api/symptoms/events/route.ts create mode 100644 src/lib/export/symptoms-backup.ts create mode 100644 src/lib/openapi/routes/symptoms.ts create mode 100644 src/lib/symptoms/server.ts create mode 100644 src/lib/symptoms/shared.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf0c25eb9..3f58070c3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21908,6 +21908,276 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": *a59 + /api/symptoms/definitions: + get: + tags: + - Symptoms + summary: List your own symptoms (v1.40) + description: The record's active symptom definitions with their 30-day summary; `includeHidden=true` adds the hidden + ones. Rides the illness module. + parameters: + - in: query + name: includeHidden + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: The definitions. + content: + application/json: + schema: + $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" + "401": *a1 + "403": &a61 + description: >- + `illness.disabled`: the record has the illness module switched off (or the operator turned it off + server-wide); symptoms ride that module. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + post: + tags: + - Symptoms + summary: Define a symptom (v1.40) + description: Creates one definition. Idempotent under `Idempotency-Key`. Audits as `symptoms.definition.create` without + the label. Rate-limited to 30 a minute. + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomDefinitionRequest" + responses: + "201": + description: Definition created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" + "401": *a1 + "403": *a61 + "409": *a7 + "422": *a4 + "429": *a5 + /api/symptoms/definitions/{id}: + patch: + tags: + - Symptoms + summary: Edit, hide or show a symptom (v1.40) + description: Partial edit. Audits as `symptoms.definition.update`, naming a label change without quoting it. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomDefinitionRequest" + responses: + "200": + description: Definition updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" + "401": *a1 + "403": *a61 + "404": &a62 + description: "`symptoms.definition.notFound`: no definition with this id on the record." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Symptoms + summary: Hide or delete a symptom (v1.40) + description: Hides the definition and keeps its history. `?purge=true` deletes it and every occurrence of it. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: purge + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: Hidden or deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" + "401": *a1 + "403": *a61 + "404": *a62 + "422": *a4 + "429": *a5 + /api/symptoms/events: + get: + tags: + - Symptoms + summary: List symptom occurrences (v1.40) + description: Occurrences of the record's symptoms, newest first, narrowed by the optional query. + parameters: + - in: query + name: from + schema: + 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)))$ + - in: query + name: to + schema: + 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)))$ + - in: query + name: definitionId + schema: + type: string + minLength: 1 + maxLength: 40 + - in: query + name: episodeId + schema: + type: string + minLength: 1 + maxLength: 40 + - in: query + name: limit + schema: + default: 200 + type: integer + minimum: 1 + maximum: 500 + responses: + "200": + description: A page of occurrences. + content: + application/json: + schema: + $ref: "#/components/schemas/ListSymptomEventsEnvelope" + "401": *a1 + "403": *a61 + "422": *a4 + "429": *a5 + post: + tags: + - Symptoms + summary: Log a symptom occurrence (v1.40) + description: Creates one occurrence. Idempotent under `Idempotency-Key`. A hidden definition can still be logged + against; another record's definition 404s (`symptoms.definition.notFound`). Audits as `symptoms.event.create` + without the note. + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomEventRequest" + responses: + "201": + description: Occurrence logged. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomEventEnvelope" + "401": *a1 + "403": *a61 + "404": *a62 + "409": *a7 + "422": *a4 + "429": *a5 + /api/symptoms/events/{id}: + patch: + tags: + - Symptoms + summary: Edit a symptom occurrence (v1.40) + description: Partial edit. Audits as `symptoms.event.update`, naming a note change without quoting it. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomEventRequest" + responses: + "200": + description: Occurrence updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomEventEnvelope" + "401": *a1 + "403": *a61 + "404": &a63 + description: "`symptoms.event.notFound`: no occurrence with this id on the record." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Symptoms + summary: Delete a symptom occurrence (v1.40) + description: Removes the occurrence. Audits as `symptoms.event.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteSymptomEventEnvelope" + "401": *a1 + "403": *a61 + "404": *a63 + "422": *a4 + "429": *a5 /api/bugreport: post: security: [] @@ -31652,6 +31922,97 @@ components: leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only when rebuilding a host from a snapshot, because those settings apply to every account on the installation." + CreateSymptomDefinitionRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + required: + - label + description: Define a symptom of your own. `label` (1-40 characters) is encrypted at rest; `icon` is one of the cycle + custom-symptom icon names, null for the default glyph. At most eight active definitions per account (422 + `symptoms.definition.limitReached`). + UpdateSymptomDefinitionRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + isActive: + type: boolean + sortOrder: + type: integer + minimum: 0 + maximum: 1000 + description: "Partial edit; an omitted key leaves the column untouched. `isActive: false` hides the symptom (history + kept, slot freed); `isActive: true` shows it again and re-checks the cap." + CreateSymptomEventRequest: + type: object + properties: + definitionId: + type: string + minLength: 1 + maxLength: 40 + occurredAt: + 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)))$ + intensity: + type: integer + minimum: 0 + maximum: 10 + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + episodeId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + required: + - definitionId + - intensity + description: Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and + may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own + illness episodes that is still open (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest. + UpdateSymptomEventRequest: + type: object + properties: + occurredAt: + 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)))$ + intensity: + type: integer + minimum: 0 + maximum: 10 + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + episodeId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + description: "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed + `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept." MedicationTreatmentClass: type: string enum: @@ -31953,14 +32314,15 @@ components: sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, - source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached, - tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, - vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — - workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, - workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, - ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, + symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; + tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, + tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) + — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, @@ -36314,6 +36676,86 @@ components: - onsetAt - dayLogs additionalProperties: {} + symptomDefinitions: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + label: + type: string + minLength: 1 + labelEncrypted: + type: string + minLength: 1 + icon: + anyOf: + - type: string + - type: "null" + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + isActive: + type: boolean + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + events: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + occurredAt: + type: string + minLength: 1 + intensity: + type: integer + minimum: 0 + maximum: 10 + note: + anyOf: + - type: string + - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + episodeId: + anyOf: + - type: string + minLength: 1 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + required: + - occurredAt + - intensity + - createdAt + - updatedAt + additionalProperties: {} + required: + - sortOrder + - isActive + - createdAt + - updatedAt + - events + additionalProperties: {} allergies: default: [] type: array @@ -38828,6 +39270,7 @@ components: - labResults - biomarkers - illnessEpisodes + - symptomDefinitions - allergies - familyHistory - workouts @@ -75632,6 +76075,293 @@ components: - data - error additionalProperties: false + ListSymptomDefinitionsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinitionList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SymptomDefinitionList: + type: object + properties: + definitions: + type: array + items: + $ref: "#/components/schemas/SymptomDefinition" + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - definitions + - limit + additionalProperties: false + description: The record's symptom definitions in display order, and the active-definition cap. + SymptomDefinition: + type: object + properties: + id: + type: string + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + isActive: + type: boolean + recent: + type: object + properties: + count30d: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + maxIntensity30d: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lastOccurredAt: + anyOf: + - type: string + - type: "null" + required: + - count30d + - maxIntensity30d + - lastOccurredAt + additionalProperties: false + required: + - id + - label + - icon + - sortOrder + - isActive + - recent + additionalProperties: false + description: "A symptom the person defined. `label` is decrypted (null only on an unreadable value). `recent` is + computed server-side: occurrences and the highest intensity over the last 30 days, and the last occurrence ever. + Each active definition is also the correlation channel `SYMPTOM:`." + CreateSymptomDefinitionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinition" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateSymptomDefinitionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinition" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteSymptomDefinitionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinitionDeleted" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SymptomDefinitionDeleted: + type: object + properties: + deleted: + type: boolean + const: true + purged: + type: boolean + eventsDeleted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - deleted + - purged + - eventsDeleted + additionalProperties: false + description: "`purged: false` means the definition was hidden and its history kept; `purged: true` means it and + `eventsDeleted` occurrences were removed." + ListSymptomEventsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomEventList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SymptomEventList: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/SymptomEvent" + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + hasMore: + type: boolean + required: + - events + - limit + - hasMore + additionalProperties: false + description: A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the + oldest `occurredAt` seen. + SymptomEvent: + type: object + properties: + id: + type: string + definitionId: + type: string + occurredAt: + type: string + intensity: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + note: + anyOf: + - type: string + - type: "null" + episodeId: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - definitionId + - occurredAt + - intensity + - note + - episodeId + - createdAt + - updatedAt + additionalProperties: false + description: "One occurrence of a defined symptom: when, how strong (0-10), the decrypted note, and the illness episode + it was filed against (null when none)." + CreateSymptomEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateSymptomEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteSymptomEventEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false __schema0: anyOf: - type: string diff --git a/prisma/migrations/0369_symptom_definitions_and_events/migration.sql b/prisma/migrations/0369_symptom_definitions_and_events/migration.sql new file mode 100644 index 000000000..a6dc2d55a --- /dev/null +++ b/prisma/migrations/0369_symptom_definitions_and_events/migration.sql @@ -0,0 +1,73 @@ +-- Person-defined symptoms and their timestamped events. A definition is the +-- person's own name for a symptom (encrypted); an event is one occurrence with +-- a 0-10 intensity and an optional link to an illness episode. Idempotent, so +-- a re-run of a half-applied migration converges. +CREATE TABLE IF NOT EXISTS "symptom_definitions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "label_encrypted" BYTEA NOT NULL, + "icon" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "symptom_definitions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "symptom_definitions_user_id_sort_order_idx" + ON "symptom_definitions" ("user_id", "sort_order"); + +DO $$ BEGIN + ALTER TABLE "symptom_definitions" + ADD CONSTRAINT "symptom_definitions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS "symptom_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "definition_id" TEXT NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "intensity" INTEGER NOT NULL, + "note_encrypted" BYTEA, + "episode_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "symptom_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "symptom_events_user_id_occurred_at_idx" + ON "symptom_events" ("user_id", "occurred_at"); +CREATE INDEX IF NOT EXISTS "symptom_events_definition_id_occurred_at_idx" + ON "symptom_events" ("definition_id", "occurred_at"); +CREATE INDEX IF NOT EXISTS "symptom_events_episode_id_idx" + ON "symptom_events" ("episode_id"); + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_intensity_check" + CHECK ("intensity" >= 0 AND "intensity" <= 10); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_definition_id_fkey" + FOREIGN KEY ("definition_id") REFERENCES "symptom_definitions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_episode_id_fkey" + FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36dbabbce..b88d38179 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -911,6 +911,8 @@ model User { // per-day timeline mirroring the CycleDayLog shape. illnessEpisodes IllnessEpisode[] illnessDayLogs IllnessDayLog[] + symptomDefinitions SymptomDefinition[] + symptomEvents SymptomEvent[] // v1.25 (W-RECORDS) — structured health-record entries. Allergies map to // FHIR AllergyIntolerance; family-history entries map to FHIR // FamilyMemberHistory. Reference records (not time-series signals); the @@ -7726,6 +7728,8 @@ model IllnessEpisode { dayLogs IllnessDayLog[] documentLinks DocumentConditionLink[] encounterLinks EncounterConditionLink[] + /// Symptom events the person filed against this episode while it was open. + symptomEvents SymptomEvent[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -7814,6 +7818,68 @@ model IllnessSymptomLink { @@map("illness_symptom_links") } +/// A symptom the person defined for themselves ("aura", "headache"), v1.40. +/// +/// Separate from the seeded `IllnessSymptom` catalogue on the illness day log: +/// that one is a fixed list of cold symptoms rated 1-3 per episode day, this one +/// is the person's own vocabulary, logged as timestamped events with a 0-10 +/// intensity whether or not an episode is open. Rides the `illness` module. +/// At most eight active definitions per account +/// (`MAX_ACTIVE_SYMPTOM_DEFINITIONS`), and each active one is its own +/// correlation channel `SYMPTOM:`, so the cap also bounds the matrix. +model SymptomDefinition { + id String @id @default(cuid()) + userId String @map("user_id") + /// The person's own name for the symptom, AES-256-GCM encrypted at rest. A + /// symptom name is health data in the person's own words. + labelEncrypted Bytes @map("label_encrypted") + /// Lucide icon name from `SYMPTOM_ICON_ALLOWLIST` (the cycle custom-symptom + /// list, which the iOS client already maps). NULL = the default glyph. + icon String? + sortOrder Int @default(0) @map("sort_order") + /// FALSE = hidden: not offered in the quick entry, not a correlation + /// channel, history kept. Does not count toward the cap. + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + events SymptomEvent[] + + @@index([userId, sortOrder]) + @@map("symptom_definitions") +} + +/// One occurrence of a person-defined symptom: when, how strong (0-10, the +/// same numeric rating scale the pain score uses), an optional note, and an +/// optional link to the illness episode it happened during. v1.40. +model SymptomEvent { + id String @id @default(cuid()) + userId String @map("user_id") + definitionId String @map("definition_id") + /// The instant it happened (UTC). Day grouping is the reader's concern. + occurredAt DateTime @map("occurred_at") + /// 0-10, CHECK-constrained in the migration. + intensity Int + /// Optional free-text note, AES-256-GCM encrypted at rest. + noteEncrypted Bytes? @map("note_encrypted") + /// The episode the person said this happened during. Only an open episode + /// of the same account can be linked (the route checks); deleting the + /// episode row keeps the event and drops the link. + episodeId String? @map("episode_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + definition SymptomDefinition @relation(fields: [definitionId], references: [id], onDelete: Cascade) + episode IllnessEpisode? @relation(fields: [episodeId], references: [id], onDelete: SetNull) + + @@index([userId, occurredAt]) + @@index([definitionId, occurredAt]) + @@index([episodeId]) + @@map("symptom_events") +} + // ─── Structured health records (v1.25 W-RECORDS) ────────────────── // // Allergies + family history kept as STRUCTURED records (not time-series diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index a814f2365..a3cd1742a 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -594,6 +594,21 @@ async function main() { prisma.illnessDayLog, ), ); + // v1.40 — person-defined symptoms: the name and the per-occurrence note. + results.push( + await rotateBytesColumn( + "SymptomDefinition", + "labelEncrypted", + prisma.symptomDefinition, + ), + ); + results.push( + await rotateBytesColumn( + "SymptomEvent", + "noteEncrypted", + prisma.symptomEvent, + ), + ); // ───── v1.19.0 ECG waveform (Bytes column) ───── // "waveformEncrypted" holds the JSON-encoded micro-volt sample array in the diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 9a5e17cd0..e13fa70b6 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -991,6 +991,22 @@ const DELEGABLE_ROUTES: Record = { domain: "illness", why: "The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.", }, + "app/api/symptoms/definitions/route.ts": { + domain: "illness", + why: "The record's own symptom list. Module-gated on the record on both arms and scoped `userId: user.id`; the create arm is MANAGE (see the manage literal), because the vocabulary is the owner's and a delegate who may log an occurrence picks from it.", + }, + "app/api/symptoms/definitions/[id]/route.ts": { + domain: "illness", + why: "One symptom definition of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line.", + }, + "app/api/symptoms/events/route.ts": { + domain: "illness", + why: "The record's symptom occurrences. Module-gated on the record; the definition and the optional episode a create names are both re-narrowed to the resolved user before anything is written, so a delegate cannot file one record's occurrence under another's episode. The create arm is a delegable write.", + }, + "app/api/symptoms/events/[id]/route.ts": { + domain: "illness", + why: "One symptom occurrence of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -1427,6 +1443,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.", "app/api/illness/episodes/route.ts": "Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.", + "app/api/symptoms/events/route.ts": + "Logging a symptom occurrence. The definition and the episode the body names are looked up under the RECORD, and an episode must still be open, so a delegate cannot attach an occurrence to an episode the owner closed or to another record's.", "app/api/encounters/route.ts": "Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.", "app/api/practitioners/route.ts": @@ -1596,6 +1614,21 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C3"], why: "Writing a day of an episode. The upsert revives a tombstoned day and replaces that day's symptom links, so C3 names the date, the previous symptom set, and the revival.", }, + "app/api/symptoms/definitions/route.ts": { + domain: "illness", + conditions: ["C1"], + why: "Defining a symptom on the record. Capped at eight active ones; C1 so a manager burns their own bucket rather than the owner's.", + }, + "app/api/symptoms/definitions/[id]/route.ts": { + domain: "illness", + conditions: ["C1", "C3", "C4"], + why: "Renaming, hiding and deleting a symptom. Hiding keeps the history; the purge is hard and takes the occurrences with it, so C3 names the definition and the count. C4 on the edit, over the plaintext fields only; a rename is named, never quoted. C1 on the bucket.", + }, + "app/api/symptoms/events/[id]/route.ts": { + domain: "illness", + conditions: ["C3", "C4"], + why: "Correcting and removing one occurrence. The delete is hard, so C3 carries its time and intensity; C4 on the edit, the encrypted note named and never quoted.", + }, "app/api/mood-entries/route.ts": { domain: "mind", conditions: ["C6"], @@ -1960,8 +1993,13 @@ const ACTOR_ROUTES: Record = { * v1.39.2 -- the body-site view adds one: a read on the record list in the * visits' domain, with conditions joined only under an illness grant. * 231 -> 232. + * + * v1.40 -- person-defined symptoms add eight: four modules on the record + * list, the occurrence create on the write literal, and the definition create, + * the definition edit/delete and the occurrence edit/delete on the manage + * literal. 232 -> 240. */ -const FROZEN_ENTRY_COUNT = 232; +const FROZEN_ENTRY_COUNT = 240; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2722,7 +2760,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(83); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 34bfae2db..f775c3e5d 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(83); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/symptoms/definitions/[id]/route.ts b/src/app/api/symptoms/definitions/[id]/route.ts new file mode 100644 index 000000000..e578f4a30 --- /dev/null +++ b/src/app/api/symptoms/definitions/[id]/route.ts @@ -0,0 +1,232 @@ +/** + * One person-defined symptom (v1.40). + * + * PATCH /api/symptoms/definitions/{id} — rename, change the icon, reorder, + * hide or show again. Showing a hidden one re-clears the cap. + * DELETE /api/symptoms/definitions/{id} — hide by default (history kept); + * `?purge=true` deletes the definition and every event of it. + * + * Owner-scoped: another account's id is a 404. Module-gated on the record. + * The label is never logged. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { + encryptSymptomText, + symptomDefinitionUpdateSchema, + toSymptomDefinitionDTO, + MAX_ACTIVE_SYMPTOM_DEFINITIONS, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +function notFound(): Response { + return apiError("Symptom not found", 404, { + errorCode: "symptoms.definition.notFound", + }); +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, actor } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `symptoms:definition:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { id } = await params; + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomDefinitionUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + + const existing = await prisma.symptomDefinition.findFirst({ + where: { id, userId: user.id }, + select: { id: true, icon: true, isActive: true, sortOrder: true }, + }); + if (!existing) return notFound(); + + // A hidden definition does not hold a slot; showing it again takes one. + if (parsed.data.isActive === true && !existing.isActive) { + const activeCount = await prisma.symptomDefinition.count({ + where: { userId: user.id, isActive: true }, + }); + if (activeCount >= MAX_ACTIVE_SYMPTOM_DEFINITIONS) { + return apiError( + `Symptom limit reached (${MAX_ACTIVE_SYMPTOM_DEFINITIONS})`, + 422, + { + errorCode: "symptoms.definition.limitReached", + limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS, + }, + ); + } + } + + const updated = await prisma.symptomDefinition.update({ + where: { id: existing.id }, + data: { + ...(parsed.data.label !== undefined + ? { labelEncrypted: encryptSymptomText(parsed.data.label) } + : {}), + ...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + ...(parsed.data.sortOrder !== undefined + ? { sortOrder: parsed.data.sortOrder } + : {}), + }, + select: { + id: true, + labelEncrypted: true, + icon: true, + sortOrder: true, + isActive: true, + }, + }); + + await auditLog("symptoms.definition.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + definitionId: id, + ...overwriteDetails({ + before: { + icon: existing.icon, + isActive: existing.isActive, + sortOrder: existing.sortOrder, + }, + after: { + icon: updated.icon, + isActive: updated.isActive, + sortOrder: updated.sortOrder, + }, + redacted: parsed.data.label !== undefined ? ["label"] : [], + }), + }, + }); + + // Hiding or showing one changes the correlation channel set and the + // Coach's symptom block. + if (parsed.data.isActive !== undefined) + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.definition.update", + entity_type: "symptom_definition", + entity_id: id, + }, + }); + + return apiSuccess(toSymptomDefinitionDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, actor } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `symptoms:definition:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { id } = await params; + const existing = await prisma.symptomDefinition.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + createdAt: true, + _count: { select: { events: true } }, + }, + }); + if (!existing) return notFound(); + + const purge = request.nextUrl.searchParams.get("purge") === "true"; + if (purge) { + // The FK cascade removes the events with it. + await prisma.symptomDefinition.delete({ where: { id: existing.id } }); + } else { + await prisma.symptomDefinition.update({ + where: { id: existing.id }, + data: { isActive: false }, + }); + } + + await auditLog("symptoms.definition.delete", { + userId: user.id, + ipAddress: getClientIp(request), + // C3 on the purge arm: the definition and its events are gone, so the + // audit row names what went. The label stays out (the person's words). + details: purge + ? destroyedDetails({ + model: "SymptomDefinition", + id, + label: null, + effectiveAt: existing.createdAt, + extra: { eventsDeleted: existing._count.events }, + }) + : { definitionId: id, purge }, + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.definition.delete", + entity_type: "symptom_definition", + entity_id: id, + }, + meta: { purge }, + }); + + return apiSuccess({ + deleted: true, + purged: purge, + eventsDeleted: purge ? existing._count.events : 0, + }); + }, +); diff --git a/src/app/api/symptoms/definitions/route.ts b/src/app/api/symptoms/definitions/route.ts new file mode 100644 index 000000000..ddcc7df04 --- /dev/null +++ b/src/app/api/symptoms/definitions/route.ts @@ -0,0 +1,139 @@ +/** + * Person-defined symptoms (v1.40). + * + * GET /api/symptoms/definitions — the record's own symptom list with a + * 30-day summary per symptom. `?includeHidden=true` adds hidden ones. + * POST /api/symptoms/definitions — define one (`{ label, icon? }`). At most + * eight active definitions; the label is encrypted at rest. + * + * Rides the illness module: an account (or instance) with it off answers 403 + * `illness.disabled` even to a valid Bearer token. The label is the person's + * own words for a health complaint, so it never reaches an audit row or a + * wide event. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { + encryptSymptomText, + listSymptomDefinitions, + symptomDefinitionCreateSchema, + toSymptomDefinitionDTO, + MAX_ACTIVE_SYMPTOM_DEFINITIONS, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const includeHidden = + request.nextUrl.searchParams.get("includeHidden") === "true"; + const definitions = await listSymptomDefinitions(user.id, { includeHidden }); + + annotate({ + action: { name: "symptoms.definition.list" }, + meta: { count: definitions.length, includeHidden }, + }); + + return apiSuccess({ definitions, limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS }); +}); + +/** + * Wrapped in `withIdempotency`: the label is ciphertext under a fresh IV, so + * no unique index can catch a replayed create, and a retry after a lost + * response would otherwise mint a second "aura" and burn a slot of eight. + */ +export const POST = apiHandler(withIdempotency<[NextRequest]>(postDefinition)); + +async function postDefinition(request: NextRequest): Promise { + // MANAGE, like the cycle module's own symptom list: the vocabulary of a + // record is the owner's, a delegate who may log an occurrence picks from it. + const { user, actor } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `symptoms:definition:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomDefinitionCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + + const activeCount = await prisma.symptomDefinition.count({ + where: { userId: user.id, isActive: true }, + }); + if (activeCount >= MAX_ACTIVE_SYMPTOM_DEFINITIONS) { + return apiError( + `Symptom limit reached (${MAX_ACTIVE_SYMPTOM_DEFINITIONS})`, + 422, + { + errorCode: "symptoms.definition.limitReached", + limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS, + }, + ); + } + + const created = await prisma.symptomDefinition.create({ + data: { + userId: user.id, + labelEncrypted: encryptSymptomText(parsed.data.label), + icon: parsed.data.icon ?? null, + sortOrder: activeCount, + }, + select: { + id: true, + labelEncrypted: true, + icon: true, + sortOrder: true, + isActive: true, + }, + }); + + await auditLog("symptoms.definition.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { definitionId: created.id, icon: created.icon }, + }); + + annotate({ + action: { + name: "symptoms.definition.create", + entity_type: "symptom_definition", + entity_id: created.id, + }, + meta: { icon: created.icon }, + }); + + return apiSuccess(toSymptomDefinitionDTO(created), 201); +} diff --git a/src/app/api/symptoms/events/[id]/route.ts b/src/app/api/symptoms/events/[id]/route.ts new file mode 100644 index 000000000..82928b38a --- /dev/null +++ b/src/app/api/symptoms/events/[id]/route.ts @@ -0,0 +1,192 @@ +/** + * One occurrence of a person-defined symptom (v1.40). + * + * PATCH /api/symptoms/events/{id} — change the time, intensity, note or + * episode link. A new link must name an open episode of the record; + * an unchanged link to an episode that has since resolved is kept. + * DELETE /api/symptoms/events/{id} — remove it. + * + * Owner-scoped (another account's id is a 404), module-gated on the record, + * MANAGE on a shared record. The note is never logged. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { + encryptSymptomText, + isLinkableEpisode, + symptomEventUpdateSchema, + toSymptomEventDTO, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +function notFound(): Response { + return apiError("Symptom event not found", 404, { + errorCode: "symptoms.event.notFound", + }); +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomEventUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + const entry = parsed.data; + + const existing = await prisma.symptomEvent.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + occurredAt: true, + intensity: true, + episodeId: true, + }, + }); + if (!existing) return notFound(); + + if ( + entry.episodeId && + entry.episodeId !== existing.episodeId && + !(await isLinkableEpisode(user.id, entry.episodeId)) + ) { + return apiError("Episode is not open", 422, { + errorCode: "symptoms.episode.notOpen", + }); + } + + const updated = await prisma.symptomEvent.update({ + where: { id: existing.id }, + data: { + ...(entry.occurredAt !== undefined + ? { occurredAt: entry.occurredAt } + : {}), + ...(entry.intensity !== undefined + ? { intensity: entry.intensity } + : {}), + ...(entry.note !== undefined + ? { + noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null, + } + : {}), + ...(entry.episodeId !== undefined + ? { episodeId: entry.episodeId ?? null } + : {}), + }, + }); + + await auditLog("symptoms.event.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + eventId: id, + ...overwriteDetails({ + before: { + occurredAt: existing.occurredAt, + intensity: existing.intensity, + episodeId: existing.episodeId, + }, + after: { + occurredAt: updated.occurredAt, + intensity: updated.intensity, + episodeId: updated.episodeId, + }, + redacted: entry.note !== undefined ? ["note"] : [], + }), + }, + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.event.update", + entity_type: "symptom_event", + entity_id: id, + }, + }); + + return apiSuccess(toSymptomEventDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const existing = await prisma.symptomEvent.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + definitionId: true, + occurredAt: true, + intensity: true, + }, + }); + if (!existing) return notFound(); + + await prisma.symptomEvent.delete({ where: { id: existing.id } }); + + await auditLog("symptoms.event.delete", { + userId: user.id, + ipAddress: getClientIp(request), + // C3: the row is gone after this; the audit row says what it was. The + // symptom's name is the person's own words and stays out of it, so the + // label is the definition id. + details: destroyedDetails({ + model: "SymptomEvent", + id, + label: existing.definitionId, + effectiveAt: existing.occurredAt, + extra: { intensity: existing.intensity }, + }), + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.event.delete", + entity_type: "symptom_event", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/symptoms/events/route.ts b/src/app/api/symptoms/events/route.ts new file mode 100644 index 000000000..447502a0b --- /dev/null +++ b/src/app/api/symptoms/events/route.ts @@ -0,0 +1,166 @@ +/** + * Occurrences of person-defined symptoms (v1.40). + * + * GET /api/symptoms/events — newest first, optionally narrowed by + * `from` / `to` (ISO instants), `definitionId` and `episodeId`; at most + * 500 per page. + * POST /api/symptoms/events — log one (`{ definitionId, intensity, + * occurredAt?, note?, episodeId? }`). `occurredAt` defaults to now and + * may not lie in the future beyond the shared five-minute skew; + * `episodeId` must name one of the record's own episodes that is still + * open. The note is encrypted at rest. + * + * Module-gated on the record (`illness.disabled`). `userId` comes from auth + * and is never a body field. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import type { Prisma } from "@/generated/prisma/client"; +import { + encryptSymptomText, + isLinkableEpisode, + symptomEventCreateSchema, + symptomEventListQuerySchema, + toSymptomEventDTO, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const sp = request.nextUrl.searchParams; + const parsed = symptomEventListQuerySchema.safeParse({ + from: sp.get("from") ?? undefined, + to: sp.get("to") ?? undefined, + definitionId: sp.get("definitionId") ?? undefined, + episodeId: sp.get("episodeId") ?? undefined, + limit: sp.get("limit") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + const q = parsed.data; + + const occurredAt: Prisma.DateTimeFilter = {}; + if (q.from) occurredAt.gte = new Date(q.from); + if (q.to) occurredAt.lte = new Date(q.to); + + const rows = await prisma.symptomEvent.findMany({ + where: { + userId: user.id, + ...(q.from || q.to ? { occurredAt } : {}), + ...(q.definitionId ? { definitionId: q.definitionId } : {}), + ...(q.episodeId ? { episodeId: q.episodeId } : {}), + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: q.limit, + }); + + annotate({ + action: { name: "symptoms.event.list" }, + meta: { count: rows.length, limit: q.limit }, + }); + + return apiSuccess({ + events: rows.map(toSymptomEventDTO), + limit: q.limit, + hasMore: rows.length === q.limit, + }); +}); + +// A retry from a flaky connection re-sends the same `Idempotency-Key` and gets +// the first answer back instead of a second occurrence at the same minute. +export const POST = apiHandler(withIdempotency<[NextRequest]>(postEvent)); + +async function postEvent(request: NextRequest): Promise { + // WRITE, like opening an episode or adding a reading: logging what happened + // is an admitted delegated write; editing or removing it is MANAGE. + const { user } = await requireRecordAuth("write", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomEventCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + const entry = parsed.data; + + // A hidden definition stays loggable from an older client's cached list; + // only another account's id (or none) is refused. + const definition = await prisma.symptomDefinition.findFirst({ + where: { id: entry.definitionId, userId: user.id }, + select: { id: true }, + }); + if (!definition) { + return apiError("Symptom not found", 404, { + errorCode: "symptoms.definition.notFound", + }); + } + + if (entry.episodeId && !(await isLinkableEpisode(user.id, entry.episodeId))) { + return apiError("Episode is not open", 422, { + errorCode: "symptoms.episode.notOpen", + }); + } + + const created = await prisma.symptomEvent.create({ + data: { + userId: user.id, + definitionId: definition.id, + occurredAt: entry.occurredAt ?? new Date(), + intensity: entry.intensity, + noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null, + episodeId: entry.episodeId ?? null, + }, + }); + + await auditLog("symptoms.event.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + eventId: created.id, + definitionId: created.definitionId, + linked: created.episodeId !== null, + }, + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.event.create", + entity_type: "symptom_event", + entity_id: created.id, + }, + meta: { linked: created.episodeId !== null }, + }); + + return apiSuccess(toSymptomEventDTO(created), 201); +} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index bcd8cca24..3a0550cc5 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -461,6 +461,9 @@ function catalogueLabel( if (catalogue === "ecgReference") { return t("admin.section.backups.restoreSkippedEcgReference"); } + if (catalogue === "symptomEpisodeReference") { + return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); + } if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 67d37b102..21deb72c0 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -310,6 +310,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // (`laterality`) stays plaintext, as on `Encounter`. { model: "IllnessEpisode", field: "bodySiteEncrypted", kind: "bytes" }, { model: "IllnessDayLog", field: "noteEncrypted", kind: "bytes" }, + // v1.40 — the person's own symptom names and the note on one occurrence. + { model: "SymptomDefinition", field: "labelEncrypted", kind: "bytes" }, + { model: "SymptomEvent", field: "noteEncrypted", kind: "bytes" }, // ───── v1.19.0 ECG waveform (Bytes column) ───── { model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" }, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 60512c4cf..c9b2926c3 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -123,6 +123,8 @@ export const WIPE_MODELS = [ "Allergy", "FamilyHistoryEntry", "IllnessDayLog", + // Before its definition and before the episode it may point at (SetNull). + "SymptomEvent", "EcgRecording", "NutrientIntakeDay", @@ -251,6 +253,7 @@ export const WIPE_MODELS = [ "MoodTagCategory", "CustomMetric", "IllnessEpisode", + "SymptomDefinition", "Workout", "InboundDocument", // Encounters point here with SetNull, so the address book outlives the diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index c4a65b1ec..d1a242631 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -426,6 +426,7 @@ function makePrisma() { // an account whose watch has never taken one is the ordinary case the // builder must still answer for. ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, + symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index a988ddfed..116fdf6d6 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -312,6 +312,7 @@ function sourceClient() { documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, + symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 6af2a3d66..14118bde3 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -95,6 +95,10 @@ export const BACKED_UP_MODELS = [ "IllnessEpisode", "IllnessDayLog", "IllnessSymptomLink", + // v1.40 — the person's own symptoms and every occurrence of them. Nothing + // else holds either; a restore without them loses the history outright. + "SymptomDefinition", + "SymptomEvent", "EcgRecording", "UserHealthProfile", "HealthProfileFactRevision", @@ -250,6 +254,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // because that reference is a pointer between two independently carried // tables and not a parent-child ride. "src/lib/export/ecg-backup.ts", + // The person's own symptoms, both ends beside each other; the occurrences + // ride inside their definition (`include: { events }` out, `events: { + // create }` back), the way a medication's side effects do. + "src/lib/export/symptoms-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -268,6 +276,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/environment-backup.ts", "src/lib/export/onboarding-backup.ts", "src/lib/export/ecg-backup.ts", + "src/lib/export/symptoms-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -308,6 +317,8 @@ export const TWO_ENDED_MODELS = [ "IllnessEpisode", "IllnessDayLog", "IllnessSymptomLink", + "SymptomDefinition", + "SymptomEvent", "UserHealthProfile", "HealthProfileFactRevision", "CycleProfile", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5f5e35fed..a57822647 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -106,6 +106,12 @@ import { type EcgBackupCounts, type EcgBackupSection, } from "@/lib/export/ecg-backup"; +import { + buildSymptomsBackupSection, + countSymptomsBackupSection, + type SymptomsBackupCounts, + type SymptomsBackupSection, +} from "@/lib/export/symptoms-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -140,7 +146,8 @@ export interface FullBackupCounts AwardsBackupCounts, EnvironmentBackupCounts, OnboardingBackupCounts, - EcgBackupCounts { + EcgBackupCounts, + SymptomsBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -710,6 +717,7 @@ export async function buildFullBackupPayload( environment, onboardingRecord, ecg, + symptoms, nutrientDays, accountSettings, ] = await Promise.all([ @@ -886,6 +894,11 @@ export async function buildFullBackupPayload( buildEcgBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The person's own symptoms with their occurrences nested inside. Both + // ends live in `src/lib/export/symptoms-backup.ts`. + buildSymptomsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -939,6 +952,7 @@ export async function buildFullBackupPayload( const awardsSection: AwardsBackupSection = awards; const environmentSection: EnvironmentBackupSection = environment; const ecgSection: EcgBackupSection = ecg; + const symptomsSection: SymptomsBackupSection = symptoms; const accountSettingsSection: AccountSettingsBackupSection = accountSettings; // The three unbounded tables, either read into arrays or left as markers @@ -1306,6 +1320,7 @@ export async function buildFullBackupPayload( ...awardsSection, ...environmentSection, ...ecgSection, + ...symptomsSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -1373,6 +1388,7 @@ export async function buildFullBackupPayload( ...countAwardsBackupSection(awards), ...countEnvironmentBackupSection(environment), ...countEcgBackupSection(ecg), + ...countSymptomsBackupSection(symptoms), }, }; } diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 8b80622d1..53e6608b9 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -106,6 +106,7 @@ import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup"; import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; +import { restoreSymptomsData } from "@/lib/export/symptoms-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; import { ensureMedicationCategoryTable, @@ -179,6 +180,7 @@ export interface RestoreResponse { environmentContexts: number; environmentTravelLocations: number; ecgRecordings: number; + symptomDefinitions: number; }; } @@ -2079,6 +2081,18 @@ export async function restoreBackup( } } + // The person's own symptoms and their occurrences. After the episodes, + // because an occurrence may point at one; the id set is the one this + // branch just wrote. Both ends live in + // `src/lib/export/symptoms-backup.ts`. + const symptomsCleared = await restoreSymptomsData( + tx, + ownerId, + payload, + episodeIds, + skips, + ); + reportSection("allergies"); for (const allergy of payload.allergies) { await tx.allergy.create({ @@ -2460,6 +2474,7 @@ export async function restoreBackup( environmentTravelLocations: environmentCleared.environmentTravelLocations, ecgRecordings: ecgCleared.ecgRecordings, + symptomDefinitions: symptomsCleared.symptomDefinitions, }; assertNoNewForeignReferences( foreignBefore, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 5e98d1b4b..d79d09bbd 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -146,6 +146,12 @@ * know, a threshold out of range, an avatar the upload would refuse. Its key * is the column (or `column.part` for one band or one site), never the value. * The account keeps what it had for that setting; everything else comes back. + * + * The fifteenth, `symptomEpisodeReference`, names the illness episode a + * symptom occurrence was filed against that the restore did not put back. A + * real foreign key, so the POINTER is dropped and the occurrence restores + * unlinked. A portable export omits soft-deleted episodes, which is the + * ordinary way an occurrence ends up naming one the file does not carry. */ export type SkippedCatalogue = | "cycleSymptom" @@ -165,7 +171,8 @@ export type SkippedCatalogue = | "medicationTarget" | "scheduleRevisionLink" | "checkupClosure" - | "accountSetting"; + | "accountSetting" + | "symptomEpisodeReference"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/export/symptoms-backup.ts b/src/lib/export/symptoms-backup.ts new file mode 100644 index 000000000..c38f8a80c --- /dev/null +++ b/src/lib/export/symptoms-backup.ts @@ -0,0 +1,289 @@ +/** + * Person-defined symptoms, with both backup ends in one file (v1.40). + * + * Same arrangement as `ecg-backup.ts` and `reminders-backup.ts`: a reader + * asking "is this carried at both ends?" answers it here, and a reader who + * greps only the restore route gets a false negative because the route + * delegates. + * + * ## Events ride inside their definition + * + * An occurrence means nothing without the symptom it is an occurrence of, so + * the file nests each definition's events under it, the way a medication's + * side effects ride inside the medication. A nested create binds the child to + * whatever id the parent row actually got, so there is no definition reference + * to resolve and none that can dangle. + * + * ## Free text follows the note contract + * + * A disaster-recovery payload carries the label and note ciphertext verbatim + * as base64 (the same instance's key reads it back unchanged). A portable + * export decrypts both, because a portable file exists to be readable by the + * person who owns it and to restore under another instance's key. A label this + * instance cannot decrypt travels as the visible unreadable marker rather than + * as nothing, the rule `records-backup.ts` states for notes: the definition is + * still the person's, and an empty name would read as one they never gave. + * + * ## The one reference that needs care + * + * `episodeId` addresses an illness episode and is a real foreign key. A + * portable export omits soft-deleted episodes, so an occurrence filed against + * an episode the person has since deleted names a row the file does not carry. + * The restore resolves the reference against the episodes it actually wrote + * and NULLS a miss (reported as `symptomEpisodeReference`) rather than writing + * it and rolling the whole restore back. The occurrence is the record; the + * episode link is a cross-reference. + */ +import { Buffer } from "node:buffer"; + +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker"; + +export interface SymptomsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One occurrence, nested under its definition. */ +export interface SymptomEventBackupEntry { + /** Present in a disaster-recovery payload. */ + id?: string; + occurredAt: string; + intensity: number; + /** Plaintext; present on a portable payload. */ + note?: string | null; + /** Ciphertext as base64; present on a disaster-recovery payload. */ + noteEncrypted?: string | null; + episodeId: string | null; + createdAt: string; + updatedAt: string; +} + +/** One definition and every occurrence of it. */ +export interface SymptomDefinitionBackupEntry { + /** Present in a disaster-recovery payload. */ + id?: string; + /** Plaintext; present on a portable payload. */ + label?: string; + /** Ciphertext as base64; present on a disaster-recovery payload. */ + labelEncrypted?: string; + icon: string | null; + sortOrder: number; + isActive: boolean; + createdAt: string; + updatedAt: string; + events: SymptomEventBackupEntry[]; +} + +export interface SymptomsBackupSection { + symptomDefinitions: SymptomDefinitionBackupEntry[]; +} + +export interface SymptomsBackupCounts { + symptomDefinitions: number; + symptomEvents: number; +} + +function base64(buf: Uint8Array): string { + return Buffer.from(buf).toString("base64"); +} + +function readTextForExport(buf: Uint8Array, field: string): string { + try { + return decryptFromBytes(buf); + } catch (err) { + getEvent()?.addWarning( + `symptom ${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + +/** + * Build the symptoms slice of a user's full backup. The tables carry no + * tombstone, so both purposes read the same rows and differ only in how the + * free text travels. + */ +export async function buildSymptomsBackupSection( + prisma: Pick, + userId: string, + options: SymptomsBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + const rows = await prisma.symptomDefinition.findMany({ + where: { userId }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], + include: { + events: { orderBy: [{ occurredAt: "asc" }, { id: "asc" }] }, + }, + }); + + return { + symptomDefinitions: rows.map((row) => ({ + ...(disasterRecovery + ? { id: row.id, labelEncrypted: base64(row.labelEncrypted) } + : { label: readTextForExport(row.labelEncrypted, "label") }), + icon: row.icon, + sortOrder: row.sortOrder, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + events: row.events.map((event) => ({ + ...(disasterRecovery + ? { + id: event.id, + noteEncrypted: event.noteEncrypted + ? base64(event.noteEncrypted) + : null, + } + : { + note: event.noteEncrypted + ? readTextForExport(event.noteEncrypted, "note") + : null, + }), + occurredAt: event.occurredAt.toISOString(), + intensity: event.intensity, + episodeId: event.episodeId, + createdAt: event.createdAt.toISOString(), + updatedAt: event.updatedAt.toISOString(), + })), + })), + }; +} + +export function countSymptomsBackupSection( + section: SymptomsBackupSection, +): SymptomsBackupCounts { + return { + symptomDefinitions: section.symptomDefinitions.length, + symptomEvents: section.symptomDefinitions.reduce( + (sum, d) => sum + d.events.length, + 0, + ), + }; +} + +/** Counts the symptoms restore wiped, for the audit trail. */ +export interface SymptomsRestoreCleared { + symptomDefinitions: number; +} + +/** + * What the parser hands over, which is looser than what the builder writes: + * an older file carries no key at all, and optional columns arrive as + * `undefined` rather than `null`. + */ +export interface SymptomsRestoreInput { + symptomDefinitions: Array<{ + id?: string | undefined; + label?: string | undefined; + labelEncrypted?: string | undefined; + icon?: string | null | undefined; + sortOrder: number; + isActive: boolean; + createdAt: string; + updatedAt: string; + events: Array<{ + id?: string | undefined; + occurredAt: string; + intensity: number; + note?: string | null | undefined; + noteEncrypted?: string | null | undefined; + episodeId?: string | null | undefined; + createdAt: string; + updatedAt: string; + }>; + }>; +} + +function decodeBytes(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * Re-create the account's symptom definitions and their occurrences. + * + * Delete-then-recreate inside the caller's transaction, like every other + * section; deleting a definition cascades its events. MUST run AFTER the + * illness episodes are restored: `episodeIds` is the set the restore actually + * wrote, passed in rather than re-queried so this stays a pure function of the + * transaction it was handed. + */ +export async function restoreSymptomsData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: SymptomsRestoreInput, + episodeIds: ReadonlySet, + skips: RestoreSkipLog, +): Promise { + const cleared = await tx.symptomDefinition.deleteMany({ + where: { userId: ownerId }, + }); + + const droppedEpisodeRefs: string[] = []; + for (const definition of payload.symptomDefinitions) { + const labelEncrypted = + definition.labelEncrypted !== undefined + ? decodeBytes(definition.labelEncrypted) + : encryptToBytes(definition.label ?? ""); + await tx.symptomDefinition.create({ + data: { + ...(definition.id ? { id: definition.id } : {}), + userId: ownerId, + labelEncrypted, + icon: definition.icon ?? null, + sortOrder: definition.sortOrder, + isActive: definition.isActive, + createdAt: new Date(definition.createdAt), + updatedAt: new Date(definition.updatedAt), + events: { + create: definition.events.map((event) => { + let episodeId = event.episodeId ?? null; + if (episodeId && !episodeIds.has(episodeId)) { + droppedEpisodeRefs.push(episodeId); + episodeId = null; + } + return { + ...(event.id ? { id: event.id } : {}), + userId: ownerId, + occurredAt: new Date(event.occurredAt), + intensity: event.intensity, + noteEncrypted: + event.noteEncrypted !== undefined + ? event.noteEncrypted === null + ? null + : decodeBytes(event.noteEncrypted) + : event.note + ? encryptToBytes(event.note) + : null, + episodeId, + createdAt: new Date(event.createdAt), + updatedAt: new Date(event.updatedAt), + }; + }), + }, + }, + }); + } + + recordUnknownKeys( + skips, + "symptomEpisodeReference", + [...new Set(droppedEpisodeRefs)], + droppedEpisodeRefs, + ); + + return { symptomDefinitions: cleared.count }; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 9f4d83bf7..b157a9918 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -346,6 +346,13 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "source-priority.body.invalid_json", "source-priority.body.invalid_shape", ], + symptoms: [ + "symptoms.definition.limitReached", + "symptoms.definition.notFound", + "symptoms.episode.notOpen", + "symptoms.event.notFound", + "symptoms.invalid", + ], tokens: [ "tokens.documents.ceiling_reached", "tokens.measurements.ceiling_reached", diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 195210048..350bc02ba 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -84,6 +84,7 @@ import { profilePaths } from "./profile"; import { recordSettingsPaths } from "./record-settings"; import { retiredPaths } from "./retired"; import { settingsPaths } from "./settings"; +import { symptomPaths } from "./symptoms"; import { syncPaths } from "./sync"; import { workoutPaths } from "./workouts"; import { coachPrefsSchema } from "./shared"; @@ -177,6 +178,9 @@ export const openApiPaths: NonNullable = { // Papra, searching it and importing picked documents; cookie-only // (appended, spread order is load-bearing). ...documentSourcePaths, + // v1.40 — person-defined symptoms and their occurrences, behind the illness + // module (appended, spread order is load-bearing). + ...symptomPaths, // The environmental-context overview. Its own module because nothing else // owns the surface (appended, spread order is load-bearing). // diff --git a/src/lib/openapi/routes/symptoms.ts b/src/lib/openapi/routes/symptoms.ts new file mode 100644 index 000000000..8f3df7bf4 --- /dev/null +++ b/src/lib/openapi/routes/symptoms.ts @@ -0,0 +1,371 @@ +/** + * OpenAPI route table for person-defined symptoms (`/api/symptoms`, v1.40). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies + * and queries reuse the runtime Zod schemas from `@/lib/symptoms/shared` so the + * wire contract stays single-source; response shapes mirror the DTOs the + * routes serialise (decrypted label and note, the 30-day summary). + * + * Every route rides the illness module: an account that turned it off (or an + * operator-disabled instance) answers 403 `illness.disabled` even to a valid + * Bearer token. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + symptomDefinitionCreateSchema, + symptomDefinitionUpdateSchema, + symptomEventCreateSchema, + symptomEventListQuerySchema, + symptomEventUpdateSchema, +} from "@/lib/symptoms/shared"; + +import { + dataEnvelope, + errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, + recordRefusal, + stdResponses, +} from "./shared"; + +const MODULE_OFF = + "`illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module."; + +const createSymptomDefinitionRequest = symptomDefinitionCreateSchema.meta({ + id: "CreateSymptomDefinitionRequest", + description: + "Define a symptom of your own. `label` (1-40 characters) is encrypted at rest; `icon` is one of the cycle custom-symptom icon names, null for the default glyph. At most eight active definitions per account (422 `symptoms.definition.limitReached`).", +}); + +const updateSymptomDefinitionRequest = symptomDefinitionUpdateSchema.meta({ + id: "UpdateSymptomDefinitionRequest", + description: + "Partial edit; an omitted key leaves the column untouched. `isActive: false` hides the symptom (history kept, slot freed); `isActive: true` shows it again and re-checks the cap.", +}); + +const createSymptomEventRequest = symptomEventCreateSchema.meta({ + id: "CreateSymptomEventRequest", + description: + "Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own illness episodes that is still open (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest.", +}); + +const updateSymptomEventRequest = symptomEventUpdateSchema.meta({ + id: "UpdateSymptomEventRequest", + description: + "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept.", +}); + +const listSymptomEventsQuery = symptomEventListQuerySchema.meta({ + id: "ListSymptomEventsQuery", + description: + "Optional `from` / `to` (ISO instants, inclusive), `definitionId`, `episodeId`, and `limit` (1-500, default 200). Newest first.", +}); + +const symptomDefinition = z + .object({ + id: z.string(), + label: z.string().nullable(), + icon: z.string().nullable(), + sortOrder: z.number().int(), + isActive: z.boolean(), + recent: z.object({ + count30d: z.number().int(), + maxIntensity30d: z.number().int().nullable(), + lastOccurredAt: z.string().nullable(), + }), + }) + .meta({ + id: "SymptomDefinition", + description: + "A symptom the person defined. `label` is decrypted (null only on an unreadable value). `recent` is computed server-side: occurrences and the highest intensity over the last 30 days, and the last occurrence ever. Each active definition is also the correlation channel `SYMPTOM:`.", + }); + +const symptomDefinitionList = z + .object({ + definitions: z.array(symptomDefinition), + limit: z.number().int(), + }) + .meta({ + id: "SymptomDefinitionList", + description: + "The record's symptom definitions in display order, and the active-definition cap.", + }); + +const symptomEvent = z + .object({ + id: z.string(), + definitionId: z.string(), + occurredAt: z.string(), + intensity: z.number().int(), + note: z.string().nullable(), + episodeId: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "SymptomEvent", + description: + "One occurrence of a defined symptom: when, how strong (0-10), the decrypted note, and the illness episode it was filed against (null when none).", + }); + +const symptomEventList = z + .object({ + events: z.array(symptomEvent), + limit: z.number().int(), + hasMore: z.boolean(), + }) + .meta({ + id: "SymptomEventList", + description: + "A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the oldest `occurredAt` seen.", + }); + +const deletedResponse = z + .object({ + deleted: z.literal(true), + purged: z.boolean(), + eventsDeleted: z.number().int(), + }) + .meta({ + id: "SymptomDefinitionDeleted", + description: + "`purged: false` means the definition was hidden and its history kept; `purged: true` means it and `eventsDeleted` occurrences were removed.", + }); + +const pathId = { path: z.object({ id: z.string() }) }; + +const definitionNotFound = { + "404": { + description: + "`symptoms.definition.notFound`: no definition with this id on the record.", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +const eventNotFound = { + "404": { + description: + "`symptoms.event.notFound`: no occurrence with this id on the record.", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +export const symptomPaths: NonNullable = { + "/api/symptoms/definitions": { + get: { + tags: ["Symptoms"], + summary: "List your own symptoms (v1.40)", + description: + "The record's active symptom definitions with their 30-day summary; `includeHidden=true` adds the hidden ones. Rides the illness module.", + requestParams: { + query: z.object({ + includeHidden: z.enum(["true", "false"]).optional(), + }), + }, + responses: { + ...recordRefusal(MODULE_OFF), + "200": { + description: "The definitions.", + content: { + "application/json": { + schema: dataEnvelope( + symptomDefinitionList, + "ListSymptomDefinitionsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Symptoms"], + summary: "Define a symptom (v1.40)", + description: + "Creates one definition. Idempotent under `Idempotency-Key`. Audits as `symptoms.definition.create` without the label. Rate-limited to 30 a minute.", + parameters: [idempotencyKeyParameter], + requestBody: { + required: true, + content: { + "application/json": { schema: createSymptomDefinitionRequest }, + }, + }, + responses: { + ...idempotentWrite(), + ...recordRefusal(MODULE_OFF), + "201": { + description: "Definition created.", + content: { + "application/json": { + schema: dataEnvelope( + symptomDefinition, + "CreateSymptomDefinitionEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/symptoms/definitions/{id}": { + patch: { + tags: ["Symptoms"], + summary: "Edit, hide or show a symptom (v1.40)", + description: + "Partial edit. Audits as `symptoms.definition.update`, naming a label change without quoting it.", + requestParams: pathId, + requestBody: { + required: true, + content: { + "application/json": { schema: updateSymptomDefinitionRequest }, + }, + }, + responses: { + ...recordRefusal(MODULE_OFF), + "200": { + description: "Definition updated.", + content: { + "application/json": { + schema: dataEnvelope( + symptomDefinition, + "UpdateSymptomDefinitionEnvelope", + ), + }, + }, + }, + ...definitionNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Symptoms"], + summary: "Hide or delete a symptom (v1.40)", + description: + "Hides the definition and keeps its history. `?purge=true` deletes it and every occurrence of it.", + requestParams: { + ...pathId, + query: z.object({ purge: z.enum(["true", "false"]).optional() }), + }, + responses: { + ...recordRefusal(MODULE_OFF), + "200": { + description: "Hidden or deleted.", + content: { + "application/json": { + schema: dataEnvelope( + deletedResponse, + "DeleteSymptomDefinitionEnvelope", + ), + }, + }, + }, + ...definitionNotFound, + ...stdResponses, + }, + }, + }, + "/api/symptoms/events": { + get: { + tags: ["Symptoms"], + summary: "List symptom occurrences (v1.40)", + description: + "Occurrences of the record's symptoms, newest first, narrowed by the optional query.", + requestParams: { query: listSymptomEventsQuery }, + responses: { + ...recordRefusal(MODULE_OFF), + "200": { + description: "A page of occurrences.", + content: { + "application/json": { + schema: dataEnvelope( + symptomEventList, + "ListSymptomEventsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Symptoms"], + summary: "Log a symptom occurrence (v1.40)", + description: + "Creates one occurrence. Idempotent under `Idempotency-Key`. A hidden definition can still be logged against; another record's definition 404s (`symptoms.definition.notFound`). Audits as `symptoms.event.create` without the note.", + parameters: [idempotencyKeyParameter], + requestBody: { + required: true, + content: { + "application/json": { schema: createSymptomEventRequest }, + }, + }, + responses: { + ...idempotentWrite(), + ...recordRefusal(MODULE_OFF), + "201": { + description: "Occurrence logged.", + content: { + "application/json": { + schema: dataEnvelope(symptomEvent, "CreateSymptomEventEnvelope"), + }, + }, + }, + ...definitionNotFound, + ...stdResponses, + }, + }, + }, + "/api/symptoms/events/{id}": { + patch: { + tags: ["Symptoms"], + summary: "Edit a symptom occurrence (v1.40)", + description: + "Partial edit. Audits as `symptoms.event.update`, naming a note change without quoting it.", + requestParams: pathId, + requestBody: { + required: true, + content: { + "application/json": { schema: updateSymptomEventRequest }, + }, + }, + responses: { + ...recordRefusal(MODULE_OFF), + "200": { + description: "Occurrence updated.", + content: { + "application/json": { + schema: dataEnvelope(symptomEvent, "UpdateSymptomEventEnvelope"), + }, + }, + }, + ...eventNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Symptoms"], + summary: "Delete a symptom occurrence (v1.40)", + description: "Removes the occurrence. Audits as `symptoms.event.delete`.", + requestParams: pathId, + responses: { + ...recordRefusal(MODULE_OFF), + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "DeleteSymptomEventEnvelope", + ), + }, + }, + }, + ...eventNotFound, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index b9e7a128d..66781e05b 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -72,6 +72,8 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.familyHistoryCreate", { name }); case "illness.episode.create": return t("recordSharing.activityVerb.illnessEpisodeCreate", { name }); + case "symptoms.event.create": + return t("recordSharing.activityVerb.symptomEventCreate", { name }); case "encounter.visit.create": return t("recordSharing.activityVerb.encounterVisitCreate", { name }); case "vaccination.record.create": @@ -193,6 +195,16 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.illnessEpisodeRestore", { name }); case "illness.day-log.upsert": return t("recordSharing.activityVerb.illnessDayLogUpsert", { name }); + case "symptoms.definition.create": + return t("recordSharing.activityVerb.symptomDefinitionCreate", { name }); + case "symptoms.definition.update": + return t("recordSharing.activityVerb.symptomDefinitionUpdate", { name }); + case "symptoms.definition.delete": + return t("recordSharing.activityVerb.symptomDefinitionDelete", { name }); + case "symptoms.event.update": + return t("recordSharing.activityVerb.symptomEventUpdate", { name }); + case "symptoms.event.delete": + return t("recordSharing.activityVerb.symptomEventDelete", { name }); case "encounter.visit.update": return t("recordSharing.activityVerb.encounterVisitUpdate", { name }); diff --git a/src/lib/symptoms/server.ts b/src/lib/symptoms/server.ts new file mode 100644 index 000000000..fbf46feae --- /dev/null +++ b/src/lib/symptoms/server.ts @@ -0,0 +1,154 @@ +/** + * Person-defined symptoms (v1.40): the server-only half. Label and note + * crypto, the row → wire mappers, the 30-day header summary and the one check + * that decides whether an event may be filed against an episode. + * + * Free text (the symptom's name, the note on one occurrence) is held + * AES-256-GCM encrypted in Bytes columns through the shared codec, the same + * codec the illness episode note uses, and decrypted fail-soft on read: a key + * gap on one row reads `null` rather than taking the page down. + */ +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import type { + SymptomDefinition, + SymptomEvent, +} from "@/generated/prisma/client"; + +import type { SymptomDefinitionDTO, SymptomEventDTO } from "./shared"; + +export * from "./shared"; + +/** Encrypt a symptom label or event note for storage. */ +export function encryptSymptomText(plaintext: string): Uint8Array { + return encryptToBytes(plaintext); +} + +/** Decrypt a stored label or note; null on a missing or unreadable value. */ +export function decryptSymptomText( + buf: Uint8Array | null, + field: "label" | "note", +): string | null { + if (!buf || buf.byteLength === 0) return null; + try { + return decryptFromBytes(buf); + } catch (err) { + getEvent()?.addWarning( + `symptom ${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + +/** Header summary of one definition over the trailing window. */ +export interface SymptomRecentSummary { + count30d: number; + maxIntensity30d: number | null; + lastOccurredAt: string | null; +} + +const EMPTY_RECENT: SymptomRecentSummary = { + count30d: 0, + maxIntensity30d: null, + lastOccurredAt: null, +}; + +export function toSymptomDefinitionDTO( + row: Pick< + SymptomDefinition, + "id" | "labelEncrypted" | "icon" | "sortOrder" | "isActive" + >, + recent: SymptomRecentSummary = EMPTY_RECENT, +): SymptomDefinitionDTO { + return { + id: row.id, + label: decryptSymptomText(row.labelEncrypted, "label"), + icon: row.icon, + sortOrder: row.sortOrder, + isActive: row.isActive, + recent, + }; +} + +export function toSymptomEventDTO(row: SymptomEvent): SymptomEventDTO { + return { + id: row.id, + definitionId: row.definitionId, + occurredAt: row.occurredAt.toISOString(), + intensity: row.intensity, + note: decryptSymptomText(row.noteEncrypted, "note"), + episodeId: row.episodeId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Every definition of the account with its 30-day summary, ordered for the + * chip row. One grouped aggregate for the counts and maxima, one for the last + * occurrence over all time (a symptom last felt two months ago still says so). + */ +export async function listSymptomDefinitions( + userId: string, + options: { includeHidden: boolean; now?: Date }, +): Promise { + const now = options.now ?? new Date(); + const since = new Date(now.getTime() - 30 * DAY_MS); + const [rows, recent, last] = await Promise.all([ + prisma.symptomDefinition.findMany({ + where: { userId, ...(options.includeHidden ? {} : { isActive: true }) }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + labelEncrypted: true, + icon: true, + sortOrder: true, + isActive: true, + }, + }), + prisma.symptomEvent.groupBy({ + by: ["definitionId"], + where: { userId, occurredAt: { gte: since, lte: now } }, + _count: { _all: true }, + _max: { intensity: true }, + }), + prisma.symptomEvent.groupBy({ + by: ["definitionId"], + where: { userId }, + _max: { occurredAt: true }, + }), + ]); + const recentById = new Map(recent.map((r) => [r.definitionId, r])); + const lastById = new Map(last.map((r) => [r.definitionId, r._max])); + return rows.map((row) => { + const r = recentById.get(row.id); + const lastAt = lastById.get(row.id)?.occurredAt ?? null; + return toSymptomDefinitionDTO(row, { + count30d: r?._count._all ?? 0, + maxIntensity30d: r?._max.intensity ?? null, + lastOccurredAt: lastAt ? lastAt.toISOString() : null, + }); + }); +} + +/** + * Whether `episodeId` names an episode an event of `userId` may be filed + * against: the caller's own, not deleted, and still open. A resolved episode + * is history; filing a new occurrence into it would rewrite what the person + * recorded about how it ended. + */ +export async function isLinkableEpisode( + userId: string, + episodeId: string, +): Promise { + const episode = await prisma.illnessEpisode.findFirst({ + where: { id: episodeId, userId, deletedAt: null, resolvedAt: null }, + select: { id: true }, + }); + return episode !== null; +} diff --git a/src/lib/symptoms/shared.ts b/src/lib/symptoms/shared.ts new file mode 100644 index 000000000..60e9b17b7 --- /dev/null +++ b/src/lib/symptoms/shared.ts @@ -0,0 +1,154 @@ +import { z } from "zod/v4"; + +import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; +import { validateEntryInstant } from "@/lib/validations/entry-instant"; + +/** + * Person-defined symptoms (v1.40): client-safe constants, wire types and the + * request schemas. Server-only helpers (label crypto, DTO mappers) live in + * `./server.ts`, so the quick-entry sheet can import this file without pulling + * `node:crypto` into the browser bundle. + * + * A definition is the person's own name for a symptom ("aura", "headache"); an + * event is one occurrence of it with a 0-10 intensity, the same numeric rating + * scale the pain score uses. Both ride the `illness` module. + */ + +/** + * Active definitions per account. The same eight the custom-metric channels + * are capped at, and for the same reason: every active definition is its own + * correlation channel, so the cap bounds the discovery matrix as well as the + * chip row. A hidden definition does not count. + */ +export const MAX_ACTIVE_SYMPTOM_DEFINITIONS = 8; + +/** The intensity scale: 0 none, 10 the worst imaginable (NRS). */ +export const SYMPTOM_INTENSITY_MIN = 0; +export const SYMPTOM_INTENSITY_MAX = 10; + +/** Upper bound on one events page. */ +export const MAX_SYMPTOM_EVENTS_PAGE = 500; + +/** + * Icon names a definition may carry: the cycle custom-symptom allowlist, which + * the iOS client already maps to SF Symbols. + */ +export const SYMPTOM_ICON_ALLOWLIST = CUSTOM_SYMPTOM_ICON_ALLOWLIST; +export type SymptomIconName = (typeof SYMPTOM_ICON_ALLOWLIST)[number]; + +const ICON_SET = new Set(SYMPTOM_ICON_ALLOWLIST); + +/** Correlation channel key prefix: `SYMPTOM:`. */ +export const SYMPTOM_CHANNEL_PREFIX = "SYMPTOM:"; + +/** Wire shape of one definition. */ +export interface SymptomDefinitionDTO { + id: string; + /** Decrypted; null only when the stored label cannot be read. */ + label: string | null; + icon: string | null; + sortOrder: number; + isActive: boolean; + /** Server-computed over the last 30 days, for the section header line. */ + recent: { + count30d: number; + maxIntensity30d: number | null; + lastOccurredAt: string | null; + }; +} + +/** Wire shape of one event. */ +export interface SymptomEventDTO { + id: string; + definitionId: string; + occurredAt: string; + intensity: number; + note: string | null; + episodeId: string | null; + createdAt: string; + updatedAt: string; +} + +const labelSchema = z + .string() + .trim() + .min(1, "Label must not be empty") + .max(40, "Label must be at most 40 characters"); + +const iconSchema = z + .string() + .refine((v) => ICON_SET.has(v), "Unknown icon") + .nullish(); + +export const symptomDefinitionCreateSchema = z.object({ + label: labelSchema, + icon: iconSchema, +}); + +export const symptomDefinitionUpdateSchema = z + .object({ + label: labelSchema.optional(), + icon: iconSchema, + isActive: z.boolean().optional(), + sortOrder: z.number().int().min(0).max(1000).optional(), + }) + .refine( + (v) => + v.label !== undefined || + v.icon !== undefined || + v.isActive !== undefined || + v.sortOrder !== undefined, + "At least one field is required", + ); + +const intensitySchema = z + .number() + .int() + .min(SYMPTOM_INTENSITY_MIN) + .max(SYMPTOM_INTENSITY_MAX); + +const occurredAtSchema = validateEntryInstant( + z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .pipe(z.date()), +); + +export const symptomEventCreateSchema = z.object({ + definitionId: z.string().min(1).max(40), + /** Defaults to now. Not in the future beyond the shared 5-minute skew. */ + occurredAt: occurredAtSchema.optional(), + intensity: intensitySchema, + note: z.string().trim().max(500).nullish(), + /** Must be one of the caller's own episodes that is still open. */ + episodeId: z.string().min(1).max(40).nullish(), +}); + +export const symptomEventUpdateSchema = z + .object({ + occurredAt: occurredAtSchema.optional(), + intensity: intensitySchema.optional(), + note: z.string().trim().max(500).nullish(), + episodeId: z.string().min(1).max(40).nullish(), + }) + .refine( + (v) => + v.occurredAt !== undefined || + v.intensity !== undefined || + v.note !== undefined || + v.episodeId !== undefined, + "At least one field is required", + ); + +export const symptomEventListQuerySchema = z.object({ + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + definitionId: z.string().min(1).max(40).optional(), + episodeId: z.string().min(1).max(40).optional(), + limit: z.coerce + .number() + .int() + .min(1) + .max(MAX_SYMPTOM_EVENTS_PAGE) + .default(200), +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 0613a290d..166373083 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1102,6 +1102,44 @@ const illnessEpisodeBackupSchema = z }) .passthrough(); +/** + * A person-defined symptom (v1.40) with every occurrence of it nested inside, + * the way a medication carries its side effects. `label` / `labelEncrypted` + * and `note` / `noteEncrypted` are the two ends of the free-text contract: a + * portable file carries the plaintext, a disaster-recovery file the ciphertext. + * An occurrence's `episodeId` is the exported episode's own id; the restore + * nulls one that names an episode the file does not carry. + */ +const symptomEventBackupSchema = z + .object({ + id: z.string().min(1).optional(), + occurredAt: isoDateTime, + intensity: z.number().int().min(0).max(10), + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + episodeId: z.string().min(1).nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + }) + .passthrough(); + +const symptomDefinitionBackupSchema = z + .object({ + id: z.string().min(1).optional(), + label: z.string().min(1).optional(), + labelEncrypted: base64BytesSchema.optional(), + icon: z.string().nullable().optional(), + sortOrder: z.number().int(), + isActive: z.boolean(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + events: z.array(symptomEventBackupSchema).default([]), + }) + .passthrough() + .refine((d) => d.label !== undefined || d.labelEncrypted !== undefined, { + message: "A symptom definition needs a label or its ciphertext", + }); + /** * A practitioner, an encounter, and the edges between an encounter and the * things it produced. @@ -1853,6 +1891,8 @@ export const backupPayloadSchema = z labResults: z.array(labResultBackupSchema).default([]), biomarkers: z.array(biomarkerBackupSchema).default([]), illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]), + // Defaulted: a file written before v1.40 carries no key. + symptomDefinitions: z.array(symptomDefinitionBackupSchema).default([]), allergies: z.array(allergyBackupSchema).default([]), familyHistory: z.array(familyHistoryBackupSchema).default([]), workouts: z.array(workoutBackupSchema).default([]), diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index e90d4748e..635a5c4aa 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -705,4 +705,52 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "export", ), + handler( + "app/api/symptoms/definitions/route.ts", + "/api/symptoms/definitions", + "POST", + "illness", + "manage", + "create", + ), + handler( + "app/api/symptoms/definitions/[id]/route.ts", + "/api/symptoms/definitions/[id]", + "PATCH", + "illness", + "manage", + "update", + ), + handler( + "app/api/symptoms/definitions/[id]/route.ts", + "/api/symptoms/definitions/[id]", + "DELETE", + "illness", + "manage", + "delete", + ), + handler( + "app/api/symptoms/events/route.ts", + "/api/symptoms/events", + "POST", + "illness", + "write", + "create", + ), + handler( + "app/api/symptoms/events/[id]/route.ts", + "/api/symptoms/events/[id]", + "PATCH", + "illness", + "manage", + "update", + ), + handler( + "app/api/symptoms/events/[id]/route.ts", + "/api/symptoms/events/[id]", + "DELETE", + "illness", + "manage", + "delete", + ), ] as const satisfies readonly MutatingHandlerContract[]; diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 2ae7458d8..58d0e9500 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -232,6 +232,9 @@ const COUNT_BACK: Record< p.familyHistoryEntry.count({ where: { userId } }), IllnessEpisode: (p, userId) => p.illnessEpisode.count({ where: { userId } }), IllnessDayLog: (p, userId) => p.illnessDayLog.count({ where: { userId } }), + SymptomDefinition: (p, userId) => + p.symptomDefinition.count({ where: { userId } }), + SymptomEvent: (p, userId) => p.symptomEvent.count({ where: { userId } }), IllnessSymptomLink: (p, userId) => p.illnessSymptomLink.count({ where: { dayLog: { userId } } }), UserHealthProfile: (p, userId) => @@ -750,6 +753,52 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); + // v1.40 — two of the person's own symptoms and three occurrences: one filed + // against the open cold, one with a note, one on its own in the gap. + const aura = await prisma.symptomDefinition.create({ + data: { + userId: OWNER_ID, + labelEncrypted: encryptToBytes("Aura"), + icon: "Zap", + sortOrder: 0, + }, + }); + const headache = await prisma.symptomDefinition.create({ + data: { + userId: OWNER_ID, + labelEncrypted: encryptToBytes("Headache"), + icon: "Brain", + sortOrder: 1, + isActive: false, + }, + }); + await prisma.symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: aura.id, + occurredAt: AT("2026-06-21T09:00:00.000Z"), + intensity: 4, + episodeId: episode.id, + }, + }); + await prisma.symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: headache.id, + occurredAt: AT("2026-06-21T11:00:00.000Z"), + intensity: 8, + noteEncrypted: encryptToBytes("left side, behind the eye"), + }, + }); + await prisma.symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: aura.id, + occurredAt: AT("2026-07-10T18:30:00.000Z"), + intensity: 2, + }, + }); + await prisma.userHealthProfile.create({ data: { userId: OWNER_ID, @@ -2108,6 +2157,69 @@ describe("every model the plan claims two-ended survives a real restore", () => "a condition's body site and side must survive the round trip", ).toEqual({ bodySite: "Stomach", laterality: "BOTH" }); + // v1.40 — the person's own symptoms: the names decrypt to what was typed, + // each occurrence keeps its time, intensity and note, and the one filed + // against the cold still points at the cold. + const restoredSymptoms = await prisma.symptomDefinition.findMany({ + where: { userId: OWNER_ID }, + orderBy: { sortOrder: "asc" }, + include: { + events: { + orderBy: { occurredAt: "asc" }, + include: { episode: { select: { label: true } } }, + }, + }, + }); + expect( + restoredSymptoms.map((definition) => ({ + label: decryptFromBytes(definition.labelEncrypted), + icon: definition.icon, + isActive: definition.isActive, + events: definition.events.map((event) => ({ + at: event.occurredAt.toISOString(), + intensity: event.intensity, + note: event.noteEncrypted + ? decryptFromBytes(event.noteEncrypted) + : null, + episode: event.episode?.label ?? null, + })), + })), + "symptom definitions and their occurrences must survive the round trip", + ).toEqual([ + { + label: "Aura", + icon: "Zap", + isActive: true, + events: [ + { + at: "2026-06-21T09:00:00.000Z", + intensity: 4, + note: null, + episode: "Cold", + }, + { + at: "2026-07-10T18:30:00.000Z", + intensity: 2, + note: null, + episode: null, + }, + ], + }, + { + label: "Headache", + icon: "Brain", + isActive: false, + events: [ + { + at: "2026-06-21T11:00:00.000Z", + intensity: 8, + note: "left side, behind the eye", + episode: null, + }, + ], + }, + ]); + // The staged fact, and the decision on it. // // This is the assertion the count cannot make. `status`, `needsReview`, diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index cb4fea195..24ac022ef 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -37,6 +37,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { ADMITTED_MUTATING_HANDLERS, type MutatingHandlerContract, @@ -281,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(83); }); }); @@ -1331,6 +1332,178 @@ manageContract("POST /api/illness/episodes/[id]/restore", { )?.deletedAt === null, }); +async function makeSymptomDefinition(userId: string) { + return getPrismaClient().symptomDefinition.create({ + data: { + userId, + labelEncrypted: encryptToBytes("Aura"), + icon: "Zap", + }, + }); +} + +async function makeSymptomEvent(userId: string) { + const definition = await makeSymptomDefinition(userId); + return getPrismaClient().symptomEvent.create({ + data: { + userId, + definitionId: definition.id, + occurredAt: new Date(ISO), + intensity: 6, + }, + }); +} + +manageContract("POST /api/symptoms/definitions", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/symptoms/definitions" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ ownerId }), + act: async () => { + const { POST } = await import("@/app/api/symptoms/definitions/route"); + return call(POST as Handler, "POST", "/api/symptoms/definitions", { + label: "Aura", + icon: "Zap", + }); + }, + ok: 201, + auditAction: "symptoms.definition.create", + applied: async ({ ownerId }) => + (await getPrismaClient().symptomDefinition.count({ + where: { userId: ownerId }, + })) === 1, +}); + +manageContract("PATCH /api/symptoms/definitions/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/symptoms/definitions/[id]" && + entry.action === "PATCH", + )!, + prepare: (ownerId) => makeSymptomDefinition(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/symptoms/definitions/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/symptoms/definitions/${row.id}`, + { icon: "Brain" }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "symptoms.definition.update", + applied: async (row) => + ( + await getPrismaClient().symptomDefinition.findUnique({ + where: { id: row.id }, + }) + )?.icon === "Brain", +}); + +manageContract("DELETE /api/symptoms/definitions/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/symptoms/definitions/[id]" && + entry.action === "DELETE", + )!, + prepare: (ownerId) => makeSymptomDefinition(ownerId), + act: async (row) => { + const { DELETE } = + await import("@/app/api/symptoms/definitions/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/symptoms/definitions/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "symptoms.definition.delete", + applied: async (row) => + ( + await getPrismaClient().symptomDefinition.findUnique({ + where: { id: row.id }, + }) + )?.isActive === false, +}); + +writeEffectContract("POST /api/symptoms/events", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/symptoms/events" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ + ownerId, + definition: await makeSymptomDefinition(ownerId), + }), + act: async ({ definition }) => { + const { POST } = await import("@/app/api/symptoms/events/route"); + return call(POST as Handler, "POST", "/api/symptoms/events", { + definitionId: definition.id, + intensity: 7, + }); + }, + ok: 201, + auditAction: "symptoms.event.create", + applied: async ({ ownerId }) => + (await getPrismaClient().symptomEvent.count({ + where: { userId: ownerId }, + })) === 1, +}); + +manageContract("PATCH /api/symptoms/events/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/symptoms/events/[id]" && entry.action === "PATCH", + )!, + prepare: (ownerId) => makeSymptomEvent(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/symptoms/events/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/symptoms/events/${row.id}`, + { intensity: 9 }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "symptoms.event.update", + applied: async (row) => + ( + await getPrismaClient().symptomEvent.findUnique({ + where: { id: row.id }, + }) + )?.intensity === 9, +}); + +manageContract("DELETE /api/symptoms/events/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/symptoms/events/[id]" && entry.action === "DELETE", + )!, + prepare: (ownerId) => makeSymptomEvent(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/symptoms/events/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/symptoms/events/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "symptoms.event.delete", + applied: async (row) => + (await getPrismaClient().symptomEvent.findUnique({ + where: { id: row.id }, + })) === null, +}); + writeEffectContract("POST /api/encounters", { contract: ADMITTED_MUTATING_HANDLERS.find( (entry) => entry.route === "/api/encounters" && entry.action === "POST", @@ -2580,13 +2753,13 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(77); + expect(STRICT_DRIVER_KEYS.size).toBe(83); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); it("executes every registered driver through its owned effect and actor audit", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(77); + expect(REAL_EFFECT_KEYS.size).toBe(83); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); }); From 9d9e1f153ca2a080e472b2150906458f8b9279a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:14:08 +0200 Subject: [PATCH 2/8] feat(insights): make each person-defined symptom its own correlation channel Every active symptom definition enters pattern discovery as an outcome channel SYMPTOM:, labelled with its decrypted name (the data label the interpretation already prefers over the key). The daily value is the day's highest intensity in the person's zone; days without an occurrence count as 0 only between the first and the last logged day. At most eight, the active-definition cap. The SYMPTOM: prefix resolves to the illness module in the surface map, so the family leaves the matrix with the module, and the discovery guard lists the new fetcher. --- src/__tests__/discovery-matrix-guard.test.ts | 1 + .../correlations/__tests__/route.test.ts | 2 + src/app/api/insights/correlations/route.ts | 1 + .../coach/__tests__/correlations-read.test.ts | 1 + .../discovery-matrix-modules.test.ts | 5 + .../metric-correlation-context.test.ts | 1 + .../__tests__/symptom-event-series.test.ts | 72 +++ .../insights/correlation-channel-series.ts | 60 ++ .../insights/correlation-series-builders.ts | 51 ++ .../__tests__/coach-read-locale.test.ts | 1 + src/lib/insights/discovery-matrix.ts | 11 + .../__tests__/period-narrative-db.test.ts | 1 + .../__tests__/surface-module-registry.test.ts | 7 + src/lib/modules/surface.ts | 11 +- tests/integration/symptoms.test.ts | 578 ++++++++++++++++++ 15 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 src/lib/insights/__tests__/symptom-event-series.test.ts create mode 100644 tests/integration/symptoms.test.ts diff --git a/src/__tests__/discovery-matrix-guard.test.ts b/src/__tests__/discovery-matrix-guard.test.ts index ca4d3c9f2..128b5e8ac 100644 --- a/src/__tests__/discovery-matrix-guard.test.ts +++ b/src/__tests__/discovery-matrix-guard.test.ts @@ -91,6 +91,7 @@ describe("T1 — only the assembler fetches discovery channels", () => { "fetchSymptomSeries", "fetchEnvironmentSeries", "fetchCustomMetricBehaviourSeries", + "fetchSymptomEventSeries", "fetchMoodWindowSeries", "fetchMoodFactorWindowSeries", "fetchMeasurementDailySeriesTiered", diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 47fa6a4a2..42413f24f 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -31,6 +31,7 @@ vi.mock("@/lib/db", () => ({ // channel produces a point. environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([]) }, + symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -153,6 +154,7 @@ beforeEach(() => { prisma.environmentContext.findMany as ReturnType ).mockResolvedValue([]); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index f93c5262f..c94021409 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -236,6 +236,7 @@ async function buildCorrelationsResponse( // available for the scan. environment_days: diagnostics.environmentDays, custom_metric_channels: diagnostics.customMetricChannels, + symptom_event_channels: diagnostics.symptomEventChannels, custom_metric_days: diagnostics.customMetricDays, }, }); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index f020b98c1..088f0677e 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -39,6 +39,7 @@ vi.mock("@/lib/db", () => ({ findMany: (a: unknown) => environmentContextFindMany(a), }, customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, + symptomDefinition: { findMany: async () => [] }, }, })); diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index e13d77393..930b52b27 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -42,6 +42,9 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({ fetchCustomMetricBehaviourSeries: vi.fn(async () => [ { key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts }, ]), + fetchSymptomEventSeries: vi.fn(async () => [ + { key: "SYMPTOM:def1", label: "Aura", role: "outcome", points: pts }, + ]), })); vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(), @@ -77,6 +80,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { "SYMPTOM_SEVERITY", "ENV_TEMP_MEAN", "CUSTOM_METRIC:abc", + "SYMPTOM:def1", "WEIGHT", ]) { expect(keys).toContain(key); @@ -103,6 +107,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { "BLOOD_GLUCOSE", "MEDICATION_COMPLIANCE", "SYMPTOM_SEVERITY", + "SYMPTOM:def1", "ENV_TEMP_MEAN", ]) { expect(keys, gone).not.toContain(gone); diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index f77052076..07927a927 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -11,6 +11,7 @@ vi.mock("@/lib/db", () => ({ illnessDayLog: { findMany: vi.fn() }, environmentContext: { findMany: vi.fn() }, customMetric: { findMany: vi.fn() }, + symptomDefinition: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/insights/__tests__/symptom-event-series.test.ts b/src/lib/insights/__tests__/symptom-event-series.test.ts new file mode 100644 index 000000000..17c7b9f3a --- /dev/null +++ b/src/lib/insights/__tests__/symptom-event-series.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { buildSymptomEventDailySeries } from "@/lib/insights/correlation-series-builders"; +import { maskSeriesByModules } from "@/lib/insights/discovery-matrix"; + +const at = (iso: string) => new Date(iso); + +describe("buildSymptomEventDailySeries", () => { + it("takes the day's highest intensity and zero-fills only between the first and last day", () => { + const series = buildSymptomEventDailySeries({ + key: "SYMPTOM:def1", + label: "Aura", + tz: "Europe/Berlin", + events: [ + { at: at("2026-07-01T07:00:00.000Z"), intensity: 3 }, + { at: at("2026-07-01T15:00:00.000Z"), intensity: 7 }, + { at: at("2026-07-04T10:00:00.000Z"), intensity: 2 }, + ], + }); + expect(series).toEqual({ + key: "SYMPTOM:def1", + label: "Aura", + role: "outcome", + points: [ + { day: "2026-07-01", value: 7 }, + { day: "2026-07-02", value: 0 }, + { day: "2026-07-03", value: 0 }, + { day: "2026-07-04", value: 2 }, + ], + }); + }); + + it("keys a late-evening occurrence to the person's own day", () => { + const series = buildSymptomEventDailySeries({ + key: "SYMPTOM:def1", + label: "Aura", + tz: "Europe/Berlin", + // 23:30 UTC on the 1st is 01:30 on the 2nd in Berlin (summer time). + events: [{ at: at("2026-07-01T23:30:00.000Z"), intensity: 5 }], + }); + expect(series.points).toEqual([{ day: "2026-07-02", value: 5 }]); + }); + + it("is empty with no occurrences, so the channel drops out", () => { + const series = buildSymptomEventDailySeries({ + key: "SYMPTOM:def1", + label: "Aura", + tz: "UTC", + events: [], + }); + expect(series.points).toEqual([]); + }); +}); + +describe("the SYMPTOM: channel family follows the illness module", () => { + const series = [ + { key: "SYMPTOM:def1", role: "outcome" as const, points: [] }, + { key: "SLEEP_DURATION", role: "behaviour" as const, points: [] }, + ]; + + it("is masked out when the illness module is off", () => { + expect( + maskSeriesByModules(series, { illness: false }).map((s) => s.key), + ).toEqual(["SLEEP_DURATION"]); + }); + + it("stays when the module is on", () => { + expect( + maskSeriesByModules(series, { illness: true }).map((s) => s.key), + ).toEqual(["SYMPTOM:def1", "SLEEP_DURATION"]); + }); +}); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index aaed68cfa..b0278790d 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -39,6 +39,7 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import { buildComplianceDailySeries, + buildSymptomEventDailySeries, buildSymptomSeverityDailySeries, type SymptomDayLogRow, type SymptomEpisodeSpan, @@ -61,6 +62,11 @@ import type { } from "@/generated/prisma/client"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { dateOnlyKey } from "@/lib/tz/date-only"; +import { decryptSymptomText } from "@/lib/symptoms/server"; +import { + MAX_ACTIVE_SYMPTOM_DEFINITIONS, + SYMPTOM_CHANNEL_PREFIX, +} from "@/lib/symptoms/shared"; /** * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. @@ -315,6 +321,60 @@ export async function fetchCustomMetricBehaviourSeries( })); } +/** + * Ceiling on `SYMPTOM:` channels. The definition cap already holds the + * active set at eight; restated here so the matrix bound does not depend on a + * route check staying where it is. + */ +export const MAX_SYMPTOM_CORRELATION_CHANNELS = MAX_ACTIVE_SYMPTOM_DEFINITIONS; + +/** + * v1.40 — one OUTCOME series per active person-defined symptom, keyed + * `SYMPTOM:` and labelled with the decrypted name (the data + * label `interpret()` prefers over the key). Daily value = that day's highest + * intensity; see `buildSymptomEventDailySeries` for the zero-fill rule. Hidden + * definitions are not channels. A symptom whose name cannot be decrypted is + * left out rather than shown under its database id. + */ +export async function fetchSymptomEventSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const definitions = await prisma.symptomDefinition.findMany({ + where: { userId, isActive: true }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], + take: MAX_SYMPTOM_CORRELATION_CHANNELS, + select: { + id: true, + labelEncrypted: true, + events: { + where: { occurredAt: { gte: since } }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: 5000, + select: { occurredAt: true, intensity: true }, + }, + }, + }); + const series: NamedSeries[] = []; + for (const definition of definitions) { + const label = decryptSymptomText(definition.labelEncrypted, "label"); + if (label === null) continue; + series.push( + buildSymptomEventDailySeries({ + key: `${SYMPTOM_CHANNEL_PREFIX}${definition.id}`, + label, + events: definition.events.map((event) => ({ + at: event.occurredAt, + intensity: event.intensity, + })), + tz, + }), + ); + } + return series; +} + /** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ function tzDayKey(at: Date, tz: string): string { const { year, month, day } = wallClockInTz(at, tz); diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts index b387fa643..7da33628e 100644 --- a/src/lib/insights/correlation-series-builders.ts +++ b/src/lib/insights/correlation-series-builders.ts @@ -35,6 +35,7 @@ * mirroring how the engine itself is pure over `NamedSeries[]`. */ import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { shiftDateKey } from "@/lib/tz/format"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { MEDICATION_COMPLIANCE_CHANNEL_KEY, @@ -191,3 +192,53 @@ export function buildSymptomSeverityDailySeries(args: { return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points }; } + +/** One occurrence of a person-defined symptom, as the builder reads it. */ +export interface SymptomEventRow { + at: Date; + intensity: number; +} + +/** + * Build one `SYMPTOM:` outcome series from that symptom's + * occurrences (v1.40). + * + * The daily value is the day's HIGHEST intensity (0-10) in the person's zone: + * two attacks in a day are as bad as the worse one. Days with no occurrence + * are 0, but only between the first and the last logged day: inside that span + * the person was tracking the symptom and its absence is a real zero, outside + * it nothing says whether they were tracking at all. One logged day yields a + * one-point series, which the n >= 20 floor then drops. Pure. + */ +export function buildSymptomEventDailySeries(args: { + key: string; + label: string | undefined; + events: SymptomEventRow[]; + tz: string; +}): NamedSeries { + const { key, label, events, tz } = args; + const byDay = new Map(); + for (const event of events) { + if (!Number.isFinite(event.intensity)) continue; + const day = dayKey(event.at, tz); + const prev = byDay.get(day); + byDay.set( + day, + prev == null ? event.intensity : Math.max(prev, event.intensity), + ); + } + const keys = [...byDay.keys()].sort(); + const points: DailySeriesPoint[] = []; + if (keys.length > 0) { + const last = keys[keys.length - 1]; + for (let day = keys[0]; day <= last; day = shiftDateKey(day, 1)) { + points.push({ day, value: byDay.get(day) ?? 0 }); + } + } + return { + key, + ...(label !== undefined ? { label } : {}), + role: "outcome", + points, + }; +} diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index 1c2208411..ebaa47e75 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -52,6 +52,7 @@ vi.mock("@/lib/db", () => ({ findMany: (a: unknown) => environmentContextFindMany(a), }, customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, + symptomDefinition: { findMany: async () => [] }, }, })); diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index 7e86c64ea..56a207a9c 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -48,6 +48,7 @@ import { buildMeasurementDailySeries, fetchComplianceSeries, fetchCustomMetricBehaviourSeries, + fetchSymptomEventSeries, fetchEnvironmentSeries, fetchMeasurementDailySeriesTiered, fetchMeasurementWindowSeries, @@ -154,6 +155,8 @@ export interface DiscoveryMatrixDiagnostics { customMetricDays: number; /** `FACTOR:*` channels admitted (0 unless `includeMoodFactors`). */ moodFactorChannels: number; + /** `SYMPTOM:*` channels read (before the module mask). */ + symptomEventChannels: number; } export interface DiscoveryMatrix { @@ -294,6 +297,7 @@ export async function assembleDiscoveryMatrix( symptomSeries, environmentSeries, customMetricSeries, + symptomEventSeries, ] = await Promise.all([ readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode), readMood(userId, tz, since, opts.includeMoodFactors === true), @@ -301,6 +305,7 @@ export async function assembleDiscoveryMatrix( fetchSymptomSeries(userId, tz, since), fetchEnvironmentSeries(userId, since), fetchCustomMetricBehaviourSeries(userId, tz, since), + fetchSymptomEventSeries(userId, tz, since), ]); const { factorSeries } = mood; @@ -331,6 +336,11 @@ export async function assembleDiscoveryMatrix( // (D → D+1) against every outcome above, under the unchanged n ≥ 20 / FDR / // effect-size gates, so a thin weather or custom series degrades to absent. series.push(...environmentSeries, ...customMetricSeries); + // The person's own symptoms are OUTCOMES: "short sleep, then an aura the + // next day" is the question they log them to answer. Each is its own + // channel and its own family, capped at eight; the illness module owns them + // through the `SYMPTOM:` prefix rule in the surface map. + series.push(...symptomEventSeries); // A factor is plausibly both a lag source ("rated work today → next-day // sleep") and a lag target ("steps today → next-day rated energy"), so it // enters as both roles. The engine skips self-pairs — every factor shares @@ -371,6 +381,7 @@ export async function assembleDiscoveryMatrix( 0, ), moodFactorChannels: factorSeries.size, + symptomEventChannels: symptomEventSeries.length, }, }; } diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index 29462480f..a2240617d 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({ findMany: (a: unknown) => environmentContextFindMany(a), }, customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, + symptomDefinition: { findMany: async () => [] }, }, })); diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index e96b1325d..a48be16df 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -66,9 +66,11 @@ import { TYPE_TO_SUB_PAGE_SLUG, } from "@/lib/insights/sub-page-metric"; import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select"; +import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared"; import { SURFACE_KINDS, SURFACE_MODULE, + correlationChannelSurfaceId, surfaceModule, surfaceModulesOfKind, type SurfaceKind, @@ -167,6 +169,11 @@ describe("every surface id names a surface that exists", () => { // `correlationChannelSurfaceId` folds rated factors onto mood by this // prefix; it has to be the prefix the engine actually writes. expect(FACTOR_CHANNEL_PREFIX).toBe("FACTOR:"); + // Person-defined symptoms fold onto the illness symptom channel by theirs. + expect(SYMPTOM_CHANNEL_PREFIX).toBe("SYMPTOM:"); + expect(correlationChannelSurfaceId(`${SYMPTOM_CHANNEL_PREFIX}abc`)).toBe( + "correlation:SYMPTOM_SEVERITY", + ); }); }); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 4e39ab30f..6fdb69f58 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -274,10 +274,13 @@ export function surfaceModulesOfKind( /** * The surface id of a correlation channel. Rated mood factors carry their own * `FACTOR:` channel names but are mood entries all the same, so they - * resolve to the mood channel. + * resolve to the mood channel; person-defined symptoms (`SYMPTOM:`) resolve + * to the illness symptom channel the same way. */ export function correlationChannelSurfaceId(channelKey: string): string { - return channelKey.startsWith("FACTOR:") - ? "correlation:MOOD" - : `correlation:${channelKey}`; + if (channelKey.startsWith("FACTOR:")) return "correlation:MOOD"; + // A person-defined symptom (`SYMPTOM:`, v1.40) rides the illness + // module, like the symptom-burden channel it sits beside. + if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY"; + return `correlation:${channelKey}`; } diff --git a/tests/integration/symptoms.test.ts b/tests/integration/symptoms.test.ts new file mode 100644 index 000000000..330d0dfbe --- /dev/null +++ b/tests/integration/symptoms.test.ts @@ -0,0 +1,578 @@ +/** + * Person-defined symptoms against real Postgres and through the real routes + * (v1.40, migration 0369). + * + * What only a real database can answer: + * + * - the two tables exist, the label and the note are ciphertext on disk, and + * the intensity CHECK holds; + * - the eight-definition cap counts active definitions only, and showing a + * hidden one again re-checks it; + * - an occurrence can be filed against the record's own OPEN episode and + * against nothing else: not a resolved one, not another account's; + * - each active definition reaches the discovery matrix as its own + * `SYMPTOM:` channel, labelled with its name, and leaves it when the + * illness module is off; + * - a portable export carries the names readable and restores them under + * the instance's key, dropping (and naming) a link to an episode the file + * does not carry. + * + * Mutation checks (each run, each seen red): + * - drop `resolvedAt: null` from `isLinkableEpisode` → "refuses a resolved + * episode" goes red (the occurrence is linked to a closed episode); + * - drop `userId` from the same lookup → "refuses another account's episode" + * goes red; + * - remove the cap check in the definitions POST → "holds eight active + * definitions" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; +import { buildSymptomsBackupSection } from "@/lib/export/symptoms-backup"; +import { restoreSymptomsData } from "@/lib/export/symptoms-backup"; +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "symptoms-owner"; +const OTHER_ID = "symptoms-other"; + +type Handler = ( + req: NextRequest, + ctx: { params: Promise> }, +) => Promise; + +async function call( + handler: unknown, + method: string, + path: string, + body?: unknown, + params: Record = {}, +): Promise { + const request = new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return (handler as Handler)(request, { params: Promise.resolve(params) }); +} + +async function json(response: Response): Promise { + return (await response.json()) as T; +} + +interface Envelope { + data: T; + error: string | null; + meta?: { errorCode?: string }; +} + +async function seedUsers() { + const prisma = getPrismaClient(); + for (const id of [OWNER_ID, OTHER_ID]) { + await prisma.user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: "Europe/Berlin", + locale: "en", + }, + }); + } +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 86_400_000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function defineSymptom(label: string): Promise { + const { POST } = await import("@/app/api/symptoms/definitions/route"); + const response = await call(POST, "POST", "/api/symptoms/definitions", { + label, + icon: "Zap", + }); + expect(response.status, label).toBe(201); + return (await json>(response)).data.id; +} + +async function logEvent(body: Record): Promise { + const { POST } = await import("@/app/api/symptoms/events/route"); + return call(POST, "POST", "/api/symptoms/events", body); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedUsers(); + await signIn(OWNER_ID); +}); + +describe("symptom definitions", () => { + it("stores the name as ciphertext and reads it back with its summary", async () => { + const id = await defineSymptom("Aura"); + const row = await getPrismaClient().symptomDefinition.findUniqueOrThrow({ + where: { id }, + }); + expect(Buffer.from(row.labelEncrypted).toString("utf8")).not.toContain( + "Aura", + ); + expect(decryptFromBytes(row.labelEncrypted)).toBe("Aura"); + + await logEvent({ definitionId: id, intensity: 3 }); + await logEvent({ definitionId: id, intensity: 7 }); + + const { GET } = await import("@/app/api/symptoms/definitions/route"); + const listed = await json< + Envelope<{ + definitions: Array<{ + id: string; + label: string; + recent: { count30d: number; maxIntensity30d: number | null }; + }>; + limit: number; + }> + >(await call(GET, "GET", "/api/symptoms/definitions")); + expect(listed.data.limit).toBe(8); + expect(listed.data.definitions).toMatchObject([ + { id, label: "Aura", recent: { count30d: 2, maxIntensity30d: 7 } }, + ]); + }); + + it("holds eight active definitions, and a hidden one frees its slot", async () => { + const ids: string[] = []; + for (let i = 0; i < 8; i += 1) ids.push(await defineSymptom(`S${i}`)); + + const { POST } = await import("@/app/api/symptoms/definitions/route"); + const ninth = await call(POST, "POST", "/api/symptoms/definitions", { + label: "Ninth", + }); + expect(ninth.status).toBe(422); + expect((await json>(ninth)).meta?.errorCode).toBe( + "symptoms.definition.limitReached", + ); + + const { DELETE, PATCH } = + await import("@/app/api/symptoms/definitions/[id]/route"); + const hidden = await call( + DELETE, + "DELETE", + `/api/symptoms/definitions/${ids[0]}`, + undefined, + { id: ids[0] }, + ); + expect(hidden.status).toBe(200); + expect(await defineSymptom("Ninth")).toBeTruthy(); + + // Showing the hidden one again would make nine. + const reshown = await call( + PATCH, + "PATCH", + `/api/symptoms/definitions/${ids[0]}`, + { isActive: true }, + { id: ids[0] }, + ); + expect(reshown.status).toBe(422); + expect( + ( + await getPrismaClient().symptomDefinition.findUniqueOrThrow({ + where: { id: ids[0] }, + }) + ).isActive, + ).toBe(false); + }); + + it("purges a definition with every occurrence of it", async () => { + const id = await defineSymptom("Aura"); + await logEvent({ definitionId: id, intensity: 4 }); + const { DELETE } = + await import("@/app/api/symptoms/definitions/[id]/route"); + const response = await call( + DELETE, + "DELETE", + `/api/symptoms/definitions/${id}?purge=true`, + undefined, + { id }, + ); + expect(response.status).toBe(200); + expect( + (await json>(response)).data + .eventsDeleted, + ).toBe(1); + expect( + await getPrismaClient().symptomEvent.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(0); + }); + + it("is a 404 for another account's definition", async () => { + const id = await defineSymptom("Aura"); + await signIn(OTHER_ID); + const { PATCH } = await import("@/app/api/symptoms/definitions/[id]/route"); + const response = await call( + PATCH, + "PATCH", + `/api/symptoms/definitions/${id}`, + { label: "Mine now" }, + { id }, + ); + expect(response.status).toBe(404); + expect( + ( + await json>( + await logEvent({ definitionId: id, intensity: 2 }), + ) + ).meta?.errorCode, + ).toBe("symptoms.definition.notFound"); + }); +}); + +describe("symptom occurrences", () => { + it("logs, lists, edits and deletes one, with the note encrypted", async () => { + const id = await defineSymptom("Headache"); + const created = await logEvent({ + definitionId: id, + intensity: 8, + occurredAt: "2026-09-01T09:30:00.000Z", + note: "behind the left eye", + }); + expect(created.status).toBe(201); + const event = (await json>(created)) + .data; + expect(event.note).toBe("behind the left eye"); + const row = await getPrismaClient().symptomEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect(decryptFromBytes(row.noteEncrypted!)).toBe("behind the left eye"); + + const { GET } = await import("@/app/api/symptoms/events/route"); + const listed = await json }>>( + await call(GET, "GET", `/api/symptoms/events?definitionId=${id}`), + ); + expect(listed.data.events.map((e) => e.id)).toEqual([event.id]); + + const { PATCH, DELETE } = + await import("@/app/api/symptoms/events/[id]/route"); + const patched = await call( + PATCH, + "PATCH", + `/api/symptoms/events/${event.id}`, + { intensity: 5, note: null }, + { id: event.id }, + ); + expect(patched.status).toBe(200); + const after = await getPrismaClient().symptomEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect(after.intensity).toBe(5); + expect(after.noteEncrypted).toBeNull(); + + const deleted = await call( + DELETE, + "DELETE", + `/api/symptoms/events/${event.id}`, + undefined, + { id: event.id }, + ); + expect(deleted.status).toBe(200); + expect( + await getPrismaClient().symptomEvent.count({ where: { id: event.id } }), + ).toBe(0); + }); + + it("refuses an intensity outside 0-10 and a time in the future", async () => { + const id = await defineSymptom("Aura"); + expect((await logEvent({ definitionId: id, intensity: 11 })).status).toBe( + 422, + ); + expect( + ( + await logEvent({ + definitionId: id, + intensity: 3, + occurredAt: new Date(Date.now() + 3_600_000).toISOString(), + }) + ).status, + ).toBe(422); + }); + + it("holds the 0-10 bound in the database too", async () => { + const id = await defineSymptom("Aura"); + await expect( + getPrismaClient().symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: id, + occurredAt: new Date(), + intensity: 12, + }, + }), + ).rejects.toThrow(); + }); + + it("links an occurrence to the record's own open episode", async () => { + const id = await defineSymptom("Aura"); + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const response = await logEvent({ + definitionId: id, + intensity: 6, + episodeId: episode.id, + }); + expect(response.status).toBe(201); + expect( + (await json>(response)).data.episodeId, + ).toBe(episode.id); + }); + + it("refuses a resolved episode, and nothing is written", async () => { + const id = await defineSymptom("Aura"); + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-08-01T00:00:00.000Z"), + resolvedAt: new Date("2026-08-08T00:00:00.000Z"), + }, + }); + const response = await logEvent({ + definitionId: id, + intensity: 6, + episodeId: episode.id, + }); + expect(response.status).toBe(422); + expect((await json>(response)).meta?.errorCode).toBe( + "symptoms.episode.notOpen", + ); + expect( + await getPrismaClient().symptomEvent.count({ + where: { episodeId: episode.id }, + }), + ).toBe(0); + }); + + it("refuses another account's episode, and nothing is written", async () => { + const id = await defineSymptom("Aura"); + const foreign = await getPrismaClient().illnessEpisode.create({ + data: { + userId: OTHER_ID, + label: "Flu", + type: "INFECTION", + onsetAt: new Date("2026-09-01T00:00:00.000Z"), + }, + }); + const response = await logEvent({ + definitionId: id, + intensity: 6, + episodeId: foreign.id, + }); + expect(response.status).toBe(422); + expect( + await getPrismaClient().symptomEvent.count({ + where: { episodeId: foreign.id }, + }), + ).toBe(0); + }); + + it("answers illness.disabled with the module off", async () => { + await getPrismaClient().user.update({ + where: { id: OWNER_ID }, + data: { modulePreferencesJson: { illness: false } }, + }); + const { GET } = await import("@/app/api/symptoms/definitions/route"); + const response = await call(GET, "GET", "/api/symptoms/definitions"); + expect(response.status).toBe(403); + expect((await json>(response)).meta?.errorCode).toBe( + "illness.disabled", + ); + }); +}); + +describe("the SYMPTOM: correlation channel", () => { + it("reaches the matrix labelled with its name, and leaves it with the module off", async () => { + const prisma = getPrismaClient(); + const definition = await prisma.symptomDefinition.create({ + data: { userId: OWNER_ID, labelEncrypted: encryptToBytes("Aura") }, + }); + const hidden = await prisma.symptomDefinition.create({ + data: { + userId: OWNER_ID, + labelEncrypted: encryptToBytes("Old"), + isActive: false, + }, + }); + for (const [id, at, intensity] of [ + [definition.id, "2026-09-01T08:00:00.000Z", 3], + [definition.id, "2026-09-01T18:00:00.000Z", 6], + [definition.id, "2026-09-03T08:00:00.000Z", 2], + [hidden.id, "2026-09-02T08:00:00.000Z", 9], + ] as const) { + await prisma.symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: id, + occurredAt: new Date(at), + intensity, + }, + }); + } + + const options = { + tz: "Europe/Berlin", + since: new Date("2026-08-01T00:00:00.000Z"), + fetchMode: "raw" as const, + }; + const on = await assembleDiscoveryMatrix(OWNER_ID, { + ...options, + modules: {}, + }); + const channels = on.series.filter((s) => s.key.startsWith("SYMPTOM:")); + expect(channels).toEqual([ + { + key: `SYMPTOM:${definition.id}`, + label: "Aura", + role: "outcome", + points: [ + { day: "2026-09-01", value: 6 }, + { day: "2026-09-02", value: 0 }, + { day: "2026-09-03", value: 2 }, + ], + }, + ]); + + const off = await assembleDiscoveryMatrix(OWNER_ID, { + ...options, + modules: { illness: false }, + }); + expect(off.series.some((s) => s.key.startsWith("SYMPTOM:"))).toBe(false); + }); +}); + +describe("the portable backup", () => { + it("carries the names readable and restores them, dropping a link to an episode the file lacks", async () => { + const prisma = getPrismaClient(); + const kept = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Migraine", + type: "CHRONIC", + onsetAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const gone = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-07-01T00:00:00.000Z"), + }, + }); + const definition = await prisma.symptomDefinition.create({ + data: { + userId: OWNER_ID, + labelEncrypted: encryptToBytes("Aura"), + icon: "Zap", + }, + }); + for (const [episodeId, intensity] of [ + [kept.id, 4], + [gone.id, 6], + [null, 2], + ] as const) { + await prisma.symptomEvent.create({ + data: { + userId: OWNER_ID, + definitionId: definition.id, + occurredAt: new Date(`2026-07-0${intensity}T08:00:00.000Z`), + intensity, + episodeId, + noteEncrypted: intensity === 2 ? encryptToBytes("mild") : null, + }, + }); + } + + const section = await buildSymptomsBackupSection(prisma, OWNER_ID); + const text = JSON.stringify(section); + expect(text).toContain('"label":"Aura"'); + expect(text).toContain('"note":"mild"'); + expect(text).not.toContain("labelEncrypted"); + + // The restore puts back only the kept episode. + const skips: RestoreSkipLog = []; + await prisma.$transaction((tx) => + restoreSymptomsData( + tx, + OWNER_ID, + JSON.parse(text), + new Set([kept.id]), + skips, + ), + ); + + const restored = await prisma.symptomDefinition.findMany({ + where: { userId: OWNER_ID }, + include: { events: { orderBy: { intensity: "asc" } } }, + }); + expect(restored).toHaveLength(1); + expect(decryptFromBytes(restored[0].labelEncrypted)).toBe("Aura"); + expect( + restored[0].events.map((e) => ({ + intensity: e.intensity, + episodeId: e.episodeId, + note: e.noteEncrypted ? decryptFromBytes(e.noteEncrypted) : null, + })), + ).toEqual([ + { intensity: 2, episodeId: null, note: "mild" }, + { intensity: 4, episodeId: kept.id, note: null }, + { intensity: 6, episodeId: null, note: null }, + ]); + expect(skips).toEqual([ + { catalogue: "symptomEpisodeReference", key: gone.id, links: 1 }, + ]); + }); +}); From 9272daa59c58d7016b214b28054d6aa37bbbb439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 21:24:12 +0200 Subject: [PATCH 3/8] feat(symptoms): log a symptom in one step and show it on the illness pages - A new capture kind "symptom" (illness module, illness write grant) in the bottom bar's picker and the dashboard Add menu. The sheet follows the measurement and mood forms: the person's symptoms as chips with the cycle sheet's dashed "new symptom" chip, the 0-10 slider with its anchors, the time prefilled with now, an optional note, and a "during" selector only while an illness episode is open. - "Your symptoms" on the illness page: one line per symptom (how often in 30 days, how strong at worst, when last), the last 30 days grouped by day, edit and delete per entry, rename, icon, hide and delete per symptom. Renders nothing until a symptom exists. - "Symptoms logged" on an episode's page: the entries filed against it or logged while it ran, the filed ones marked. This is the reader of the episode link. - Query keys live under the illness root, so either side's writes refresh both. --- .../__tests__/dashboard-header.test.tsx | 4 +- src/components/dashboard/dashboard-header.tsx | 17 +- .../dashboard/quick-entry-sheets.tsx | 25 +- .../illness/illness-episode-detail.tsx | 5 + src/components/illness/illness-view.tsx | 36 +- src/components/layout/capture-picker.tsx | 25 +- .../__tests__/symptom-entry-form.test.tsx | 114 ++++ src/components/symptoms/add-symptom-chip.tsx | 165 ++++++ .../symptoms/episode-symptoms-card.tsx | 135 +++++ .../symptoms/symptom-edit-sheets.tsx | 267 +++++++++ .../symptoms/symptom-entry-form.tsx | 395 +++++++++++++ src/components/symptoms/symptom-icons.ts | 79 +++ src/components/symptoms/symptoms-section.tsx | 524 ++++++++++++++++++ src/components/symptoms/use-symptoms.ts | 154 +++++ .../__tests__/surface-module-registry.test.ts | 1 + src/lib/modules/surface.ts | 2 + src/lib/query-keys/illness.ts | 11 + 17 files changed, 1940 insertions(+), 19 deletions(-) create mode 100644 src/components/symptoms/__tests__/symptom-entry-form.test.tsx create mode 100644 src/components/symptoms/add-symptom-chip.tsx create mode 100644 src/components/symptoms/episode-symptoms-card.tsx create mode 100644 src/components/symptoms/symptom-edit-sheets.tsx create mode 100644 src/components/symptoms/symptom-entry-form.tsx create mode 100644 src/components/symptoms/symptom-icons.ts create mode 100644 src/components/symptoms/symptoms-section.tsx create mode 100644 src/components/symptoms/use-symptoms.ts diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 0e5579c44..ab0909ef2 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -153,7 +153,8 @@ describe(" — every entry follows its module, as the capture p { mood: false }, { medications: false }, { workouts: false }, - { mood: false, medications: false, workouts: false }, + { illness: false }, + { mood: false, medications: false, workouts: false, illness: false }, ]; for (const modules of cases) { modulesRef.value = modules; @@ -170,6 +171,7 @@ describe(" — every entry follows its module, as the capture p measurement: "Log measurement", medication: "Log medication intake", mood: "Log mood", + symptom: "Log symptom", workout: "Log workout", } as const; for (const kind of CAPTURE_KIND_ORDER) { diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index db6ad7cc0..c969eb495 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -12,7 +12,15 @@ */ import { useMemo } from "react"; import Link from "next/link"; -import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react"; +import { + Activity, + Footprints, + Pill, + Plus, + Stethoscope, + Waves, + Wrench, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -62,6 +70,7 @@ export function DashboardHeader({ const canAddMeasurement = offered.includes("measurement"); const canAddIntake = offered.includes("medication"); const canAddMood = offered.includes("mood"); + const canAddSymptom = offered.includes("symptom"); const canAddWorkout = offered.includes("workout"); const canAddAnything = offered.length > 0; @@ -198,6 +207,12 @@ export function DashboardHeader({ {t("dashboard.quickAddMedicationIntake")} )} + {canAddSymptom && ( + onQuickEntry("symptom")}> + + )} {canAddWorkout && ( onQuickEntry("workout")}>