mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(schema): MedicationInventoryItem + 30-day in-use clock (Migration 0056)
This commit is contained in:
1 parent
a7cc5de9b5
commit
570b14d83f
2 files changed
+131
No files matched your search
@@ -0,0 +1,72 @@
|
||||
-- v1.4.25 W19b — MedicationInventoryItem (pen / vial entity).
|
||||
--
|
||||
-- The existing MedicationInventoryEvent ledger (W4d) tracks running
|
||||
-- stock as a stream of ±delta rows; this new model tracks the
|
||||
-- *individual* pen / vial entities so the UI can show "Pen #2 of 3 —
|
||||
-- 12 days left in the 30-day in-use window" rather than just an
|
||||
-- aggregate count. Both surfaces coexist: the ledger is the
|
||||
-- consumption stream, this model is the physical-supply inventory.
|
||||
--
|
||||
-- Why a dedicated entity per pen / vial:
|
||||
-- - EMA EPAR §6.3 sets a 30-day in-use clock per opened pen
|
||||
-- (Mounjaro KwikPen, Saxenda, Trulicity), 56 days for Ozempic.
|
||||
-- The clock starts at first-use, not at purchase — so we have to
|
||||
-- persist the per-pen `firstUseAt` somewhere.
|
||||
-- - A pen also carries its own `printedExpiry` (the date stamped
|
||||
-- on the carton), which can pre-empt the 30-day clock if the
|
||||
-- printed expiry lands earlier.
|
||||
-- - Dose-depletion math is per-pen (Mounjaro KwikPen = 4 doses,
|
||||
-- Trulicity = 1, Byetta = 60) and needs a `dosesRemaining`
|
||||
-- counter independent of the aggregate ledger.
|
||||
--
|
||||
-- State machine (see `src/lib/medications/inventory/state-machine.ts`):
|
||||
-- ACTIVE → refrigerated, unopened (default after purchase)
|
||||
-- IN_USE → opened, 30-day clock counting down from firstUseAt
|
||||
-- EXPIRED → past 30-day in-use window OR past printedExpiry
|
||||
-- USED_UP → all doses consumed
|
||||
--
|
||||
-- Composite index (user_id, medication_id, state) for the hot read
|
||||
-- path: "show me my active inventory for this medication". The state
|
||||
-- column is the variable axis — the UI filters on ACTIVE | IN_USE for
|
||||
-- the live tile and on EXPIRED | USED_UP for the history collapsed
|
||||
-- list — so it goes last in the composite for index-prefix selectivity.
|
||||
|
||||
CREATE TYPE "medication_inventory_state" AS ENUM (
|
||||
'ACTIVE',
|
||||
'IN_USE',
|
||||
'EXPIRED',
|
||||
'USED_UP'
|
||||
);
|
||||
|
||||
CREATE TABLE "medication_inventory_items" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"medication_id" TEXT NOT NULL REFERENCES "medications"("id") ON DELETE CASCADE,
|
||||
"state" "medication_inventory_state" NOT NULL DEFAULT 'ACTIVE',
|
||||
"doses_total" INTEGER NOT NULL,
|
||||
"doses_remaining" INTEGER NOT NULL,
|
||||
-- NULL until the pen is first opened. Sets the 30-day in-use clock.
|
||||
"first_use_at" TIMESTAMP(3),
|
||||
-- Computed = MIN(first_use_at + 30 days, printed_expiry). Persisted
|
||||
-- rather than derived so a DB-side index / sort by expiry is cheap.
|
||||
"expires_at" TIMESTAMP(3),
|
||||
-- The printed carton expiry date the user enters on creation.
|
||||
"printed_expiry" TIMESTAMP(3),
|
||||
"purchased_at" TIMESTAMP(3),
|
||||
-- Free-text note ("Apotheke Müller, 2025-10-12"). Hard-capped to
|
||||
-- 200 chars at the application layer (see Zod schema); the DB
|
||||
-- column is unbounded TEXT for consistency with the other note
|
||||
-- columns on this schema (MedicationDoseChange.note, etc.).
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX "medication_inventory_items_user_med_state_idx"
|
||||
ON "medication_inventory_items" ("user_id", "medication_id", "state");
|
||||
|
||||
-- A separate index on (user_id, expires_at) supports the daily
|
||||
-- expire-stale background job — it scans IN_USE rows by expiry-asc
|
||||
-- and the (user, expires_at) prefix makes that O(log n) per user.
|
||||
CREATE INDEX "medication_inventory_items_user_expires_idx"
|
||||
ON "medication_inventory_items" ("user_id", "expires_at");
|
||||
@@ -195,6 +195,10 @@ model User {
|
||||
// ships with v1.4.25 so the iOS app can build its query path
|
||||
// against a stable contract.
|
||||
personalRecords PersonalRecord[]
|
||||
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
|
||||
// companion to the running-sum MedicationInventoryEvent ledger.
|
||||
// Carries the EMA §6.3 30-day in-use clock per opened pen.
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -560,6 +564,10 @@ model Medication {
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
doseChanges MedicationDoseChange[]
|
||||
inventoryEvents MedicationInventoryEvent[]
|
||||
/// v1.4.25 W19b — per-pen / per-vial entities (state machine +
|
||||
/// 30-day in-use clock). Complements the running-sum
|
||||
/// MedicationInventoryEvent ledger.
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -704,6 +712,57 @@ model MedicationInventoryEvent {
|
||||
@@map("medication_inventory_events")
|
||||
}
|
||||
|
||||
/// v1.4.25 W19b — per-pen / per-vial inventory entity. The EMA EPAR
|
||||
/// §6.3 30-day in-use clock lives here as `firstUseAt` + `expiresAt`;
|
||||
/// the state machine (see `src/lib/medications/inventory/state-machine.ts`)
|
||||
/// transitions ACTIVE → IN_USE → EXPIRED | USED_UP based on first-use,
|
||||
/// printed expiry, and dose depletion.
|
||||
///
|
||||
/// Coexists with `MedicationInventoryEvent`: that ledger is the
|
||||
/// running-sum consumption stream; this model is the entity-level
|
||||
/// view that the UI tile renders ("Pen #2 of 3 — 12 days left").
|
||||
enum MedicationInventoryState {
|
||||
ACTIVE
|
||||
IN_USE
|
||||
EXPIRED
|
||||
USED_UP
|
||||
|
||||
@@map("medication_inventory_state")
|
||||
}
|
||||
|
||||
model MedicationInventoryItem {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
state MedicationInventoryState @default(ACTIVE)
|
||||
/// Doses the pen / vial shipped with. Mounjaro KwikPen = 4,
|
||||
/// Trulicity single-dose = 1, Byetta = 60.
|
||||
dosesTotal Int @map("doses_total")
|
||||
/// Remaining doses; decremented by the intake hook.
|
||||
dosesRemaining Int @map("doses_remaining")
|
||||
/// NULL until the pen is first opened. Sets the 30-day in-use
|
||||
/// clock per EMA EPAR §6.3.
|
||||
firstUseAt DateTime? @map("first_use_at")
|
||||
/// Persisted = MIN(firstUseAt + 30 days, printedExpiry). Persisting
|
||||
/// rather than deriving keeps the daily "expire-stale" cron a
|
||||
/// single indexed scan.
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
/// The carton-printed expiry date the user enters at creation.
|
||||
printedExpiry DateTime? @map("printed_expiry")
|
||||
purchasedAt DateTime? @map("purchased_at")
|
||||
/// Free-text note, application-layer max 200 chars.
|
||||
notes String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, medicationId, state])
|
||||
@@index([userId, expiresAt])
|
||||
@@map("medication_inventory_items")
|
||||
}
|
||||
|
||||
model ReminderPhaseConfig {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @unique @map("medication_id")
|
||||
|
||||
Reference in new issue
Block a user