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:
Marc-André Bombeck committed 2026-10-03 21:13:45 +02:00
1 parent 7cd323499e
commit d6571c4e9c
30 files changed
+3091 -16

No files matched your search

@@ -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 $$;
+66
View File
@@ -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