From cbcc0598b2a8cc47a072ed0abc75704795efb1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:22:47 +0200 Subject: [PATCH] feat(schema): MedicationCategory + MedicationDoseChange + InjectionSite (migration 0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0046_glp1_dose_history/migration.sql | 127 ++++++++++++++++++ prisma/schema.prisma | 108 ++++++++++++++- 2 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/0046_glp1_dose_history/migration.sql diff --git a/prisma/migrations/0046_glp1_dose_history/migration.sql b/prisma/migrations/0046_glp1_dose_history/migration.sql new file mode 100644 index 000000000..8234d50a9 --- /dev/null +++ b/prisma/migrations/0046_glp1_dose_history/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8724dfd90..1665e010f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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")