mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user