feat(schema): MedicationCategory + MedicationDoseChange + InjectionSite (migration 0046)

Phase 1 of W4d GLP-1 full integration. Forward-only additive migration: a
new `medication_category` enum (GENERIC | GLP1) lands as
`medications.treatment_class` (defaults to GENERIC so existing rows render
exactly as before). `medications.doses_per_unit` lets pen/vial-tracked
meds carry their dose count. `medication_intake_events.injection_site`
captures the 8-zone body-map pick — nullable; non-GLP-1 meds never set
it. `medication_dose_changes` holds titration history (Mounjaro 2.5 → 5
→ 7.5 → 10 → 12.5 → 15 mg per Eli Lilly's PI). `medication_inventory_events`
is the append-only ledger backing the low-stock dashboard caption.

Naming nuance: the new column is `treatmentClass`, not `category`, because
the existing `medication_categories` side-table already owns the
`category` slot in the API response (clinical taxonomy:
BLOOD_PRESSURE / VITAMIN / ...). Two orthogonal concepts, two columns.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:22:47 +02:00
1 parent c2fc95f815
commit cbcc0598b2
2 files changed
+231 -4

No files matched your search

@@ -0,0 +1,127 @@
-- v1.4.25 W4d — GLP-1 full integration (research §5).
--
-- Adds the foundation for first-class GLP-1 receptor agonist support
-- (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus,
-- compounded semaglutide/tirzepatide). Holistic landing — schema +
-- snapshot + Coach guardrails + UI variants — happens together so the
-- feature surfaces all at once instead of trickling in across releases.
--
-- Design principle: every change is additive. Existing medications keep
-- working untouched. The new columns default to NULL / GENERIC so the
-- previous read paths read the same shape they did before the migration.
--
-- Shapes:
-- - MedicationCategory enum (GENERIC default; GLP1 turns on the
-- specialist code paths). Future categories — INSULIN, BIOLOGIC,
-- FERTILITY — drop into this enum.
-- - Medication.treatment_class column with GENERIC default. The
-- existing `medication_categories` side-table (BLOOD_PRESSURE /
-- VITAMIN / ...) tracks the clinical taxonomy and stays put — it
-- is a different concept (clinical category) and the UI keeps
-- reading it under the existing `category` slot. The new column
-- models "medication kind that needs special handling" so the
-- GLP-1 surfaces (card variant, dashboard tile, Coach snapshot)
-- have a single boolean-ish discriminator.
-- - Medication.doses_per_unit — nullable integer; only meaningful for
-- pen / vial meds (Mounjaro KwikPen = 4 doses, Ozempic 0.25/0.5 mg
-- = 4 doses, Wegovy single-dose pen = 1 dose, compounded vials
-- vary). NULL = inventory tracking is off for this medication.
-- - MedicationDoseChange — append-only titration history. The
-- current dose is "latest row by effective_from". Existing
-- Medication.dose remains the convenience pointer; this table is
-- the history of record.
-- - InjectionSite enum (8 zones — front-of-body view: 4 abdomen
-- quadrants, 2 thighs, 2 upper arms). The body-map picker maps each
-- zone to a clickable region.
-- - MedicationIntakeEvent.injection_site — nullable; only populated
-- for GLP-1 intake events. Existing rows stay NULL and read
-- untouched.
-- - MedicationInventoryEvent — append-only inventory log. Positive
-- delta = stock added (purchase), negative = consumed (one per
-- intake event when doses_per_unit is set). Reason is a free-text
-- audit slot ("purchased", "consumed", "expired", "damaged").
--
-- Forward-only, additive, idempotent. No backfill: every new column
-- carries a sensible NULL/default, and the read paths handle the
-- pre-migration shape verbatim.
-- 1. MedicationCategory enum.
DO $$ BEGIN
CREATE TYPE "medication_category" AS ENUM ('GENERIC', 'GLP1');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- 2. Medication.treatment_class + Medication.doses_per_unit columns.
ALTER TABLE "medications"
ADD COLUMN IF NOT EXISTS "treatment_class" "medication_category" NOT NULL DEFAULT 'GENERIC';
ALTER TABLE "medications"
ADD COLUMN IF NOT EXISTS "doses_per_unit" INTEGER;
CREATE INDEX IF NOT EXISTS "medications_treatment_class_idx" ON "medications" ("treatment_class");
-- 3. InjectionSite enum + MedicationIntakeEvent.injection_site column.
DO $$ BEGIN
CREATE TYPE "injection_site" AS ENUM (
'ABDOMEN_LEFT',
'ABDOMEN_RIGHT',
'ABDOMEN_UPPER_LEFT',
'ABDOMEN_UPPER_RIGHT',
'THIGH_LEFT',
'THIGH_RIGHT',
'UPPER_ARM_LEFT',
'UPPER_ARM_RIGHT'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
ALTER TABLE "medication_intake_events"
ADD COLUMN IF NOT EXISTS "injection_site" "injection_site";
-- 4. MedicationDoseChange — titration history.
CREATE TABLE IF NOT EXISTS "medication_dose_changes" (
"id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"effective_from" TIMESTAMP(3) NOT NULL,
"dose_value" DOUBLE PRECISION NOT NULL,
"dose_unit" TEXT NOT NULL,
"note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "medication_dose_changes_pkey" PRIMARY KEY ("id")
);
DO $$ BEGIN
ALTER TABLE "medication_dose_changes"
ADD CONSTRAINT "medication_dose_changes_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE INDEX IF NOT EXISTS "medication_dose_changes_medication_id_effective_from_idx"
ON "medication_dose_changes" ("medication_id", "effective_from");
-- 5. MedicationInventoryEvent — append-only inventory ledger.
CREATE TABLE IF NOT EXISTS "medication_inventory_events" (
"id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"delta" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "medication_inventory_events_pkey" PRIMARY KEY ("id")
);
DO $$ BEGIN
ALTER TABLE "medication_inventory_events"
ADD CONSTRAINT "medication_inventory_events_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE INDEX IF NOT EXISTS "medication_inventory_events_medication_id_occurred_at_idx"
ON "medication_inventory_events" ("medication_id", "occurred_at");
+104 -4
View File
@@ -317,11 +317,37 @@ model Measurement {
// ─── Medications ───────────────────────────────────────────
/// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the
/// specialist surfaces — body-map injection-site picker, weekly-cadence
/// preset, titration history, GLP-1-aware Coach context, plateau
/// detection. GENERIC keeps existing meds rendering exactly as before.
/// Future categories (INSULIN, BIOLOGIC, FERTILITY, ...) drop into this
/// enum without churning the side-table `medication_categories` that
/// holds the clinical taxonomy (BLOOD_PRESSURE / VITAMIN / ...).
enum MedicationCategory {
GENERIC
GLP1
@@map("medication_category")
}
model Medication {
id String @id @default(cuid())
userId String @map("user_id")
name String
dose String // e.g. "10mg"
/// v1.4.25 W4d — treatment-class discriminator. GENERIC for the
/// vast majority of meds; GLP1 unlocks injection-site rotation,
/// dose-titration history, pen inventory, plateau detection, and
/// the Coach's GLP-1-aware reply mode. Field name is intentionally
/// not `category` to avoid clashing with the existing
/// `medication_categories` side-table (clinical taxonomy) that the
/// UI continues to read.
treatmentClass MedicationCategory @default(GENERIC) @map("treatment_class")
/// Doses per pen/vial — only meaningful for inventory-tracked meds
/// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
/// pen = 1). NULL = inventory tracking off.
dosesPerUnit Int? @map("doses_per_unit")
active Boolean @default(true)
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
pausedAt DateTime? @map("paused_at")
@@ -329,14 +355,17 @@ model Medication {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
schedules MedicationSchedule[]
intakeEvents MedicationIntakeEvent[]
phaseConfig ReminderPhaseConfig?
telegramMessages TelegramReminderMessage[]
schedules MedicationSchedule[]
intakeEvents MedicationIntakeEvent[]
phaseConfig ReminderPhaseConfig?
telegramMessages TelegramReminderMessage[]
doseChanges MedicationDoseChange[]
inventoryEvents MedicationInventoryEvent[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([treatmentClass])
@@map("medications")
}
@@ -380,6 +409,26 @@ enum PhaseMode {
@@map("phase_mode")
}
/// v1.4.25 W4d — eight self-injection zones for GLP-1 receptor
/// agonists. Front-of-body view used by the picker.
/// - Abdomen quadrants avoid a 2-inch radius around the navel per
/// Eli Lilly's Mounjaro IFU + Novo Nordisk's Ozempic/Wegovy PI.
/// - Thighs cover the anterior / outer surface; user-injectable.
/// - Upper arms typically require assistance per the same PIs; the
/// picker surfaces them but flags the constraint via i18n.
enum InjectionSite {
ABDOMEN_LEFT
ABDOMEN_RIGHT
ABDOMEN_UPPER_LEFT
ABDOMEN_UPPER_RIGHT
THIGH_LEFT
THIGH_RIGHT
UPPER_ARM_LEFT
UPPER_ARM_RIGHT
@@map("injection_site")
}
model MedicationIntakeEvent {
id String @id @default(cuid())
userId String @map("user_id")
@@ -390,6 +439,10 @@ model MedicationIntakeEvent {
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
/// v1.4.25 W4d — only populated for GLP-1 intake events (the picker
/// is the only surface that writes this column). Existing rows stay
/// NULL forever and the read paths treat NULL as "no site recorded".
injectionSite InjectionSite? @map("injection_site")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@ -398,6 +451,53 @@ model MedicationIntakeEvent {
@@map("medication_intake_events")
}
/// v1.4.25 W4d — titration history for any medication, optimised for
/// GLP-1 (Mounjaro 2.5 → 5 → 7.5 → 10 → 12.5 → 15 mg with min 4-week
/// steps per Eli Lilly's PI). Append-only; current dose = latest row
/// by effectiveFrom. The Medication.dose string field is the
/// convenience pointer for legacy reads; this table is the history of
/// record.
model MedicationDoseChange {
id String @id @default(cuid())
medicationId String @map("medication_id")
/// When this dose started. Stored UTC; the UI renders in the user's
/// timezone via the existing display helpers.
effectiveFrom DateTime @map("effective_from")
/// Numeric value — easier titration math (does this user step up by
/// 2.5 mg every 4 weeks?). Pair with doseUnit for display.
doseValue Float @map("dose_value")
/// Free-text unit ("mg", "µg", "IE", etc.). Flexible because GLP-1
/// is "mg" but the column is reused for future categories.
doseUnit String @map("dose_unit")
/// User-entered titration note ("stepped up after 4 weeks on 5 mg,
/// tolerated 7.5 mg with mild nausea day 2").
note String?
createdAt DateTime @default(now()) @map("created_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([medicationId, effectiveFrom])
@@map("medication_dose_changes")
}
/// v1.4.25 W4d — append-only inventory ledger. Positive delta when
/// stock arrives ("purchased"), negative when consumed ("consumed",
/// fired automatically on intake-event creation for meds with
/// Medication.dosesPerUnit set), or marked out of usable stock
/// ("expired", "damaged"). Current inventory is the running sum.
model MedicationInventoryEvent {
id String @id @default(cuid())
medicationId String @map("medication_id")
delta Int // positive = added, negative = consumed/lost
reason String // "purchased" / "consumed" / "expired" / "damaged"
occurredAt DateTime @default(now()) @map("occurred_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([medicationId, occurredAt])
@@map("medication_inventory_events")
}
model ReminderPhaseConfig {
id String @id @default(cuid())
medicationId String @unique @map("medication_id")