diff --git a/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql b/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql new file mode 100644 index 000000000..d33de4079 --- /dev/null +++ b/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql @@ -0,0 +1,47 @@ +-- v1.19.0 — reminder-engine fields for the "one engine" unification. +-- +-- Two additive columns on `measurement_reminders` so the AI coach can +-- produce cadence reminders into the SAME table the Vorsorge surface uses +-- (one model, one recurrence helper, one cron, one create endpoint — not a +-- second "coach reminder" table): +-- +-- * `ends_on` (nullable timestamp) — an optional course-window end. NULL = +-- open-ended cadence (the existing behaviour, every current row). Non-NULL +-- bounds a finite cadence so a Coach "measure BP morning+evening for a +-- week" self-expires: the recurrence engine stops producing occurrences +-- past this instant (the `medication.ends_on` precedent the engine already +-- supports). +-- +-- * `origin` (enum `reminder_origin`, default `VORSORGE`) — provenance. +-- `VORSORGE` = the user created it through the Vorsorge surface (the +-- existing behaviour; backfilled onto every current row by the column +-- default). `COACH` = the coach minted it from an evidence-based cadence +-- suggestion. Lets the UI label provenance and lets Coach dedupe its own +-- suggestions. +-- +-- No backfill needed: `ends_on` defaults NULL (open-ended) and `origin` +-- defaults `VORSORGE`, so every existing reminder keeps its current +-- behaviour. No new index — the cron already rides `(user_id, next_due_at)` +-- and these two columns are read alongside the row, never scanned on. +-- +-- Idempotent guards (`IF NOT EXISTS` / `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- ALTER TABLE "measurement_reminders" DROP COLUMN IF EXISTS "origin"; +-- ALTER TABLE "measurement_reminders" DROP COLUMN IF EXISTS "ends_on"; +-- DROP TYPE IF EXISTS "reminder_origin"; +-- A roll-back loses the coach/vorsorge distinction + any course-window end +-- (every reminder falls back to open-ended user-created), which is the safe +-- default — no data is destroyed. + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "reminder_origin" AS ENUM ('VORSORGE', 'COACH'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AlterTable +ALTER TABLE "measurement_reminders" + ADD COLUMN IF NOT EXISTS "ends_on" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "origin" "reminder_origin" NOT NULL DEFAULT 'VORSORGE'; diff --git a/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql new file mode 100644 index 000000000..e7ebc3553 --- /dev/null +++ b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql @@ -0,0 +1,80 @@ +-- v1.19.0 — user-scoped biomarker catalog. +-- +-- The lean v1.17.1 `lab_results` row stores analyte / unit / reference_low / +-- reference_high on EVERY row, so recording the same marker three times means +-- re-typing the name + unit + both bounds three times — each an opportunity +-- to fork the "same" biomarker into "LDL" / "ldl" / "LDL-C" or mistype +-- `mg/dL` as `mg/dl`. This adds a per-user catalog that defines a marker ONCE +-- (canonical name, unit, reference bounds, optional context note); readings +-- link to it and resolve their range/unit from the catalog row. +-- +-- * `biomarkers` table — user-scoped, unique per (user_id, name) so there +-- is never two "LDL" definitions. `context_encrypted` is a Bytes column +-- (the AES-256-GCM `*Encrypted` convention). `lower_bound` / `upper_bound` +-- are the optional reference window; `panel` is the optional grouping +-- moved off the per-entry row. +-- +-- * `lab_results.biomarker_id` (nullable FK) — every existing row stays +-- NULL. A pg-boss backfill job (not a tsx CLI per the standalone-image +-- rule) will later GROUP BY lower(analyte) per user, mint one Biomarker +-- per group, and link the rows. This migration only adds the column + +-- relation + supporting index; no row is touched, no data destroyed. +-- ON DELETE SET NULL: deleting a catalog marker orphans its readings +-- (they keep their legacy analyte/unit/reference columns) rather than +-- cascading the historical values away. +-- +-- Additive throughout; no backfill in the migration itself. +-- +-- Idempotent guards (`IF NOT EXISTS`) so reruns are safe on prod. The FK is +-- added under a DO-block existence guard for the same reason. +-- +-- Reversibility (down): +-- ALTER TABLE "lab_results" DROP CONSTRAINT IF EXISTS "lab_results_biomarker_id_fkey"; +-- DROP INDEX IF EXISTS "lab_results_biomarker_id_idx"; +-- ALTER TABLE "lab_results" DROP COLUMN IF EXISTS "biomarker_id"; +-- DROP TABLE IF EXISTS "biomarkers"; +-- A roll-back drops the catalog + un-links readings (which keep their legacy +-- per-row analyte/unit/reference values), the safe default — no reading lost. + +-- CreateTable +CREATE TABLE IF NOT EXISTS "biomarkers" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "unit" TEXT NOT NULL, + "lower_bound" DOUBLE PRECISION, + "upper_bound" DOUBLE PRECISION, + "context_encrypted" BYTEA, + "panel" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "biomarkers_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "biomarkers_user_id_name_key" ON "biomarkers"("user_id", "name"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "biomarkers_user_id_idx" ON "biomarkers"("user_id"); + +-- AlterTable +ALTER TABLE "lab_results" + ADD COLUMN IF NOT EXISTS "biomarker_id" TEXT; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "lab_results_biomarker_id_idx" ON "lab_results"("biomarker_id"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "biomarkers" ADD CONSTRAINT "biomarkers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "lab_results" ADD CONSTRAINT "lab_results_biomarker_id_fkey" FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/migrations/0171_v1181_illness_episode/migration.sql b/prisma/migrations/0171_v1181_illness_episode/migration.sql new file mode 100644 index 000000000..60ec415ee --- /dev/null +++ b/prisma/migrations/0171_v1181_illness_episode/migration.sql @@ -0,0 +1,203 @@ +-- v1.19.0 — illness / condition journal. +-- +-- A CONDITION journal (not a flu log): any illness/condition kept for +-- oneself, with a retrospective lens. Retrospective + correlated, NEVER a +-- predictor/diagnoser. Born-gated by the module system — the gate is +-- app-layer, so there is no enable flag in the schema. +-- +-- Tables (the daily timeline mirrors the Cycle three-table shape): +-- +-- * `illness_episodes` — one episode. `illness_type` widens beyond an +-- infection log (infection / allergy / injury / mental-health / +-- autoimmune / chronic / other). `illness_lifecycle` +-- (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE) distinguishes a bout from +-- an ongoing chronic condition; CHRONIC_ONGOING carries no resolved_at +-- and is excluded from the recovery-gap math. `parent_condition_id` is a +-- self-FK (ON DELETE SET NULL) so a flare/recurrence threads under one +-- parent condition. `note_encrypted` is a Bytes column (the AES-256-GCM +-- `*Encrypted` convention). Soft-deleted. +-- +-- * `illness_day_logs` — one row per day of an episode (mirrors +-- cycle_day_logs): a coarse 0–3 `functional_impact` slider, an optional +-- `fever_c` reading (queryable plaintext, the basal_body_temp_c +-- precedent), an encrypted day note, the day's symptom links. Unique per +-- (episode_id, date). Soft-deleted. +-- +-- * `illness_symptoms` — a seeded, icon-bearing, labelKey-i18n catalog +-- (Jackson / WURSS-derived; the cycle_symptoms precedent). Eight +-- high-signal cold/illness symptoms, scored 0–3 on the link. +-- +-- * `illness_symptom_links` — the per-day-log join, composite PK +-- (day_log_id, symptom_id), 0–3 severity, cascade on both sides. +-- +-- Additive; no existing row touched. The catalog seed uses deterministic +-- ids + ON CONFLICT ("key") DO NOTHING so re-running is a no-op (the +-- cycle/mood-tag seed precedent). Keep in sync with the app-side taxonomy +-- module if either changes. +-- +-- Idempotent guards (`IF NOT EXISTS` / `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "illness_symptom_links"; +-- DROP TABLE IF EXISTS "illness_symptoms"; +-- DROP TABLE IF EXISTS "illness_day_logs"; +-- DROP TABLE IF EXISTS "illness_episodes"; +-- DROP TYPE IF EXISTS "illness_lifecycle"; +-- DROP TYPE IF EXISTS "illness_type"; +-- A roll-back drops the journal wholesale — no other domain depends on it. + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "illness_type" AS ENUM ('INFECTION', 'ALLERGY', 'INJURY', 'MENTAL_HEALTH', 'AUTOIMMUNE', 'CHRONIC', 'OTHER'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "illness_lifecycle" AS ENUM ('ACUTE', 'CHRONIC_ONGOING', 'RECURRING', 'FLARE'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_episodes" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "type" "illness_type" NOT NULL, + "lifecycle" "illness_lifecycle" NOT NULL DEFAULT 'ACUTE', + "onset_at" TIMESTAMP(3) NOT NULL, + "resolved_at" TIMESTAMP(3), + "parent_condition_id" TEXT, + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "illness_episodes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_day_logs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "episode_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "functional_impact" INTEGER, + "fever_c" DOUBLE PRECISION, + "note_encrypted" BYTEA, + "tz" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "illness_day_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_symptoms" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label_key" TEXT NOT NULL, + "icon" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "illness_symptoms_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_symptom_links" ( + "day_log_id" TEXT NOT NULL, + "symptom_id" TEXT NOT NULL, + "severity" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "illness_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_episodes_user_id_deleted_at_idx" ON "illness_episodes"("user_id", "deleted_at"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_episodes_user_id_onset_at_idx" ON "illness_episodes"("user_id", "onset_at"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_episodes_parent_condition_id_idx" ON "illness_episodes"("parent_condition_id"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "illness_day_logs_episode_id_date_key" ON "illness_day_logs"("episode_id", "date"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_day_logs_user_id_date_idx" ON "illness_day_logs"("user_id", "date"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_day_logs_episode_id_idx" ON "illness_day_logs"("episode_id"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "illness_symptoms_key_key" ON "illness_symptoms"("key"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_symptoms_sort_order_idx" ON "illness_symptoms"("sort_order"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_symptom_links_symptom_id_idx" ON "illness_symptom_links"("symptom_id"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_episodes" ADD CONSTRAINT "illness_episodes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_episodes" ADD CONSTRAINT "illness_episodes_parent_condition_id_fkey" FOREIGN KEY ("parent_condition_id") REFERENCES "illness_episodes"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_day_logs" ADD CONSTRAINT "illness_day_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_day_logs" ADD CONSTRAINT "illness_day_logs_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_symptom_links" ADD CONSTRAINT "illness_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "illness_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_symptom_links" ADD CONSTRAINT "illness_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "illness_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ─── Seed the default illness-symptom catalog ──────────────────────── +-- Jackson/WURSS-derived high-signal cold/illness symptoms, scored 0–3 on +-- the link. Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING +-- (the cycle_symptoms seed precedent 0129). Keep in sync with the app-side +-- illness symptom taxonomy if either changes. +INSERT INTO "illness_symptoms" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES + ('is_runny_nose', 'runny_nose', 'illness.symptom.runnyNose', 'Droplets', 0, true), + ('is_stuffy_nose', 'stuffy_nose', 'illness.symptom.stuffyNose', 'Wind', 1, true), + ('is_sneezing', 'sneezing', 'illness.symptom.sneezing', 'Waves', 2, true), + ('is_sore_throat', 'sore_throat', 'illness.symptom.soreThroat', 'Flame', 3, true), + ('is_cough', 'cough', 'illness.symptom.cough', 'Megaphone', 4, true), + ('is_headache', 'headache', 'illness.symptom.headache', 'Brain', 5, true), + ('is_body_aches', 'body_aches', 'illness.symptom.bodyAches', 'PersonStanding', 6, true), + ('is_fatigue', 'fatigue', 'illness.symptom.fatigue', 'BatteryLow', 7, true) +ON CONFLICT ("key") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c07d244d..83e335d28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -588,6 +588,14 @@ model User { // that pairs with the Vorsorge "annual blood panel" reminder so a result // has somewhere to land. Soft-deleted. labResults LabResult[] + // v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per + // marker); LabResult rows link to a Biomarker for the resolved range/unit. + biomarkers Biomarker[] + // v1.19.0 — illness / condition journal (born-gated by the module system). + // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the + // per-day timeline mirroring the CycleDayLog shape. + illnessEpisodes IllnessEpisode[] + illnessDayLogs IllnessDayLog[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -3255,6 +3263,19 @@ model MeasurementReminder { /// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt` /// when NULL. anchorDate DateTime? @map("anchor_date") + /// Optional course-window end. NULL = open-ended cadence (the existing + /// behaviour). Non-NULL bounds a finite cadence: the recurrence engine + /// (`medication.endsOn` precedent) stops producing occurrences past this + /// instant, so a Coach-suggested "measure BP morning+evening for a week" + /// self-expires. Additive; every existing reminder keeps `NULL` and stays + /// open-ended. + endsOn DateTime? @map("ends_on") + /// Who produced this reminder. `VORSORGE` = the user created it through the + /// Vorsorge surface (the existing, default behaviour). `COACH` = the AI + /// coach minted it from an evidence-based cadence suggestion, posted to the + /// SAME `POST /api/measurement-reminders` endpoint — one engine, not two. + /// Lets the UI label provenance and lets Coach dedupe its own suggestions. + origin ReminderOrigin @default(VORSORGE) /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour. /// DST-safe windowing is applied at the cron via the existing TZ helpers. notifyHour Int @default(9) @map("notify_hour") @@ -3280,6 +3301,16 @@ model MeasurementReminder { @@map("measurement_reminders") } +/// Provenance of a MeasurementReminder. `VORSORGE` = user-created through the +/// Vorsorge surface (default); `COACH` = minted by the AI coach from a cadence +/// suggestion. Both feed the same engine / endpoint / cron. +enum ReminderOrigin { + VORSORGE + COACH + + @@map("reminder_origin") +} + // ─── Lab results ───────────────────────────────────────────── /// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual @@ -3304,6 +3335,14 @@ model LabResult { /// When the sample was taken (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). takenAt DateTime @map("taken_at") + /// v1.19.0 — optional link to the user-scoped Biomarker catalog. NULL on + /// every existing row; a backfill job grouping by `lower(analyte)` will + /// populate it later (this migration only adds the column + relation). Once + /// linked, server reads resolve the canonical unit + reference bounds from + /// the Biomarker, ending the per-row free-text divergence ("LDL" vs "ldl" + /// vs "LDL-C", `mg/dL` vs `mg/dl`). The legacy `analyte` / `unit` / + /// `reference*` columns stay as historical truth for one release. + biomarkerId String? @map("biomarker_id") /// Provenance: "MANUAL" today; import paths set their own source string. source String @default("MANUAL") /// Optional free-text note, AES-256-GCM encrypted at rest via @@ -3314,13 +3353,55 @@ model LabResult { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) @@index([userId, analyte, takenAt]) @@index([userId, deletedAt]) + @@index([biomarkerId]) @@map("lab_results") } +// ─── Biomarker catalog ─────────────────────────────────────── + +/// v1.19.0 — user-scoped catalog of blood/lab biomarkers. Defining a marker +/// once (canonical name, unit, reference bounds, optional context note) and +/// recording LabResult values against it ends the per-row free-text +/// divergence the lean v1.17.1 LabResult model invites (re-typing analyte / +/// unit / both bounds on every reading). The reference range + unit are +/// authoritative on the catalog row, not on the entry; server reads resolve +/// from here and the resolved values flow into the DTO (iOS renders, never +/// recomputes). Unique per user + name so there is never two "LDL" +/// definitions. +model Biomarker { + id String @id @default(cuid()) + userId String @map("user_id") + /// Canonical display name, e.g. "LDL Cholesterol". + name String + /// Canonical unit for every reading against this marker, e.g. "mg/dL". + unit String + /// Optional reference-range bounds. The lab/sex/age-dependent "normal" + /// window; the server's range verdict is computed against these. + lowerBound Float? @map("lower_bound") + upperBound Float? @map("upper_bound") + /// Optional per-marker context ("what this means"), AES-256-GCM encrypted + /// at rest via `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per + /// convention). + contextEncrypted Bytes? @map("context_encrypted") + /// Optional panel grouping moved off the per-entry row, e.g. + /// "Großes Blutbild". NULL = standalone marker. + panel String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + results LabResult[] + + @@unique([userId, name]) + @@index([userId]) + @@map("biomarkers") +} + // ─── User Achievements ─────────────────────────────────────── model UserAchievement { @@ -3935,6 +4016,33 @@ enum CervixOpening { @@map("cervix_opening") } +/// Broad condition class for an IllnessEpisode (v1.19.0). Widened beyond an +/// infection log so the journal covers any condition the user keeps for +/// themselves. +enum IllnessType { + INFECTION + ALLERGY + INJURY + MENTAL_HEALTH + AUTOIMMUNE + CHRONIC + OTHER + + @@map("illness_type") +} + +/// Where an IllnessEpisode sits in its lifecycle (v1.19.0). `CHRONIC_ONGOING` +/// has no "recovered" date and is excluded from the recovery-gap math; +/// `FLARE` rows hang off a parent condition via `parentConditionId`. +enum IllnessLifecycle { + ACUTE + CHRONIC_ONGOING + RECURRING + FLARE + + @@map("illness_lifecycle") +} + /// Which method produced a cached CyclePrediction. `CALENDAR` is the /// median+MAD variability predictor; `SYMPTOTHERMAL` folds the /// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the @@ -4227,6 +4335,141 @@ model CycleSymptomLink { @@map("cycle_symptom_links") } +// ─── Illness / condition journal (v1.19.0) ──────────────────── +// +// A CONDITION journal, not a flu log: any illness/condition kept for +// oneself, with a retrospective lens ("how did it announce itself?", +// what dropped, recovery time). Retrospective + correlated, NEVER a +// predictor/diagnoser. Born-gated by the module system (the gate is +// app-layer; there is no enable flag in the schema). The daily timeline +// mirrors the Cycle three-table shape (DayLog + Symptom catalog + Link). + +/// One illness/condition episode. `lifecycle` distinguishes an acute +/// bout from an ongoing chronic condition, a recurring one, or a flare +/// hanging off a parent condition (`parentConditionId`). CHRONIC_ONGOING +/// has no "recovered" date and is excluded from the recovery-gap math. +model IllnessEpisode { + id String @id @default(cuid()) + userId String @map("user_id") + /// User-facing label for the episode, e.g. "Erkältung", "Heuschnupfen". + label String + /// Broad condition class. Widened beyond an infection log to cover the + /// condition journal's scope. + type IllnessType + /// Where this episode sits in its lifecycle. FLARE rows reference a + /// parent condition via `parentConditionId`. + lifecycle IllnessLifecycle @default(ACUTE) + /// When the episode began (UTC instant; wall-clock anchoring is the + /// reader's concern per the schema's UTC-only convention). + onsetAt DateTime @map("onset_at") + /// When the episode resolved. NULL = still ongoing. CHRONIC_ONGOING + /// episodes stay NULL by design. + resolvedAt DateTime? @map("resolved_at") + /// Self-FK for flares: a FLARE/RECURRING bout hangs off the parent + /// condition episode so the history threads under one condition. + parentConditionId String? @map("parent_condition_id") + /// Optional free-text note, AES-256-GCM encrypted at rest via + /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per + /// convention). + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// Parent condition for a flare/recurrence (self-relation). + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] + + @@index([userId, deletedAt]) + @@index([userId, onsetAt]) + @@index([parentConditionId]) + @@map("illness_episodes") +} + +/// One row per day of an illness episode (mirrors CycleDayLog): the +/// day's free-text note (encrypted), a coarse functional-impact slider, +/// an optional fever reading, plus the symptom links. Soft-deleted. +model IllnessDayLog { + id String @id @default(cuid()) + userId String @map("user_id") + episodeId String @map("episode_id") + /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy + /// Europe/Berlin (the CycleDayLog / MoodEntry date convention). + date String + /// Coarse self-rated functional impact for the day (0 = no impact / + /// fully functional … 3 = bedbound). NULL = not logged. A slider, not a + /// symptom — kept on the day row like CycleDayLog's per-day signs. + functionalImpact Int? @map("functional_impact") + /// Optional body temperature for the day, in Celsius. Queryable + /// plaintext (the CycleDayLog.basalBodyTempC precedent): the fever + /// signal feeds the retrospective curve. NULL = not measured. + feverC Float? @map("fever_c") + /// AES-256-GCM ciphertext envelope of the day's free-text note (always + /// encrypted — the `*Encrypted` Bytes convention). + noteEncrypted Bytes? @map("note_encrypted") + /// Optional timezone the `date` is anchored to (the CycleDayLog + /// precedent). + tz String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + symptomLinks IllnessSymptomLink[] + + @@unique([episodeId, date]) + @@index([userId, date]) + @@index([episodeId]) + @@map("illness_day_logs") +} + +/// Seeded catalog of illness symptoms (Jackson / WURSS-derived). Mirrors +/// CycleSymptom: a global, icon-bearing, labelKey-i18n, isActive/sortOrder +/// catalog joined per day. Severity is captured 0–3 on the link. +model IllnessSymptom { + id String @id @default(cuid()) + /// Stable machine key (e.g. `cough`). Unique across the catalog so the + /// seed migration can upsert idempotently. + key String @unique + /// i18n message key for the symptom label (`illness.symptom.`). + labelKey String @map("label_key") + /// Lucide icon name for the symptom chip. + icon String? + sortOrder Int @default(0) @map("sort_order") + /// Soft-hide a symptom without deleting its historical links. + isActive Boolean @default(true) @map("is_active") + + links IllnessSymptomLink[] + + @@index([sortOrder]) + @@map("illness_symptoms") +} + +/// Join: which symptoms an illness day-log carries, with a 0–3 severity. +/// Composite PK keeps the pairing unique; cascade on both sides so +/// deleting a day-log (or a catalog symptom) cleans up its links. +model IllnessSymptomLink { + dayLogId String @map("day_log_id") + symptomId String @map("symptom_id") + /// 0–3 severity for this symptom on this day (the Jackson/WURSS scale: + /// 0 = none, 1 = mild, 2 = moderate, 3 = severe). NULL = a plain + /// presence link. + severity Int? @map("severity") + createdAt DateTime @default(now()) @map("created_at") + + dayLog IllnessDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade) + symptom IllnessSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade) + + @@id([dayLogId, symptomId]) + @@index([symptomId]) + @@map("illness_symptom_links") +} + /// One cached forecast per user, regenerated debounced / /// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment /// cache pattern), NOT per-read. Derived — not soft-deleted.