mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(symptoms): add person-defined symptoms and timestamped occurrences
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.
- Migration 0369 (idempotent): symptom_definitions and symptom_events,
intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
eight active definitions per record (a hidden one frees its slot,
showing it again re-checks), hide or purge, events create/list/edit/
delete, a new link must name an open episode of the same record
(422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
plan; both carried at both backup ends with occurrences nested in their
definition, plaintext in a portable file and ciphertext in a recovery
file, and a link to an episode the file lacks dropped and named
(symptomEpisodeReference).
- OpenAPI regenerated.
This commit is contained in:
1 parent
7cd323499e
commit
d6571c4e9c
30 files changed
+3091
-16
No files matched your search
+738
-8
@@ -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:<id>`."
|
||||
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
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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:<id>`, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -991,6 +991,22 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
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<string, string> = {
|
||||
"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<string, ManageEntry> = {
|
||||
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<string, string> = {
|
||||
* 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 }) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<Response> {
|
||||
// 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);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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<Response> {
|
||||
// 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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<PrismaClient, "symptomDefinition">,
|
||||
userId: string,
|
||||
options: SymptomsBackupOptions = {},
|
||||
): Promise<SymptomsBackupSection> {
|
||||
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<ArrayBuffer> {
|
||||
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<string>,
|
||||
skips: RestoreSkipLog,
|
||||
): Promise<SymptomsRestoreCleared> {
|
||||
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 };
|
||||
}
|
||||
@@ -346,6 +346,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"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",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
// 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).
|
||||
//
|
||||
|
||||
@@ -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:<id>`.",
|
||||
});
|
||||
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<ArrayBuffer> {
|
||||
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<SymptomDefinitionDTO[]> {
|
||||
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<boolean> {
|
||||
const episode = await prisma.illnessEpisode.findFirst({
|
||||
where: { id: episodeId, userId, deletedAt: null, resolvedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
return episode !== null;
|
||||
}
|
||||
@@ -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<string>(SYMPTOM_ICON_ALLOWLIST);
|
||||
|
||||
/** Correlation channel key prefix: `SYMPTOM:<definitionId>`. */
|
||||
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),
|
||||
});
|
||||
@@ -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([]),
|
||||
|
||||
+48
@@ -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[];
|
||||
@@ -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<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// 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`,
|
||||
|
||||
@@ -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<Record<string, unknown>>(
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user