Implement medicine library and cabinet

This commit is contained in:
Aerilyn Weber 2026-03-28 08:19:48 +09:00
parent db79af06f7
commit 1f66fab30f
72 changed files with 7642 additions and 319 deletions

View file

@ -1,6 +1,6 @@
# Phase 2 — Medicine Cabinet
**Goal**: Track medicine inventory — what you have, how much of each, and when it expires. Provide aggregate views and low-stock/expiry warnings.
**Goal**: Track medicine inventory — what you have, how much of each, and when it expires.
**Depends on**: Phase 0, Phase 1 (medicines)
@ -10,9 +10,8 @@
1. `CabinetItem` MongoDB schema and full CRUD API
2. Aggregate quantity view per medicine
3. Expiry date tracking and warnings
4. Low stock alerts (based on configurable thresholds)
5. Medicine cabinet web UI with status indicators
3. Expiry date tracking with visual indicators
4. Medicine cabinet web UI with status indicators
---
@ -25,19 +24,18 @@
export interface CabinetItem {
id: string;
householdId: string;
medicineId: string;
medicineId: string; // Reference to Medicine (generic level)
medicineName: string; // Denormalized
medicineStrength: number; // Denormalized for display
medicineStrengthUnit: StrengthUnit; // Denormalized
medicineForm: MedicineForm; // Denormalized
quantity: number;
medicineProductId?: string; // Optional reference to MedicineProduct (what was purchased)
medicineProductBrand?: string; // Denormalized
concentration?: number; // Denormalized from product (injection vials)
concentrationUnit?: ConcentrationUnit; // Denormalized from product
quantity: number; // Current quantity (independent of package size)
unit: DosageUnit;
expirationDate?: Date;
lotNumber?: string;
purchaseDate?: Date;
purchasePrice?: number;
storeId?: string;
storeName?: string; // Denormalized
status: CabinetItemStatus;
notes?: string;
createdBy: string;
@ -45,23 +43,10 @@ export interface CabinetItem {
updatedAt: Date;
}
export enum DosageUnit {
PILL = 'pill',
CAPSULE = 'capsule',
ML = 'ml',
G = 'g',
PATCH = 'patch',
DOSE = 'dose',
PUFF = 'puff',
DROP = 'drop',
APPLICATION = 'application',
}
export enum CabinetItemStatus {
ACTIVE = 'active',
DEPLETED = 'depleted',
EXPIRED = 'expired',
DISCARDED = 'discarded',
}
```
@ -79,8 +64,6 @@ export interface CabinetSummary {
unit: DosageUnit;
earliestExpiry: Date | null;
itemCount: number; // How many cabinet items (bottles/boxes)
lowStockThreshold?: number; // From household settings
isLowStock: boolean;
}
```
@ -89,8 +72,7 @@ export interface CabinetSummary {
```javascript
{ householdId: 1, medicineId: 1, status: 1 }
{ householdId: 1, status: 1 }
{ householdId: 1, expirationDate: 1 } // For expiry warnings
{ householdId: 1, 'quantity': 1 }
{ householdId: 1, expirationDate: 1 }
```
---
@ -107,9 +89,8 @@ export interface CabinetSummary {
| POST | `/cabinet` | Add item to cabinet | member |
| PATCH | `/cabinet/:id` | Update item (quantity, notes, etc.) | member |
| POST | `/cabinet/:id/adjust` | Adjust quantity (add/subtract without full edit) | member |
| DELETE | `/cabinet/:id` | Hard delete (admin) | admin |
| DELETE | `/cabinet/:id` | Delete cabinet item | member |
| GET | `/cabinet/expiring-soon` | Items expiring within N days | member |
| GET | `/cabinet/low-stock` | Medicines below threshold quantity | member |
### Query Parameters for GET `/cabinet`
@ -117,7 +98,6 @@ export interface CabinetSummary {
?medicineId=abc123 # Filter by medicine
&status=active # Filter by status
&expiringWithin=30 # Days until expiry
&sort=-expirationDate|name # Sort field
&cursor=abc123
&limit=20
```
@ -128,7 +108,7 @@ export interface CabinetSummary {
// POST /cabinet/:id/adjust
interface AdjustQuantityRequest {
delta: number; // Positive to add, negative to subtract
reason?: string; // e.g., "Correcting count", "Dropped a pill"
reason?: string; // e.g., "Correcting count"
}
```
@ -151,102 +131,56 @@ interface AdjustQuantityRequest {
- `CabinetRepository` with:
- `findByHousehold(householdId, query)` — filtered, paginated
- `findById(id, householdId)`
- `findByMedicine(householdId, medicineId)` — all items for a medicine
- `getAggregateSummary(householdId)` — MongoDB aggregation pipeline
- `create(data)`
- `update(id, householdId, data)`
- `adjustQuantity(id, householdId, delta)` — atomic `$inc`
- `adjustQuantity(id, householdId, delta)` — atomic adjust with floor at 0
- `findExpiringSoon(householdId, withinDays)`
- `delete(id, householdId)`
- `softDelete(id, householdId)`
### 2.3 — Cabinet Service
```typescript
class CabinetService {
/** Add item, denormalizing medicine fields */
/** Add item, denormalizing medicine and product fields */
addItem(data: CreateCabinetItem): Promise<CabinetItem>;
/** Adjust quantity with floor at 0, auto-set depleted status */
adjustQuantity(id: string, householdId: string, delta: number, reason?: string): Promise<CabinetItem>;
adjustQuantity(id: string, householdId: string, delta: number): Promise<CabinetItem>;
/** Get aggregate summary with low stock flags */
/** Get aggregate summary */
getSummary(householdId: string): Promise<CabinetSummary[]>;
/** Find items expiring within N days */
getExpiringSoon(householdId: string, withinDays: number): Promise<CabinetItem[]>;
/** Find medicines below low stock threshold */
getLowStock(householdId: string): Promise<CabinetSummary[]>;
/**
* Deduct quantity from cabinet items for a medicine (used by Pill Organizer in Phase 3).
* Uses FEFO (First Expiry, First Out) — draws from items with earliest expiry first.
* Returns actual quantity deducted (may be less than requested if insufficient).
*/
deductStock(householdId: string, medicineId: string, quantity: number): Promise<DeductionResult>;
/** Reverse a deduction (used by Pill Organizer undo) */
restoreStock(householdId: string, cabinetItemId: string, quantity: number): Promise<CabinetItem>;
}
interface DeductionResult {
totalDeducted: number;
requested: number;
isShort: boolean;
deductions: {
cabinetItemId: string;
quantityTaken: number;
remainingInItem: number;
}[];
}
```
### 2.4 — Expiry Check Job
### 2.4 — Web UI: Medicine Cabinet
- Scheduled job (daily at 6 AM, configurable):
1. Query all active cabinet items with `expirationDate <= today`
2. Update status to `expired`
3. Create in-app notifications for expired items
4. Query items expiring within 7 days, create warning notifications
### 2.5 — Web UI: Medicine Cabinet
- `/cabinet` page:
- `/medicines/cabinet` page:
- **Summary view** (default): aggregated per medicine
- Medicine name, total quantity, earliest expiry, low stock indicator
- Medicine name, total quantity, earliest expiry
- Expand to see individual items (bottles/boxes)
- **Detail view**: all individual cabinet items
- Each item shows: medicine name, quantity, expiry date, status badge
- Color-coded expiry: green (>30 days), yellow (7-30 days), red (<7 days), grey (expired)
- Low stock badge on medicines below threshold
- Quick actions: adjust quantity (+/-), discard
- "Add to Cabinet" button -> modal:
- Medicine autocomplete (from library)
- Quantity + unit
- Expiration date (optional)
- Lot number (optional)
- Purchase date, price, store (optional)
- `/cabinet/alerts` or notification panel:
- Expiring soon items
- Low stock warnings
- **Detail view**: all individual cabinet items with status filter
- Each item shows: quantity, unit, expiry date, status badge
- Color-coded expiry: green (>30 days), yellow (7-30 days), red (<7 days), bold red (expired)
- Quick actions: adjust quantity (+/-), delete
- "Add to Cabinet" form with medicine search/select
- `/medicines/[id]` detail page:
- Inventory section showing cabinet items for that medicine
- Adjust and delete actions inline
---
## Acceptance Criteria
- [ ] Can add items to cabinet linked to medicines
- [ ] Aggregate summary shows total quantity per medicine
- [ ] Quantity adjustments are atomic and floor at 0
- [ ] Items auto-transition to `depleted` when quantity reaches 0
- [ ] Items auto-transition to `expired` when past expiration date
- [ ] Expiring-soon endpoint returns items within N days
- [ ] Low-stock endpoint compares against configurable thresholds
- [ ] FEFO deduction draws from earliest-expiring items first
- [ ] Web UI shows color-coded expiry indicators
- [ ] All cabinet queries are scoped to `householdId`
---
## Estimated Effort
Medium. CRUD with aggregation pipeline, FEFO logic, and scheduled expiry job. Simpler than food pantry tracking (no freshness estimation).
- [x] Can add items to cabinet linked to medicines
- [x] Aggregate summary shows total quantity per medicine
- [x] Quantity adjustments are atomic and floor at 0
- [x] Items auto-transition to `depleted` when quantity reaches 0
- [x] Expiring-soon endpoint returns items within N days
- [x] Web UI shows color-coded expiry indicators
- [x] All cabinet queries are scoped to `householdId`
- [x] Medicine detail page shows inventory for that medicine
- [x] Concentration is denormalized from product for injection vials