# 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. ```typescript // 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 ```javascript // 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`: ```typescript // Added to packages/shared/src/types/cabinet.ts purchaseDate?: Date; unitPrice?: number; totalPrice?: number; currency?: string; storeId?: string; storeName?: string; ``` ### Regimen Schema ```typescript // 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 ```typescript // 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) ```typescript 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 ```javascript // 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 ```typescript // 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 ```typescript // POST /organizer/fill interface OrganizerFillRequest { regimenId: string; numberOfDays: number; allowPartial: boolean; notes?: string; } ``` --- ## Frequency Multiplier Logic ```typescript /** * 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`