merge: v1.18.1 data foundation (reminder engine, biomarker catalog, illness journal)

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 20:36:22 +02:00
commit bee7295b19
4 files changed
+574 -1

No files matched your search

@@ -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';
@@ -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 $$;
@@ -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;
+244 -1
View File
@@ -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.<key>`).
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.