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
CabinetEventappend-only audit log for all cabinet mutationsRegimenMongoDB schema and CRUD APIOrganizerFillschema and fill/undo API- Pill organizer fill flow with shortage detection and FEFO allocation
- Burn rate calculation with spending projections
- Cabinet discard endpoint (zero quantity + soft-delete)
- 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:
- Sum daily consumption per medicine from active regimens
- Get cabinet stock per medicine
- Query PURCHASED events for weighted average unit price per medicine
projectedDailyCost = avgUnitPrice * dailyConsumption- Monthly = daily * 30, yearly = daily * 365
- Return
nullfor medicines with no purchase price data
Tasks
3.0 -- Phase 3 Spec Doc
- Write/update
docs/phases/phase-3-regimens-pill-organizer.mdwith revised scope
3.1 -- Shared Enums
packages/shared/src/enums/cabinet-event.enums.ts--CabinetEventType,CabinetEventSourceTypepackages/shared/src/enums/regimen.enums.ts--DosageFrequency,TimeOfDay,OrganizerFillStatus- Update barrel, write tests
3.2 -- Shared Types
packages/shared/src/types/cabinet-event.tspackages/shared/src/types/regimen.tspackages/shared/src/types/organizer-fill.tspackages/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.tspackages/shared/src/validation/regimen.schemas.tspackages/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 functionspackages/shared/src/utils/index.tsbarrel- 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()andfindActiveByMedicineForFEFO()to repository - Inject
cabinetEventsServiceintoCabinetService - Emit events on all mutations (add, update, adjust, delete, discard)
- Add
POST /:id/discardendpoint
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
Storeentity CRUD MedicinePriceRecordfor 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
allowPartialis true - Fill is rejected when
allowPartialis 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_neededfrequency 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 touserId