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); }); });