mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge: v1.18.1 data foundation (reminder engine, biomarker catalog, illness journal)
This commit is contained in:
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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user