MeshiTrack/docs/phases/phase-3-regimens-pill-organizer.md

15 KiB

Phase 3 — Regimens, Pill Organizer & Cabinet Activity Log

Goal: Define daily medication schedules (regimens), batch-dispense from the medicine cabinet into a pill organizer, and track every cabinet mutation in an append-only audit log. The audit log provides the financial foundation for spending projections (Phase 4 enriches with formal price surveillance).

Depends on: Phase 0, Phase 1 (medicines), Phase 2 (cabinet)


Deliverables

  1. CabinetEvent append-only audit log for all cabinet mutations
  2. Regimen MongoDB schema and CRUD API
  3. OrganizerFill schema and fill/undo API
  4. Pill organizer fill flow with shortage detection and FEFO allocation
  5. Burn rate calculation with spending projections
  6. Cabinet discard endpoint (zero quantity + soft-delete)
  7. Regimen, pill organizer, and cabinet activity web UI

Data Model

CabinetEvent Schema (new collection, append-only)

Every mutation to a cabinet item is recorded as a CabinetEvent. This provides the audit trail, financial history, and data for spending projections.

// packages/shared/src/types/cabinet-event.ts
export interface CabinetEvent {
  id: string;
  householdId: string;
  userId: string;
  cabinetItemId: string;
  medicineId: string;
  medicineName: string;              // Denormalized
  eventType: CabinetEventType;
  quantity: number;                  // Signed: positive=added, negative=removed
  quantityBefore: number;
  quantityAfter: number;
  unitPrice?: number;                // PURCHASED events only
  totalPrice?: number;
  currency?: string;
  storeId?: string;                  // Optional string reference (formal Store entity in Phase 4)
  storeName?: string;                // Denormalized
  sourceType: CabinetEventSourceType;
  sourceId?: string;                 // OrganizerFill ID, etc.
  reason?: string;                   // User-provided for adjust/discard
  notes?: string;
  createdAt: Date;
}

export enum CabinetEventType {
  PURCHASED = 'purchased',           // Cabinet item added (via addItem)
  CONSUMED = 'consumed',             // Deducted by organizer fill
  ADJUSTED = 'adjusted',             // Manual quantity adjustment
  DISCARDED = 'discarded',           // Item discarded (zero qty + soft-delete)
  RESTORED = 'restored',             // Reversed by organizer undo
  DELETED = 'deleted',               // Soft-deleted from cabinet
}

export enum CabinetEventSourceType {
  MANUAL = 'manual',
  ORGANIZER_FILL = 'organizer_fill',
  ORGANIZER_UNDO = 'organizer_undo',
  REFILL_LIST = 'refill_list',       // Phase 4
}

export interface SpendingSummary {
  byMedicine: SpendingByMedicine[];
  byPeriod: SpendingByPeriod[];
  total: number;
  currency: string | null;
}

export interface SpendingByMedicine {
  medicineId: string;
  medicineName: string;
  totalSpent: number;
  totalQuantity: number;
  avgUnitPrice: number;
  purchaseCount: number;
}

export interface SpendingByPeriod {
  period: string;
  totalSpent: number;
}

Event Emission Strategy

Mutation Event Type Source Type
cabinet.addItem() PURCHASED manual
cabinet.update() (qty change) ADJUSTED manual
cabinet.adjustQuantity() ADJUSTED manual
cabinet.discard() DISCARDED manual
cabinet.delete() DELETED manual
organizer.fill() CONSUMED (per deduction) organizer_fill
organizer.undoFill() RESTORED (per deduction) organizer_undo

Events are logged after the primary mutation succeeds (not inside transactions for fill/undo -- events are audit records, not source of truth).

MongoDB Indexes

// CabinetEvent
{ householdId: 1, createdAt: -1 }
{ householdId: 1, cabinetItemId: 1, createdAt: -1 }
{ householdId: 1, medicineId: 1, createdAt: -1 }
{ householdId: 1, eventType: 1, createdAt: -1 }

CabinetItem Enhancement

Add optional purchase fields to existing CabinetItem:

// Added to packages/shared/src/types/cabinet.ts
purchaseDate?: Date;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;

Regimen Schema

// packages/shared/src/types/regimen.ts
export interface Regimen {
  id: string;
  householdId: string;
  userId: string;             // Regimens are per-person
  name: string;               // e.g., "Daily medications", "Morning routine"
  isActive: boolean;
  medications: RegimenMedication[];
  createdBy: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface RegimenMedication {
  medicineId: string;
  medicineName: string;       // Denormalized
  medicineStrength: number;   // Denormalized
  medicineStrengthUnit: StrengthUnit;
  medicineForm: MedicineForm;
  dosage: number;             // e.g., 2 (pills per dose)
  dosageUnit: DosageUnit;
  frequency: DosageFrequency;
  customFrequencyPerDay?: number;
  timeOfDay?: TimeOfDay;
  instructions?: string;
}

export enum DosageFrequency {
  DAILY = 'daily',
  TWICE_DAILY = 'twice_daily',
  THREE_TIMES_DAILY = 'three_times_daily',
  WEEKLY = 'weekly',
  EVERY_OTHER_DAY = 'every_other_day',
  AS_NEEDED = 'as_needed',   // Excluded from organizer fill calculations
  CUSTOM = 'custom',         // Uses customFrequencyPerDay
}

export enum TimeOfDay {
  MORNING = 'morning',
  AFTERNOON = 'afternoon',
  EVENING = 'evening',
  BEDTIME = 'bedtime',
}

OrganizerFill Schema

// packages/shared/src/types/organizer-fill.ts
export interface OrganizerFill {
  id: string;
  householdId: string;
  userId: string;
  regimenId: string;
  regimenName: string;        // Denormalized
  numberOfDays: number;
  fillDate: Date;
  items: OrganizerFillItem[];
  status: OrganizerFillStatus;
  notes?: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface OrganizerFillItem {
  medicineId: string;
  medicineName: string;
  quantityNeeded: number;
  quantityTaken: number;
  wasShort: boolean;
  shortage: number;
  deductions: OrganizerDeduction[];
}

export interface OrganizerDeduction {
  cabinetItemId: string;
  quantityTaken: number;
}

export enum OrganizerFillStatus {
  COMPLETED = 'completed',
  PARTIAL = 'partial',
  REVERSED = 'reversed',
}

BurnRate (Computed, not stored)

export interface BurnRate {
  medicineId: string;
  medicineName: string;
  dailyConsumption: number;
  totalInCabinet: number;
  daysUntilEmpty: number | null;
  earliestExpiry: Date | null;
  avgUnitPrice: number | null;         // Weighted avg from PURCHASED events
  projectedDailyCost: number | null;   // avgUnitPrice * dailyConsumption
  projectedMonthlyCost: number | null; // daily * 30
  projectedYearlyCost: number | null;  // daily * 365
  currency: string | null;
}

Discard Behavior

Discarding a cabinet item: zero the quantity and soft-delete (isDeleted=true). The item disappears from the active cabinet list. A DISCARDED event preserves the audit trail (what was discarded, why, how much).

MongoDB Indexes

// Regimen
{ householdId: 1, userId: 1, isActive: 1 }
{ householdId: 1, 'medications.medicineId': 1 }

// OrganizerFill
{ householdId: 1, userId: 1, fillDate: -1 }
{ householdId: 1, regimenId: 1, fillDate: -1 }
{ householdId: 1, status: 1 }

API Endpoints

CabinetEventsModule (new)

Method Path Description Auth
GET /cabinet-events List events (filtered by medicineId, eventType, dateRange) member
GET /cabinet-events/by-item/:cabinetItemId Events for a single cabinet item member
GET /cabinet-events/spending-summary Aggregated spending per medicine and period member

CabinetModule (modifications)

Method Path Description Auth
POST /cabinet/:id/discard Zero qty + soft-delete + DISCARDED event. Body: { reason, notes? } member

All existing mutation endpoints (POST /cabinet, PATCH /cabinet/:id, POST /cabinet/:id/adjust, DELETE /cabinet/:id) now emit CabinetEvents. The addItem endpoint accepts optional purchase fields (unitPrice, totalPrice, currency, storeId, storeName).

RegimensModule (new)

Method Path Description Auth
GET /regimens List user's regimens member
GET /regimens/:id Get single regimen member
POST /regimens Create regimen member
PATCH /regimens/:id Update regimen member
DELETE /regimens/:id Delete regimen member
GET /regimens/burn-rate Burn rate + spending projection member

OrganizerModule (new)

Method Path Description Auth
GET /organizer/fills List fill history (paginated) member
GET /organizer/fills/:id Get single fill details member
POST /organizer/preview Preview a fill (shows quantities, shortages) member
POST /organizer/fill Execute a fill (deduct from cabinet) member
POST /organizer/fills/:id/undo Reverse a fill (restore cabinet quantities) member

Preview Request/Response

// POST /organizer/preview
interface OrganizerPreviewRequest {
  regimenId: string;
  numberOfDays: number;
}

interface OrganizerPreviewResponse {
  regimenName: string;
  numberOfDays: number;
  items: {
    medicineId: string;
    medicineName: string;
    quantityNeeded: number;
    quantityAvailable: number;
    isShort: boolean;
    shortage: number;
    cabinetBreakdown: {
      cabinetItemId: string;
      expirationDate: string | null;
      quantityToTake: number;
      quantityBefore: number;
    }[];
  }[];
  canFillCompletely: boolean;
  hasShortages: boolean;
}

Fill Request

// POST /organizer/fill
interface OrganizerFillRequest {
  regimenId: string;
  numberOfDays: number;
  allowPartial: boolean;
  notes?: string;
}

Frequency Multiplier Logic

/**
 * Calculate total pills needed for N days based on frequency.
 *
 * daily:             dosage * numberOfDays
 * twice_daily:       dosage * 2 * numberOfDays
 * three_times_daily: dosage * 3 * numberOfDays
 * weekly:            dosage * ceil(numberOfDays / 7)
 * every_other_day:   dosage * ceil(numberOfDays / 2)
 * as_needed:         0 (excluded from organizer fills)
 * custom:            dosage * customFrequencyPerDay * numberOfDays
 */
function calculateQuantityNeeded(
  dosage: number,
  frequency: DosageFrequency,
  numberOfDays: number,
  customFrequencyPerDay?: number,
): number;

Implemented as a pure function in packages/shared/src/utils/frequency.ts.


Spending Projection

In GET /regimens/burn-rate:

  1. Sum daily consumption per medicine from active regimens
  2. Get cabinet stock per medicine
  3. Query PURCHASED events for weighted average unit price per medicine
  4. projectedDailyCost = avgUnitPrice * dailyConsumption
  5. Monthly = daily * 30, yearly = daily * 365
  6. Return null for medicines with no purchase price data

Tasks

3.0 -- Phase 3 Spec Doc

  • Write/update docs/phases/phase-3-regimens-pill-organizer.md with revised scope

3.1 -- Shared Enums

  • packages/shared/src/enums/cabinet-event.enums.ts -- CabinetEventType, CabinetEventSourceType
  • packages/shared/src/enums/regimen.enums.ts -- DosageFrequency, TimeOfDay, OrganizerFillStatus
  • Update barrel, write tests

3.2 -- Shared Types

  • packages/shared/src/types/cabinet-event.ts
  • packages/shared/src/types/regimen.ts
  • packages/shared/src/types/organizer-fill.ts
  • packages/shared/src/types/burn-rate.ts
  • Modify packages/shared/src/types/cabinet.ts -- add purchase fields
  • Update barrel

3.3 -- Shared Validation Schemas

  • packages/shared/src/validation/cabinet-event.schemas.ts
  • packages/shared/src/validation/regimen.schemas.ts
  • packages/shared/src/validation/organizer.schemas.ts
  • Modify packages/shared/src/validation/cabinet.schemas.ts -- add purchase fields
  • Update barrel, write tests

3.4 -- Frequency Multiplier Utility

  • packages/shared/src/utils/frequency.ts -- pure functions
  • packages/shared/src/utils/index.ts barrel
  • Tests

3.5 -- CabinetEvent Schema + Repository + Service + Routes

  • Mongoose schema with indexes
  • Repository with create, find, aggregation methods
  • Service as thin wrapper
  • Routes: list, by-item, spending-summary

3.6 -- Modify Cabinet for Events + Discard

  • Add purchase fields to cabinet Mongoose schema
  • Add discard() and findActiveByMedicineForFEFO() to repository
  • Inject cabinetEventsService into CabinetService
  • Emit events on all mutations (add, update, adjust, delete, discard)
  • Add POST /:id/discard endpoint

3.7 -- Regimen Schema + Repository + Service + Routes

  • Mongoose schema with embedded medications
  • Repository with CRUD scoped to householdId + userId
  • Service with CRUD + medicine validation/denormalization + calculateBurnRates()
  • Routes for all CRUD + burn-rate endpoint

3.8 -- OrganizerFill Schema + Repository + Service + Routes

  • Mongoose schema with embedded items/deductions
  • Repository with CRUD + status update
  • Service with preview (FEFO), fill (transactional), undoFill (transactional)
  • Routes for all 5 endpoints

3.9 -- Register in main.ts

  • Add cabinetEventsRoutes, regimensRoutes, organizerRoutes

3.10 -- Web UI: Regimens

  • Regimen list/detail views
  • Add/edit regimen forms with medication management
  • Active/inactive toggle

3.11 -- Web UI: Pill Organizer

  • Fill flow: regimen select, day count, preview, fill
  • Burn rate table with spending projections
  • Fill history with undo

3.12 -- Web UI: Cabinet Activity

  • Timeline of CabinetEvents
  • Filter by medicine, event type, date range
  • Spending summary view

Impact on Phase 4

Phase 4 (Stores, Prices, Refills) scope unchanged -- it still provides:

  • Formal Store entity CRUD
  • MedicinePriceRecord for price surveillance (observed prices, not just purchases)
  • Refill alerts + lists
  • Spending analytics dashboards (enriched by CabinetEvent PURCHASED data)

The storeId/storeName on CabinetEvent are optional strings in Phase 3. They reference Store documents once Phase 4 is built.


Acceptance Criteria

  • Every cabinet mutation (add, update, adjust, discard, delete, fill, undo) creates a CabinetEvent
  • Cabinet items can be created with optional purchase price data
  • Discard zeros quantity, soft-deletes, and logs DISCARDED event
  • Can create and manage regimens with multiple medications
  • Frequency multiplier correctly calculates quantities for all frequency types
  • Preview accurately shows needed quantities and shortages
  • Fill deducts from cabinet using FEFO (earliest expiry first)
  • Partial fills work when allowPartial is true
  • Fill is rejected when allowPartial is false and any medicine is short
  • Undo fully restores cabinet quantities and logs RESTORED events
  • Undo is idempotent (cannot undo an already-reversed fill)
  • Burn rate correctly accounts for all active regimens with spending projections
  • as_needed frequency is excluded from fill calculations and burn rate
  • Spending summary aggregates PURCHASED events by medicine and period
  • All queries scoped to householdId; regimens additionally scoped to userId