Implement regimens

This commit is contained in:
Aerilyn Weber 2026-03-28 18:25:49 +09:00
parent 1f66fab30f
commit 9f416903ef
66 changed files with 9130 additions and 189 deletions

View file

@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run:*)", "Bash(npm run:*)",
"Bash(ls:*)" "Bash(ls:*)",
"Bash(npm test:*)"
] ]
} }
} }

View file

@ -98,6 +98,7 @@ Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `Forbidd
5. **`.lean().exec()`** on all Mongoose read queries 5. **`.lean().exec()`** on all Mongoose read queries
6. **`householdId` filter** on every domain query — this is the multi-tenancy boundary 6. **`householdId` filter** on every domain query — this is the multi-tenancy boundary
7. **No emojis** — never use emoji characters in source code, UI text, console output, or documentation 7. **No emojis** — never use emoji characters in source code, UI text, console output, or documentation
8. **`npx` is banned** — never run `npx` for any reason. Use `npm run <script>` for all test, lint, build, and tool invocations. No exceptions.
## Testing ## Testing

View file

@ -497,6 +497,8 @@ Use `/* v8 ignore start */` / `/* v8 ignore stop */` for code that cannot be uni
## Running Tests ## Running Tests
**`npx` is banned. Never use it.** Always use `npm run <script>` to invoke Vitest, Playwright, or any other tool.
```bash ```bash
# All tests (via turbo) # All tests (via turbo)
npm run test npm run test

View file

@ -1,6 +1,6 @@
# Phase 3 — Regimens & Pill Organizer # Phase 3 — Regimens, Pill Organizer & Cabinet Activity Log
**Goal**: Define daily medication schedules (regimens) and batch-dispense from the medicine cabinet into a pill organizer. This is the core convenience feature: instead of tracking individual pill consumption daily, users fill their organizer for N days in a single action. **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) **Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet)
@ -8,16 +8,123 @@
## Deliverables ## Deliverables
1. `Regimen` MongoDB schema and CRUD API 1. `CabinetEvent` append-only audit log for all cabinet mutations
2. `OrganizerFill` schema and fill/undo API 2. `Regimen` MongoDB schema and CRUD API
3. Pill organizer fill flow with shortage detection 3. `OrganizerFill` schema and fill/undo API
4. Burn rate calculation (days until empty per medicine) 4. Pill organizer fill flow with shortage detection and FEFO allocation
5. Regimen and pill organizer web UI 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 ## 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 ### Regimen Schema
```typescript ```typescript
@ -25,8 +132,8 @@
export interface Regimen { export interface Regimen {
id: string; id: string;
householdId: string; householdId: string;
userId: string; // Regimens are per-person userId: string; // Regimens are per-person
name: string; // e.g., "Daily medications", "Morning routine" name: string; // e.g., "Daily medications", "Morning routine"
isActive: boolean; isActive: boolean;
medications: RegimenMedication[]; medications: RegimenMedication[];
createdBy: string; createdBy: string;
@ -36,26 +143,26 @@ export interface Regimen {
export interface RegimenMedication { export interface RegimenMedication {
medicineId: string; medicineId: string;
medicineName: string; // Denormalized medicineName: string; // Denormalized
medicineStrength: number; // Denormalized medicineStrength: number; // Denormalized
medicineStrengthUnit: StrengthUnit; // Denormalized medicineStrengthUnit: StrengthUnit;
medicineForm: MedicineForm; // Denormalized medicineForm: MedicineForm;
dosage: number; // e.g., 2 (pills per dose) dosage: number; // e.g., 2 (pills per dose)
dosageUnit: DosageUnit; dosageUnit: DosageUnit;
frequency: DosageFrequency; frequency: DosageFrequency;
customFrequencyPerDay?: number; // When frequency is 'custom' customFrequencyPerDay?: number;
timeOfDay?: TimeOfDay; timeOfDay?: TimeOfDay;
instructions?: string; // e.g., "Take with food", "Do not crush" instructions?: string;
} }
export enum DosageFrequency { export enum DosageFrequency {
DAILY = 'daily', // 1x per day DAILY = 'daily',
TWICE_DAILY = 'twice_daily', // 2x per day TWICE_DAILY = 'twice_daily',
THREE_TIMES_DAILY = 'three_times_daily', // 3x per day THREE_TIMES_DAILY = 'three_times_daily',
WEEKLY = 'weekly', // 1x per week WEEKLY = 'weekly',
EVERY_OTHER_DAY = 'every_other_day', EVERY_OTHER_DAY = 'every_other_day',
AS_NEEDED = 'as_needed', // Excluded from organizer fill calculations AS_NEEDED = 'as_needed', // Excluded from organizer fill calculations
CUSTOM = 'custom', // Uses customFrequencyPerDay CUSTOM = 'custom', // Uses customFrequencyPerDay
} }
export enum TimeOfDay { export enum TimeOfDay {
@ -75,8 +182,8 @@ export interface OrganizerFill {
householdId: string; householdId: string;
userId: string; userId: string;
regimenId: string; regimenId: string;
regimenName: string; // Denormalized regimenName: string; // Denormalized
numberOfDays: number; // Flexible: 1, 6, 7, 14, etc. numberOfDays: number;
fillDate: Date; fillDate: Date;
items: OrganizerFillItem[]; items: OrganizerFillItem[];
status: OrganizerFillStatus; status: OrganizerFillStatus;
@ -88,10 +195,10 @@ export interface OrganizerFill {
export interface OrganizerFillItem { export interface OrganizerFillItem {
medicineId: string; medicineId: string;
medicineName: string; medicineName: string;
quantityNeeded: number; // Total pills needed for N days quantityNeeded: number;
quantityTaken: number; // Actual pills taken from cabinet quantityTaken: number;
wasShort: boolean; // quantityTaken < quantityNeeded wasShort: boolean;
shortage: number; // quantityNeeded - quantityTaken (0 if not short) shortage: number;
deductions: OrganizerDeduction[]; deductions: OrganizerDeduction[];
} }
@ -101,26 +208,34 @@ export interface OrganizerDeduction {
} }
export enum OrganizerFillStatus { export enum OrganizerFillStatus {
COMPLETED = 'completed', // All medicines fully dispensed COMPLETED = 'completed',
PARTIAL = 'partial', // Some medicines were short PARTIAL = 'partial',
REVERSED = 'reversed', // Fill was undone REVERSED = 'reversed',
} }
``` ```
### BurnRate (Computed, not stored) ### BurnRate (Computed, not stored)
```typescript ```typescript
// Calculated from active regimens + cabinet stock
export interface BurnRate { export interface BurnRate {
medicineId: string; medicineId: string;
medicineName: string; medicineName: string;
dailyConsumption: number; // Total pills per day across all regimens dailyConsumption: number;
totalInCabinet: number; totalInCabinet: number;
daysUntilEmpty: number | null; // null if dailyConsumption is 0 daysUntilEmpty: number | null;
earliestExpiry: Date | 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 ### MongoDB Indexes
```javascript ```javascript
@ -138,26 +253,42 @@ export interface BurnRate {
## API Endpoints ## API Endpoints
### RegimensModule ### CabinetEventsModule (new)
| Method | Path | Description | Auth | | Method | Path | Description | Auth |
| ------ | ----------------------- | ------------------------------------ | ------ | |--------|------|-------------|------|
| GET | `/regimens` | List user's regimens | member | | GET | `/cabinet-events` | List events (filtered by medicineId, eventType, dateRange) | member |
| GET | `/regimens/:id` | Get single regimen | member | | GET | `/cabinet-events/by-item/:cabinetItemId` | Events for a single cabinet item | member |
| POST | `/regimens` | Create regimen | member | | GET | `/cabinet-events/spending-summary` | Aggregated spending per medicine and period | member |
| PATCH | `/regimens/:id` | Update regimen | member |
| DELETE | `/regimens/:id` | Delete regimen | member |
| GET | `/regimens/burn-rate` | Burn rate for all active regimens | member |
### OrganizerModule ### CabinetModule (modifications)
| Method | Path | Description | Auth | | Method | Path | Description | Auth |
| ------ | ----------------------------- | -------------------------------------------- | ------ | |--------|------|-------------|------|
| GET | `/organizer/fills` | List fill history (paginated) | member | | POST | `/cabinet/:id/discard` | Zero qty + soft-delete + DISCARDED event. Body: `{ reason, notes? }` | member |
| GET | `/organizer/fills/:id` | Get single fill details | member |
| POST | `/organizer/preview` | Preview a fill (shows quantities, shortages) | 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`).
| POST | `/organizer/fill` | Execute a fill (deduct from cabinet) | member |
| POST | `/organizer/fills/:id/undo` | Reverse a fill (restore cabinet quantities) | member | ### 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 ### Preview Request/Response
@ -165,7 +296,7 @@ export interface BurnRate {
// POST /organizer/preview // POST /organizer/preview
interface OrganizerPreviewRequest { interface OrganizerPreviewRequest {
regimenId: string; regimenId: string;
numberOfDays: number; // Default: 7 numberOfDays: number;
} }
interface OrganizerPreviewResponse { interface OrganizerPreviewResponse {
@ -180,8 +311,9 @@ interface OrganizerPreviewResponse {
shortage: number; shortage: number;
cabinetBreakdown: { cabinetBreakdown: {
cabinetItemId: string; cabinetItemId: string;
expirationDate: Date | null; expirationDate: string | null;
quantityToTake: number; quantityToTake: number;
quantityBefore: number;
}[]; }[];
}[]; }[];
canFillCompletely: boolean; canFillCompletely: boolean;
@ -196,33 +328,14 @@ interface OrganizerPreviewResponse {
interface OrganizerFillRequest { interface OrganizerFillRequest {
regimenId: string; regimenId: string;
numberOfDays: number; numberOfDays: number;
allowPartial: boolean; // If false, reject when any medicine is short allowPartial: boolean;
notes?: string; notes?: string;
} }
``` ```
--- ---
## Tasks ## Frequency Multiplier Logic
### 3.1 — Shared Types & Validation
- Add regimen types to `packages/shared/src/types/regimen.ts`
- Add organizer fill types to `packages/shared/src/types/organizer-fill.ts`
- Zod schemas:
- `CreateRegimenSchema`
- `UpdateRegimenSchema`
- `OrganizerPreviewSchema`
- `OrganizerFillSchema`
### 3.2 — Regimen CRUD
- `RegimensRepository` and `RegimensService`
- Standard CRUD scoped to `householdId` + `userId`
- On create/update: validate that all `medicineId` references exist in the medicine library
- Denormalize medicine fields (name, strength, unit, form)
### 3.3 — Frequency Multiplier Logic
```typescript ```typescript
/** /**
@ -237,118 +350,131 @@ interface OrganizerFillRequest {
* custom: dosage * customFrequencyPerDay * numberOfDays * custom: dosage * customFrequencyPerDay * numberOfDays
*/ */
function calculateQuantityNeeded( function calculateQuantityNeeded(
medication: RegimenMedication, dosage: number,
frequency: DosageFrequency,
numberOfDays: number, numberOfDays: number,
customFrequencyPerDay?: number,
): number; ): number;
``` ```
### 3.4 — Organizer Fill Service Implemented as a pure function in `packages/shared/src/utils/frequency.ts`.
```typescript ---
class OrganizerService {
/**
* Preview: calculate what would happen without deducting.
* For each medicine in the regimen:
* 1. Calculate quantity needed (via frequency multiplier)
* 2. Check cabinet stock (via CabinetService.getAggregateSummary)
* 3. Plan FEFO deductions (earliest expiry first)
* 4. Flag shortages
*/
preview(householdId: string, regimenId: string, numberOfDays: number): Promise<OrganizerPreviewResponse>;
/** ## Spending Projection
* Fill: execute the preview plan.
* 1. Re-validate stock (may have changed since preview)
* 2. If allowPartial=false and any shortage, reject
* 3. Call CabinetService.deductStock for each medicine
* 4. Create OrganizerFill record
* 5. Return fill details
*/
fill(householdId: string, userId: string, request: OrganizerFillRequest): Promise<OrganizerFill>;
/** In `GET /regimens/burn-rate`:
* Undo: reverse a fill.
* 1. Verify fill is not already reversed
* 2. For each deduction, call CabinetService.restoreStock
* 3. Mark fill as reversed
*/
undoFill(householdId: string, fillId: string): Promise<OrganizerFill>;
}
```
### 3.5 — Burn Rate Calculation 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
```typescript ---
class BurnRateService {
/**
* For each medicine across all active regimens for a user:
* 1. Sum daily consumption: dosage * daily_frequency_multiplier
* 2. Get total cabinet stock for that medicine
* 3. daysUntilEmpty = floor(totalInCabinet / dailyConsumption)
* 4. Include earliest expiry date from cabinet
*
* Note: 'as_needed' frequency is excluded from burn rate.
* Note: If multiple users in household have regimens, each sees their own burn rate.
*/
calculateBurnRates(householdId: string, userId: string): Promise<BurnRate[]>;
}
```
### 3.6 — Web UI: Regimens ## Tasks
- `/regimens` page: ### 3.0 -- Phase 3 Spec Doc
- List of user's regimens with active/inactive toggle - Write/update `docs/phases/phase-3-regimens-pill-organizer.md` with revised scope
- Each regimen shows: name, medication count, active status
- Expand/click to see all medications with dosage details
- Add/Edit regimen form:
- Name, active toggle
- Medications list:
- Medicine autocomplete (from library)
- Dosage (number + unit)
- Frequency dropdown
- Time of day (optional)
- Instructions (optional)
- Add/remove medications
### 3.7 — Web UI: Pill Organizer ### 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
- `/organizer` page: ### 3.2 -- Shared Types
- **Fill organizer** section: - `packages/shared/src/types/cabinet-event.ts`
- Select regimen dropdown - `packages/shared/src/types/regimen.ts`
- Number of days input (default: 7, adjustable) - `packages/shared/src/types/organizer-fill.ts`
- "Preview" button -> shows: - `packages/shared/src/types/burn-rate.ts`
- Per-medicine breakdown: needed vs available - Modify `packages/shared/src/types/cabinet.ts` -- add purchase fields
- Shortage warnings (highlighted) - Update barrel
- Which cabinet items will be drawn from (FEFO order)
- "Fill" button -> executes the fill, shows confirmation ### 3.3 -- Shared Validation Schemas
- Option for partial fill when shortages exist - `packages/shared/src/validation/cabinet-event.schemas.ts`
- **Burn rate** section: - `packages/shared/src/validation/regimen.schemas.ts`
- Table: medicine name, daily consumption, total in cabinet, days until empty - `packages/shared/src/validation/organizer.schemas.ts`
- Color-coded: green (>14 days), yellow (7-14 days), red (<7 days) - Modify `packages/shared/src/validation/cabinet.schemas.ts` -- add purchase fields
- Links to refill alerts (Phase 4) - Update barrel, write tests
- **Fill history** section:
- Recent fills with date, regimen, day count, status ### 3.4 -- Frequency Multiplier Utility
- Expand to see per-medicine details - `packages/shared/src/utils/frequency.ts` -- pure functions
- "Undo" button on recent fills (with confirmation) - `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 ## 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 - [ ] Can create and manage regimens with multiple medications
- [ ] Frequency multiplier correctly calculates quantities for all frequency types - [ ] Frequency multiplier correctly calculates quantities for all frequency types
- [ ] Preview accurately shows needed quantities and shortages - [ ] Preview accurately shows needed quantities and shortages
- [ ] Fill deducts from cabinet using FEFO (earliest expiry first) - [ ] Fill deducts from cabinet using FEFO (earliest expiry first)
- [ ] Partial fills work when `allowPartial` is true - [ ] Partial fills work when `allowPartial` is true
- [ ] Fill is rejected when `allowPartial` is false and any medicine is short - [ ] Fill is rejected when `allowPartial` is false and any medicine is short
- [ ] Undo fully restores cabinet quantities - [ ] Undo fully restores cabinet quantities and logs RESTORED events
- [ ] Undo is idempotent (cannot undo an already-reversed fill) - [ ] Undo is idempotent (cannot undo an already-reversed fill)
- [ ] Burn rate correctly accounts for all active regimens - [ ] Burn rate correctly accounts for all active regimens with spending projections
- [ ] `as_needed` frequency is excluded from fill calculations and burn rate - [ ] `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` - [ ] All queries scoped to `householdId`; regimens additionally scoped to `userId`
---
## Estimated Effort
Medium-large. The fill/undo transactional logic with FEFO, shortage handling, and burn rate calculations are the most complex parts. UI is moderately complex with the preview/fill flow.

View file

@ -31,6 +31,9 @@ import householdsRoutes from './modules/households/households.routes.js';
import medicinesRoutes from './modules/medicines/medicines.routes.js'; import medicinesRoutes from './modules/medicines/medicines.routes.js';
import medicineProductsRoutes from './modules/medicine-products/medicine-products.routes.js'; import medicineProductsRoutes from './modules/medicine-products/medicine-products.routes.js';
import cabinetRoutes from './modules/cabinet/cabinet.routes.js'; import cabinetRoutes from './modules/cabinet/cabinet.routes.js';
import cabinetEventsRoutes from './modules/cabinet-events/cabinet-events.routes.js';
import regimensRoutes from './modules/regimens/regimens.routes.js';
import organizerRoutes from './modules/organizer/organizer.routes.js';
export async function buildApp(opts: { logger?: boolean | object } = {}) { export async function buildApp(opts: { logger?: boolean | object } = {}) {
const app = Fastify({ const app = Fastify({
@ -103,6 +106,9 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
await app.register(medicinesRoutes); await app.register(medicinesRoutes);
await app.register(medicineProductsRoutes); await app.register(medicineProductsRoutes);
await app.register(cabinetRoutes); await app.register(cabinetRoutes);
await app.register(cabinetEventsRoutes);
await app.register(regimensRoutes);
await app.register(organizerRoutes);
// Global error handler // Global error handler
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, request, reply) => {

View file

@ -0,0 +1,396 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockSave, mockInsertMany, mockAggregate } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockSave: vi.fn(),
mockInsertMany: vi.fn(),
mockAggregate: vi.fn(),
}));
vi.mock('../../schemas/cabinet-event.schema.js', () => {
const chain = () => ({
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: mockFind,
});
const aggChain = () => ({
exec: mockAggregate,
});
class FakeModel {
data: unknown;
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static insertMany = mockInsertMany;
static aggregate = vi.fn(() => aggChain());
}
return { CabinetEventModel: FakeModel };
});
import { CabinetEventsRepository } from './cabinet-events.repository.js';
describe(CabinetEventsRepository.name, () => {
let repo: CabinetEventsRepository;
const baseEventData = {
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: 'purchased' as const,
quantity: 10,
quantityBefore: 0,
quantityAfter: 10,
sourceType: 'manual' as const,
};
beforeEach(() => {
vi.clearAllMocks();
repo = new CabinetEventsRepository();
});
describe('create', () => {
it('creates and returns a cabinet event', async () => {
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(baseEventData);
expect(result).toEqual(baseEventData);
expect(mockSave).toHaveBeenCalled();
});
});
describe('createMany', () => {
it('inserts multiple events', async () => {
const events = [baseEventData, { ...baseEventData, quantity: 5 }];
mockInsertMany.mockResolvedValue(events);
const result = await repo.createMany(events);
expect(result).toEqual(events);
expect(mockInsertMany).toHaveBeenCalledWith(events);
});
});
describe('findByHousehold', () => {
it('returns paginated events with no filters', async () => {
const items = [{ _id: 'ev-1', quantity: 10 }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'ev-2', quantity: 5 }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('ev-1').toString('base64');
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ev-${i}`, quantity: i }));
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
it('filters by medicineId', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { medicineId: 'med-1', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by eventType', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { eventType: 'purchased' as never, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by startDate only', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
startDate: '2024-01-01T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
it('filters by endDate only', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
endDate: '2024-12-31T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
it('filters by both startDate and endDate', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
startDate: '2024-01-01T00:00:00.000Z',
endDate: '2024-12-31T00:00:00.000Z',
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
});
describe('findByCabinetItem', () => {
it('returns paginated events for a cabinet item', async () => {
const items = [{ _id: 'ev-1', cabinetItemId: 'ci-1' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'ev-2' }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('ev-1').toString('base64');
const result = await repo.findByCabinetItem('hh1', 'ci-1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ev-${i}` }));
mockFind.mockResolvedValue(items);
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('getSpendingSummary', () => {
it('returns spending summary with default month period', async () => {
const byMedicine = [
{
_id: 'med-1',
medicineName: 'Metformin',
totalSpent: 100,
totalQuantity: 10,
avgUnitPrice: 10,
purchaseCount: 2,
currency: 'USD',
},
];
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
mockAggregate
.mockResolvedValueOnce(byMedicine)
.mockResolvedValueOnce(byPeriod);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
expect(result.totalSpent).toBe(100);
expect(result.currency).toBe('USD');
expect(result.byMedicine).toHaveLength(1);
expect(result.byMedicine[0].medicineId).toBe('med-1');
expect(result.byMedicine[0].medicineName).toBe('Metformin');
expect(result.byMedicine[0].totalSpent).toBe(100);
expect(result.byMedicine[0].totalQuantity).toBe(10);
expect(result.byMedicine[0].avgUnitPrice).toBe(10);
expect(result.byMedicine[0].purchaseCount).toBe(2);
expect(result.byPeriod).toHaveLength(1);
expect(result.byPeriod[0].period).toBe('2024-01');
expect(result.byPeriod[0].totalSpent).toBe(100);
});
it('returns null currency when no medicine data', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
expect(result.totalSpent).toBe(0);
expect(result.currency).toBeNull();
expect(result.byMedicine).toHaveLength(0);
expect(result.byPeriod).toHaveLength(0);
});
it('filters by medicineId', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('filters by startDate only', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
startDate: '2024-01-01T00:00:00.000Z',
});
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('filters by endDate only', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
endDate: '2024-12-31T00:00:00.000Z',
});
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('filters by both startDate and endDate', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
startDate: '2024-01-01T00:00:00.000Z',
endDate: '2024-12-31T00:00:00.000Z',
});
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('uses quarter date format', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'quarter' });
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('uses year date format', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'year' });
expect(mockAggregate).toHaveBeenCalledTimes(2);
});
it('handles null currency in first medicine entry', async () => {
const byMedicine = [
{
_id: 'med-1',
medicineName: 'Metformin',
totalSpent: 50,
totalQuantity: 5,
avgUnitPrice: 10,
purchaseCount: 1,
currency: null,
},
];
mockAggregate
.mockResolvedValueOnce(byMedicine)
.mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
expect(result.currency).toBeNull();
});
});
describe('getAvgUnitPriceByMedicine', () => {
it('returns empty map when no medicine ids provided', async () => {
const result = await repo.getAvgUnitPriceByMedicine('hh1', []);
expect(result).toEqual(new Map());
expect(mockAggregate).not.toHaveBeenCalled();
});
it('returns map of avg unit prices', async () => {
const results = [
{ _id: 'med-1', avgUnitPrice: 10.5, currency: 'USD', totalSpent: 105, totalQuantity: 10 },
{ _id: 'med-2', avgUnitPrice: 5.0, currency: 'EUR', totalSpent: 50, totalQuantity: 10 },
];
mockAggregate.mockResolvedValue(results);
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1', 'med-2']);
expect(map.size).toBe(2);
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10.5, currency: 'USD' });
expect(map.get('med-2')).toEqual({ avgUnitPrice: 5.0, currency: 'EUR' });
});
it('handles null currency in results', async () => {
const results = [
{ _id: 'med-1', avgUnitPrice: 10, currency: null, totalSpent: 100, totalQuantity: 10 },
];
mockAggregate.mockResolvedValue(results);
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1']);
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10, currency: null });
});
});
});

View file

@ -0,0 +1,214 @@
import { CabinetEventModel } from '../../schemas/cabinet-event.schema.js';
import type {
CabinetEventType,
CabinetEventSourceType,
CabinetEventQueryInput,
SpendingSummaryQueryInput,
} from '@meshitrack/shared';
export interface CreateCabinetEventData {
householdId: string;
userId: string;
cabinetItemId: string;
medicineId: string;
medicineName: string;
eventType: CabinetEventType;
quantity: number;
quantityBefore: number;
quantityAfter: number;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;
sourceType: CabinetEventSourceType;
sourceId?: string;
reason?: string;
notes?: string;
}
export class CabinetEventsRepository {
public async create(data: CreateCabinetEventData) {
const event = new CabinetEventModel(data);
const saved = await event.save();
return saved.toObject();
}
public async createMany(events: CreateCabinetEventData[]) {
return CabinetEventModel.insertMany(events);
}
public async findByHousehold(householdId: string, query: CabinetEventQueryInput) {
const filter: Record<string, unknown> = { householdId };
if (query.medicineId) filter['medicineId'] = query.medicineId;
if (query.eventType) filter['eventType'] = query.eventType;
if (query.startDate || query.endDate) {
const dateFilter: Record<string, string> = {};
if (query.startDate) dateFilter['$gte'] = query.startDate;
if (query.endDate) dateFilter['$lte'] = query.endDate;
filter['createdAt'] = dateFilter;
}
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $lt: id };
}
const limit = query.limit;
const items = await CabinetEventModel.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findByCabinetItem(
householdId: string,
cabinetItemId: string,
query: { cursor?: string; limit: number },
) {
const filter: Record<string, unknown> = { householdId, cabinetItemId };
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $lt: id };
}
const limit = query.limit;
const items = await CabinetEventModel.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
const match: Record<string, unknown> = {
householdId,
eventType: 'purchased',
unitPrice: { $ne: null },
};
if (query.medicineId) match['medicineId'] = query.medicineId;
if (query.startDate || query.endDate) {
const dateFilter: Record<string, string> = {};
if (query.startDate) dateFilter['$gte'] = query.startDate;
if (query.endDate) dateFilter['$lte'] = query.endDate;
match['createdAt'] = dateFilter;
}
const dateFormat =
query.period === 'year' ? '%Y' : query.period === 'quarter' ? '%Y-Q%q' : '%Y-%m';
const [byMedicine, byPeriod] = await Promise.all([
CabinetEventModel.aggregate([
{ $match: match },
{
$group: {
_id: '$medicineId',
medicineName: { $first: '$medicineName' },
totalSpent: { $sum: { $multiply: ['$unitPrice', '$quantity'] } },
totalQuantity: { $sum: '$quantity' },
purchaseCount: { $sum: 1 },
currency: { $first: '$currency' },
},
},
{
$addFields: {
avgUnitPrice: {
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
},
},
},
{ $sort: { totalSpent: -1 } },
]).exec(),
CabinetEventModel.aggregate([
{ $match: match },
{
$group: {
_id: { $dateToString: { format: dateFormat, date: '$createdAt' } },
totalSpent: { $sum: { $multiply: ['$unitPrice', '$quantity'] } },
},
},
{ $sort: { _id: 1 } },
]).exec(),
]);
const totalSpent = byMedicine.reduce(
(sum: number, m: Record<string, unknown>) => sum + (m.totalSpent as number),
0,
);
const currency =
byMedicine.length > 0 ? (byMedicine[0].currency as string | null) ?? null : null;
return {
totalSpent,
currency,
byMedicine: byMedicine.map((m: Record<string, unknown>) => ({
medicineId: m._id as string,
medicineName: m.medicineName as string,
totalSpent: m.totalSpent as number,
totalQuantity: m.totalQuantity as number,
avgUnitPrice: m.avgUnitPrice as number,
purchaseCount: m.purchaseCount as number,
})),
byPeriod: byPeriod.map((p: Record<string, unknown>) => ({
period: p._id as string,
totalSpent: p.totalSpent as number,
})),
};
}
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
if (medicineIds.length === 0) return new Map<string, { avgUnitPrice: number; currency: string | null }>();
const results = await CabinetEventModel.aggregate([
{
$match: {
householdId,
eventType: 'purchased',
medicineId: { $in: medicineIds },
unitPrice: { $ne: null },
},
},
{
$group: {
_id: '$medicineId',
totalSpent: { $sum: { $multiply: ['$unitPrice', '$quantity'] } },
totalQuantity: { $sum: '$quantity' },
currency: { $first: '$currency' },
},
},
{
$addFields: {
avgUnitPrice: {
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
},
},
},
]).exec();
const map = new Map<string, { avgUnitPrice: number; currency: string | null }>();
for (const r of results) {
map.set(r._id as string, {
avgUnitPrice: r.avgUnitPrice as number,
currency: (r.currency as string | null) ?? null,
});
}
return map;
}
}

View file

@ -0,0 +1,387 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const { mockListEvents, mockGetEventsByItem, mockGetSpendingSummary } = vi.hoisted(() => ({
mockListEvents: vi.fn(),
mockGetEventsByItem: vi.fn(),
mockGetSpendingSummary: vi.fn(),
}));
vi.mock('./cabinet-events.repository.js', () => ({
CabinetEventsRepository: class {
create = vi.fn();
createMany = vi.fn();
findByHousehold = vi.fn();
findByCabinetItem = vi.fn();
getSpendingSummary = vi.fn();
getAvgUnitPriceByMedicine = vi.fn();
},
}));
vi.mock('./cabinet-events.service.js', () => ({
CabinetEventsService: class {
logEvent = vi.fn();
logEvents = vi.fn();
listEvents = mockListEvents;
getEventsByItem = mockGetEventsByItem;
getSpendingSummary = mockGetSpendingSummary;
getAvgUnitPrices = vi.fn();
},
}));
vi.mock('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import cabinetEventsRoutes from './cabinet-events.routes.js';
function makeFakeEvent(overrides = {}) {
return {
_id: 'ev-1',
householdId: 'hh1',
userId: 'kc-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: 'purchased',
quantity: 10,
quantityBefore: 0,
quantityAfter: 10,
sourceType: 'manual',
createdAt: '2024-06-01T00:00:00.000Z',
...overrides,
};
}
describe('cabinet-events.routes', () => {
let app: Awaited<ReturnType<typeof buildTestApp>>;
async function buildTestApp() {
const instance = Fastify({ logger: false });
instance.setValidatorCompiler(validatorCompiler);
instance.setSerializerCompiler(serializerCompiler);
await instance.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
await instance.register(authPlugin);
await instance.register(householdPlugin);
await instance.register(usersRoutes);
await instance.register(cabinetEventsRoutes);
await instance.ready();
return instance;
}
const authHeaders = { authorization: 'Bearer valid-token' };
beforeEach(async () => {
vi.clearAllMocks();
app = await buildTestApp();
});
afterEach(async () => {
if (app) await app.close();
});
describe('GET /api/v1/households/:householdId/cabinet-events', () => {
it('returns paginated event list', async () => {
const event = makeFakeEvent();
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].medicineName).toBe('Metformin');
expect(body.data[0].eventType).toBe('purchased');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const event = makeFakeEvent({
_id: { toString: () => 'ev-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
unitPrice: 5.5,
totalPrice: 55,
currency: 'USD',
storeId: 'store-1',
storeName: 'Pharmacy A',
sourceId: 'src-1',
reason: 'restocking',
notes: 'bulk purchase',
});
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('ev-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].unitPrice).toBe(5.5);
expect(body.data[0].totalPrice).toBe(55);
expect(body.data[0].currency).toBe('USD');
expect(body.data[0].storeId).toBe('store-1');
expect(body.data[0].storeName).toBe('Pharmacy A');
expect(body.data[0].sourceId).toBe('src-1');
expect(body.data[0].reason).toBe('restocking');
expect(body.data[0].notes).toBe('bulk purchase');
});
it('handles Date instances in createdAt', async () => {
const event = makeFakeEvent({
createdAt: new Date('2024-03-15T12:00:00.000Z'),
});
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].createdAt).toBe('2024-03-15T12:00:00.000Z');
});
it('omits null optional fields from response', async () => {
const event = makeFakeEvent({
unitPrice: null,
totalPrice: null,
currency: null,
storeId: null,
storeName: null,
sourceId: null,
reason: null,
notes: null,
});
mockListEvents.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].unitPrice).toBeUndefined();
expect(body.data[0].totalPrice).toBeUndefined();
expect(body.data[0].currency).toBeUndefined();
expect(body.data[0].storeId).toBeUndefined();
expect(body.data[0].storeName).toBeUndefined();
expect(body.data[0].sourceId).toBeUndefined();
expect(body.data[0].reason).toBeUndefined();
expect(body.data[0].notes).toBeUndefined();
});
it('passes query parameters to service', async () => {
mockListEvents.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events?medicineId=med-1&eventType=purchased&limit=10',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockListEvents).toHaveBeenCalledWith('hh1', expect.objectContaining({
medicineId: 'med-1',
eventType: 'purchased',
limit: 10,
}));
});
});
describe('GET /api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId', () => {
it('returns paginated events for a cabinet item', async () => {
const event = makeFakeEvent();
mockGetEventsByItem.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].cabinetItemId).toBe('ci-1');
expect(body.pagination.hasMore).toBe(false);
});
it('passes query parameters to service', async () => {
mockGetEventsByItem.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1?limit=5&cursor=abc',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockGetEventsByItem).toHaveBeenCalledWith('hh1', 'ci-1', expect.objectContaining({
limit: 5,
cursor: 'abc',
}));
});
it('handles ObjectId and Date objects in by-item response', async () => {
const event = makeFakeEvent({
_id: { toString: () => 'ev-obj-2' },
createdAt: new Date('2024-05-01T00:00:00.000Z'),
});
mockGetEventsByItem.mockResolvedValue({
data: [event],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('ev-obj-2');
expect(body.data[0].createdAt).toBe('2024-05-01T00:00:00.000Z');
});
});
describe('GET /api/v1/households/:householdId/cabinet-events/spending-summary', () => {
it('returns spending summary', async () => {
mockGetSpendingSummary.mockResolvedValue({
totalSpent: 250,
currency: 'USD',
byMedicine: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
totalSpent: 250,
totalQuantity: 25,
avgUnitPrice: 10,
purchaseCount: 5,
},
],
byPeriod: [
{ period: '2024-01', totalSpent: 100 },
{ period: '2024-02', totalSpent: 150 },
],
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.totalSpent).toBe(250);
expect(body.currency).toBe('USD');
expect(body.byMedicine).toHaveLength(1);
expect(body.byMedicine[0].medicineId).toBe('med-1');
expect(body.byPeriod).toHaveLength(2);
});
it('passes query parameters to service', async () => {
mockGetSpendingSummary.mockResolvedValue({
totalSpent: 0,
currency: null,
byMedicine: [],
byPeriod: [],
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/spending-summary?period=quarter&medicineId=med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.objectContaining({
period: 'quarter',
medicineId: 'med-1',
}));
});
it('returns empty summary with null currency', async () => {
mockGetSpendingSummary.mockResolvedValue({
totalSpent: 0,
currency: null,
byMedicine: [],
byPeriod: [],
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.totalSpent).toBe(0);
expect(body.currency).toBeNull();
expect(body.byMedicine).toHaveLength(0);
expect(body.byPeriod).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,157 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CabinetEventQuerySchema,
CabinetEventListResponseSchema,
SpendingSummaryQuerySchema,
SpendingSummaryResponseSchema,
} from '@meshitrack/shared';
import { CabinetEventsRepository } from './cabinet-events.repository.js';
import { CabinetEventsService } from './cabinet-events.service.js';
type AnyCabinetEventDoc = {
_id: string | { toString: () => string };
householdId: string;
userId: string;
cabinetItemId: string;
medicineId: string;
medicineName: string;
eventType: string;
quantity: number;
quantityBefore: number;
quantityAfter: number;
unitPrice?: number | null;
totalPrice?: number | null;
currency?: string | null;
storeId?: string | null;
storeName?: string | null;
sourceType: string;
sourceId?: string | null;
reason?: string | null;
notes?: string | null;
createdAt: string | Date | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
}
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
if (v instanceof Date) return v.toISOString();
return v.toISOString();
}
function toCabinetEventResponse(doc: AnyCabinetEventDoc) {
return {
_id: toStr(doc._id),
householdId: doc.householdId,
userId: doc.userId,
cabinetItemId: doc.cabinetItemId,
medicineId: doc.medicineId,
medicineName: doc.medicineName,
eventType: doc.eventType,
quantity: doc.quantity,
quantityBefore: doc.quantityBefore,
quantityAfter: doc.quantityAfter,
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
...(doc.currency ? { currency: doc.currency } : {}),
...(doc.storeId ? { storeId: doc.storeId } : {}),
...(doc.storeName ? { storeName: doc.storeName } : {}),
sourceType: doc.sourceType,
...(doc.sourceId ? { sourceId: doc.sourceId } : {}),
...(doc.reason ? { reason: doc.reason } : {}),
...(doc.notes ? { notes: doc.notes } : {}),
createdAt: toIso(doc.createdAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
cabinetEventsRepository: CabinetEventsRepository;
cabinetEventsService: CabinetEventsService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
cabinetEventsRepository: asClass(CabinetEventsRepository, { lifetime: Lifetime.SINGLETON }),
cabinetEventsService: asClass(CabinetEventsService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
// GET /api/v1/households/:householdId/cabinet-events — list events
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/cabinet-events',
schema: {
params: householdParams,
querystring: CabinetEventQuerySchema,
response: { 200: CabinetEventListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const result = await service.listEvents(request.params.householdId, request.query);
return reply.send({
data: result.data.map(toCabinetEventResponse),
pagination: result.pagination,
});
},
});
// GET /api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId — events for one item
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId',
schema: {
params: householdParams.extend({ cabinetItemId: z.string() }),
querystring: z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
response: { 200: CabinetEventListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const result = await service.getEventsByItem(
request.params.householdId,
request.params.cabinetItemId,
request.query,
);
return reply.send({
data: result.data.map(toCabinetEventResponse),
pagination: result.pagination,
});
},
});
// GET /api/v1/households/:householdId/cabinet-events/spending-summary — spending analytics
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/cabinet-events/spending-summary',
schema: {
params: householdParams,
querystring: SpendingSummaryQuerySchema,
response: { 200: SpendingSummaryResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const summary = await service.getSpendingSummary(
request.params.householdId,
request.query,
);
return reply.send(summary);
},
});
},
{
name: 'cabinet-events-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CabinetEventsService } from './cabinet-events.service.js';
describe(CabinetEventsService.name, () => {
const mockCabinetEventsRepo = {
create: vi.fn(),
createMany: vi.fn(),
findByHousehold: vi.fn(),
findByCabinetItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPriceByMedicine: vi.fn(),
};
let service: CabinetEventsService;
const baseEventData = {
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: 'purchased' as const,
quantity: 10,
quantityBefore: 0,
quantityAfter: 10,
sourceType: 'manual' as const,
};
beforeEach(() => {
vi.clearAllMocks();
service = new CabinetEventsService({
cabinetEventsRepository: mockCabinetEventsRepo as never,
});
});
describe('logEvent', () => {
it('delegates to repository create', async () => {
const created = { _id: 'ev-1', ...baseEventData };
mockCabinetEventsRepo.create.mockResolvedValue(created);
const result = await service.logEvent(baseEventData);
expect(result).toEqual(created);
expect(mockCabinetEventsRepo.create).toHaveBeenCalledWith(baseEventData);
});
});
describe('logEvents', () => {
it('delegates to repository createMany', async () => {
const events = [baseEventData, { ...baseEventData, quantity: 5 }];
const inserted = events.map((e, i) => ({ _id: `ev-${i}`, ...e }));
mockCabinetEventsRepo.createMany.mockResolvedValue(inserted);
const result = await service.logEvents(events);
expect(result).toEqual(inserted);
expect(mockCabinetEventsRepo.createMany).toHaveBeenCalledWith(events);
});
it('returns empty array for empty input without calling repository', async () => {
const result = await service.logEvents([]);
expect(result).toEqual([]);
expect(mockCabinetEventsRepo.createMany).not.toHaveBeenCalled();
});
});
describe('listEvents', () => {
it('delegates to repository findByHousehold', async () => {
const expected = {
data: [{ _id: 'ev-1' }],
pagination: { cursor: null, hasMore: false },
};
mockCabinetEventsRepo.findByHousehold.mockResolvedValue(expected);
const query = { limit: 20 };
const result = await service.listEvents('hh1', query);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.findByHousehold).toHaveBeenCalledWith('hh1', query);
});
});
describe('getEventsByItem', () => {
it('delegates to repository findByCabinetItem', async () => {
const expected = {
data: [{ _id: 'ev-1', cabinetItemId: 'ci-1' }],
pagination: { cursor: null, hasMore: false },
};
mockCabinetEventsRepo.findByCabinetItem.mockResolvedValue(expected);
const query = { limit: 20 };
const result = await service.getEventsByItem('hh1', 'ci-1', query);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.findByCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1', query);
});
it('passes cursor through to repository', async () => {
const expected = {
data: [],
pagination: { cursor: null, hasMore: false },
};
mockCabinetEventsRepo.findByCabinetItem.mockResolvedValue(expected);
const query = { cursor: 'abc123', limit: 10 };
await service.getEventsByItem('hh1', 'ci-1', query);
expect(mockCabinetEventsRepo.findByCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1', query);
});
});
describe('getSpendingSummary', () => {
it('delegates to repository getSpendingSummary', async () => {
const expected = {
totalSpent: 100,
currency: 'USD',
byMedicine: [],
byPeriod: [],
};
mockCabinetEventsRepo.getSpendingSummary.mockResolvedValue(expected);
const query = { period: 'month' as const };
const result = await service.getSpendingSummary('hh1', query);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.getSpendingSummary).toHaveBeenCalledWith('hh1', query);
});
});
describe('getAvgUnitPrices', () => {
it('delegates to repository getAvgUnitPriceByMedicine', async () => {
const expected = new Map([['med-1', { avgUnitPrice: 10, currency: 'USD' }]]);
mockCabinetEventsRepo.getAvgUnitPriceByMedicine.mockResolvedValue(expected);
const result = await service.getAvgUnitPrices('hh1', ['med-1']);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', ['med-1']);
});
});
});

View file

@ -0,0 +1,43 @@
import type { CabinetEventsRepository, CreateCabinetEventData } from './cabinet-events.repository.js';
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
interface Deps {
cabinetEventsRepository: CabinetEventsRepository;
}
export class CabinetEventsService {
private readonly cabinetEventsRepository: CabinetEventsRepository;
public constructor({ cabinetEventsRepository }: Deps) {
this.cabinetEventsRepository = cabinetEventsRepository;
}
public async logEvent(data: CreateCabinetEventData) {
return this.cabinetEventsRepository.create(data);
}
public async logEvents(events: CreateCabinetEventData[]) {
if (events.length === 0) return [];
return this.cabinetEventsRepository.createMany(events);
}
public async listEvents(householdId: string, query: CabinetEventQueryInput) {
return this.cabinetEventsRepository.findByHousehold(householdId, query);
}
public async getEventsByItem(
householdId: string,
cabinetItemId: string,
query: { cursor?: string; limit: number },
) {
return this.cabinetEventsRepository.findByCabinetItem(householdId, cabinetItemId, query);
}
public async getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
return this.cabinetEventsRepository.getSpendingSummary(householdId, query);
}
public async getAvgUnitPrices(householdId: string, medicineIds: string[]) {
return this.cabinetEventsRepository.getAvgUnitPriceByMedicine(householdId, medicineIds);
}
}

View file

@ -251,4 +251,45 @@ describe(CabinetRepository.name, () => {
expect(result).toEqual(deleted); expect(result).toEqual(deleted);
}); });
}); });
describe('discard', () => {
it('zeros quantity, marks depleted and deleted', async () => {
const discarded = { _id: 'ci-1', quantity: 0, status: 'depleted', isDeleted: true };
mockFindOneAndUpdate.mockResolvedValue(discarded);
const result = await repo.discard('ci-1', 'hh1');
expect(result).toEqual(discarded);
});
it('returns null when item not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.discard('ci-missing', 'hh1');
expect(result).toBeNull();
});
});
describe('findActiveByMedicineForFEFO', () => {
it('returns active items sorted by expiration date', async () => {
const items = [
{ _id: 'ci-1', quantity: 10, expirationDate: new Date('2025-06-01') },
{ _id: 'ci-2', quantity: 20, expirationDate: new Date('2025-12-01') },
];
mockFind.mockResolvedValue(items);
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
expect(result).toEqual(items);
});
it('returns empty array when no active items exist', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
expect(result).toHaveLength(0);
});
});
}); });

View file

@ -141,4 +141,25 @@ export class CabinetRepository {
{ new: true, lean: true }, { new: true, lean: true },
).exec(); ).exec();
} }
public async discard(id: string, householdId: string) {
return CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { quantity: 0, status: 'depleted', isDeleted: true } },
{ new: true, lean: true },
).exec();
}
public async findActiveByMedicineForFEFO(householdId: string, medicineId: string) {
return CabinetItemModel.find({
householdId,
medicineId,
isDeleted: false,
status: 'active',
quantity: { $gt: 0 },
})
.sort({ expirationDate: 1, _id: 1 })
.lean()
.exec();
}
} }

View file

@ -29,6 +29,8 @@ const {
mockFindExpiringSoon, mockFindExpiringSoon,
mockSoftDelete, mockSoftDelete,
mockCountByMedicineId, mockCountByMedicineId,
mockDiscard,
mockFindActiveByMedicineForFEFO,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(), mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(), mockFindById: vi.fn(),
@ -39,6 +41,8 @@ const {
mockFindExpiringSoon: vi.fn(), mockFindExpiringSoon: vi.fn(),
mockSoftDelete: vi.fn(), mockSoftDelete: vi.fn(),
mockCountByMedicineId: vi.fn(), mockCountByMedicineId: vi.fn(),
mockDiscard: vi.fn(),
mockFindActiveByMedicineForFEFO: vi.fn(),
})); }));
vi.mock('./cabinet.repository.js', () => ({ vi.mock('./cabinet.repository.js', () => ({
@ -52,6 +56,8 @@ vi.mock('./cabinet.repository.js', () => ({
findExpiringSoon = mockFindExpiringSoon; findExpiringSoon = mockFindExpiringSoon;
softDelete = mockSoftDelete; softDelete = mockSoftDelete;
countByMedicineId = mockCountByMedicineId; countByMedicineId = mockCountByMedicineId;
discard = mockDiscard;
findActiveByMedicineForFEFO = mockFindActiveByMedicineForFEFO;
}, },
})); }));
@ -105,6 +111,28 @@ vi.mock('../medicines/medicines.service.js', () => ({
}, },
})); }));
vi.mock('../cabinet-events/cabinet-events.repository.js', () => ({
CabinetEventsRepository: class {
create = vi.fn();
createMany = vi.fn();
findByHousehold = vi.fn();
findByCabinetItem = vi.fn();
getSpendingSummary = vi.fn();
getAvgUnitPriceByMedicine = vi.fn();
},
}));
vi.mock('../cabinet-events/cabinet-events.service.js', () => ({
CabinetEventsService: class {
logEvent = vi.fn();
logEvents = vi.fn();
listEvents = vi.fn();
getEventsByItem = vi.fn();
getSpendingSummary = vi.fn();
getAvgUnitPrices = vi.fn();
},
}));
vi.mock('../users/users.repository.js', () => ({ vi.mock('../users/users.repository.js', () => ({
UsersRepository: class { UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
@ -117,6 +145,7 @@ import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js'; import usersRoutes from '../users/users.routes.js';
import medicinesRoutes from '../medicines/medicines.routes.js'; import medicinesRoutes from '../medicines/medicines.routes.js';
import medicineProductsRoutes from '../medicine-products/medicine-products.routes.js'; import medicineProductsRoutes from '../medicine-products/medicine-products.routes.js';
import cabinetEventsRoutes from '../cabinet-events/cabinet-events.routes.js';
import cabinetRoutes from './cabinet.routes.js'; import cabinetRoutes from './cabinet.routes.js';
function makeFakeCabinetItem(overrides = {}) { function makeFakeCabinetItem(overrides = {}) {
@ -155,6 +184,7 @@ describe('cabinet.routes', () => {
await instance.register(usersRoutes); await instance.register(usersRoutes);
await instance.register(medicinesRoutes); await instance.register(medicinesRoutes);
await instance.register(medicineProductsRoutes); await instance.register(medicineProductsRoutes);
await instance.register(cabinetEventsRoutes);
await instance.register(cabinetRoutes); await instance.register(cabinetRoutes);
await instance.ready(); await instance.ready();
return instance; return instance;
@ -242,6 +272,40 @@ describe('cabinet.routes', () => {
expect(body.data[0].expirationDate).toBe('2026-12-31T00:00:00.000Z'); expect(body.data[0].expirationDate).toBe('2026-12-31T00:00:00.000Z');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z'); expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
}); });
it('includes optional purchase and store fields when present', async () => {
const item = makeFakeCabinetItem({
concentration: 5.0,
concentrationUnit: 'mg/mL',
purchaseDate: new Date('2024-03-01T00:00:00.000Z'),
unitPrice: 1.5,
totalPrice: 45.0,
currency: 'USD',
storeId: 'store-1',
storeName: 'Pharmacy Plus',
});
mockFindByHousehold.mockResolvedValue({
data: [item],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].concentration).toBe(5.0);
expect(body.data[0].concentrationUnit).toBe('mg/mL');
expect(body.data[0].purchaseDate).toBe('2024-03-01T00:00:00.000Z');
expect(body.data[0].unitPrice).toBe(1.5);
expect(body.data[0].totalPrice).toBe(45.0);
expect(body.data[0].currency).toBe('USD');
expect(body.data[0].storeId).toBe('store-1');
expect(body.data[0].storeName).toBe('Pharmacy Plus');
});
}); });
describe('GET /api/v1/households/:householdId/cabinet/summary', () => { describe('GET /api/v1/households/:householdId/cabinet/summary', () => {
@ -379,4 +443,32 @@ describe('cabinet.routes', () => {
expect(res.statusCode).toBe(204); expect(res.statusCode).toBe(204);
}); });
}); });
describe('POST /api/v1/households/:householdId/cabinet/:id/discard', () => {
it('discards a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem({ quantity: 20 }));
mockDiscard.mockResolvedValue(makeFakeCabinetItem({ quantity: 0, isDeleted: true }));
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet/ci-1/discard',
headers: authHeaders,
payload: { reason: 'expired' },
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(0);
});
it('returns 400 for missing reason', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet/ci-1/discard',
headers: authHeaders,
payload: {},
});
expect(res.statusCode).toBe(400);
});
});
}); });

View file

@ -10,6 +10,7 @@ import {
CabinetItemResponseSchema, CabinetItemResponseSchema,
CabinetItemListResponseSchema, CabinetItemListResponseSchema,
CabinetSummaryResponseSchema, CabinetSummaryResponseSchema,
DiscardCabinetItemSchema,
} from '@meshitrack/shared'; } from '@meshitrack/shared';
import { CabinetRepository } from './cabinet.repository.js'; import { CabinetRepository } from './cabinet.repository.js';
import { CabinetService } from './cabinet.service.js'; import { CabinetService } from './cabinet.service.js';
@ -30,6 +31,12 @@ type AnyCabinetDoc = {
unit: string; unit: string;
expirationDate?: Date | string | null; expirationDate?: Date | string | null;
status: string; status: string;
purchaseDate?: Date | string | null;
unitPrice?: number | null;
totalPrice?: number | null;
currency?: string | null;
storeId?: string | null;
storeName?: string | null;
notes?: string | null; notes?: string | null;
createdBy: string; createdBy: string;
createdAt: string | { toISOString: () => string }; createdAt: string | { toISOString: () => string };
@ -70,6 +77,12 @@ function toCabinetItemResponse(doc: AnyCabinetDoc): z.infer<typeof CabinetItemRe
unit: doc.unit, unit: doc.unit,
...(doc.expirationDate ? { expirationDate: toOptIso(doc.expirationDate) } : {}), ...(doc.expirationDate ? { expirationDate: toOptIso(doc.expirationDate) } : {}),
status: doc.status, status: doc.status,
...(doc.purchaseDate ? { purchaseDate: toOptIso(doc.purchaseDate) } : {}),
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
...(doc.currency ? { currency: doc.currency } : {}),
...(doc.storeId ? { storeId: doc.storeId } : {}),
...(doc.storeName ? { storeName: doc.storeName } : {}),
...(doc.notes ? { notes: doc.notes } : {}), ...(doc.notes ? { notes: doc.notes } : {}),
createdBy: doc.createdBy, createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt), createdAt: toIso(doc.createdAt),
@ -198,6 +211,7 @@ export default fp(
request.params.id, request.params.id,
request.params.householdId, request.params.householdId,
request.body, request.body,
request.user.keycloakId,
); );
return reply.send(toCabinetItemResponse(item)); return reply.send(toCabinetItemResponse(item));
}, },
@ -218,6 +232,8 @@ export default fp(
request.params.id, request.params.id,
request.params.householdId, request.params.householdId,
request.body.delta, request.body.delta,
request.user.keycloakId,
request.body.reason,
); );
return reply.send(toCabinetItemResponse(item)); return reply.send(toCabinetItemResponse(item));
}, },
@ -233,10 +249,32 @@ export default fp(
}, },
handler: async (request, reply) => { handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService'); const service = fastify.diContainer.resolve('cabinetService');
await service.delete(request.params.id, request.params.householdId); await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
return reply.status(204).send(); return reply.status(204).send();
}, },
}); });
// POST /api/v1/households/:householdId/cabinet/:id/discard — discard item
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/cabinet/:id/discard',
schema: {
params: householdParams.extend({ id: z.string() }),
body: DiscardCabinetItemSchema,
response: { 200: CabinetItemResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const item = await service.discard(
request.params.id,
request.params.householdId,
request.user.keycloakId,
request.body.reason,
request.body.notes,
);
return reply.send(toCabinetItemResponse(item));
},
});
}, },
{ {
name: 'cabinet-routes', name: 'cabinet-routes',

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CabinetService } from './cabinet.service.js'; import { CabinetService } from './cabinet.service.js';
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
describe(CabinetService.name, () => { describe(CabinetService.name, () => {
const mockCabinetRepo = { const mockCabinetRepo = {
@ -12,6 +13,8 @@ describe(CabinetService.name, () => {
findExpiringSoon: vi.fn(), findExpiringSoon: vi.fn(),
softDelete: vi.fn(), softDelete: vi.fn(),
countByMedicineId: vi.fn(), countByMedicineId: vi.fn(),
discard: vi.fn(),
findActiveByMedicineForFEFO: vi.fn(),
}; };
const mockMedicinesRepo = { const mockMedicinesRepo = {
@ -32,6 +35,15 @@ describe(CabinetService.name, () => {
countByMedicineId: vi.fn(), countByMedicineId: vi.fn(),
}; };
const mockCabinetEventsService = {
logEvent: vi.fn(),
logEvents: vi.fn(),
listEvents: vi.fn(),
getEventsByItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPrices: vi.fn(),
};
let service: CabinetService; let service: CabinetService;
beforeEach(() => { beforeEach(() => {
@ -40,6 +52,7 @@ describe(CabinetService.name, () => {
cabinetRepository: mockCabinetRepo as never, cabinetRepository: mockCabinetRepo as never,
medicinesRepository: mockMedicinesRepo as never, medicinesRepository: mockMedicinesRepo as never,
medicineProductsRepository: mockProductsRepo as never, medicineProductsRepository: mockProductsRepo as never,
cabinetEventsService: mockCabinetEventsService as never,
}); });
}); });
@ -150,6 +163,33 @@ describe(CabinetService.name, () => {
); );
}); });
it('logs PURCHASED event after creation', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockCabinetRepo.create.mockResolvedValue({ _id: 'ci-1' });
await service.addItem(createInput, 'hh1', 'user-1');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
eventType: CabinetEventType.PURCHASED,
quantity: 30,
quantityBefore: 0,
quantityAfter: 30,
sourceType: CabinetEventSourceType.MANUAL,
}),
);
});
it('throws NotFoundError when medicine not found', async () => { it('throws NotFoundError when medicine not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null); mockMedicinesRepo.findById.mockResolvedValue(null);
@ -199,28 +239,53 @@ describe(CabinetService.name, () => {
describe('update', () => { describe('update', () => {
it('updates and returns item', async () => { it('updates and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' }); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
const updated = { _id: 'ci-1', quantity: 25 }; const updated = { _id: 'ci-1', quantity: 25 };
mockCabinetRepo.update.mockResolvedValue(updated); mockCabinetRepo.update.mockResolvedValue(updated);
const result = await service.update('ci-1', 'hh1', { quantity: 25 }); const result = await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
expect(result).toEqual(updated); expect(result).toEqual(updated);
}); });
it('logs ADJUSTED event when quantity changes', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.ADJUSTED,
quantity: -5,
quantityBefore: 30,
quantityAfter: 25,
}),
);
});
it('does not log event when quantity unchanged', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
expect(mockCabinetEventsService.logEvent).not.toHaveBeenCalled();
});
it('throws NotFoundError when item does not exist', async () => { it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null); mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.update('ci-missing', 'hh1', { quantity: 25 })).rejects.toThrow( await expect(service.update('ci-missing', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
'Cabinet item not found', 'Cabinet item not found',
); );
}); });
it('throws NotFoundError when update returns null', async () => { it('throws NotFoundError when update returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' }); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.update.mockResolvedValue(null); mockCabinetRepo.update.mockResolvedValue(null);
await expect(service.update('ci-1', 'hh1', { quantity: 25 })).rejects.toThrow( await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
'Cabinet item not found', 'Cabinet item not found',
); );
}); });
@ -228,17 +293,34 @@ describe(CabinetService.name, () => {
describe('adjustQuantity', () => { describe('adjustQuantity', () => {
it('adjusts quantity and returns item', async () => { it('adjusts quantity and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30 }); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
const updated = { _id: 'ci-1', quantity: 27 }; const updated = { _id: 'ci-1', quantity: 27 };
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated); mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
const result = await service.adjustQuantity('ci-1', 'hh1', -3); const result = await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1');
expect(result).toEqual(updated); expect(result).toEqual(updated);
}); });
it('logs ADJUSTED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.ADJUSTED,
quantity: -3,
quantityBefore: 30,
quantityAfter: 27,
reason: 'took some',
}),
);
});
it('throws BadRequestError when delta is 0', async () => { it('throws BadRequestError when delta is 0', async () => {
await expect(service.adjustQuantity('ci-1', 'hh1', 0)).rejects.toThrow( await expect(service.adjustQuantity('ci-1', 'hh1', 0, 'user-1')).rejects.toThrow(
'Delta must be non-zero', 'Delta must be non-zero',
); );
}); });
@ -246,16 +328,16 @@ describe(CabinetService.name, () => {
it('throws NotFoundError when item does not exist', async () => { it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null); mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-missing', 'hh1', 5)).rejects.toThrow( await expect(service.adjustQuantity('ci-missing', 'hh1', 5, 'user-1')).rejects.toThrow(
'Cabinet item not found', 'Cabinet item not found',
); );
}); });
it('throws NotFoundError when adjust returns null', async () => { it('throws NotFoundError when adjust returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' }); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.adjustQuantity.mockResolvedValue(null); mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-1', 'hh1', 5)).rejects.toThrow( await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
'Cabinet item not found', 'Cabinet item not found',
); );
}); });
@ -274,26 +356,81 @@ describe(CabinetService.name, () => {
}); });
describe('delete', () => { describe('delete', () => {
it('soft deletes item', async () => { it('soft deletes item and logs DELETED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' }); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true }); mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
const result = await service.delete('ci-1', 'hh1'); const result = await service.delete('ci-1', 'hh1', 'user-1');
expect(result.isDeleted).toBe(true); expect(result.isDeleted).toBe(true);
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.DELETED,
quantity: -10,
quantityBefore: 10,
quantityAfter: 0,
}),
);
}); });
it('throws NotFoundError when item does not exist', async () => { it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null); mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.delete('ci-missing', 'hh1')).rejects.toThrow('Cabinet item not found'); await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
}); });
it('throws NotFoundError when softDelete returns null', async () => { it('throws NotFoundError when softDelete returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' }); mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 5, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.softDelete.mockResolvedValue(null); mockCabinetRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('ci-1', 'hh1')).rejects.toThrow('Cabinet item not found'); await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
});
});
describe('discard', () => {
it('discards item and logs DISCARDED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 20, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
expect(result.quantity).toBe(0);
expect(result.isDeleted).toBe(true);
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: CabinetEventType.DISCARDED,
quantity: -20,
quantityBefore: 20,
quantityAfter: 0,
reason: 'expired',
notes: 'smelled off',
}),
);
});
it('throws BadRequestError when quantity is zero', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 0, medicineId: 'med-1', medicineName: 'Test' });
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cannot discard an item with zero quantity',
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.discard('ci-missing', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when discard returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.discard.mockResolvedValue(null);
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cabinet item not found',
);
}); });
}); });
}); });

View file

@ -1,6 +1,11 @@
import type { CabinetRepository } from './cabinet.repository.js'; import type { CabinetRepository } from './cabinet.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js'; import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js'; import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import {
CabinetEventType,
CabinetEventSourceType,
} from '@meshitrack/shared';
import type { import type {
CreateCabinetItemInput, CreateCabinetItemInput,
UpdateCabinetItemInput, UpdateCabinetItemInput,
@ -12,17 +17,25 @@ interface Deps {
cabinetRepository: CabinetRepository; cabinetRepository: CabinetRepository;
medicinesRepository: MedicinesRepository; medicinesRepository: MedicinesRepository;
medicineProductsRepository: MedicineProductsRepository; medicineProductsRepository: MedicineProductsRepository;
cabinetEventsService: CabinetEventsService;
} }
export class CabinetService { export class CabinetService {
private readonly cabinetRepository: CabinetRepository; private readonly cabinetRepository: CabinetRepository;
private readonly medicinesRepository: MedicinesRepository; private readonly medicinesRepository: MedicinesRepository;
private readonly medicineProductsRepository: MedicineProductsRepository; private readonly medicineProductsRepository: MedicineProductsRepository;
private readonly cabinetEventsService: CabinetEventsService;
public constructor({ cabinetRepository, medicinesRepository, medicineProductsRepository }: Deps) { public constructor({
cabinetRepository,
medicinesRepository,
medicineProductsRepository,
cabinetEventsService,
}: Deps) {
this.cabinetRepository = cabinetRepository; this.cabinetRepository = cabinetRepository;
this.medicinesRepository = medicinesRepository; this.medicinesRepository = medicinesRepository;
this.medicineProductsRepository = medicineProductsRepository; this.medicineProductsRepository = medicineProductsRepository;
this.cabinetEventsService = cabinetEventsService;
} }
public async list(householdId: string, query: CabinetQueryInput) { public async list(householdId: string, query: CabinetQueryInput) {
@ -74,7 +87,7 @@ export class CabinetService {
concentrationUnit = product.concentrationUnit ?? undefined; concentrationUnit = product.concentrationUnit ?? undefined;
} }
return this.cabinetRepository.create( const item = await this.cabinetRepository.create(
{ {
...data, ...data,
medicineName: medicine.name, medicineName: medicine.name,
@ -88,23 +101,80 @@ export class CabinetService {
householdId, householdId,
createdBy, createdBy,
); );
await this.cabinetEventsService.logEvent({
householdId,
userId: createdBy,
cabinetItemId: item._id.toString(),
medicineId: data.medicineId,
medicineName: medicine.name,
eventType: CabinetEventType.PURCHASED,
quantity: data.quantity,
quantityBefore: 0,
quantityAfter: data.quantity,
unitPrice: data.unitPrice,
totalPrice: data.totalPrice,
currency: data.currency,
storeId: data.storeId,
storeName: data.storeName,
sourceType: CabinetEventSourceType.MANUAL,
});
return item;
} }
public async update(id: string, householdId: string, data: UpdateCabinetItemInput) { public async update(id: string, householdId: string, data: UpdateCabinetItemInput, userId: string) {
await this.getById(id, householdId); const existing = await this.getById(id, householdId);
const updated = await this.cabinetRepository.update(id, householdId, data); const updated = await this.cabinetRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Cabinet item not found'); if (!updated) throw new NotFoundError('Cabinet item not found');
if (data.quantity !== undefined && data.quantity !== existing.quantity) {
await this.cabinetEventsService.logEvent({
householdId,
userId,
cabinetItemId: id,
medicineId: existing.medicineId,
medicineName: existing.medicineName,
eventType: CabinetEventType.ADJUSTED,
quantity: data.quantity - existing.quantity,
quantityBefore: existing.quantity,
quantityAfter: data.quantity,
sourceType: CabinetEventSourceType.MANUAL,
});
}
return updated; return updated;
} }
public async adjustQuantity(id: string, householdId: string, delta: number) { public async adjustQuantity(
id: string,
householdId: string,
delta: number,
userId: string,
reason?: string,
) {
if (delta === 0) { if (delta === 0) {
throw new BadRequestError('Delta must be non-zero'); throw new BadRequestError('Delta must be non-zero');
} }
await this.getById(id, householdId); const existing = await this.getById(id, householdId);
const updated = await this.cabinetRepository.adjustQuantity(id, householdId, delta); const updated = await this.cabinetRepository.adjustQuantity(id, householdId, delta);
if (!updated) throw new NotFoundError('Cabinet item not found'); if (!updated) throw new NotFoundError('Cabinet item not found');
await this.cabinetEventsService.logEvent({
householdId,
userId,
cabinetItemId: id,
medicineId: existing.medicineId,
medicineName: existing.medicineName,
eventType: CabinetEventType.ADJUSTED,
quantity: delta,
quantityBefore: existing.quantity,
quantityAfter: updated.quantity,
reason,
sourceType: CabinetEventSourceType.MANUAL,
});
return updated; return updated;
} }
@ -112,10 +182,51 @@ export class CabinetService {
return this.cabinetRepository.findExpiringSoon(householdId, withinDays); return this.cabinetRepository.findExpiringSoon(householdId, withinDays);
} }
public async delete(id: string, householdId: string) { public async delete(id: string, householdId: string, userId: string) {
await this.getById(id, householdId); const existing = await this.getById(id, householdId);
const deleted = await this.cabinetRepository.softDelete(id, householdId); const deleted = await this.cabinetRepository.softDelete(id, householdId);
if (!deleted) throw new NotFoundError('Cabinet item not found'); if (!deleted) throw new NotFoundError('Cabinet item not found');
await this.cabinetEventsService.logEvent({
householdId,
userId,
cabinetItemId: id,
medicineId: existing.medicineId,
medicineName: existing.medicineName,
eventType: CabinetEventType.DELETED,
quantity: -existing.quantity,
quantityBefore: existing.quantity,
quantityAfter: 0,
sourceType: CabinetEventSourceType.MANUAL,
});
return deleted; return deleted;
} }
public async discard(id: string, householdId: string, userId: string, reason: string, notes?: string) {
const existing = await this.getById(id, householdId);
if (existing.quantity === 0) {
throw new BadRequestError('Cannot discard an item with zero quantity');
}
const discarded = await this.cabinetRepository.discard(id, householdId);
if (!discarded) throw new NotFoundError('Cabinet item not found');
await this.cabinetEventsService.logEvent({
householdId,
userId,
cabinetItemId: id,
medicineId: existing.medicineId,
medicineName: existing.medicineName,
eventType: CabinetEventType.DISCARDED,
quantity: -existing.quantity,
quantityBefore: existing.quantity,
quantityAfter: 0,
reason,
notes,
sourceType: CabinetEventSourceType.MANUAL,
});
return discarded;
}
} }

View file

@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockSave: vi.fn(),
}));
vi.mock('../../schemas/organizer-fill.schema.js', () => {
const chain = () => ({
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: mockFind,
});
const findOneChain = () => ({
lean: vi.fn().mockReturnThis(),
exec: mockFindOne,
});
const updateChain = () => ({
exec: mockFindOneAndUpdate,
});
class FakeModel {
data: unknown;
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
}
return { OrganizerFillModel: FakeModel };
});
import { OrganizerRepository } from './organizer.repository.js';
describe(OrganizerRepository.name, () => {
let repo: OrganizerRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new OrganizerRepository();
});
describe('findByHousehold', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'fill-1', regimenName: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'fill-2', regimenName: 'Evening' }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('fill-1').toString('base64');
const result = await repo.findByHousehold('hh1', 'user-1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `fill-${i}`, regimenName: `R${i}` }));
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
it('filters by regimenId', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { regimenId: 'reg-1', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by status', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { status: 'completed' as never, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('returns cursor as null when hasMore is false even with data', async () => {
const items = [{ _id: 'fill-1', regimenName: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('findById', () => {
it('returns fill by id and householdId', async () => {
const fill = { _id: 'fill-1', householdId: 'hh1', regimenName: 'Morning' };
mockFindOne.mockResolvedValue(fill);
const result = await repo.findById('fill-1', 'hh1');
expect(result).toEqual(fill);
});
it('returns null when not found', async () => {
mockFindOne.mockResolvedValue(null);
const result = await repo.findById('fill-missing', 'hh1');
expect(result).toBeNull();
});
});
describe('create', () => {
it('creates and returns organizer fill', async () => {
const data = {
householdId: 'hh1',
userId: 'user-1',
regimenId: 'reg-1',
regimenName: 'Morning',
numberOfDays: 7,
fillDate: new Date(),
items: [],
status: 'completed' as const,
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data as never);
expect(result).toBeTruthy();
expect(mockSave).toHaveBeenCalled();
});
});
describe('updateStatus', () => {
it('updates and returns fill with new status', async () => {
const updated = { _id: 'fill-1', status: 'reversed' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.updateStatus('fill-1', 'hh1', 'reversed' as never);
expect(result).toEqual(updated);
});
it('returns null when fill not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.updateStatus('fill-missing', 'hh1', 'reversed' as never);
expect(result).toBeNull();
});
});
});

View file

@ -0,0 +1,77 @@
import { OrganizerFillModel } from '../../schemas/organizer-fill.schema.js';
import type { OrganizerFillStatus } from '@meshitrack/shared';
interface OrganizerFillItemData {
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityTaken: number;
wasShort: boolean;
shortage: number;
deductions: { cabinetItemId: string; quantityTaken: number }[];
}
interface CreateOrganizerFillData {
householdId: string;
userId: string;
regimenId: string;
regimenName: string;
numberOfDays: number;
fillDate: Date;
items: OrganizerFillItemData[];
status: OrganizerFillStatus;
notes?: string;
}
interface FindByHouseholdQuery {
regimenId?: string;
status?: OrganizerFillStatus;
cursor?: string;
limit: number;
}
export class OrganizerRepository {
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
const filter: Record<string, unknown> = { householdId, userId };
if (query.regimenId) filter['regimenId'] = query.regimenId;
if (query.status) filter['status'] = query.status;
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $lt: id };
}
const limit = query.limit;
const items = await OrganizerFillModel.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findById(id: string, householdId: string) {
return OrganizerFillModel.findOne({ _id: id, householdId }).lean().exec();
}
public async create(data: CreateOrganizerFillData) {
const fill = new OrganizerFillModel(data);
const saved = await fill.save();
return saved.toObject();
}
public async updateStatus(id: string, householdId: string, status: OrganizerFillStatus) {
return OrganizerFillModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: { status } },
{ new: true, lean: true },
).exec();
}
}

View file

@ -0,0 +1,478 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { OrganizerFillStatus } from '@meshitrack/shared';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const { mockListFills, mockGetFillById, mockPreview, mockFill, mockUndoFill } = vi.hoisted(() => ({
mockListFills: vi.fn(),
mockGetFillById: vi.fn(),
mockPreview: vi.fn(),
mockFill: vi.fn(),
mockUndoFill: vi.fn(),
}));
vi.mock('./organizer.repository.js', () => ({
OrganizerRepository: class {
create = vi.fn();
update = vi.fn();
findById = vi.fn();
findByHousehold = vi.fn();
updateStatus = vi.fn();
},
}));
vi.mock('./organizer.service.js', () => ({
OrganizerService: class {
listFills = mockListFills;
getFillById = mockGetFillById;
preview = mockPreview;
fill = mockFill;
undoFill = mockUndoFill;
},
}));
vi.mock('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import organizerRoutes from './organizer.routes.js';
function makeFakeFill(overrides = {}) {
return {
_id: 'fill-1',
householdId: 'hh1',
userId: 'kc-1',
regimenId: 'reg-1',
regimenName: 'Daily Medications',
numberOfDays: 7,
fillDate: '2024-06-01T00:00:00.000Z',
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [
{ cabinetItemId: 'ci-1', quantityTaken: 7 },
],
},
],
status: OrganizerFillStatus.COMPLETED,
notes: null,
createdAt: '2024-06-01T00:00:00.000Z',
updatedAt: '2024-06-01T00:00:00.000Z',
...overrides,
};
}
function makeFakePreview(overrides = {}) {
return {
regimenName: 'Daily Medications',
numberOfDays: 7,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityAvailable: 30,
isShort: false,
shortage: 0,
cabinetBreakdown: [
{
cabinetItemId: 'ci-1',
expirationDate: '2025-12-31T00:00:00.000Z',
quantityToTake: 7,
},
],
},
],
canFillCompletely: true,
hasShortages: false,
...overrides,
};
}
describe('organizer.routes', () => {
let app: Awaited<ReturnType<typeof buildTestApp>>;
async function buildTestApp() {
const instance = Fastify({ logger: false });
instance.setValidatorCompiler(validatorCompiler);
instance.setSerializerCompiler(serializerCompiler);
await instance.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
await instance.register(authPlugin);
await instance.register(householdPlugin);
await instance.register(usersRoutes);
await instance.register(organizerRoutes);
await instance.ready();
return instance;
}
const authHeaders = { authorization: 'Bearer valid-token' };
beforeEach(async () => {
vi.clearAllMocks();
app = await buildTestApp();
});
afterEach(async () => {
if (app) await app.close();
});
describe('GET /api/v1/households/:householdId/organizer/fills', () => {
it('returns paginated fill list', async () => {
const fill = makeFakeFill();
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].regimenName).toBe('Daily Medications');
expect(body.data[0].status).toBe(OrganizerFillStatus.COMPLETED);
expect(body.pagination.hasMore).toBe(false);
});
it('passes query parameters to service', async () => {
mockListFills.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills?regimenId=reg-1&status=completed&limit=10',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockListFills).toHaveBeenCalledWith('hh1', 'kc-1', expect.objectContaining({
regimenId: 'reg-1',
status: OrganizerFillStatus.COMPLETED,
limit: 10,
}));
});
it('handles ObjectId and Date serialization in fill response', async () => {
const fill = makeFakeFill({
_id: { toString: () => 'fill-obj' },
fillDate: new Date('2024-06-01T00:00:00.000Z'),
createdAt: { toISOString: () => '2024-06-01T00:00:00.000Z' },
updatedAt: new Date('2024-06-02T00:00:00.000Z'),
});
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('fill-obj');
expect(body.data[0].fillDate).toBe('2024-06-01T00:00:00.000Z');
expect(body.data[0].createdAt).toBe('2024-06-01T00:00:00.000Z');
expect(body.data[0].updatedAt).toBe('2024-06-02T00:00:00.000Z');
});
it('omits notes from response when null', async () => {
const fill = makeFakeFill({ notes: null });
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].notes).toBeUndefined();
});
it('includes notes in response when present', async () => {
const fill = makeFakeFill({ notes: 'Refilled before holiday' });
mockListFills.mockResolvedValue({
data: [fill],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].notes).toBe('Refilled before holiday');
});
});
describe('GET /api/v1/households/:householdId/organizer/fills/:id', () => {
it('returns single fill by id', async () => {
const fill = makeFakeFill();
mockGetFillById.mockResolvedValue(fill);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills/fill-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body._id).toBe('fill-1');
expect(body.regimenId).toBe('reg-1');
expect(body.items).toHaveLength(1);
expect(body.items[0].deductions).toHaveLength(1);
expect(body.items[0].deductions[0].cabinetItemId).toBe('ci-1');
});
it('passes id and householdId to service correctly', async () => {
const fill = makeFakeFill();
mockGetFillById.mockResolvedValue(fill);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/organizer/fills/fill-42',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockGetFillById).toHaveBeenCalledWith('fill-42', 'hh1');
});
});
describe('POST /api/v1/households/:householdId/organizer/preview', () => {
it('returns preview result with items and shortage info', async () => {
const preview = makeFakePreview();
mockPreview.mockResolvedValue(preview);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/preview',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 7 },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.regimenName).toBe('Daily Medications');
expect(body.canFillCompletely).toBe(true);
expect(body.hasShortages).toBe(false);
expect(body.items).toHaveLength(1);
expect(body.items[0].medicineName).toBe('Metformin');
expect(body.items[0].cabinetBreakdown).toHaveLength(1);
});
it('passes regimenId and numberOfDays to service', async () => {
const preview = makeFakePreview();
mockPreview.mockResolvedValue(preview);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/preview',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-99', numberOfDays: 14 },
});
expect(res.statusCode).toBe(200);
expect(mockPreview).toHaveBeenCalledWith('hh1', 'kc-1', 'reg-99', 14);
});
it('returns preview with hasShortages=true and isShort items', async () => {
const preview = makeFakePreview({
canFillCompletely: false,
hasShortages: true,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 14,
quantityAvailable: 5,
isShort: true,
shortage: 9,
cabinetBreakdown: [
{
cabinetItemId: 'ci-1',
expirationDate: '2025-12-31T00:00:00.000Z',
quantityToTake: 5,
},
],
},
],
});
mockPreview.mockResolvedValue(preview);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/preview',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 14 },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.hasShortages).toBe(true);
expect(body.canFillCompletely).toBe(false);
expect(body.items[0].isShort).toBe(true);
expect(body.items[0].shortage).toBe(9);
});
});
describe('POST /api/v1/households/:householdId/organizer/fill', () => {
it('creates fill and returns 201 with COMPLETED status', async () => {
const fill = makeFakeFill({ status: OrganizerFillStatus.COMPLETED });
mockFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body._id).toBe('fill-1');
expect(body.status).toBe(OrganizerFillStatus.COMPLETED);
});
it('returns 201 with PARTIAL status when wasShort items exist', async () => {
const fill = makeFakeFill({
status: OrganizerFillStatus.PARTIAL,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 14,
quantityTaken: 5,
wasShort: true,
shortage: 9,
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 5 }],
},
],
});
mockFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 14, allowPartial: true },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.status).toBe(OrganizerFillStatus.PARTIAL);
expect(body.items[0].wasShort).toBe(true);
expect(body.items[0].shortage).toBe(9);
});
it('returns 400 on invalid body (missing regimenId)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { numberOfDays: 7, allowPartial: false },
});
expect(res.statusCode).toBe(400);
});
it('passes correct args (householdId, userId, body) to service', async () => {
const fill = makeFakeFill();
mockFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fill',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' },
});
expect(res.statusCode).toBe(201);
expect(mockFill).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({ regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' }),
);
});
});
describe('POST /api/v1/households/:householdId/organizer/fills/:id/undo', () => {
it('reverses fill and returns 200 with REVERSED status', async () => {
const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED });
mockUndoFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fills/fill-1/undo',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body._id).toBe('fill-1');
expect(body.status).toBe(OrganizerFillStatus.REVERSED);
});
it('passes correct args to service (householdId first, then id, then userId)', async () => {
const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED });
mockUndoFill.mockResolvedValue(fill);
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/organizer/fills/fill-42/undo',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockUndoFill).toHaveBeenCalledWith('hh1', 'fill-42', 'kc-1');
});
});
});

View file

@ -0,0 +1,203 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
OrganizerPreviewSchema,
OrganizerFillSchema,
OrganizerFillQuerySchema,
OrganizerPreviewResponseSchema,
OrganizerFillResponseSchema,
OrganizerFillListResponseSchema,
} from '@meshitrack/shared';
import { OrganizerRepository } from './organizer.repository.js';
import { OrganizerService } from './organizer.service.js';
type AnyFillDeduction = {
cabinetItemId: string;
quantityTaken: number;
};
type AnyFillItem = {
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityTaken: number;
wasShort: boolean;
shortage: number;
deductions: AnyFillDeduction[];
};
type AnyFillDoc = {
_id: string | { toString: () => string };
householdId: string;
userId: string;
regimenId: string;
regimenName: string;
numberOfDays: number;
fillDate: string | Date | { toISOString: () => string };
items: AnyFillItem[];
status: string;
notes?: string | null;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
}
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
if (v instanceof Date) return v.toISOString();
return v.toISOString();
}
function toFillResponse(doc: AnyFillDoc) {
return {
_id: toStr(doc._id),
householdId: doc.householdId,
userId: doc.userId,
regimenId: doc.regimenId,
regimenName: doc.regimenName,
numberOfDays: doc.numberOfDays,
fillDate: toIso(doc.fillDate),
items: doc.items.map((item) => ({
medicineId: item.medicineId,
medicineName: item.medicineName,
quantityNeeded: item.quantityNeeded,
quantityTaken: item.quantityTaken,
wasShort: item.wasShort,
shortage: item.shortage,
deductions: item.deductions.map((d) => ({
cabinetItemId: d.cabinetItemId,
quantityTaken: d.quantityTaken,
})),
})),
status: doc.status,
...(doc.notes ? { notes: doc.notes } : {}),
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
organizerRepository: OrganizerRepository;
organizerService: OrganizerService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
organizerRepository: asClass(OrganizerRepository, { lifetime: Lifetime.SINGLETON }),
organizerService: asClass(OrganizerService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
// GET /api/v1/households/:householdId/organizer/fills — list fill history
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/organizer/fills',
schema: {
params: householdParams,
querystring: OrganizerFillQuerySchema,
response: { 200: OrganizerFillListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const result = await service.listFills(
request.params.householdId,
request.user.keycloakId,
request.query,
);
return reply.send({
data: result.data.map(toFillResponse),
pagination: result.pagination,
});
},
});
// GET /api/v1/households/:householdId/organizer/fills/:id — get fill details
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/organizer/fills/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: OrganizerFillResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const fill = await service.getFillById(request.params.id, request.params.householdId);
return reply.send(toFillResponse(fill));
},
});
// POST /api/v1/households/:householdId/organizer/preview — preview fill
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/organizer/preview',
schema: {
params: householdParams,
body: OrganizerPreviewSchema,
response: { 200: OrganizerPreviewResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const preview = await service.preview(
request.params.householdId,
request.user.keycloakId,
request.body.regimenId,
request.body.numberOfDays,
);
return reply.send(preview);
},
});
// POST /api/v1/households/:householdId/organizer/fill — execute fill
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/organizer/fill',
schema: {
params: householdParams,
body: OrganizerFillSchema,
response: { 201: OrganizerFillResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const fill = await service.fill(
request.params.householdId,
request.user.keycloakId,
request.body,
);
return reply.status(201).send(toFillResponse(fill));
},
});
// POST /api/v1/households/:householdId/organizer/fills/:id/undo — reverse fill
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/organizer/fills/:id/undo',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: OrganizerFillResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const fill = await service.undoFill(
request.params.householdId,
request.params.id,
request.user.keycloakId,
);
return reply.send(toFillResponse(fill));
},
});
},
{
name: 'organizer-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,647 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
CabinetEventType,
CabinetEventSourceType,
OrganizerFillStatus,
DosageFrequency,
} from '@meshitrack/shared';
const { mockSession } = vi.hoisted(() => ({
mockSession: {
startTransaction: vi.fn(),
commitTransaction: vi.fn(),
abortTransaction: vi.fn(),
endSession: vi.fn(),
},
}));
vi.mock('mongoose', () => {
return { default: { startSession: vi.fn().mockResolvedValue(mockSession) } };
});
import { OrganizerService } from './organizer.service.js';
describe(OrganizerService.name, () => {
const mockOrganizerRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
updateStatus: vi.fn(),
};
const mockRegimensService = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getActiveByUser: vi.fn(),
calculateBurnRates: vi.fn(),
};
const mockCabinetRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
getAggregateSummary: vi.fn(),
create: vi.fn(),
update: vi.fn(),
adjustQuantity: vi.fn(),
findExpiringSoon: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
discard: vi.fn(),
findActiveByMedicineForFEFO: vi.fn(),
};
const mockCabinetEventsService = {
logEvent: vi.fn(),
logEvents: vi.fn(),
listEvents: vi.fn(),
getEventsByItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPrices: vi.fn(),
};
let service: OrganizerService;
beforeEach(() => {
vi.clearAllMocks();
service = new OrganizerService({
organizerRepository: mockOrganizerRepo as never,
regimensService: mockRegimensService as never,
cabinetRepository: mockCabinetRepo as never,
cabinetEventsService: mockCabinetEventsService as never,
});
});
describe('listFills', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockOrganizerRepo.findByHousehold.mockResolvedValue(result);
const response = await service.listFills('hh1', 'user-1', { limit: 20 });
expect(response).toEqual(result);
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
});
});
describe('getFillById', () => {
it('returns fill when found', async () => {
const fill = { _id: 'fill-1', regimenName: 'Morning' };
mockOrganizerRepo.findById.mockResolvedValue(fill);
const result = await service.getFillById('fill-1', 'hh1');
expect(result).toEqual(fill);
});
it('throws NotFoundError when not found', async () => {
mockOrganizerRepo.findById.mockResolvedValue(null);
await expect(service.getFillById('fill-missing', 'hh1')).rejects.toThrow(
'Organizer fill not found',
);
});
});
describe('preview', () => {
const makeRegimen = (overrides = {}) => ({
_id: 'reg-1',
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
},
],
...overrides,
});
it('returns preview with no shortages when stock is sufficient', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.regimenName).toBe('Morning Routine');
expect(result.numberOfDays).toBe(7);
expect(result.canFillCompletely).toBe(true);
expect(result.hasShortages).toBe(false);
expect(result.items).toHaveLength(1);
expect(result.items[0].medicineId).toBe('med-1');
expect(result.items[0].quantityNeeded).toBe(7);
expect(result.items[0].quantityAvailable).toBe(30);
expect(result.items[0].isShort).toBe(false);
expect(result.items[0].shortage).toBe(0);
expect(result.items[0].cabinetBreakdown).toHaveLength(1);
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7);
expect(result.items[0].cabinetBreakdown[0].quantityBefore).toBe(30);
});
it('returns preview with shortages when stock is insufficient', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.canFillCompletely).toBe(false);
expect(result.hasShortages).toBe(true);
expect(result.items[0].isShort).toBe(true);
expect(result.items[0].shortage).toBe(4);
expect(result.items[0].quantityAvailable).toBe(3);
});
it('handles FEFO allocation across multiple cabinet items', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: new Date('2026-06-01') },
{ _id: { toString: () => 'ci-2' }, quantity: 5, expirationDate: new Date('2026-12-01') },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.canFillCompletely).toBe(true);
expect(result.items[0].cabinetBreakdown).toHaveLength(2);
expect(result.items[0].cabinetBreakdown[0].cabinetItemId).toBe('ci-1');
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(3);
expect(result.items[0].cabinetBreakdown[1].cabinetItemId).toBe('ci-2');
expect(result.items[0].cabinetBreakdown[1].quantityToTake).toBe(4);
});
it('skips AS_NEEDED medications', async () => {
mockRegimensService.getById.mockResolvedValue(
makeRegimen({
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
customFrequencyPerDay: null,
},
{
medicineId: 'med-2',
medicineName: 'Ibuprofen',
dosage: 2,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
},
],
}),
);
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-2' }, quantity: 20, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.items).toHaveLength(1);
expect(result.items[0].medicineId).toBe('med-2');
});
it('throws BadRequestError when regimen is not active', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen({ isActive: false }));
await expect(service.preview('hh1', 'user-1', 'reg-1', 7)).rejects.toThrow(
'Regimen is not active',
);
});
it('handles null expirationDate in cabinet items', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 10, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.items[0].cabinetBreakdown[0].expirationDate).toBeNull();
});
it('handles empty cabinet (no items available)', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
expect(result.canFillCompletely).toBe(false);
expect(result.hasShortages).toBe(true);
expect(result.items[0].quantityAvailable).toBe(0);
expect(result.items[0].shortage).toBe(7);
expect(result.items[0].cabinetBreakdown).toHaveLength(0);
});
it('uses customFrequencyPerDay when present', async () => {
mockRegimensService.getById.mockResolvedValue(
makeRegimen({
medications: [
{
medicineId: 'med-1',
medicineName: 'Custom Med',
dosage: 2,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 3,
},
],
}),
);
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 100, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
// dosage(2) * customFrequencyPerDay(3) * numberOfDays(7) = 42
expect(result.items[0].quantityNeeded).toBe(42);
});
it('stops taking from cabinet items once remaining is zero', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 7, expirationDate: null },
{ _id: { toString: () => 'ci-2' }, quantity: 10, expirationDate: null },
]);
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
// Needs 7, first item has 7 -- second item should not be touched
expect(result.items[0].cabinetBreakdown).toHaveLength(1);
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7);
});
});
describe('fill', () => {
const fillInput = {
regimenId: 'reg-1',
numberOfDays: 7,
allowPartial: false,
notes: 'Weekly fill',
};
const makeRegimen = () => ({
_id: 'reg-1',
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
},
],
});
it('executes fill successfully with no shortages', async () => {
// preview dependencies
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
householdId: 'hh1',
userId: 'user-1',
regimenId: 'reg-1',
regimenName: 'Morning Routine',
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.fill('hh1', 'user-1', fillInput);
expect(result.status).toBe(OrganizerFillStatus.COMPLETED);
expect(mockSession.startTransaction).toHaveBeenCalled();
expect(mockSession.commitTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', -7);
expect(mockCabinetEventsService.logEvents).toHaveBeenCalled();
});
it('throws BadRequestError when shortages exist and allowPartial is false', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
]);
await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow(
'Not enough stock to fill completely',
);
});
it('allows partial fill when allowPartial is true', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 0 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.PARTIAL,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true });
expect(result.status).toBe(OrganizerFillStatus.PARTIAL);
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: OrganizerFillStatus.PARTIAL,
}),
);
});
it('creates CONSUMED events for each deduction', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.fill('hh1', 'user-1', fillInput);
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: CabinetEventType.CONSUMED,
quantity: -7,
quantityBefore: 30,
quantityAfter: 23,
sourceType: CabinetEventSourceType.ORGANIZER_FILL,
sourceId: 'fill-1',
}),
);
});
it('handles adjustQuantity returning null (skips deduction)', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.PARTIAL,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true });
expect(result).toBeTruthy();
// No events logged since adjustQuantity returned null
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(0);
});
it('aborts transaction and rethrows on error', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure'));
await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow('DB failure');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('creates fill with notes when provided', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.fill('hh1', 'user-1', fillInput);
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
notes: 'Weekly fill',
}),
);
});
it('sets COMPLETED status when no items have shortages', async () => {
mockRegimensService.getById.mockResolvedValue(makeRegimen());
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
]);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockOrganizerRepo.create.mockResolvedValue({
_id: { toString: () => 'fill-1' },
status: OrganizerFillStatus.COMPLETED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.fill('hh1', 'user-1', fillInput);
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: OrganizerFillStatus.COMPLETED,
}),
);
});
});
describe('undoFill', () => {
const makeFill = (overrides = {}) => ({
_id: 'fill-1',
householdId: 'hh1',
userId: 'user-1',
status: OrganizerFillStatus.COMPLETED,
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }],
},
],
...overrides,
});
it('reverses fill and restores quantities', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...makeFill(),
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
const result = await service.undoFill('hh1', 'fill-1', 'user-1');
expect(result.status).toBe(OrganizerFillStatus.REVERSED);
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', 7);
expect(mockOrganizerRepo.updateStatus).toHaveBeenCalledWith(
'fill-1',
'hh1',
OrganizerFillStatus.REVERSED,
);
expect(mockSession.startTransaction).toHaveBeenCalled();
expect(mockSession.commitTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('creates RESTORED events for each deduction', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...makeFill(),
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.undoFill('hh1', 'fill-1', 'user-1');
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
householdId: 'hh1',
userId: 'user-1',
cabinetItemId: 'ci-1',
medicineId: 'med-1',
medicineName: 'Metformin',
eventType: CabinetEventType.RESTORED,
quantity: 7,
quantityBefore: 23,
quantityAfter: 30,
sourceType: CabinetEventSourceType.ORGANIZER_UNDO,
sourceId: 'fill-1',
}),
);
});
it('throws BadRequestError when fill is already reversed', async () => {
mockOrganizerRepo.findById.mockResolvedValue(
makeFill({ status: OrganizerFillStatus.REVERSED }),
);
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow(
'Fill has already been reversed',
);
});
it('throws NotFoundError when fill does not exist', async () => {
mockOrganizerRepo.findById.mockResolvedValue(null);
await expect(service.undoFill('hh1', 'fill-missing', 'user-1')).rejects.toThrow(
'Organizer fill not found',
);
});
it('aborts transaction and rethrows on error', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure'));
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow('DB failure');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('handles cabinet item not found when restoring (uses 0 as quantityBefore)', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
mockCabinetRepo.findById.mockResolvedValue(null);
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 7 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...makeFill(),
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.undoFill('hh1', 'fill-1', 'user-1');
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events[0].quantityBefore).toBe(0);
expect(events[0].quantityAfter).toBe(7);
});
it('throws NotFoundError when updateStatus returns null', async () => {
mockOrganizerRepo.findById.mockResolvedValue(makeFill({ items: [] }));
mockOrganizerRepo.updateStatus.mockResolvedValue(null);
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow(
'Organizer fill not found',
);
});
it('restores multiple deductions across items', async () => {
const fill = makeFill({
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [
{ cabinetItemId: 'ci-1', quantityTaken: 4 },
{ cabinetItemId: 'ci-2', quantityTaken: 3 },
],
},
{
medicineId: 'med-2',
medicineName: 'Aspirin',
quantityNeeded: 14,
quantityTaken: 14,
wasShort: false,
shortage: 0,
deductions: [{ cabinetItemId: 'ci-3', quantityTaken: 14 }],
},
],
});
mockOrganizerRepo.findById.mockResolvedValue(fill);
mockCabinetRepo.findById.mockResolvedValue({ quantity: 10 });
mockCabinetRepo.adjustQuantity.mockResolvedValue({ quantity: 20 });
mockOrganizerRepo.updateStatus.mockResolvedValue({
...fill,
status: OrganizerFillStatus.REVERSED,
});
mockCabinetEventsService.logEvents.mockResolvedValue([]);
await service.undoFill('hh1', 'fill-1', 'user-1');
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledTimes(3);
expect(mockCabinetRepo.findById).toHaveBeenCalledTimes(3);
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
expect(events).toHaveLength(3);
});
});
});

View file

@ -0,0 +1,290 @@
import mongoose from 'mongoose';
import type { OrganizerRepository } from './organizer.repository.js';
import type { RegimensService } from '../regimens/regimens.service.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import type { OrganizerFillInput, OrganizerFillQueryInput } from '@meshitrack/shared';
import {
CabinetEventType,
CabinetEventSourceType,
OrganizerFillStatus,
DosageFrequency,
calculateQuantityNeeded,
} from '@meshitrack/shared';
import type { CreateCabinetEventData } from '../cabinet-events/cabinet-events.repository.js';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
interface Deps {
organizerRepository: OrganizerRepository;
regimensService: RegimensService;
cabinetRepository: CabinetRepository;
cabinetEventsService: CabinetEventsService;
}
interface PreviewDeduction {
cabinetItemId: string;
expirationDate: string | null;
quantityToTake: number;
quantityBefore: number;
}
interface PreviewItem {
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityAvailable: number;
isShort: boolean;
shortage: number;
cabinetBreakdown: PreviewDeduction[];
}
export class OrganizerService {
private readonly organizerRepository: OrganizerRepository;
private readonly regimensService: RegimensService;
private readonly cabinetRepository: CabinetRepository;
private readonly cabinetEventsService: CabinetEventsService;
public constructor({
organizerRepository,
regimensService,
cabinetRepository,
cabinetEventsService,
}: Deps) {
this.organizerRepository = organizerRepository;
this.regimensService = regimensService;
this.cabinetRepository = cabinetRepository;
this.cabinetEventsService = cabinetEventsService;
}
public async listFills(householdId: string, userId: string, query: OrganizerFillQueryInput) {
return this.organizerRepository.findByHousehold(householdId, userId, query);
}
public async getFillById(id: string, householdId: string) {
const fill = await this.organizerRepository.findById(id, householdId);
if (!fill) throw new NotFoundError('Organizer fill not found');
return fill;
}
public async preview(householdId: string, userId: string, regimenId: string, numberOfDays: number) {
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
if (!regimen.isActive) {
throw new BadRequestError('Regimen is not active');
}
const items: PreviewItem[] = [];
let hasShortages = false;
for (const med of regimen.medications) {
if (med.frequency === DosageFrequency.AS_NEEDED) continue;
const quantityNeeded = calculateQuantityNeeded(
med.dosage,
med.frequency as DosageFrequency,
numberOfDays,
med.customFrequencyPerDay ?? undefined,
);
// Get cabinet items for FEFO allocation
const cabinetItems = await this.cabinetRepository.findActiveByMedicineForFEFO(
householdId,
med.medicineId,
);
let quantityAvailable = 0;
const breakdown: PreviewDeduction[] = [];
let remaining = quantityNeeded;
for (const ci of cabinetItems) {
if (remaining <= 0) break;
const toTake = Math.min(remaining, ci.quantity);
quantityAvailable += ci.quantity;
breakdown.push({
cabinetItemId: ci._id.toString(),
expirationDate: ci.expirationDate ? ci.expirationDate.toISOString() : null,
quantityToTake: toTake,
quantityBefore: ci.quantity,
});
remaining -= toTake;
}
const isShort = remaining > 0;
if (isShort) hasShortages = true;
items.push({
medicineId: med.medicineId,
medicineName: med.medicineName,
quantityNeeded,
quantityAvailable,
isShort,
shortage: Math.max(0, remaining),
cabinetBreakdown: breakdown,
});
}
return {
regimenName: regimen.name,
numberOfDays,
items,
canFillCompletely: !hasShortages,
hasShortages,
};
}
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
const previewResult = await this.preview(householdId, userId, input.regimenId, input.numberOfDays);
if (!input.allowPartial && previewResult.hasShortages) {
throw new BadRequestError(
'Not enough stock to fill completely. Use allowPartial=true to allow partial fills.',
);
}
const regimen = await this.regimensService.getById(input.regimenId, householdId, userId);
// Execute deductions within a transaction
const session = await mongoose.startSession();
const events: CreateCabinetEventData[] = [];
const fillItems = [];
try {
session.startTransaction();
for (const previewItem of previewResult.items) {
const deductions = [];
let totalTaken = 0;
for (const bd of previewItem.cabinetBreakdown) {
const updated = await this.cabinetRepository.adjustQuantity(
bd.cabinetItemId,
householdId,
-bd.quantityToTake,
);
if (updated) {
deductions.push({
cabinetItemId: bd.cabinetItemId,
quantityTaken: bd.quantityToTake,
});
totalTaken += bd.quantityToTake;
events.push({
householdId,
userId,
cabinetItemId: bd.cabinetItemId,
medicineId: previewItem.medicineId,
medicineName: previewItem.medicineName,
eventType: CabinetEventType.CONSUMED,
quantity: -bd.quantityToTake,
quantityBefore: bd.quantityBefore,
quantityAfter: bd.quantityBefore - bd.quantityToTake,
sourceType: CabinetEventSourceType.ORGANIZER_FILL,
sourceId: '', // Will be set after fill is created
});
}
}
const wasShort = totalTaken < previewItem.quantityNeeded;
fillItems.push({
medicineId: previewItem.medicineId,
medicineName: previewItem.medicineName,
quantityNeeded: previewItem.quantityNeeded,
quantityTaken: totalTaken,
wasShort,
shortage: previewItem.quantityNeeded - totalTaken,
deductions,
});
}
const hasAnyShortage = fillItems.some((fi) => fi.wasShort);
const fillRecord = await this.organizerRepository.create({
householdId,
userId,
regimenId: input.regimenId,
regimenName: regimen.name,
numberOfDays: input.numberOfDays,
fillDate: new Date(),
items: fillItems,
status: hasAnyShortage ? OrganizerFillStatus.PARTIAL : OrganizerFillStatus.COMPLETED,
notes: input.notes,
});
await session.commitTransaction();
// Set sourceId on events and log them
const fillId = fillRecord._id.toString();
for (const event of events) {
event.sourceId = fillId;
}
await this.cabinetEventsService.logEvents(events);
return fillRecord;
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
}
public async undoFill(householdId: string, fillId: string, userId: string) {
const fill = await this.getFillById(fillId, householdId);
if (fill.status === OrganizerFillStatus.REVERSED) {
throw new BadRequestError('Fill has already been reversed');
}
const session = await mongoose.startSession();
const events: CreateCabinetEventData[] = [];
try {
session.startTransaction();
for (const item of fill.items) {
for (const deduction of item.deductions) {
// Get current quantity before restoring
const current = await this.cabinetRepository.findById(deduction.cabinetItemId, householdId);
const quantityBefore = current?.quantity ?? 0;
await this.cabinetRepository.adjustQuantity(
deduction.cabinetItemId,
householdId,
deduction.quantityTaken,
);
events.push({
householdId,
userId,
cabinetItemId: deduction.cabinetItemId,
medicineId: item.medicineId,
medicineName: item.medicineName,
eventType: CabinetEventType.RESTORED,
quantity: deduction.quantityTaken,
quantityBefore,
quantityAfter: quantityBefore + deduction.quantityTaken,
sourceType: CabinetEventSourceType.ORGANIZER_UNDO,
sourceId: fillId,
});
}
}
const updated = await this.organizerRepository.updateStatus(
fillId,
householdId,
OrganizerFillStatus.REVERSED,
);
await session.commitTransaction();
await this.cabinetEventsService.logEvents(events);
if (!updated) throw new NotFoundError('Organizer fill not found');
return updated;
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
}
}

View file

@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockSave: vi.fn(),
}));
vi.mock('../../schemas/regimen.schema.js', () => {
const chain = () => ({
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: mockFind,
});
const findOneChain = () => ({
lean: vi.fn().mockReturnThis(),
exec: mockFindOne,
});
const updateChain = () => ({
exec: mockFindOneAndUpdate,
});
class FakeModel {
data: unknown;
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
}
return { RegimenModel: FakeModel };
});
import { RegimensRepository } from './regimens.repository.js';
describe(RegimensRepository.name, () => {
let repo: RegimensRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new RegimensRepository();
});
describe('findByHousehold', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'reg-1', name: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('handles cursor-based pagination', async () => {
const items = [{ _id: 'reg-2', name: 'Evening' }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('reg-1').toString('base64');
const result = await repo.findByHousehold('hh1', 'user-1', { cursor, limit: 20 });
expect(result.data).toEqual(items);
expect(result.pagination.hasMore).toBe(false);
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `reg-${i}`, name: `Reg ${i}` }));
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('returns null cursor when no data', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
it('filters by isActive', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { isActive: true, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('does not add isActive filter when undefined', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('returns cursor only when hasMore is true', async () => {
const items = [{ _id: 'reg-1', name: 'Morning' }];
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('findById', () => {
it('returns regimen by id, householdId, and userId', async () => {
const regimen = { _id: 'reg-1', householdId: 'hh1', userId: 'user-1', name: 'Morning' };
mockFindOne.mockResolvedValue(regimen);
const result = await repo.findById('reg-1', 'hh1', 'user-1');
expect(result).toEqual(regimen);
});
it('returns null when not found', async () => {
mockFindOne.mockResolvedValue(null);
const result = await repo.findById('reg-missing', 'hh1', 'user-1');
expect(result).toBeNull();
});
});
describe('findActiveByUser', () => {
it('returns active regimens for user', async () => {
const regimens = [
{ _id: 'reg-1', isActive: true },
{ _id: 'reg-2', isActive: true },
];
mockFind.mockResolvedValue(regimens);
const result = await repo.findActiveByUser('hh1', 'user-1');
expect(result).toEqual(regimens);
});
it('returns empty array when no active regimens', async () => {
mockFind.mockResolvedValue([]);
const result = await repo.findActiveByUser('hh1', 'user-1');
expect(result).toEqual([]);
});
});
describe('create', () => {
it('creates and returns regimen', async () => {
const data = {
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
},
],
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data, 'hh1', 'user-1', 'user-1');
expect(result).toBeTruthy();
expect(mockSave).toHaveBeenCalled();
});
});
describe('update', () => {
it('updates and returns regimen', async () => {
const updated = { _id: 'reg-1', name: 'Updated' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.update('reg-1', 'hh1', 'user-1', { name: 'Updated' });
expect(result).toEqual(updated);
});
it('returns null when regimen not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' });
expect(result).toBeNull();
});
});
describe('softDelete', () => {
it('soft deletes and returns regimen', async () => {
const deleted = { _id: 'reg-1', isDeleted: true };
mockFindOneAndUpdate.mockResolvedValue(deleted);
const result = await repo.softDelete('reg-1', 'hh1', 'user-1');
expect(result).toEqual(deleted);
});
it('returns null when regimen not found', async () => {
mockFindOneAndUpdate.mockResolvedValue(null);
const result = await repo.softDelete('reg-missing', 'hh1', 'user-1');
expect(result).toBeNull();
});
});
});

View file

@ -0,0 +1,92 @@
import { RegimenModel } from '../../schemas/regimen.schema.js';
interface FindByHouseholdQuery {
isActive?: boolean;
cursor?: string;
limit: number;
}
interface RegimenMedicationData {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
dosage: number;
dosageUnit: string;
frequency: string;
customFrequencyPerDay?: number;
timeOfDay?: string;
instructions?: string;
}
interface CreateRegimenData {
name: string;
isActive: boolean;
medications: RegimenMedicationData[];
}
interface UpdateRegimenData {
name?: string;
isActive?: boolean;
medications?: RegimenMedicationData[];
}
export class RegimensRepository {
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
const filter: Record<string, unknown> = { householdId, userId, isDeleted: false };
if (query.isActive !== undefined) filter['isActive'] = query.isActive;
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $gt: id };
}
const limit = query.limit;
const items = await RegimenModel.find(filter)
.sort({ _id: 1 })
.limit(limit + 1)
.lean()
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findById(id: string, householdId: string, userId: string) {
return RegimenModel.findOne({ _id: id, householdId, userId, isDeleted: false }).lean().exec();
}
public async findActiveByUser(householdId: string, userId: string) {
return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
.lean()
.exec();
}
public async create(data: CreateRegimenData, householdId: string, userId: string, createdBy: string) {
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
const saved = await regimen.save();
return saved.toObject();
}
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenData) {
return RegimenModel.findOneAndUpdate(
{ _id: id, householdId, userId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
}
public async softDelete(id: string, householdId: string, userId: string) {
return RegimenModel.findOneAndUpdate(
{ _id: id, householdId, userId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
}
}

View file

@ -0,0 +1,488 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { DosageFrequency, DosageUnit, StrengthUnit, MedicineForm } from '@meshitrack/shared';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } = vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockCalculateBurnRates: vi.fn(),
}));
vi.mock('./regimens.repository.js', () => ({
RegimensRepository: class {
findByHousehold = vi.fn();
findById = vi.fn();
findActiveByUser = vi.fn();
create = vi.fn();
update = vi.fn();
softDelete = vi.fn();
delete = vi.fn();
},
}));
vi.mock('./regimens.service.js', () => ({
RegimensService: class {
list = mockList;
getById = mockGetById;
create = mockCreate;
update = mockUpdate;
delete = mockDelete;
getActiveByUser = vi.fn();
calculateBurnRates = mockCalculateBurnRates;
},
}));
vi.mock('../users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../plugins/auth.plugin.js';
import householdPlugin from '../../plugins/household.plugin.js';
import usersRoutes from '../users/users.routes.js';
import regimensRoutes from './regimens.routes.js';
function makeFakeRegimen(overrides = {}) {
return {
_id: 'reg-1',
householdId: 'hh1',
userId: 'kc-1',
name: 'Daily Medications',
isActive: true,
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: StrengthUnit.MG,
medicineForm: MedicineForm.TABLET,
dosage: 1,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.DAILY,
customFrequencyPerDay: null,
timeOfDay: null,
instructions: null,
},
],
createdBy: 'kc-1',
createdAt: '2024-06-01T00:00:00.000Z',
updatedAt: '2024-06-01T00:00:00.000Z',
...overrides,
};
}
const validPostBody = {
name: 'Daily Medications',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
},
],
};
describe('regimens.routes', () => {
let app: Awaited<ReturnType<typeof buildTestApp>>;
async function buildTestApp() {
const instance = Fastify({ logger: false });
instance.setValidatorCompiler(validatorCompiler);
instance.setSerializerCompiler(serializerCompiler);
await instance.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
await instance.register(authPlugin);
await instance.register(householdPlugin);
await instance.register(usersRoutes);
await instance.register(regimensRoutes);
await instance.ready();
return instance;
}
const authHeaders = { authorization: 'Bearer valid-token' };
beforeEach(async () => {
vi.clearAllMocks();
app = await buildTestApp();
});
afterEach(async () => {
if (app) await app.close();
});
describe('GET /api/v1/households/:householdId/regimens', () => {
it('returns paginated list of regimens', async () => {
const regimen = makeFakeRegimen();
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe('Daily Medications');
expect(body.data[0].isActive).toBe(true);
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const regimen = makeFakeRegimen({
_id: { toString: () => 'reg-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
});
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('reg-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].updatedAt).toBe('2024-01-02T00:00:00.000Z');
});
it('handles actual Date objects for createdAt/updatedAt', async () => {
const regimen = makeFakeRegimen({
createdAt: new Date('2024-03-01T00:00:00.000Z'),
updatedAt: new Date('2024-03-02T00:00:00.000Z'),
});
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].createdAt).toBe('2024-03-01T00:00:00.000Z');
expect(body.data[0].updatedAt).toBe('2024-03-02T00:00:00.000Z');
});
it('includes optional medication fields when present', async () => {
const regimen = makeFakeRegimen({
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: StrengthUnit.MG,
medicineForm: MedicineForm.TABLET,
dosage: 2,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 4,
timeOfDay: 'morning',
instructions: 'Take with food',
},
],
});
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
const med = body.data[0].medications[0];
expect(med.customFrequencyPerDay).toBe(4);
expect(med.timeOfDay).toBe('morning');
expect(med.instructions).toBe('Take with food');
});
it('omits null optional medication fields from response', async () => {
const regimen = makeFakeRegimen();
mockList.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
const med = body.data[0].medications[0];
expect(med.customFrequencyPerDay).toBeUndefined();
expect(med.timeOfDay).toBeUndefined();
expect(med.instructions).toBeUndefined();
});
it('passes query parameters to service', async () => {
mockList.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens?isActive=true&limit=5',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(mockList).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({ isActive: true, limit: 5 }),
);
});
});
describe('GET /api/v1/households/:householdId/regimens/burn-rate', () => {
it('returns burn rate data array', async () => {
const burnRateItem = {
medicineId: 'med-1',
medicineName: 'Metformin',
dailyConsumption: 1,
totalInCabinet: 30,
daysUntilEmpty: 30,
earliestExpiry: '2025-01-01T00:00:00.000Z',
avgUnitPrice: 2.5,
projectedDailyCost: 2.5,
projectedMonthlyCost: 75,
projectedYearlyCost: 912.5,
currency: 'USD',
};
mockCalculateBurnRates.mockResolvedValue([burnRateItem]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/burn-rate',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].medicineId).toBe('med-1');
expect(body.data[0].medicineName).toBe('Metformin');
expect(body.data[0].dailyConsumption).toBe(1);
expect(body.data[0].daysUntilEmpty).toBe(30);
expect(body.data[0].currency).toBe('USD');
});
it('returns empty array when no active regimens', async () => {
mockCalculateBurnRates.mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/burn-rate',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(0);
});
it('handles null monetary fields correctly', async () => {
const burnRateItem = {
medicineId: 'med-1',
medicineName: 'Metformin',
dailyConsumption: 1,
totalInCabinet: 30,
daysUntilEmpty: 30,
earliestExpiry: null,
avgUnitPrice: null,
projectedDailyCost: null,
projectedMonthlyCost: null,
projectedYearlyCost: null,
currency: null,
};
mockCalculateBurnRates.mockResolvedValue([burnRateItem]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/burn-rate',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0].avgUnitPrice).toBeNull();
expect(body.data[0].projectedDailyCost).toBeNull();
expect(body.data[0].projectedMonthlyCost).toBeNull();
expect(body.data[0].projectedYearlyCost).toBeNull();
expect(body.data[0].currency).toBeNull();
});
});
describe('GET /api/v1/households/:householdId/regimens/:id', () => {
it('returns single regimen by id', async () => {
mockGetById.mockResolvedValue(makeFakeRegimen());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.name).toBe('Daily Medications');
expect(body._id).toBe('reg-1');
});
it('passes id and householdId to service', async () => {
mockGetById.mockResolvedValue(makeFakeRegimen());
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
});
expect(mockGetById).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1');
});
});
describe('POST /api/v1/households/:householdId/regimens', () => {
it('creates regimen and returns 201', async () => {
mockCreate.mockResolvedValue(makeFakeRegimen());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
payload: validPostBody,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.name).toBe('Daily Medications');
expect(body._id).toBe('reg-1');
});
it('returns 400 on invalid body with empty medications array', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
payload: { name: 'Bad Regimen', isActive: true, medications: [] },
});
expect(res.statusCode).toBe(400);
});
it('returns 400 on invalid body with missing name', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/regimens',
headers: authHeaders,
payload: {
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet',
frequency: 'daily',
},
],
},
});
expect(res.statusCode).toBe(400);
});
});
describe('PATCH /api/v1/households/:householdId/regimens/:id', () => {
it('updates regimen and returns 200', async () => {
mockUpdate.mockResolvedValue(makeFakeRegimen({ name: 'Updated Regimen' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
payload: { name: 'Updated Regimen' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated Regimen');
});
it('passes id, householdId, and body to service', async () => {
mockUpdate.mockResolvedValue(makeFakeRegimen({ isActive: false }));
await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
payload: { isActive: false },
});
expect(mockUpdate).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1', expect.objectContaining({ isActive: false }));
});
});
describe('DELETE /api/v1/households/:householdId/regimens/:id', () => {
it('deletes regimen and returns 204', async () => {
mockDelete.mockResolvedValue(undefined);
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/regimens/reg-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
expect(mockDelete).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1');
});
});
});

View file

@ -0,0 +1,211 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreateRegimenSchema,
UpdateRegimenSchema,
RegimenQuerySchema,
RegimenResponseSchema,
RegimenListResponseSchema,
BurnRateResponseSchema,
} from '@meshitrack/shared';
import { RegimensRepository } from './regimens.repository.js';
import { RegimensService } from './regimens.service.js';
type AnyRegimenMedication = {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
dosage: number;
dosageUnit: string;
frequency: string;
customFrequencyPerDay?: number | null;
timeOfDay?: string | null;
instructions?: string | null;
};
type AnyRegimenDoc = {
_id: string | { toString: () => string };
householdId: string;
userId: string;
name: string;
isActive: boolean;
medications: AnyRegimenMedication[];
createdBy: string;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
}
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
if (v instanceof Date) return v.toISOString();
return v.toISOString();
}
function toRegimenResponse(doc: AnyRegimenDoc) {
return {
_id: toStr(doc._id),
householdId: doc.householdId,
userId: doc.userId,
name: doc.name,
isActive: doc.isActive,
medications: doc.medications.map((med) => ({
medicineId: med.medicineId,
medicineName: med.medicineName,
medicineStrength: med.medicineStrength,
medicineStrengthUnit: med.medicineStrengthUnit,
medicineForm: med.medicineForm,
dosage: med.dosage,
dosageUnit: med.dosageUnit,
frequency: med.frequency,
...(med.customFrequencyPerDay != null ? { customFrequencyPerDay: med.customFrequencyPerDay } : {}),
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
...(med.instructions ? { instructions: med.instructions } : {}),
})),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
regimensRepository: RegimensRepository;
regimensService: RegimensService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
regimensRepository: asClass(RegimensRepository, { lifetime: Lifetime.SINGLETON }),
regimensService: asClass(RegimensService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
// GET /api/v1/households/:householdId/regimens — list user's regimens
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/regimens',
schema: {
params: householdParams,
querystring: RegimenQuerySchema,
response: { 200: RegimenListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const result = await service.list(
request.params.householdId,
request.user.keycloakId,
request.query,
);
return reply.send({
data: result.data.map(toRegimenResponse),
pagination: result.pagination,
});
},
});
// GET /api/v1/households/:householdId/regimens/burn-rate — burn rate + spending projection
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/regimens/burn-rate',
schema: {
params: householdParams,
response: { 200: BurnRateResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const data = await service.calculateBurnRates(
request.params.householdId,
request.user.keycloakId,
);
return reply.send({ data });
},
});
// GET /api/v1/households/:householdId/regimens/:id — get single regimen
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/regimens/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: RegimenResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const regimen = await service.getById(request.params.id, request.params.householdId, request.user.keycloakId);
return reply.send(toRegimenResponse(regimen));
},
});
// POST /api/v1/households/:householdId/regimens — create regimen
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/regimens',
schema: {
params: householdParams,
body: CreateRegimenSchema,
response: { 201: RegimenResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const regimen = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toRegimenResponse(regimen));
},
});
// PATCH /api/v1/households/:householdId/regimens/:id — update regimen
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/regimens/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
body: UpdateRegimenSchema,
response: { 200: RegimenResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const regimen = await service.update(
request.params.id,
request.params.householdId,
request.user.keycloakId,
request.body,
);
return reply.send(toRegimenResponse(regimen));
},
});
// DELETE /api/v1/households/:householdId/regimens/:id — delete regimen
app.route({
method: 'DELETE',
url: '/api/v1/households/:householdId/regimens/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 204: z.undefined() },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
return reply.status(204).send();
},
});
},
{
name: 'regimens-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,757 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RegimensService } from './regimens.service.js';
import { DosageFrequency } from '@meshitrack/shared';
describe(RegimensService.name, () => {
const mockRegimensRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
findActiveByUser: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockMedicinesRepo = {
findById: vi.fn(),
findByHousehold: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockCabinetRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
getAggregateSummary: vi.fn(),
create: vi.fn(),
update: vi.fn(),
adjustQuantity: vi.fn(),
findExpiringSoon: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
discard: vi.fn(),
findActiveByMedicineForFEFO: vi.fn(),
};
const mockCabinetEventsService = {
logEvent: vi.fn(),
logEvents: vi.fn(),
listEvents: vi.fn(),
getEventsByItem: vi.fn(),
getSpendingSummary: vi.fn(),
getAvgUnitPrices: vi.fn(),
};
let service: RegimensService;
beforeEach(() => {
vi.clearAllMocks();
service = new RegimensService({
regimensRepository: mockRegimensRepo as never,
medicinesRepository: mockMedicinesRepo as never,
cabinetRepository: mockCabinetRepo as never,
cabinetEventsService: mockCabinetEventsService as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockRegimensRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', 'user-1', { limit: 20 });
expect(response).toEqual(result);
expect(mockRegimensRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
});
});
describe('getById', () => {
it('returns regimen when found', async () => {
const regimen = { _id: 'reg-1', name: 'Morning' };
mockRegimensRepo.findById.mockResolvedValue(regimen);
const result = await service.getById('reg-1', 'hh1', 'user-1');
expect(result).toEqual(regimen);
expect(mockRegimensRepo.findById).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1');
});
it('throws NotFoundError when not found', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
});
});
describe('create', () => {
const createInput = {
name: 'Morning Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
],
};
it('creates regimen with denormalized medications', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
const created = { _id: 'reg-1', ...createInput, medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }] };
mockRegimensRepo.create.mockResolvedValue(created);
const result = await service.create(createInput, 'hh1', 'user-1');
expect(result).toEqual(created);
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Morning Routine',
isActive: true,
medications: expect.arrayContaining([
expect.objectContaining({
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
dosage: 1,
dosageUnit: 'tablet',
frequency: DosageFrequency.DAILY,
}),
]),
}),
'hh1',
'user-1',
'user-1',
);
});
it('preserves optional medication fields', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' });
await service.create(
{
name: 'Morning',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 2,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 4,
timeOfDay: 'morning' as never,
instructions: 'Take with food',
},
],
},
'hh1',
'user-1',
);
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
medications: expect.arrayContaining([
expect.objectContaining({
customFrequencyPerDay: 4,
timeOfDay: 'morning',
instructions: 'Take with food',
}),
]),
}),
'hh1',
'user-1',
'user-1',
);
});
it('throws NotFoundError when medicine not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(service.create(createInput, 'hh1', 'user-1')).rejects.toThrow(
'Medicine not found: med-1',
);
});
it('denormalizes multiple medications', async () => {
mockMedicinesRepo.findById
.mockResolvedValueOnce({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
})
.mockResolvedValueOnce({
_id: 'med-2',
name: 'Aspirin',
strength: 100,
strengthUnit: 'mg',
form: 'tablet',
});
mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' });
await service.create(
{
name: 'Full Routine',
isActive: true,
medications: [
{
medicineId: 'med-1',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
'hh1',
'user-1',
);
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
medications: expect.arrayContaining([
expect.objectContaining({ medicineId: 'med-1', medicineName: 'Metformin' }),
expect.objectContaining({ medicineId: 'med-2', medicineName: 'Aspirin' }),
]),
}),
'hh1',
'user-1',
'user-1',
);
});
});
describe('update', () => {
it('updates name only', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
const updated = { _id: 'reg-1', name: 'Evening' };
mockRegimensRepo.update.mockResolvedValue(updated);
const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' });
expect(result).toEqual(updated);
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { name: 'Evening' });
});
it('updates isActive only', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', isActive: true });
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1', isActive: false });
await service.update('reg-1', 'hh1', 'user-1', { isActive: false });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { isActive: false });
});
it('updates medications with denormalization', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' });
await service.update('reg-1', 'hh1', 'user-1', {
medications: [
{
medicineId: 'med-1',
dosage: 2,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
],
});
expect(mockRegimensRepo.update).toHaveBeenCalledWith(
'reg-1',
'hh1',
'user-1',
expect.objectContaining({
medications: expect.arrayContaining([
expect.objectContaining({ medicineName: 'Metformin' }),
]),
}),
);
});
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
'Regimen not found',
);
});
it('throws NotFoundError when update returns null', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
mockRegimensRepo.update.mockResolvedValue(null);
await expect(service.update('reg-1', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
'Regimen not found',
);
});
it('skips undefined fields in updateData', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' });
await service.update('reg-1', 'hh1', 'user-1', {});
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {});
});
it('throws NotFoundError when medicine in medications not found', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(
service.update('reg-1', 'hh1', 'user-1', {
medications: [
{
medicineId: 'med-missing',
dosage: 1,
dosageUnit: 'tablet' as const,
frequency: DosageFrequency.DAILY,
},
],
}),
).rejects.toThrow('Medicine not found: med-missing');
});
});
describe('delete', () => {
it('soft deletes regimen', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockRegimensRepo.softDelete.mockResolvedValue({ _id: 'reg-1', isDeleted: true });
const result = await service.delete('reg-1', 'hh1', 'user-1');
expect(result.isDeleted).toBe(true);
expect(mockRegimensRepo.softDelete).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1');
});
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
});
it('throws NotFoundError when softDelete returns null', async () => {
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
mockRegimensRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('reg-1', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
});
});
describe('getActiveByUser', () => {
it('delegates to repository', async () => {
const regimens = [{ _id: 'reg-1', isActive: true }];
mockRegimensRepo.findActiveByUser.mockResolvedValue(regimens);
const result = await service.getActiveByUser('hh1', 'user-1');
expect(result).toEqual(regimens);
expect(mockRegimensRepo.findActiveByUser).toHaveBeenCalledWith('hh1', 'user-1');
});
});
describe('calculateBurnRates', () => {
it('returns empty array when no active regimens', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('returns empty array when regimens have no medications', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([{ _id: 'reg-1', medications: [] }]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('calculates burn rates for single medicine', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 30, earliestExpiry: new Date('2026-06-01T00:00:00.000Z') },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([['med-1', { avgUnitPrice: 0.5, currency: 'USD' }]]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
expect(result[0].medicineName).toBe('Metformin');
expect(result[0].dailyConsumption).toBe(1);
expect(result[0].totalInCabinet).toBe(30);
expect(result[0].daysUntilEmpty).toBe(30);
expect(result[0].earliestExpiry).toBe('2026-06-01T00:00:00.000Z');
expect(result[0].avgUnitPrice).toBe(0.5);
expect(result[0].projectedDailyCost).toBe(0.5);
expect(result[0].projectedMonthlyCost).toBe(15);
expect(result[0].projectedYearlyCost).toBe(182.5);
expect(result[0].currency).toBe('USD');
});
it('sums daily consumption across multiple regimens', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
],
},
{
_id: 'reg-2',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 90, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 1*1 + 1*2 = 3 daily
expect(result[0].dailyConsumption).toBe(3);
expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30
expect(result[0].earliestExpiry).toBeNull();
expect(result[0].avgUnitPrice).toBeNull();
expect(result[0].projectedDailyCost).toBeNull();
expect(result[0].projectedMonthlyCost).toBeNull();
expect(result[0].projectedYearlyCost).toBeNull();
expect(result[0].currency).toBeNull();
});
it('handles medicine not in cabinet stock', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result[0].totalInCabinet).toBe(0);
expect(result[0].daysUntilEmpty).toBe(0); // Math.floor(0/1) = 0
});
it('excludes AS_NEEDED frequency from burn rate results', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Ibuprofen', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
],
},
]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('handles CUSTOM frequency with customFrequencyPerDay', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Custom Med',
dosage: 2,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 3,
},
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 60, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 2 * 3 = 6 daily
expect(result[0].dailyConsumption).toBe(6);
expect(result[0].daysUntilEmpty).toBe(10); // 60 / 6 = 10
});
it('excludes CUSTOM frequency without customFrequencyPerDay (zero consumption)', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{
medicineId: 'med-1',
medicineName: 'Custom Med',
dosage: 2,
frequency: DosageFrequency.CUSTOM,
// no customFrequencyPerDay -> 0 daily consumption -> excluded
},
],
},
]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('sorts by daysUntilEmpty ascending (most urgent first, nulls last)', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 30, earliestExpiry: null },
{ _id: 'med-2', totalQuantity: 10, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// AS_NEEDED (med-3) excluded; med-2: 10 days, med-1: 30 days
expect(result).toHaveLength(2);
expect(result[0].medicineId).toBe('med-2');
expect(result[0].daysUntilEmpty).toBe(10);
expect(result[1].medicineId).toBe('med-1');
expect(result[1].daysUntilEmpty).toBe(30);
});
it('returns empty array when all medications are AS_NEEDED', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
],
},
]);
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result).toEqual([]);
});
it('excludes AS_NEEDED from results even when mixed with scheduled frequencies', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.DAILY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-2', totalQuantity: 10, earliestExpiry: null },
{ _id: 'med-3', totalQuantity: 30, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// AS_NEEDED (med-1) excluded; med-2: 10 days, med-3: 30 days
expect(result).toHaveLength(2);
expect(result[0].medicineId).toBe('med-2');
expect(result[0].daysUntilEmpty).toBe(10);
expect(result[1].medicineId).toBe('med-3');
expect(result[1].daysUntilEmpty).toBe(30);
});
it('handles WEEKLY frequency', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Weekly Med', dosage: 1, frequency: DosageFrequency.WEEKLY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 4, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 1 * (1/7) ~= 0.1429 daily
expect(result[0].dailyConsumption).toBeCloseTo(1 / 7);
expect(result[0].daysUntilEmpty).toBe(28); // Math.floor(4 / (1/7)) = 28
});
it('handles EVERY_OTHER_DAY frequency', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'EOD Med', dosage: 1, frequency: DosageFrequency.EVERY_OTHER_DAY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 15, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
// 1 * 0.5 = 0.5 daily
expect(result[0].dailyConsumption).toBe(0.5);
expect(result[0].daysUntilEmpty).toBe(30); // Math.floor(15 / 0.5) = 30
});
it('handles THREE_TIMES_DAILY frequency', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'TID Med', dosage: 1, frequency: DosageFrequency.THREE_TIMES_DAILY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 90, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
const result = await service.calculateBurnRates('hh1', 'user-1');
expect(result[0].dailyConsumption).toBe(3);
expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30
});
it('calculates projected costs correctly', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Expensive Med', dosage: 2, frequency: DosageFrequency.DAILY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 60, earliestExpiry: null },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([['med-1', { avgUnitPrice: 1.5, currency: 'EUR' }]]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');
// dailyConsumption = 2, avgUnitPrice = 1.5
expect(result[0].projectedDailyCost).toBe(3); // 1.5 * 2
expect(result[0].projectedMonthlyCost).toBe(90); // 3 * 30
expect(result[0].projectedYearlyCost).toBe(1095); // 3 * 365
expect(result[0].currency).toBe('EUR');
});
it('handles multiple medicines with different stock and price data', async () => {
mockRegimensRepo.findActiveByUser.mockResolvedValue([
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
],
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', totalQuantity: 10, earliestExpiry: new Date('2026-03-01T00:00:00.000Z') },
{ _id: 'med-2', totalQuantity: 60, earliestExpiry: new Date('2026-12-01T00:00:00.000Z') },
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([
['med-1', { avgUnitPrice: 2.0, currency: 'USD' }],
]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');
// med-1: 10 days until empty, med-2: 30 days
expect(result[0].medicineId).toBe('med-1');
expect(result[0].daysUntilEmpty).toBe(10);
expect(result[0].avgUnitPrice).toBe(2.0);
expect(result[1].medicineId).toBe('med-2');
expect(result[1].daysUntilEmpty).toBe(30);
expect(result[1].avgUnitPrice).toBeNull();
expect(result[1].currency).toBeNull();
});
});
});

View file

@ -0,0 +1,202 @@
import type { RegimensRepository } from './regimens.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import type { CreateRegimenInput, UpdateRegimenInput, RegimenQueryInput } from '@meshitrack/shared';
import { getFrequencyMultiplier } from '@meshitrack/shared';
import type { DosageFrequency } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
regimensRepository: RegimensRepository;
medicinesRepository: MedicinesRepository;
cabinetRepository: CabinetRepository;
cabinetEventsService: CabinetEventsService;
}
export class RegimensService {
private readonly regimensRepository: RegimensRepository;
private readonly medicinesRepository: MedicinesRepository;
private readonly cabinetRepository: CabinetRepository;
private readonly cabinetEventsService: CabinetEventsService;
public constructor({
regimensRepository,
medicinesRepository,
cabinetRepository,
cabinetEventsService,
}: Deps) {
this.regimensRepository = regimensRepository;
this.medicinesRepository = medicinesRepository;
this.cabinetRepository = cabinetRepository;
this.cabinetEventsService = cabinetEventsService;
}
public async list(householdId: string, userId: string, query: RegimenQueryInput) {
return this.regimensRepository.findByHousehold(householdId, userId, query);
}
public async getById(id: string, householdId: string, userId: string) {
const regimen = await this.regimensRepository.findById(id, householdId, userId);
if (!regimen) throw new NotFoundError('Regimen not found');
return regimen;
}
public async create(data: CreateRegimenInput, householdId: string, userId: string) {
const medications = await this.denormalizeMedications(data.medications, householdId);
return this.regimensRepository.create(
{ name: data.name, isActive: data.isActive, medications },
householdId,
userId,
userId,
);
}
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenInput) {
await this.getById(id, householdId, userId);
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.isActive !== undefined) updateData['isActive'] = data.isActive;
if (data.medications !== undefined) {
updateData['medications'] = await this.denormalizeMedications(data.medications, householdId);
}
const updated = await this.regimensRepository.update(id, householdId, userId, updateData);
if (!updated) throw new NotFoundError('Regimen not found');
return updated;
}
public async delete(id: string, householdId: string, userId: string) {
await this.getById(id, householdId, userId);
const deleted = await this.regimensRepository.softDelete(id, householdId, userId);
if (!deleted) throw new NotFoundError('Regimen not found');
return deleted;
}
public async getActiveByUser(householdId: string, userId: string) {
return this.regimensRepository.findActiveByUser(householdId, userId);
}
public async calculateBurnRates(householdId: string, userId: string) {
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
// Sum daily consumption per medicine across all active regimens
const consumptionMap = new Map<
string,
{ medicineName: string; dailyConsumption: number }
>();
for (const regimen of regimens) {
for (const med of regimen.medications) {
const multiplier = getFrequencyMultiplier(
med.frequency as DosageFrequency,
med.customFrequencyPerDay ?? undefined,
);
const dailyDose = med.dosage * multiplier;
const existing = consumptionMap.get(med.medicineId);
if (existing) {
existing.dailyConsumption += dailyDose;
} else {
consumptionMap.set(med.medicineId, {
medicineName: med.medicineName,
dailyConsumption: dailyDose,
});
}
}
}
// Exclude medicines with zero daily consumption (e.g., AS_NEEDED frequency)
for (const [id, consumption] of consumptionMap) {
if (consumption.dailyConsumption === 0) consumptionMap.delete(id);
}
if (consumptionMap.size === 0) return [];
// Get cabinet summary for all medicines in regimens
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
const stockMap = new Map<
string,
{ totalQuantity: number; earliestExpiry: Date | null }
>();
for (const s of summaryResults) {
stockMap.set(s._id as string, {
totalQuantity: s.totalQuantity as number,
earliestExpiry: (s.earliestExpiry as Date | null) ?? null,
});
}
// Get average unit prices from purchase events
const medicineIds = [...consumptionMap.keys()];
const priceMap = await this.cabinetEventsService.getAvgUnitPrices(householdId, medicineIds);
// Build burn rate array
const burnRates = [];
for (const [medicineId, consumption] of consumptionMap) {
const stock = stockMap.get(medicineId);
const totalInCabinet = stock?.totalQuantity ?? 0;
const earliestExpiry = stock?.earliestExpiry ?? null;
const dailyConsumption = consumption.dailyConsumption;
const daysUntilEmpty =
dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null;
const priceData = priceMap.get(medicineId);
const avgUnitPrice = priceData?.avgUnitPrice ?? null;
const currency = priceData?.currency ?? null;
const projectedDailyCost =
avgUnitPrice !== null && dailyConsumption > 0 ? avgUnitPrice * dailyConsumption : null;
burnRates.push({
medicineId,
medicineName: consumption.medicineName,
dailyConsumption,
totalInCabinet,
daysUntilEmpty,
earliestExpiry: earliestExpiry ? earliestExpiry.toISOString() : null,
avgUnitPrice,
projectedDailyCost,
projectedMonthlyCost: projectedDailyCost !== null ? projectedDailyCost * 30 : null,
projectedYearlyCost: projectedDailyCost !== null ? projectedDailyCost * 365 : null,
currency,
});
}
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
burnRates.sort((a, b) => {
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
if (a.daysUntilEmpty === null) return 1;
if (b.daysUntilEmpty === null) return -1;
return a.daysUntilEmpty - b.daysUntilEmpty;
});
return burnRates;
}
private async denormalizeMedications(
medications: CreateRegimenInput['medications'],
householdId: string,
) {
const result = [];
for (const med of medications) {
const medicine = await this.medicinesRepository.findById(med.medicineId, householdId);
if (!medicine) {
throw new NotFoundError(`Medicine not found: ${med.medicineId}`);
}
result.push({
medicineId: med.medicineId,
medicineName: medicine.name,
medicineStrength: medicine.strength,
medicineStrengthUnit: medicine.strengthUnit,
medicineForm: medicine.form,
dosage: med.dosage,
dosageUnit: med.dosageUnit,
frequency: med.frequency,
customFrequencyPerDay: med.customFrequencyPerDay,
timeOfDay: med.timeOfDay,
instructions: med.instructions,
});
}
return result;
}
}

View file

@ -0,0 +1,40 @@
import mongoose from 'mongoose';
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
const cabinetEventSchema = new mongoose.Schema(
{
householdId: { type: String, required: true },
userId: { type: String, required: true },
cabinetItemId: { type: String, required: true },
medicineId: { type: String, required: true },
medicineName: { type: String, required: true },
eventType: { type: String, enum: Object.values(CabinetEventType), required: true },
quantity: { type: Number, required: true },
quantityBefore: { type: Number, required: true },
quantityAfter: { type: Number, required: true },
unitPrice: { type: Number },
totalPrice: { type: Number },
currency: { type: String },
storeId: { type: String },
storeName: { type: String },
sourceType: { type: String, enum: Object.values(CabinetEventSourceType), required: true },
sourceId: { type: String },
reason: { type: String },
notes: { type: String },
},
{
timestamps: { createdAt: true, updatedAt: false },
toJSON: { virtuals: true },
toObject: { virtuals: true },
},
);
cabinetEventSchema.index({ householdId: 1, createdAt: -1 });
cabinetEventSchema.index({ householdId: 1, cabinetItemId: 1, createdAt: -1 });
cabinetEventSchema.index({ householdId: 1, medicineId: 1, createdAt: -1 });
cabinetEventSchema.index({ householdId: 1, eventType: 1, createdAt: -1 });
export const CabinetEventModel = mongoose.model('CabinetEvent', cabinetEventSchema);
export type CabinetEventDocument = mongoose.InferSchemaType<typeof cabinetEventSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

@ -22,6 +22,12 @@ const cabinetItemSchema = new mongoose.Schema(
quantity: { type: Number, required: true, min: 0 }, quantity: { type: Number, required: true, min: 0 },
unit: { type: String, enum: Object.values(DosageUnit), required: true }, unit: { type: String, enum: Object.values(DosageUnit), required: true },
expirationDate: { type: Date }, expirationDate: { type: Date },
purchaseDate: { type: Date },
unitPrice: { type: Number },
totalPrice: { type: Number },
currency: { type: String },
storeId: { type: String },
storeName: { type: String },
status: { status: {
type: String, type: String,
enum: Object.values(CabinetItemStatus), enum: Object.values(CabinetItemStatus),

View file

@ -0,0 +1,55 @@
import mongoose from 'mongoose';
import { OrganizerFillStatus } from '@meshitrack/shared';
const organizerDeductionSchema = new mongoose.Schema(
{
cabinetItemId: { type: String, required: true },
quantityTaken: { type: Number, required: true },
},
{ _id: false },
);
const organizerFillItemSchema = new mongoose.Schema(
{
medicineId: { type: String, required: true },
medicineName: { type: String, required: true },
quantityNeeded: { type: Number, required: true },
quantityTaken: { type: Number, required: true },
wasShort: { type: Boolean, required: true },
shortage: { type: Number, required: true },
deductions: { type: [organizerDeductionSchema], required: true },
},
{ _id: false },
);
const organizerFillSchema = new mongoose.Schema(
{
householdId: { type: String, required: true },
userId: { type: String, required: true },
regimenId: { type: String, required: true },
regimenName: { type: String, required: true },
numberOfDays: { type: Number, required: true },
fillDate: { type: Date, required: true },
items: { type: [organizerFillItemSchema], required: true },
status: {
type: String,
enum: Object.values(OrganizerFillStatus),
required: true,
},
notes: { type: String },
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
},
);
organizerFillSchema.index({ householdId: 1, userId: 1, fillDate: -1 });
organizerFillSchema.index({ householdId: 1, regimenId: 1, fillDate: -1 });
organizerFillSchema.index({ householdId: 1, status: 1 });
export const OrganizerFillModel = mongoose.model('OrganizerFill', organizerFillSchema);
export type OrganizerFillDocument = mongoose.InferSchemaType<typeof organizerFillSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

@ -0,0 +1,50 @@
import mongoose from 'mongoose';
import {
DosageFrequency,
DosageUnit,
MedicineForm,
StrengthUnit,
TimeOfDay,
} from '@meshitrack/shared';
const regimenMedicationSchema = new mongoose.Schema(
{
medicineId: { type: String, required: true },
medicineName: { type: String, required: true },
medicineStrength: { type: Number, required: true },
medicineStrengthUnit: { type: String, enum: Object.values(StrengthUnit), required: true },
medicineForm: { type: String, enum: Object.values(MedicineForm), required: true },
dosage: { type: Number, required: true },
dosageUnit: { type: String, enum: Object.values(DosageUnit), required: true },
frequency: { type: String, enum: Object.values(DosageFrequency), required: true },
customFrequencyPerDay: { type: Number },
timeOfDay: { type: String, enum: Object.values(TimeOfDay) },
instructions: { type: String },
},
{ _id: false },
);
const regimenSchema = new mongoose.Schema(
{
householdId: { type: String, required: true },
userId: { type: String, required: true },
name: { type: String, required: true },
isActive: { type: Boolean, default: true },
medications: { type: [regimenMedicationSchema], required: true },
createdBy: { type: String, required: true },
isDeleted: { type: Boolean, default: false },
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
},
);
regimenSchema.index({ householdId: 1, userId: 1, isActive: 1 });
regimenSchema.index({ householdId: 1, 'medications.medicineId': 1 });
export const RegimenModel = mongoose.model('Regimen', regimenSchema);
export type RegimenDocument = mongoose.InferSchemaType<typeof regimenSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import {
CabinetEventType,
CabinetEventSourceType,
} from './cabinet-event.enums.js';
describe(CabinetEventType.name, () => {
it('has exactly 6 values', () => {
expect(Object.values(CabinetEventType)).toHaveLength(6);
});
it.each([
['PURCHASED', 'purchased'],
['CONSUMED', 'consumed'],
['ADJUSTED', 'adjusted'],
['DISCARDED', 'discarded'],
['RESTORED', 'restored'],
['DELETED', 'deleted'],
])('%s = %s', (key, value) => {
expect(CabinetEventType[key as keyof typeof CabinetEventType]).toBe(value);
});
});
describe(CabinetEventSourceType.name, () => {
it('has exactly 4 values', () => {
expect(Object.values(CabinetEventSourceType)).toHaveLength(4);
});
it.each([
['MANUAL', 'manual'],
['ORGANIZER_FILL', 'organizer_fill'],
['ORGANIZER_UNDO', 'organizer_undo'],
['REFILL_LIST', 'refill_list'],
])('%s = %s', (key, value) => {
expect(
CabinetEventSourceType[key as keyof typeof CabinetEventSourceType],
).toBe(value);
});
});

View file

@ -0,0 +1,15 @@
export enum CabinetEventType {
PURCHASED = 'purchased',
CONSUMED = 'consumed',
ADJUSTED = 'adjusted',
DISCARDED = 'discarded',
RESTORED = 'restored',
DELETED = 'deleted',
}
export enum CabinetEventSourceType {
MANUAL = 'manual',
ORGANIZER_FILL = 'organizer_fill',
ORGANIZER_UNDO = 'organizer_undo',
REFILL_LIST = 'refill_list',
}

View file

@ -1,3 +1,5 @@
export * from './roles.enums.js'; export * from './roles.enums.js';
export * from './medicine.enums.js'; export * from './medicine.enums.js';
export * from './cabinet.enums.js'; export * from './cabinet.enums.js';
export * from './cabinet-event.enums.js';
export * from './regimen.enums.js';

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import {
DosageFrequency,
TimeOfDay,
OrganizerFillStatus,
} from './regimen.enums.js';
describe(DosageFrequency.name, () => {
it('has exactly 7 values', () => {
expect(Object.values(DosageFrequency)).toHaveLength(7);
});
it.each([
['DAILY', 'daily'],
['TWICE_DAILY', 'twice_daily'],
['THREE_TIMES_DAILY', 'three_times_daily'],
['WEEKLY', 'weekly'],
['EVERY_OTHER_DAY', 'every_other_day'],
['AS_NEEDED', 'as_needed'],
['CUSTOM', 'custom'],
])('%s = %s', (key, value) => {
expect(DosageFrequency[key as keyof typeof DosageFrequency]).toBe(value);
});
});
describe(TimeOfDay.name, () => {
it('has exactly 4 values', () => {
expect(Object.values(TimeOfDay)).toHaveLength(4);
});
it.each([
['MORNING', 'morning'],
['AFTERNOON', 'afternoon'],
['EVENING', 'evening'],
['BEDTIME', 'bedtime'],
])('%s = %s', (key, value) => {
expect(TimeOfDay[key as keyof typeof TimeOfDay]).toBe(value);
});
});
describe(OrganizerFillStatus.name, () => {
it('has exactly 3 values', () => {
expect(Object.values(OrganizerFillStatus)).toHaveLength(3);
});
it.each([
['COMPLETED', 'completed'],
['PARTIAL', 'partial'],
['REVERSED', 'reversed'],
])('%s = %s', (key, value) => {
expect(
OrganizerFillStatus[key as keyof typeof OrganizerFillStatus],
).toBe(value);
});
});

View file

@ -0,0 +1,22 @@
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',
CUSTOM = 'custom',
}
export enum TimeOfDay {
MORNING = 'morning',
AFTERNOON = 'afternoon',
EVENING = 'evening',
BEDTIME = 'bedtime',
}
export enum OrganizerFillStatus {
COMPLETED = 'completed',
PARTIAL = 'partial',
REVERSED = 'reversed',
}

View file

@ -6,3 +6,6 @@ export * from './enums/index.js';
// Validation schemas // Validation schemas
export * from './validation/index.js'; export * from './validation/index.js';
// Utils
export * from './utils/index.js';

View file

@ -0,0 +1,13 @@
export interface BurnRate {
medicineId: string;
medicineName: string;
dailyConsumption: number;
totalInCabinet: number;
daysUntilEmpty: number | null;
earliestExpiry: Date | null;
avgUnitPrice: number | null;
projectedDailyCost: number | null;
projectedMonthlyCost: number | null;
projectedYearlyCost: number | null;
currency: string | null;
}

View file

@ -0,0 +1,48 @@
import type {
CabinetEventType,
CabinetEventSourceType,
} from '../enums/cabinet-event.enums.js';
export interface CabinetEvent {
id: string;
householdId: string;
userId: string;
cabinetItemId: string;
medicineId: string;
medicineName: string;
eventType: CabinetEventType;
quantity: number;
quantityBefore: number;
quantityAfter: number;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;
sourceType: CabinetEventSourceType;
sourceId?: string;
reason?: string;
notes?: string;
createdAt: Date;
}
export interface SpendingSummary {
totalSpent: number;
currency: string | null;
byMedicine: SpendingByMedicine[];
byPeriod: SpendingByPeriod[];
}
export interface SpendingByMedicine {
medicineId: string;
medicineName: string;
totalSpent: number;
totalQuantity: number;
avgUnitPrice: number;
purchaseCount: number;
}
export interface SpendingByPeriod {
period: string;
totalSpent: number;
}

View file

@ -22,6 +22,12 @@ export interface CabinetItem {
unit: DosageUnit; unit: DosageUnit;
expirationDate?: Date; expirationDate?: Date;
status: CabinetItemStatus; status: CabinetItemStatus;
purchaseDate?: Date;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;
notes?: string; notes?: string;
createdBy: string; createdBy: string;
createdAt: Date; createdAt: Date;

View file

@ -3,3 +3,7 @@ export * from './household.js';
export * from './common.js'; export * from './common.js';
export * from './medicine.js'; export * from './medicine.js';
export * from './cabinet.js'; export * from './cabinet.js';
export * from './cabinet-event.js';
export * from './regimen.js';
export * from './organizer-fill.js';
export * from './burn-rate.js';

View file

@ -0,0 +1,31 @@
import type { OrganizerFillStatus } from '../enums/regimen.enums.js';
export interface OrganizerFill {
id: string;
householdId: string;
userId: string;
regimenId: string;
regimenName: string;
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;
}

View file

@ -0,0 +1,31 @@
import type {
DosageFrequency,
TimeOfDay,
} from '../enums/regimen.enums.js';
import type { DosageUnit, MedicineForm, StrengthUnit } from '../enums/medicine.enums.js';
export interface Regimen {
id: string;
householdId: string;
userId: string;
name: string;
isActive: boolean;
medications: RegimenMedication[];
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RegimenMedication {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
medicineForm: MedicineForm;
dosage: number;
dosageUnit: DosageUnit;
frequency: DosageFrequency;
customFrequencyPerDay?: number;
timeOfDay?: TimeOfDay;
instructions?: string;
}

View file

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { DosageFrequency } from '../enums/regimen.enums.js';
import { getFrequencyMultiplier, calculateQuantityNeeded } from './frequency.js';
describe('getFrequencyMultiplier', () => {
it.each([
[DosageFrequency.DAILY, undefined, 1],
[DosageFrequency.TWICE_DAILY, undefined, 2],
[DosageFrequency.THREE_TIMES_DAILY, undefined, 3],
[DosageFrequency.WEEKLY, undefined, 1 / 7],
[DosageFrequency.EVERY_OTHER_DAY, undefined, 1 / 2],
[DosageFrequency.AS_NEEDED, undefined, 0],
[DosageFrequency.CUSTOM, 4, 4],
[DosageFrequency.CUSTOM, undefined, 0],
])('%s (customPerDay=%s) = %s', (frequency, custom, expected) => {
expect(getFrequencyMultiplier(frequency, custom)).toBe(expected);
});
});
describe('calculateQuantityNeeded', () => {
it('daily: 2 pills * 7 days = 14', () => {
expect(calculateQuantityNeeded(2, DosageFrequency.DAILY, 7)).toBe(14);
});
it('twice_daily: 1 pill * 2 * 7 days = 14', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.TWICE_DAILY, 7)).toBe(14);
});
it('three_times_daily: 1 pill * 3 * 7 days = 21', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.THREE_TIMES_DAILY, 7)).toBe(21);
});
it('weekly: 1 pill * ceil(7/7) = 1', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.WEEKLY, 7)).toBe(1);
});
it('weekly: 2 pills * ceil(10/7) = 4', () => {
expect(calculateQuantityNeeded(2, DosageFrequency.WEEKLY, 10)).toBe(4);
});
it('every_other_day: 1 pill * ceil(7/2) = 4', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.EVERY_OTHER_DAY, 7)).toBe(4);
});
it('every_other_day: 1 pill * ceil(6/2) = 3', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.EVERY_OTHER_DAY, 6)).toBe(3);
});
it('as_needed: always 0', () => {
expect(calculateQuantityNeeded(5, DosageFrequency.AS_NEEDED, 30)).toBe(0);
});
it('custom: 1 pill * 4/day * 7 days = 28', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.CUSTOM, 7, 4)).toBe(28);
});
it('custom without customFrequencyPerDay: 0', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.CUSTOM, 7)).toBe(0);
});
it('handles fractional dosage: 0.5 pill * 7 days = 3.5', () => {
expect(calculateQuantityNeeded(0.5, DosageFrequency.DAILY, 7)).toBe(3.5);
});
it('single day daily: 1 pill', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.DAILY, 1)).toBe(1);
});
});

View file

@ -0,0 +1,63 @@
import { DosageFrequency } from '../enums/regimen.enums.js';
/**
* Returns the daily frequency multiplier for a given dosage frequency.
* For example, TWICE_DAILY returns 2, WEEKLY returns 1/7.
* AS_NEEDED returns 0 (excluded from calculations).
*/
export function getFrequencyMultiplier(
frequency: DosageFrequency,
customPerDay?: number,
): number {
switch (frequency) {
case DosageFrequency.DAILY:
return 1;
case DosageFrequency.TWICE_DAILY:
return 2;
case DosageFrequency.THREE_TIMES_DAILY:
return 3;
case DosageFrequency.WEEKLY:
return 1 / 7;
case DosageFrequency.EVERY_OTHER_DAY:
return 1 / 2;
case DosageFrequency.AS_NEEDED:
return 0;
case DosageFrequency.CUSTOM:
return customPerDay ?? 0;
}
}
/**
* Calculate total quantity needed for N days based on dosage and 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
*/
export function calculateQuantityNeeded(
dosage: number,
frequency: DosageFrequency,
numberOfDays: number,
customFrequencyPerDay?: number,
): number {
switch (frequency) {
case DosageFrequency.DAILY:
return dosage * numberOfDays;
case DosageFrequency.TWICE_DAILY:
return dosage * 2 * numberOfDays;
case DosageFrequency.THREE_TIMES_DAILY:
return dosage * 3 * numberOfDays;
case DosageFrequency.WEEKLY:
return dosage * Math.ceil(numberOfDays / 7);
case DosageFrequency.EVERY_OTHER_DAY:
return dosage * Math.ceil(numberOfDays / 2);
case DosageFrequency.AS_NEEDED:
return 0;
case DosageFrequency.CUSTOM:
return dosage * (customFrequencyPerDay ?? 0) * numberOfDays;
}
}

View file

@ -0,0 +1 @@
export * from './frequency.js';

View file

@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { CabinetEventType } from '../enums/cabinet-event.enums.js';
import {
CabinetEventQuerySchema,
SpendingSummaryQuerySchema,
DiscardCabinetItemSchema,
} from './cabinet-event.schemas.js';
describe('CabinetEventQuerySchema', () => {
it('applies default limit', () => {
const result = CabinetEventQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('accepts all filters', () => {
const result = CabinetEventQuerySchema.safeParse({
medicineId: 'med-1',
eventType: CabinetEventType.PURCHASED,
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-12-31T00:00:00.000Z',
cursor: 'abc',
limit: 50,
});
expect(result.success).toBe(true);
});
it('rejects invalid eventType', () => {
const result = CabinetEventQuerySchema.safeParse({ eventType: 'invalid' });
expect(result.success).toBe(false);
});
it('rejects limit over 100', () => {
const result = CabinetEventQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
});
describe('SpendingSummaryQuerySchema', () => {
it('defaults period to month', () => {
const result = SpendingSummaryQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.period).toBe('month');
}
});
it('accepts quarter period', () => {
const result = SpendingSummaryQuerySchema.safeParse({ period: 'quarter' });
expect(result.success).toBe(true);
});
it('accepts year period', () => {
const result = SpendingSummaryQuerySchema.safeParse({ period: 'year' });
expect(result.success).toBe(true);
});
it('rejects invalid period', () => {
const result = SpendingSummaryQuerySchema.safeParse({ period: 'week' });
expect(result.success).toBe(false);
});
it('accepts optional medicineId and date range', () => {
const result = SpendingSummaryQuerySchema.safeParse({
medicineId: 'med-1',
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-06-30T00:00:00.000Z',
});
expect(result.success).toBe(true);
});
});
describe('DiscardCabinetItemSchema', () => {
it('accepts valid input', () => {
const result = DiscardCabinetItemSchema.safeParse({
reason: 'expired',
notes: 'Found during cleanup',
});
expect(result.success).toBe(true);
});
it('accepts reason only', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: 'damaged' });
expect(result.success).toBe(true);
});
it('rejects empty reason', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: '' });
expect(result.success).toBe(false);
});
it('rejects missing reason', () => {
const result = DiscardCabinetItemSchema.safeParse({});
expect(result.success).toBe(false);
});
it('trims reason', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: ' expired ' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe('expired');
}
});
it('rejects reason over 500 chars', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: 'x'.repeat(501) });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,87 @@
import { z } from 'zod/v4';
import { CabinetEventType } from '../enums/cabinet-event.enums.js';
// --- Query ---
export const CabinetEventQuerySchema = z.object({
medicineId: z.string().optional(),
eventType: z.nativeEnum(CabinetEventType).optional(),
startDate: z.iso.datetime().optional(),
endDate: z.iso.datetime().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const SpendingSummaryQuerySchema = z.object({
period: z.enum(['month', 'quarter', 'year']).default('month'),
medicineId: z.string().optional(),
startDate: z.iso.datetime().optional(),
endDate: z.iso.datetime().optional(),
});
// --- Discard ---
export const DiscardCabinetItemSchema = z.object({
reason: z.string().min(1).max(500).trim(),
notes: z.string().max(1000).trim().optional(),
});
// --- Response ---
export const CabinetEventResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
userId: z.string(),
cabinetItemId: z.string(),
medicineId: z.string(),
medicineName: z.string(),
eventType: z.string(),
quantity: z.number(),
quantityBefore: z.number(),
quantityAfter: z.number(),
unitPrice: z.number().optional(),
totalPrice: z.number().optional(),
currency: z.string().optional(),
storeId: z.string().optional(),
storeName: z.string().optional(),
sourceType: z.string(),
sourceId: z.string().optional(),
reason: z.string().optional(),
notes: z.string().optional(),
createdAt: z.string(),
});
export const CabinetEventListResponseSchema = z.object({
data: z.array(CabinetEventResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export const SpendingByMedicineSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
totalSpent: z.number(),
totalQuantity: z.number(),
avgUnitPrice: z.number(),
purchaseCount: z.number(),
});
export const SpendingByPeriodSchema = z.object({
period: z.string(),
totalSpent: z.number(),
});
export const SpendingSummaryResponseSchema = z.object({
totalSpent: z.number(),
currency: z.string().nullable(),
byMedicine: z.array(SpendingByMedicineSchema),
byPeriod: z.array(SpendingByPeriodSchema),
});
// Type exports
export type CabinetEventQueryInput = z.infer<typeof CabinetEventQuerySchema>;
export type SpendingSummaryQueryInput = z.infer<typeof SpendingSummaryQuerySchema>;
export type DiscardCabinetItemInput = z.infer<typeof DiscardCabinetItemSchema>;

View file

@ -10,6 +10,12 @@ export const CreateCabinetItemSchema = z.object({
quantity: z.number().nonnegative(), quantity: z.number().nonnegative(),
unit: z.nativeEnum(DosageUnit), unit: z.nativeEnum(DosageUnit),
expirationDate: z.iso.datetime().optional(), expirationDate: z.iso.datetime().optional(),
purchaseDate: z.iso.datetime().optional(),
unitPrice: z.number().nonnegative().optional(),
totalPrice: z.number().nonnegative().optional(),
currency: z.string().max(10).trim().optional(),
storeId: z.string().min(1).optional(),
storeName: z.string().max(200).trim().optional(),
notes: z.string().max(1000).trim().optional(), notes: z.string().max(1000).trim().optional(),
}); });
@ -50,6 +56,12 @@ export const CabinetItemResponseSchema = z.object({
unit: z.string(), unit: z.string(),
expirationDate: z.string().optional(), expirationDate: z.string().optional(),
status: z.string(), status: z.string(),
purchaseDate: z.string().optional(),
unitPrice: z.number().optional(),
totalPrice: z.number().optional(),
currency: z.string().optional(),
storeId: z.string().optional(),
storeName: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
createdBy: z.string(), createdBy: z.string(),
createdAt: z.string(), createdAt: z.string(),

View file

@ -2,3 +2,6 @@ export * from './user.schemas.js';
export * from './household.schemas.js'; export * from './household.schemas.js';
export * from './medicine.schemas.js'; export * from './medicine.schemas.js';
export * from './cabinet.schemas.js'; export * from './cabinet.schemas.js';
export * from './cabinet-event.schemas.js';
export * from './regimen.schemas.js';
export * from './organizer.schemas.js';

View file

@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { OrganizerFillStatus } from '../enums/regimen.enums.js';
import {
OrganizerPreviewSchema,
OrganizerFillSchema,
OrganizerFillQuerySchema,
} from './organizer.schemas.js';
describe('OrganizerPreviewSchema', () => {
it('accepts valid input', () => {
const result = OrganizerPreviewSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 7,
});
expect(result.success).toBe(true);
});
it('defaults numberOfDays to 7', () => {
const result = OrganizerPreviewSchema.safeParse({ regimenId: 'reg-1' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.numberOfDays).toBe(7);
}
});
it('rejects missing regimenId', () => {
const result = OrganizerPreviewSchema.safeParse({ numberOfDays: 7 });
expect(result.success).toBe(false);
});
it('rejects numberOfDays over 90', () => {
const result = OrganizerPreviewSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 91,
});
expect(result.success).toBe(false);
});
it('rejects numberOfDays of 0', () => {
const result = OrganizerPreviewSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 0,
});
expect(result.success).toBe(false);
});
});
describe('OrganizerFillSchema', () => {
it('accepts valid input', () => {
const result = OrganizerFillSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 7,
allowPartial: true,
});
expect(result.success).toBe(true);
});
it('defaults allowPartial to false', () => {
const result = OrganizerFillSchema.safeParse({ regimenId: 'reg-1' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.allowPartial).toBe(false);
}
});
it('accepts optional notes', () => {
const result = OrganizerFillSchema.safeParse({
regimenId: 'reg-1',
notes: 'Weekly fill',
});
expect(result.success).toBe(true);
});
it('rejects notes over 1000 chars', () => {
const result = OrganizerFillSchema.safeParse({
regimenId: 'reg-1',
notes: 'x'.repeat(1001),
});
expect(result.success).toBe(false);
});
});
describe('OrganizerFillQuerySchema', () => {
it('applies default limit', () => {
const result = OrganizerFillQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('accepts status filter', () => {
const result = OrganizerFillQuerySchema.safeParse({
status: OrganizerFillStatus.COMPLETED,
});
expect(result.success).toBe(true);
});
it('accepts regimenId filter', () => {
const result = OrganizerFillQuerySchema.safeParse({ regimenId: 'reg-1' });
expect(result.success).toBe(true);
});
it('rejects invalid status', () => {
const result = OrganizerFillQuerySchema.safeParse({ status: 'invalid' });
expect(result.success).toBe(false);
});
it('rejects limit over 100', () => {
const result = OrganizerFillQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,93 @@
import { z } from 'zod/v4';
import { OrganizerFillStatus } from '../enums/regimen.enums.js';
// --- Request ---
export const OrganizerPreviewSchema = z.object({
regimenId: z.string().min(1),
numberOfDays: z.number().int().min(1).max(90).default(7),
});
export const OrganizerFillSchema = z.object({
regimenId: z.string().min(1),
numberOfDays: z.number().int().min(1).max(90).default(7),
allowPartial: z.boolean().default(false),
notes: z.string().max(1000).trim().optional(),
});
export const OrganizerFillQuerySchema = z.object({
regimenId: z.string().optional(),
status: z.nativeEnum(OrganizerFillStatus).optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// --- Response ---
export const OrganizerDeductionResponseSchema = z.object({
cabinetItemId: z.string(),
quantityTaken: z.number(),
});
export const OrganizerPreviewItemSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
quantityNeeded: z.number(),
quantityAvailable: z.number(),
isShort: z.boolean(),
shortage: z.number(),
cabinetBreakdown: z.array(
z.object({
cabinetItemId: z.string(),
expirationDate: z.string().nullable(),
quantityToTake: z.number(),
}),
),
});
export const OrganizerPreviewResponseSchema = z.object({
regimenName: z.string(),
numberOfDays: z.number(),
items: z.array(OrganizerPreviewItemSchema),
canFillCompletely: z.boolean(),
hasShortages: z.boolean(),
});
export const OrganizerFillItemResponseSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
quantityNeeded: z.number(),
quantityTaken: z.number(),
wasShort: z.boolean(),
shortage: z.number(),
deductions: z.array(OrganizerDeductionResponseSchema),
});
export const OrganizerFillResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
userId: z.string(),
regimenId: z.string(),
regimenName: z.string(),
numberOfDays: z.number(),
fillDate: z.string(),
items: z.array(OrganizerFillItemResponseSchema),
status: z.string(),
notes: z.string().optional(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const OrganizerFillListResponseSchema = z.object({
data: z.array(OrganizerFillResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
// Type exports
export type OrganizerPreviewInput = z.infer<typeof OrganizerPreviewSchema>;
export type OrganizerFillInput = z.infer<typeof OrganizerFillSchema>;
export type OrganizerFillQueryInput = z.infer<typeof OrganizerFillQuerySchema>;

View file

@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { DosageUnit } from '../enums/medicine.enums.js';
import { DosageFrequency, TimeOfDay } from '../enums/regimen.enums.js';
import {
CreateRegimenSchema,
UpdateRegimenSchema,
RegimenQuerySchema,
RegimenMedicationInputSchema,
} from './regimen.schemas.js';
const validMedication = {
medicineId: 'med-1',
dosage: 1,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.DAILY,
};
describe('RegimenMedicationInputSchema', () => {
it('accepts valid medication', () => {
const result = RegimenMedicationInputSchema.safeParse(validMedication);
expect(result.success).toBe(true);
});
it('accepts full medication with optional fields', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
timeOfDay: TimeOfDay.MORNING,
instructions: 'Take with food',
});
expect(result.success).toBe(true);
});
it('accepts custom frequency with customFrequencyPerDay', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 4,
});
expect(result.success).toBe(true);
});
it('rejects custom frequency without customFrequencyPerDay', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
frequency: DosageFrequency.CUSTOM,
});
expect(result.success).toBe(false);
});
it('rejects zero dosage', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
dosage: 0,
});
expect(result.success).toBe(false);
});
it('rejects missing medicineId', () => {
const { medicineId: _, ...rest } = validMedication;
const result = RegimenMedicationInputSchema.safeParse(rest);
expect(result.success).toBe(false);
});
});
describe('CreateRegimenSchema', () => {
const validRegimen = {
name: 'Daily medications',
medications: [validMedication],
};
it('accepts valid input', () => {
const result = CreateRegimenSchema.safeParse(validRegimen);
expect(result.success).toBe(true);
});
it('defaults isActive to true', () => {
const result = CreateRegimenSchema.safeParse(validRegimen);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isActive).toBe(true);
}
});
it('accepts isActive override', () => {
const result = CreateRegimenSchema.safeParse({ ...validRegimen, isActive: false });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isActive).toBe(false);
}
});
it('rejects empty name', () => {
const result = CreateRegimenSchema.safeParse({ ...validRegimen, name: '' });
expect(result.success).toBe(false);
});
it('rejects empty medications array', () => {
const result = CreateRegimenSchema.safeParse({ ...validRegimen, medications: [] });
expect(result.success).toBe(false);
});
it('accepts multiple medications', () => {
const result = CreateRegimenSchema.safeParse({
...validRegimen,
medications: [
validMedication,
{ ...validMedication, medicineId: 'med-2', dosage: 2 },
],
});
expect(result.success).toBe(true);
});
});
describe('UpdateRegimenSchema', () => {
it('accepts partial update', () => {
const result = UpdateRegimenSchema.safeParse({ name: 'Updated name' });
expect(result.success).toBe(true);
});
it('accepts empty object', () => {
const result = UpdateRegimenSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts isActive toggle', () => {
const result = UpdateRegimenSchema.safeParse({ isActive: false });
expect(result.success).toBe(true);
});
it('rejects empty medications array when provided', () => {
const result = UpdateRegimenSchema.safeParse({ medications: [] });
expect(result.success).toBe(false);
});
});
describe('RegimenQuerySchema', () => {
it('applies default limit', () => {
const result = RegimenQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('parses isActive string to boolean', () => {
const result = RegimenQuerySchema.safeParse({ isActive: 'true' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isActive).toBe(true);
}
});
it('rejects limit over 100', () => {
const result = RegimenQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,105 @@
import { z } from 'zod/v4';
import { DosageFrequency, TimeOfDay } from '../enums/regimen.enums.js';
import { DosageUnit } from '../enums/medicine.enums.js';
// --- RegimenMedication (embedded) ---
export const RegimenMedicationInputSchema = z
.object({
medicineId: z.string().min(1),
dosage: z.number().positive(),
dosageUnit: z.nativeEnum(DosageUnit),
frequency: z.nativeEnum(DosageFrequency),
customFrequencyPerDay: z.number().int().positive().optional(),
timeOfDay: z.nativeEnum(TimeOfDay).optional(),
instructions: z.string().max(500).trim().optional(),
})
.refine(
(data) =>
data.frequency !== DosageFrequency.CUSTOM ||
data.customFrequencyPerDay !== undefined,
{ message: 'customFrequencyPerDay is required when frequency is custom' },
);
// --- Regimen ---
export const CreateRegimenSchema = z.object({
name: z.string().min(1).max(200).trim(),
isActive: z.boolean().default(true),
medications: z.array(RegimenMedicationInputSchema).min(1),
});
export const UpdateRegimenSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
isActive: z.boolean().optional(),
medications: z.array(RegimenMedicationInputSchema).min(1).optional(),
});
export const RegimenQuerySchema = z.object({
isActive: z
.string()
.transform((v) => v === 'true')
.optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// --- Response ---
export const RegimenMedicationResponseSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
medicineStrength: z.number(),
medicineStrengthUnit: z.string(),
medicineForm: z.string(),
dosage: z.number(),
dosageUnit: z.string(),
frequency: z.string(),
customFrequencyPerDay: z.number().optional(),
timeOfDay: z.string().optional(),
instructions: z.string().optional(),
});
export const RegimenResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
userId: z.string(),
name: z.string(),
isActive: z.boolean(),
medications: z.array(RegimenMedicationResponseSchema),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const RegimenListResponseSchema = z.object({
data: z.array(RegimenResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export const BurnRateItemSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
dailyConsumption: z.number(),
totalInCabinet: z.number(),
daysUntilEmpty: z.number().nullable(),
earliestExpiry: z.string().nullable(),
avgUnitPrice: z.number().nullable(),
projectedDailyCost: z.number().nullable(),
projectedMonthlyCost: z.number().nullable(),
projectedYearlyCost: z.number().nullable(),
currency: z.string().nullable(),
});
export const BurnRateResponseSchema = z.object({
data: z.array(BurnRateItemSchema),
});
// Type exports
export type CreateRegimenInput = z.infer<typeof CreateRegimenSchema>;
export type UpdateRegimenInput = z.infer<typeof UpdateRegimenSchema>;
export type RegimenQueryInput = z.infer<typeof RegimenQuerySchema>;

View file

@ -0,0 +1,420 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { listCabinetEvents, getSpendingSummary } from '@/services/cabinet-events';
import { listMedicines } from '@/services/medicines';
import { CabinetEventType } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type {
CabinetEventResponseSchema,
SpendingSummaryResponseSchema,
} from '@meshitrack/shared';
type CabinetEvent = z.infer<typeof CabinetEventResponseSchema>;
type SpendingSummary = z.infer<typeof SpendingSummaryResponseSchema>;
type MedicineOption = {
_id: string;
name: string;
};
const EVENT_TYPE_LABELS: Record<string, string> = {
purchased: 'Purchased',
consumed: 'Consumed',
adjusted: 'Adjusted',
discarded: 'Discarded',
restored: 'Restored',
deleted: 'Deleted',
};
const EVENT_TYPE_COLORS: Record<string, string> = {
purchased: 'bg-green-100 text-green-700',
consumed: 'bg-blue-100 text-blue-700',
adjusted: 'bg-yellow-100 text-yellow-700',
discarded: 'bg-red-100 text-red-700',
restored: 'bg-purple-100 text-purple-700',
deleted: 'bg-gray-100 text-gray-600',
};
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
function formatQuantityChange(event: CabinetEvent): string {
const sign = event.quantity > 0 ? '+' : '';
return `${sign}${event.quantity}`;
}
function QuantityBadge({ quantity }: { quantity: number }) {
const isPositive = quantity > 0;
return (
<span
className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}
>
{isPositive ? '+' : ''}
{quantity}
</span>
);
}
// --- Spending summary ---
function SpendingSummaryView({
householdId,
medicines,
}: {
householdId: string;
medicines: MedicineOption[];
}) {
const [summary, setSummary] = useState<SpendingSummary | null>(null);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState<'month' | 'quarter' | 'year'>('month');
const [medicineId, setMedicineId] = useState('');
const [error, setError] = useState('');
const fetchSummary = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getSpendingSummary(householdId, {
period,
medicineId: medicineId || undefined,
});
setSummary(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load spending summary');
} finally {
setLoading(false);
}
}, [householdId, period, medicineId]);
useEffect(() => {
fetchSummary();
}, [fetchSummary]);
const PERIOD_LABELS = { month: 'This month', quarter: 'This quarter', year: 'This year' };
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<h2 className="text-lg font-semibold">Spending Summary</h2>
<div className="flex items-center gap-2">
<select
value={period}
onChange={(e) => setPeriod(e.target.value as 'month' | 'quarter' | 'year')}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{Object.entries(PERIOD_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
<select
value={medicineId}
onChange={(e) => setMedicineId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All medicines</option>
{medicines.map((m) => (
<option key={m._id} value={m._id}>
{m.name}
</option>
))}
</select>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<div className="animate-pulse space-y-2">
<div className="h-6 w-32 rounded bg-gray-200" />
<div className="h-20 rounded bg-gray-200" />
</div>
) : summary && summary.totalSpent > 0 ? (
<div className="space-y-5">
<div className="text-2xl font-bold text-gray-900">
{summary.currency ? `${summary.currency} ` : ''}
{summary.totalSpent.toFixed(2)}
<span className="text-sm font-normal text-gray-500 ml-2">total spent</span>
</div>
{summary.byMedicine.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2">By medicine</h3>
<div className="space-y-2">
{summary.byMedicine.map((item) => (
<div
key={item.medicineId}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<span className="text-sm font-medium text-gray-900">
{item.medicineName}
</span>
<span className="ml-2 text-xs text-gray-500">
{item.purchaseCount} purchase{item.purchaseCount !== 1 ? 's' : ''} &bull;{' '}
avg {summary.currency ? `${summary.currency} ` : ''}
{item.avgUnitPrice.toFixed(2)}/unit
</span>
</div>
<span className="text-sm font-semibold text-gray-800">
{summary.currency ? `${summary.currency} ` : ''}
{item.totalSpent.toFixed(2)}
</span>
</div>
))}
</div>
</div>
)}
{summary.byPeriod.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2">By period</h3>
<div className="space-y-2">
{summary.byPeriod.map((item) => (
<div
key={item.period}
className="flex items-center justify-between rounded-lg border p-3"
>
<span className="text-sm text-gray-700">{item.period}</span>
<span className="text-sm font-semibold text-gray-800">
{summary.currency ? `${summary.currency} ` : ''}
{item.totalSpent.toFixed(2)}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-500 py-4 text-center">
No purchase data found for this period.
</p>
)}
</div>
);
}
// --- Event timeline ---
function EventTimeline({
householdId,
medicines,
}: {
householdId: string;
medicines: MedicineOption[];
}) {
const [events, setEvents] = useState<CabinetEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterEventType, setFilterEventType] = useState('');
const [filterMedicineId, setFilterMedicineId] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const fetchEvents = useCallback(
async (append = false) => {
if (!append) setLoading(true);
setError('');
try {
const result = await listCabinetEvents(householdId, {
eventType: filterEventType || undefined,
medicineId: filterMedicineId || undefined,
startDate: startDate ? new Date(startDate).toISOString() : undefined,
endDate: endDate ? new Date(endDate).toISOString() : undefined,
cursor: append ? (cursor ?? undefined) : undefined,
limit: 20,
});
setEvents((prev) => (append ? [...prev, ...result.data] : result.data));
setCursor(result.pagination.cursor);
setHasMore(result.pagination.hasMore);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load events');
} finally {
setLoading(false);
}
},
[householdId, filterEventType, filterMedicineId, startDate, endDate, cursor],
);
// Refetch from scratch when filters change
useEffect(() => {
setCursor(null);
setEvents([]);
fetchEvents(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [householdId, filterEventType, filterMedicineId, startDate, endDate]);
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Cabinet Activity</h2>
<div className="mb-4 flex flex-wrap items-center gap-3">
<select
value={filterEventType}
onChange={(e) => setFilterEventType(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All event types</option>
{Object.values(CabinetEventType).map((t) => (
<option key={t} value={t}>
{EVENT_TYPE_LABELS[t] ?? t}
</option>
))}
</select>
<select
value={filterMedicineId}
onChange={(e) => setFilterMedicineId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All medicines</option>
{medicines.map((m) => (
<option key={m._id} value={m._id}>
{m.name}
</option>
))}
</select>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
title="Start date"
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
title="End date"
/>
{(filterEventType || filterMedicineId || startDate || endDate) && (
<button
onClick={() => {
setFilterEventType('');
setFilterMedicineId('');
setStartDate('');
setEndDate('');
}}
className="text-sm text-gray-500 underline"
>
Clear filters
</button>
)}
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse h-14 rounded-lg bg-gray-200" />
))}
</div>
) : events.length === 0 ? (
<p className="text-sm text-center text-gray-500 py-6">
No events found for the selected filters.
</p>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-4 top-0 bottom-0 w-px bg-gray-200" />
<div className="space-y-4 pl-10">
{events.map((event) => (
<div key={event._id} className="relative">
{/* Dot */}
<div
className={`absolute -left-6 top-2 h-3 w-3 rounded-full border-2 border-white ${
event.quantity > 0 ? 'bg-green-400' : 'bg-red-400'
}`}
/>
<div className="rounded-lg border bg-gray-50 p-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
>
{EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
</span>
<span className="text-sm font-medium text-gray-900">
{event.medicineName}
</span>
<QuantityBadge quantity={event.quantity} />
<span className="text-xs text-gray-500">
{event.quantityBefore} &rarr; {event.quantityAfter}
</span>
</div>
<span className="text-xs text-gray-400 shrink-0">
{formatDateTime(event.createdAt)}
</span>
</div>
{(event.reason || event.notes || event.storeName || event.totalPrice) && (
<div className="mt-1 flex flex-wrap gap-3 text-xs text-gray-500">
{event.reason && <span>Reason: {event.reason}</span>}
{event.storeName && <span>Store: {event.storeName}</span>}
{event.totalPrice && (
<span>
{event.currency ? `${event.currency} ` : ''}
{event.totalPrice.toFixed(2)}
</span>
)}
{event.notes && <span>{event.notes}</span>}
</div>
)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="mt-4 text-center">
<button
onClick={() => fetchEvents(true)}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Load more
</button>
</div>
)}
</div>
)}
</div>
);
}
// --- Main component ---
export function ActivityTab({ householdId }: { householdId: string }) {
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
useEffect(() => {
listMedicines(householdId, { limit: 100 })
.then((r) =>
setMedicines(r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name }))),
)
.catch(() => {});
}, [householdId]);
return (
<div className="space-y-6">
<SpendingSummaryView householdId={householdId} medicines={medicines} />
<EventTimeline householdId={householdId} medicines={medicines} />
</div>
);
}

View file

@ -0,0 +1,455 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
listFills,
previewFill,
executeFill,
undoFill,
} from '@/services/organizer';
import { listRegimens } from '@/services/regimens';
import { OrganizerFillStatus } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type {
OrganizerFillResponseSchema,
OrganizerPreviewResponseSchema,
RegimenResponseSchema,
} from '@meshitrack/shared';
type OrganizerFill = z.infer<typeof OrganizerFillResponseSchema>;
type OrganizerPreview = z.infer<typeof OrganizerPreviewResponseSchema>;
type Regimen = z.infer<typeof RegimenResponseSchema>;
const STATUS_LABELS: Record<string, string> = {
completed: 'Completed',
partial: 'Partial',
reversed: 'Reversed',
};
const STATUS_COLORS: Record<string, string> = {
completed: 'bg-green-100 text-green-700',
partial: 'bg-yellow-100 text-yellow-700',
reversed: 'bg-gray-100 text-gray-500',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
// --- Preview result display ---
function PreviewResult({
preview,
onConfirm,
onCancel,
submitting,
allowPartial,
onTogglePartial,
}: {
preview: OrganizerPreview;
onConfirm: () => void;
onCancel: () => void;
submitting: boolean;
allowPartial: boolean;
onTogglePartial: (v: boolean) => void;
}) {
return (
<div className="rounded-xl border bg-white p-6 shadow-sm space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
Preview: {preview.regimenName} &mdash; {preview.numberOfDays} day
{preview.numberOfDays !== 1 ? 's' : ''}
</h3>
{preview.hasShortages ? (
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700">
Shortages detected
</span>
) : (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700">
Ready to fill
</span>
)}
</div>
<div className="space-y-2">
{preview.items.map((item) => (
<div
key={item.medicineId}
className={`rounded-lg border p-3 ${item.isShort ? 'border-yellow-300 bg-yellow-50' : 'border-gray-200'}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{item.medicineName}</span>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500">
Need: <strong>{item.quantityNeeded}</strong>
</span>
<span className="text-gray-500">
Available: <strong>{item.quantityAvailable}</strong>
</span>
{item.isShort && (
<span className="text-yellow-700 font-semibold">
Short: {item.shortage}
</span>
)}
</div>
</div>
{item.cabinetBreakdown.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{item.cabinetBreakdown.map((b, i) => (
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
{b.quantityToTake} units
{b.expirationDate ? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})` : ''}
</span>
))}
</div>
)}
</div>
))}
</div>
{preview.hasShortages && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allowPartial"
checked={allowPartial}
onChange={(e) => onTogglePartial(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="allowPartial" className="text-sm text-gray-700">
Allow partial fill (fill what is available)
</label>
</div>
)}
<div className="flex gap-3">
<button
onClick={onConfirm}
disabled={submitting || (preview.hasShortages && !allowPartial)}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Filling...' : 'Confirm fill'}
</button>
<button
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Back
</button>
</div>
</div>
);
}
// --- Fill wizard ---
function FillWizard({
householdId,
regimens,
onFilled,
}: {
householdId: string;
regimens: Regimen[];
onFilled: () => void;
}) {
const [regimenId, setRegimenId] = useState('');
const [numberOfDays, setNumberOfDays] = useState(7);
const [notes, setNotes] = useState('');
const [allowPartial, setAllowPartial] = useState(false);
const [preview, setPreview] = useState<OrganizerPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [filling, setFilling] = useState(false);
const [error, setError] = useState('');
const activeRegimens = regimens.filter((r) => r.isActive);
async function handlePreview(e: React.FormEvent) {
e.preventDefault();
setError('');
setPreviewing(true);
try {
const result = await previewFill(householdId, { regimenId, numberOfDays });
setPreview(result);
setAllowPartial(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate preview');
} finally {
setPreviewing(false);
}
}
async function handleFill() {
setError('');
setFilling(true);
try {
await executeFill(householdId, { regimenId, numberOfDays, allowPartial, notes: notes || undefined });
setPreview(null);
setRegimenId('');
setNumberOfDays(7);
setNotes('');
setAllowPartial(false);
onFilled();
} catch (err) {
setError(err instanceof Error ? err.message : 'Fill failed');
setPreview(null);
} finally {
setFilling(false);
}
}
if (preview) {
return (
<>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<PreviewResult
preview={preview}
onConfirm={handleFill}
onCancel={() => setPreview(null)}
submitting={filling}
allowPartial={allowPartial}
onTogglePartial={setAllowPartial}
/>
</>
);
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Fill Pill Organizer</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{activeRegimens.length === 0 ? (
<p className="text-sm text-gray-500">
No active regimens found. Create and activate a regimen before filling.
</p>
) : (
<form onSubmit={handlePreview} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen</label>
<select
required
value={regimenId}
onChange={(e) => setRegimenId(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">Select regimen...</option>
{activeRegimens.map((r) => (
<option key={r._id} value={r._id}>
{r.name} ({r.medications.length} medication{r.medications.length !== 1 ? 's' : ''})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Number of days</label>
<input
type="number"
required
min={1}
max={90}
value={numberOfDays}
onChange={(e) => setNumberOfDays(Number(e.target.value))}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes (optional)
</label>
<input
type="text"
maxLength={1000}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes for this fill"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
</div>
<button
type="submit"
disabled={previewing}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{previewing ? 'Calculating...' : 'Preview fill'}
</button>
</form>
)}
</div>
);
}
// --- Fill history list ---
function FillHistory({
householdId,
refreshKey,
}: {
householdId: string;
refreshKey: number;
}) {
const [fills, setFills] = useState<OrganizerFill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const fetchFills = useCallback(async () => {
setLoading(true);
try {
const result = await listFills(householdId, {
status: filterStatus || undefined,
limit: 50,
});
setFills(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load fill history');
} finally {
setLoading(false);
}
}, [householdId, filterStatus]);
useEffect(() => {
fetchFills();
}, [fetchFills, refreshKey]);
async function handleUndo(fillId: string) {
if (!confirm('Reverse this fill? Cabinet quantities will be restored.')) return;
try {
const updated = await undoFill(householdId, fillId);
setFills((prev) => prev.map((f) => (f._id === updated._id ? updated : f)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to undo fill');
}
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Fill History</h2>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All statuses</option>
{Object.values(OrganizerFillStatus).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</option>
))}
</select>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse h-16 rounded-lg bg-gray-200" />
))}
</div>
) : fills.length === 0 ? (
<p className="text-sm text-center text-gray-500 py-4">No fills recorded yet.</p>
) : (
<div className="space-y-3">
{fills.map((fill) => (
<div key={fill._id} className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{fill.regimenName}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[fill.status] ?? STATUS_COLORS['completed']}`}
>
{STATUS_LABELS[fill.status] ?? fill.status}
</span>
</div>
<p className="text-sm text-gray-500">
{fill.numberOfDays} day{fill.numberOfDays !== 1 ? 's' : ''} &bull;{' '}
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} &bull;{' '}
{formatDate(fill.fillDate)}
</p>
{fill.notes && (
<p className="text-xs text-gray-400 mt-1">{fill.notes}</p>
)}
<div className="flex flex-wrap gap-1 mt-2">
{fill.items.map((item, i) => (
<span
key={i}
className={`rounded-full px-2 py-0.5 text-xs ${
item.wasShort
? 'bg-yellow-50 text-yellow-700'
: 'bg-blue-50 text-blue-700'
}`}
>
{item.medicineName}: {item.quantityTaken}/{item.quantityNeeded}
{item.wasShort ? ' (short)' : ''}
</span>
))}
</div>
</div>
{fill.status !== OrganizerFillStatus.REVERSED && (
<button
onClick={() => handleUndo(fill._id)}
className="shrink-0 rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
>
Undo
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
// --- Main component ---
export function OrganizerTab({ householdId }: { householdId: string }) {
const [regimens, setRegimens] = useState<Regimen[]>([]);
const [regimensLoading, setRegimensLoading] = useState(true);
const [fillRefreshKey, setFillRefreshKey] = useState(0);
useEffect(() => {
listRegimens(householdId, { limit: 100 })
.then((r) => setRegimens(r.data))
.catch(() => {})
.finally(() => setRegimensLoading(false));
}, [householdId]);
function handleFilled() {
setFillRefreshKey((k) => k + 1);
}
return (
<div className="space-y-6">
{regimensLoading ? (
<div className="animate-pulse rounded-xl border bg-white p-6 h-40" />
) : (
<FillWizard
householdId={householdId}
regimens={regimens}
onFilled={handleFilled}
/>
)}
<FillHistory householdId={householdId} refreshKey={fillRefreshKey} />
</div>
);
}

View file

@ -0,0 +1,722 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
listRegimens,
createRegimen,
updateRegimen,
deleteRegimen,
getBurnRates,
} from '@/services/regimens';
import { listMedicines } from '@/services/medicines';
import {
DosageFrequency,
TimeOfDay,
DosageUnit,
MedicineForm,
allowedUnitsForForm,
defaultUnitForForm,
} from '@meshitrack/shared';
import type { CreateRegimenInput } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type {
RegimenResponseSchema,
RegimenMedicationInputSchema,
BurnRateItemSchema,
} from '@meshitrack/shared';
type Regimen = z.infer<typeof RegimenResponseSchema>;
type MedicationInput = z.infer<typeof RegimenMedicationInputSchema>;
type BurnRateItem = z.infer<typeof BurnRateItemSchema>;
type MedicineOption = {
_id: string;
name: string;
strength: number;
strengthUnit: string;
form: MedicineForm;
};
const FREQUENCY_LABELS: Record<string, string> = {
daily: 'Once daily',
twice_daily: 'Twice daily',
three_times_daily: 'Three times daily',
weekly: 'Weekly',
every_other_day: 'Every other day',
as_needed: 'As needed',
custom: 'Custom',
};
const TIME_LABELS: Record<string, string> = {
morning: 'Morning',
afternoon: 'Afternoon',
evening: 'Evening',
bedtime: 'Bedtime',
};
const FORM_LABELS: Record<string, string> = {
tablet: 'Tablet',
capsule: 'Capsule',
liquid: 'Liquid',
injection: 'Injection',
other: 'Other',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
// --- Medication sub-form row ---
function MedicationRow({
medication,
index,
medicines,
onChange,
onRemove,
}: {
medication: MedicationInput;
index: number;
medicines: MedicineOption[];
onChange: (index: number, updated: MedicationInput) => void;
onRemove: (index: number) => void;
}) {
const selectedMed = medicines.find((m) => m._id === medication.medicineId);
const allowedUnits = selectedMed
? allowedUnitsForForm(selectedMed.form)
: Object.values(DosageUnit);
function handleMedicineChange(medicineId: string) {
const med = medicines.find((m) => m._id === medicineId);
const newUnit = med ? defaultUnitForForm(med.form) : DosageUnit.TABLET;
onChange(index, { ...medication, medicineId, dosageUnit: newUnit });
}
return (
<div className="rounded-lg border bg-gray-50 p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-gray-600">Medication {index + 1}</span>
<button
type="button"
onClick={() => onRemove(index)}
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Remove medication"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Medicine</label>
<select
required
value={medication.medicineId}
onChange={(e) => handleMedicineChange(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">Select medicine...</option>
{medicines.map((m) => (
<option key={m._id} value={m._id}>
{m.name} {m.strength} {m.strengthUnit} ({FORM_LABELS[m.form] ?? m.form})
</option>
))}
</select>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">Dosage</label>
<input
type="number"
required
min={0.01}
step="any"
value={medication.dosage || ''}
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">Unit</label>
<select
value={medication.dosageUnit}
onChange={(e) =>
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{allowedUnits.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
<select
value={medication.frequency}
onChange={(e) =>
onChange(index, {
...medication,
frequency: e.target.value as DosageFrequency,
customFrequencyPerDay:
e.target.value === DosageFrequency.CUSTOM
? (medication.customFrequencyPerDay ?? 1)
: undefined,
})
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{Object.values(DosageFrequency).map((f) => (
<option key={f} value={f}>
{FREQUENCY_LABELS[f] ?? f}
</option>
))}
</select>
</div>
{medication.frequency === DosageFrequency.CUSTOM && (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Times per day</label>
<input
type="number"
required
min={1}
step={1}
value={medication.customFrequencyPerDay ?? ''}
onChange={(e) =>
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Time of day (optional)
</label>
<select
value={medication.timeOfDay ?? ''}
onChange={(e) =>
onChange(index, {
...medication,
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
})
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">Any time</option>
{Object.values(TimeOfDay).map((t) => (
<option key={t} value={t}>
{TIME_LABELS[t] ?? t}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Instructions (optional)
</label>
<input
type="text"
maxLength={500}
value={medication.instructions ?? ''}
onChange={(e) =>
onChange(index, { ...medication, instructions: e.target.value || undefined })
}
placeholder="e.g. Take with food"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
</div>
</div>
);
}
// --- Regimen create / edit form ---
function emptyMedication(): MedicationInput {
return {
medicineId: '',
dosage: 1,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.DAILY,
};
}
function RegimenForm({
householdId,
medicines,
initial,
onSaved,
onCancel,
}: {
householdId: string;
medicines: MedicineOption[];
initial?: Regimen;
onSaved: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [isActive, setIsActive] = useState(initial?.isActive ?? true);
const [medications, setMedications] = useState<MedicationInput[]>(
initial?.medications.map((m) => ({
medicineId: m.medicineId,
dosage: m.dosage,
dosageUnit: m.dosageUnit as DosageUnit,
frequency: m.frequency as DosageFrequency,
customFrequencyPerDay: m.customFrequencyPerDay,
timeOfDay: m.timeOfDay as TimeOfDay | undefined,
instructions: m.instructions,
})) ?? [emptyMedication()],
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
function updateMedication(index: number, updated: MedicationInput) {
setMedications((prev) => prev.map((m, i) => (i === index ? updated : m)));
}
function removeMedication(index: number) {
setMedications((prev) => prev.filter((_, i) => i !== index));
}
function addMedication() {
setMedications((prev) => [...prev, emptyMedication()]);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (medications.length === 0) {
setError('At least one medication is required.');
return;
}
setError('');
setSubmitting(true);
try {
const payload: CreateRegimenInput = { name: name.trim(), isActive, medications };
if (initial) {
await updateRegimen(householdId, initial._id, payload);
} else {
await createRegimen(householdId, payload);
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save regimen');
} finally {
setSubmitting(false);
}
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
required
maxLength={200}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Morning routine"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
<div className="flex items-center gap-3 pt-6">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
Active
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
<button
type="button"
onClick={addMedication}
className="rounded-lg border border-primary-600 px-3 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
>
+ Add medication
</button>
</div>
{medications.length === 0 ? (
<p className="text-sm text-gray-500 italic">No medications added yet.</p>
) : (
<div className="space-y-3">
{medications.map((med, i) => (
<MedicationRow
key={i}
index={i}
medication={med}
medicines={medicines}
onChange={updateMedication}
onRemove={removeMedication}
/>
))}
</div>
)}
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
);
}
// --- Burn rate table ---
function BurnRateTable({ burnRates }: { burnRates: BurnRateItem[] }) {
if (burnRates.length === 0) {
return (
<p className="text-sm text-gray-500">
No active regimens with cabinet stock to calculate burn rates.
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="pb-2 font-medium">Medicine</th>
<th className="pb-2 font-medium text-right">Daily use</th>
<th className="pb-2 font-medium text-right">In cabinet</th>
<th className="pb-2 font-medium text-right">Days left</th>
<th className="pb-2 font-medium text-right">Monthly cost</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{burnRates.map((item) => {
const daysLeft = item.daysUntilEmpty;
const daysColor =
daysLeft === null
? 'text-gray-400'
: daysLeft <= 7
? 'text-red-600 font-semibold'
: daysLeft <= 30
? 'text-yellow-600'
: 'text-green-600';
return (
<tr key={item.medicineId} className="py-2">
<td className="py-2 font-medium text-gray-900">{item.medicineName}</td>
<td className="py-2 text-right text-gray-600">
{item.dailyConsumption.toFixed(2)}
</td>
<td className="py-2 text-right text-gray-600">{item.totalInCabinet}</td>
<td className={`py-2 text-right ${daysColor}`}>
{daysLeft !== null ? daysLeft : '-'}
</td>
<td className="py-2 text-right text-gray-600">
{item.projectedMonthlyCost !== null
? `${item.currency ?? ''} ${item.projectedMonthlyCost.toFixed(2)}`.trim()
: '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// --- Main component ---
export function RegimensTab({ householdId }: { householdId: string }) {
const [regimens, setRegimens] = useState<Regimen[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null);
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
const [burnRates, setBurnRates] = useState<BurnRateItem[]>([]);
const [showBurnRate, setShowBurnRate] = useState(false);
const [burnRateLoading, setBurnRateLoading] = useState(false);
const fetchRegimens = useCallback(async () => {
setLoading(true);
try {
const query =
filterActive === 'active'
? { isActive: true }
: filterActive === 'inactive'
? { isActive: false }
: {};
const result = await listRegimens(householdId, { ...query, limit: 50 });
setRegimens(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load regimens');
} finally {
setLoading(false);
}
}, [householdId, filterActive]);
useEffect(() => {
fetchRegimens();
}, [fetchRegimens]);
// Medicines are needed for the form
useEffect(() => {
listMedicines(householdId, { limit: 100 })
.then((r) => setMedicines(r.data as MedicineOption[]))
.catch(() => {});
}, [householdId]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete regimen "${name}"?`)) return;
try {
await deleteRegimen(householdId, id);
setRegimens((prev) => prev.filter((r) => r._id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
async function handleToggleActive(regimen: Regimen) {
try {
const updated = await updateRegimen(householdId, regimen._id, {
isActive: !regimen.isActive,
});
setRegimens((prev) => prev.map((r) => (r._id === regimen._id ? updated : r)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update');
}
}
async function handleShowBurnRate() {
setShowBurnRate((prev) => !prev);
if (!showBurnRate && burnRates.length === 0) {
setBurnRateLoading(true);
try {
const result = await getBurnRates(householdId);
setBurnRates(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load burn rates');
} finally {
setBurnRateLoading(false);
}
}
}
const isFormOpen = showForm || editingRegimen !== null;
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<select
value={filterActive}
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="all">All regimens</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<button
onClick={handleShowBurnRate}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
</button>
</div>
<button
onClick={() => {
setEditingRegimen(null);
setShowForm(!showForm);
}}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
{showForm ? 'Cancel' : 'New Regimen'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{showBurnRate && (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Burn Rate &amp; Spending Projections</h2>
{burnRateLoading ? (
<div className="animate-pulse space-y-2">
<div className="h-6 w-full rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
) : (
<BurnRateTable burnRates={burnRates} />
)}
</div>
)}
{showForm && !editingRegimen && (
<RegimenForm
householdId={householdId}
medicines={medicines}
onSaved={() => {
setShowForm(false);
fetchRegimens();
setBurnRates([]);
}}
onCancel={() => setShowForm(false)}
/>
)}
{editingRegimen && (
<RegimenForm
householdId={householdId}
medicines={medicines}
initial={editingRegimen}
onSaved={() => {
setEditingRegimen(null);
fetchRegimens();
setBurnRates([]);
}}
onCancel={() => setEditingRegimen(null)}
/>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-24" />
))}
</div>
) : regimens.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
{filterActive !== 'all'
? `No ${filterActive} regimens found.`
: isFormOpen
? null
: 'No regimens yet. Create your first medication schedule above.'}
</div>
) : (
<div className="space-y-3">
{regimens.map((regimen) => (
<div key={regimen._id} className="rounded-xl border bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
regimen.isActive
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{regimen.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<p className="text-sm text-gray-500 mb-2">
{regimen.medications.length} medication
{regimen.medications.length !== 1 ? 's' : ''}
</p>
<div className="flex flex-wrap gap-1">
{regimen.medications.map((med, i) => (
<span
key={i}
className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700"
>
{med.medicineName} {med.dosage} {med.dosageUnit} (
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
</span>
))}
</div>
<p className="mt-2 text-xs text-gray-400">Created {formatDate(regimen.createdAt)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleToggleActive(regimen)}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 transition-colors"
title={regimen.isActive ? 'Deactivate' : 'Activate'}
>
{regimen.isActive ? 'Deactivate' : 'Activate'}
</button>
<button
onClick={() => {
setShowForm(false);
setEditingRegimen(regimen);
}}
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
title="Edit"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={() => handleDelete(regimen._id, regimen.name)}
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,51 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { ActivityTab } from '../ActivityTab';
export default function ActivityPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
<div className="animate-pulse space-y-3">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before viewing cabinet activity.
</p>
</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Cabinet Activity</h1>
</div>
<ActivityTab householdId={householdId} />
</div>
);
}

View file

@ -0,0 +1,51 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { OrganizerTab } from '../OrganizerTab';
export default function OrganizerPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
<div className="animate-pulse space-y-3">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before using the pill organizer.
</p>
</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Pill Organizer</h1>
</div>
<OrganizerTab householdId={householdId} />
</div>
);
}

View file

@ -41,6 +41,21 @@ export default function MedicinesPage() {
description="Track your medicine inventory, quantities and expiry dates" description="Track your medicine inventory, quantities and expiry dates"
href="/medicines/cabinet" href="/medicines/cabinet"
/> />
<SectionCard
title="Regimens"
description="Define daily medication schedules and track dosage frequency"
href="/medicines/regimens"
/>
<SectionCard
title="Organizer"
description="Fill your pill organizer and track cabinet usage"
href="/medicines/organizer"
/>
<SectionCard
title="Activity"
description="View cabinet event history and spending summaries"
href="/medicines/activity"
/>
</div> </div>
</div> </div>
); );
@ -73,6 +88,9 @@ function PageSkeleton() {
<div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="h-24 rounded-xl bg-gray-200" /> <div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" /> <div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,52 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { RegimensTab } from '../RegimensTab';
export default function RegimensPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-64 rounded-lg bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing regimens.
</p>
</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Regimens</h1>
</div>
<RegimensTab householdId={householdId} />
</div>
);
}

View file

@ -0,0 +1,60 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
CabinetEventListResponseSchema,
SpendingSummaryResponseSchema,
} from '@meshitrack/shared';
type CabinetEventListResponse = z.infer<typeof CabinetEventListResponseSchema>;
type SpendingSummaryResponse = z.infer<typeof SpendingSummaryResponseSchema>;
export async function listCabinetEvents(
householdId: string,
query?: {
medicineId?: string;
eventType?: string;
startDate?: string;
endDate?: string;
cursor?: string;
limit?: number;
},
): Promise<CabinetEventListResponse> {
const params = new URLSearchParams();
if (query?.medicineId) params.set('medicineId', query.medicineId);
if (query?.eventType) params.set('eventType', query.eventType);
if (query?.startDate) params.set('startDate', query.startDate);
if (query?.endDate) params.set('endDate', query.endDate);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<CabinetEventListResponse>(
`/households/${householdId}/cabinet-events${qs ? `?${qs}` : ''}`,
);
}
export async function getEventsByItem(
householdId: string,
cabinetItemId: string,
query?: { cursor?: string; limit?: number },
): Promise<CabinetEventListResponse> {
const params = new URLSearchParams();
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<CabinetEventListResponse>(
`/households/${householdId}/cabinet-events/by-item/${cabinetItemId}${qs ? `?${qs}` : ''}`,
);
}
export async function getSpendingSummary(
householdId: string,
query?: { period?: string; medicineId?: string },
): Promise<SpendingSummaryResponse> {
const params = new URLSearchParams();
if (query?.period) params.set('period', query.period);
if (query?.medicineId) params.set('medicineId', query.medicineId);
const qs = params.toString();
return apiClient.get<SpendingSummaryResponse>(
`/households/${householdId}/cabinet-events/spending-summary${qs ? `?${qs}` : ''}`,
);
}

View file

@ -0,0 +1,55 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
OrganizerFillListResponseSchema,
OrganizerFillResponseSchema,
OrganizerPreviewResponseSchema,
OrganizerFillSchema,
OrganizerPreviewSchema,
} from '@meshitrack/shared';
type OrganizerFillListResponse = z.infer<typeof OrganizerFillListResponseSchema>;
type OrganizerFillResponse = z.infer<typeof OrganizerFillResponseSchema>;
type OrganizerPreviewResponse = z.infer<typeof OrganizerPreviewResponseSchema>;
type OrganizerFillInput = z.infer<typeof OrganizerFillSchema>;
type OrganizerPreviewInput = z.infer<typeof OrganizerPreviewSchema>;
export async function listFills(
householdId: string,
query?: { regimenId?: string; status?: string; cursor?: string; limit?: number },
): Promise<OrganizerFillListResponse> {
const params = new URLSearchParams();
if (query?.regimenId) params.set('regimenId', query.regimenId);
if (query?.status) params.set('status', query.status);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<OrganizerFillListResponse>(
`/households/${householdId}/organizer/fills${qs ? `?${qs}` : ''}`,
);
}
export async function getFill(householdId: string, id: string): Promise<OrganizerFillResponse> {
return apiClient.get<OrganizerFillResponse>(`/households/${householdId}/organizer/fills/${id}`);
}
export async function previewFill(
householdId: string,
data: OrganizerPreviewInput,
): Promise<OrganizerPreviewResponse> {
return apiClient.post<OrganizerPreviewResponse>(`/households/${householdId}/organizer/preview`, data);
}
export async function executeFill(
householdId: string,
data: OrganizerFillInput,
): Promise<OrganizerFillResponse> {
return apiClient.post<OrganizerFillResponse>(`/households/${householdId}/organizer/fill`, data);
}
export async function undoFill(householdId: string, fillId: string): Promise<OrganizerFillResponse> {
return apiClient.post<OrganizerFillResponse>(
`/households/${householdId}/organizer/fills/${fillId}/undo`,
{},
);
}

View file

@ -0,0 +1,51 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
RegimenResponseSchema,
RegimenListResponseSchema,
BurnRateResponseSchema,
CreateRegimenSchema,
UpdateRegimenSchema,
} from '@meshitrack/shared';
type RegimenResponse = z.infer<typeof RegimenResponseSchema>;
type RegimenListResponse = z.infer<typeof RegimenListResponseSchema>;
type BurnRateResponse = z.infer<typeof BurnRateResponseSchema>;
type CreateRegimenInput = z.infer<typeof CreateRegimenSchema>;
type UpdateRegimenInput = z.infer<typeof UpdateRegimenSchema>;
export async function listRegimens(
householdId: string,
query?: { isActive?: boolean; cursor?: string; limit?: number },
): Promise<RegimenListResponse> {
const params = new URLSearchParams();
if (query?.isActive !== undefined) params.set('isActive', String(query.isActive));
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<RegimenListResponse>(`/households/${householdId}/regimens${qs ? `?${qs}` : ''}`);
}
export async function getRegimen(householdId: string, id: string): Promise<RegimenResponse> {
return apiClient.get<RegimenResponse>(`/households/${householdId}/regimens/${id}`);
}
export async function getBurnRates(householdId: string): Promise<BurnRateResponse> {
return apiClient.get<BurnRateResponse>(`/households/${householdId}/regimens/burn-rate`);
}
export async function createRegimen(householdId: string, data: CreateRegimenInput): Promise<RegimenResponse> {
return apiClient.post<RegimenResponse>(`/households/${householdId}/regimens`, data);
}
export async function updateRegimen(
householdId: string,
id: string,
data: UpdateRegimenInput,
): Promise<RegimenResponse> {
return apiClient.patch<RegimenResponse>(`/households/${householdId}/regimens/${id}`, data);
}
export async function deleteRegimen(householdId: string, id: string): Promise<void> {
return apiClient.delete<void>(`/households/${householdId}/regimens/${id}`);
}