Implement regimens
This commit is contained in:
parent
1f66fab30f
commit
9f416903ef
66 changed files with 9130 additions and 189 deletions
|
|
@ -2,7 +2,8 @@
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
"Bash(ls:*)"
|
"Bash(ls:*)",
|
||||||
|
"Bash(npm test:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -38,21 +145,21 @@ 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
|
||||||
|
|
@ -76,7 +183,7 @@ export interface OrganizerFill {
|
||||||
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,21 +253,37 @@ export interface BurnRate {
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### RegimensModule
|
### CabinetEventsModule (new)
|
||||||
|
|
||||||
| Method | Path | Description | Auth |
|
| Method | Path | Description | Auth |
|
||||||
| ------ | ----------------------- | ------------------------------------ | ------ |
|
|--------|------|-------------|------|
|
||||||
|
| GET | `/cabinet-events` | List events (filtered by medicineId, eventType, dateRange) | member |
|
||||||
|
| GET | `/cabinet-events/by-item/:cabinetItemId` | Events for a single cabinet item | member |
|
||||||
|
| GET | `/cabinet-events/spending-summary` | Aggregated spending per medicine and period | member |
|
||||||
|
|
||||||
|
### CabinetModule (modifications)
|
||||||
|
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| POST | `/cabinet/:id/discard` | Zero qty + soft-delete + DISCARDED event. Body: `{ reason, notes? }` | member |
|
||||||
|
|
||||||
|
All existing mutation endpoints (`POST /cabinet`, `PATCH /cabinet/:id`, `POST /cabinet/:id/adjust`, `DELETE /cabinet/:id`) now emit CabinetEvents. The `addItem` endpoint accepts optional purchase fields (`unitPrice`, `totalPrice`, `currency`, `storeId`, `storeName`).
|
||||||
|
|
||||||
|
### RegimensModule (new)
|
||||||
|
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
| GET | `/regimens` | List user's regimens | member |
|
| GET | `/regimens` | List user's regimens | member |
|
||||||
| GET | `/regimens/:id` | Get single regimen | member |
|
| GET | `/regimens/:id` | Get single regimen | member |
|
||||||
| POST | `/regimens` | Create regimen | member |
|
| POST | `/regimens` | Create regimen | member |
|
||||||
| PATCH | `/regimens/:id` | Update regimen | member |
|
| PATCH | `/regimens/:id` | Update regimen | member |
|
||||||
| DELETE | `/regimens/:id` | Delete regimen | member |
|
| DELETE | `/regimens/:id` | Delete regimen | member |
|
||||||
| GET | `/regimens/burn-rate` | Burn rate for all active regimens | member |
|
| GET | `/regimens/burn-rate` | Burn rate + spending projection | member |
|
||||||
|
|
||||||
### OrganizerModule
|
### OrganizerModule (new)
|
||||||
|
|
||||||
| Method | Path | Description | Auth |
|
| Method | Path | Description | Auth |
|
||||||
| ------ | ----------------------------- | -------------------------------------------- | ------ |
|
|--------|------|-------------|------|
|
||||||
| GET | `/organizer/fills` | List fill history (paginated) | member |
|
| GET | `/organizer/fills` | List fill history (paginated) | member |
|
||||||
| GET | `/organizer/fills/:id` | Get single fill details | member |
|
| GET | `/organizer/fills/:id` | Get single fill details | member |
|
||||||
| POST | `/organizer/preview` | Preview a fill (shows quantities, shortages) | member |
|
| POST | `/organizer/preview` | Preview a fill (shows quantities, shortages) | member |
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
157
packages/api/src/modules/cabinet-events/cabinet-events.routes.ts
Normal file
157
packages/api/src/modules/cabinet-events/cabinet-events.routes.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
183
packages/api/src/modules/organizer/organizer.repository.test.ts
Normal file
183
packages/api/src/modules/organizer/organizer.repository.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
77
packages/api/src/modules/organizer/organizer.repository.ts
Normal file
77
packages/api/src/modules/organizer/organizer.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
478
packages/api/src/modules/organizer/organizer.routes.test.ts
Normal file
478
packages/api/src/modules/organizer/organizer.routes.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
203
packages/api/src/modules/organizer/organizer.routes.ts
Normal file
203
packages/api/src/modules/organizer/organizer.routes.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
);
|
||||||
647
packages/api/src/modules/organizer/organizer.service.test.ts
Normal file
647
packages/api/src/modules/organizer/organizer.service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
290
packages/api/src/modules/organizer/organizer.service.ts
Normal file
290
packages/api/src/modules/organizer/organizer.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
packages/api/src/modules/regimens/regimens.repository.test.ts
Normal file
230
packages/api/src/modules/regimens/regimens.repository.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
packages/api/src/modules/regimens/regimens.repository.ts
Normal file
92
packages/api/src/modules/regimens/regimens.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
488
packages/api/src/modules/regimens/regimens.routes.test.ts
Normal file
488
packages/api/src/modules/regimens/regimens.routes.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
211
packages/api/src/modules/regimens/regimens.routes.ts
Normal file
211
packages/api/src/modules/regimens/regimens.routes.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
);
|
||||||
757
packages/api/src/modules/regimens/regimens.service.test.ts
Normal file
757
packages/api/src/modules/regimens/regimens.service.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
202
packages/api/src/modules/regimens/regimens.service.ts
Normal file
202
packages/api/src/modules/regimens/regimens.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
packages/api/src/schemas/cabinet-event.schema.ts
Normal file
40
packages/api/src/schemas/cabinet-event.schema.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
55
packages/api/src/schemas/organizer-fill.schema.ts
Normal file
55
packages/api/src/schemas/organizer-fill.schema.ts
Normal 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;
|
||||||
|
};
|
||||||
50
packages/api/src/schemas/regimen.schema.ts
Normal file
50
packages/api/src/schemas/regimen.schema.ts
Normal 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;
|
||||||
|
};
|
||||||
39
packages/shared/src/enums/cabinet-event.enums.test.ts
Normal file
39
packages/shared/src/enums/cabinet-event.enums.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
packages/shared/src/enums/cabinet-event.enums.ts
Normal file
15
packages/shared/src/enums/cabinet-event.enums.ts
Normal 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',
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
55
packages/shared/src/enums/regimen.enums.test.ts
Normal file
55
packages/shared/src/enums/regimen.enums.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
packages/shared/src/enums/regimen.enums.ts
Normal file
22
packages/shared/src/enums/regimen.enums.ts
Normal 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',
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
13
packages/shared/src/types/burn-rate.ts
Normal file
13
packages/shared/src/types/burn-rate.ts
Normal 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;
|
||||||
|
}
|
||||||
48
packages/shared/src/types/cabinet-event.ts
Normal file
48
packages/shared/src/types/cabinet-event.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
31
packages/shared/src/types/organizer-fill.ts
Normal file
31
packages/shared/src/types/organizer-fill.ts
Normal 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;
|
||||||
|
}
|
||||||
31
packages/shared/src/types/regimen.ts
Normal file
31
packages/shared/src/types/regimen.ts
Normal 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;
|
||||||
|
}
|
||||||
68
packages/shared/src/utils/frequency.test.ts
Normal file
68
packages/shared/src/utils/frequency.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
packages/shared/src/utils/frequency.ts
Normal file
63
packages/shared/src/utils/frequency.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/shared/src/utils/index.ts
Normal file
1
packages/shared/src/utils/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './frequency.js';
|
||||||
111
packages/shared/src/validation/cabinet-event.schemas.test.ts
Normal file
111
packages/shared/src/validation/cabinet-event.schemas.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
packages/shared/src/validation/cabinet-event.schemas.ts
Normal file
87
packages/shared/src/validation/cabinet-event.schemas.ts
Normal 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>;
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
113
packages/shared/src/validation/organizer.schemas.test.ts
Normal file
113
packages/shared/src/validation/organizer.schemas.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
packages/shared/src/validation/organizer.schemas.ts
Normal file
93
packages/shared/src/validation/organizer.schemas.ts
Normal 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>;
|
||||||
157
packages/shared/src/validation/regimen.schemas.test.ts
Normal file
157
packages/shared/src/validation/regimen.schemas.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
packages/shared/src/validation/regimen.schemas.ts
Normal file
105
packages/shared/src/validation/regimen.schemas.ts
Normal 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>;
|
||||||
420
packages/web/src/app/(dashboard)/medicines/ActivityTab.tsx
Normal file
420
packages/web/src/app/(dashboard)/medicines/ActivityTab.tsx
Normal 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' : ''} •{' '}
|
||||||
|
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} → {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
455
packages/web/src/app/(dashboard)/medicines/OrganizerTab.tsx
Normal file
455
packages/web/src/app/(dashboard)/medicines/OrganizerTab.tsx
Normal 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} — {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' : ''} •{' '}
|
||||||
|
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} •{' '}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
722
packages/web/src/app/(dashboard)/medicines/RegimensTab.tsx
Normal file
722
packages/web/src/app/(dashboard)/medicines/RegimensTab.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
packages/web/src/app/(dashboard)/medicines/activity/page.tsx
Normal file
51
packages/web/src/app/(dashboard)/medicines/activity/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
52
packages/web/src/app/(dashboard)/medicines/regimens/page.tsx
Normal file
52
packages/web/src/app/(dashboard)/medicines/regimens/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
packages/web/src/services/cabinet-events.ts
Normal file
60
packages/web/src/services/cabinet-events.ts
Normal 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}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
55
packages/web/src/services/organizer.ts
Normal file
55
packages/web/src/services/organizer.ts
Normal 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`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
51
packages/web/src/services/regimens.ts
Normal file
51
packages/web/src/services/regimens.ts
Normal 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}`);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue