mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user