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 1 — Medicine Library
**Goal**: A searchable catalog of medicines with dosage and form information. Medicines are the atomic building blocks for regimens, cabinet inventory, and refill tracking.
**Goal**: A two-level catalog of medicines. The **Medicine** level represents the generic substance you take (what regimens reference). The **MedicineProduct** level represents a specific purchasable item from a brand/manufacturer (what you buy and track prices for). Cabinet inventory tracks current quantity at the Medicine level, with an optional link back to which product it came from.
**Depends on**: Phase 0 (auth, households, shared types)
@ -8,37 +8,31 @@
## Deliverables
1. `Medicine` MongoDB schema and full CRUD API
2. Full-text search with filters
3. Barcode lookup (future: integration with drug database APIs)
4. Bulk import (CSV/JSON)
5. Medicine library web UI (search, add, edit)
1. `Medicine` and `MedicineProduct` MongoDB schemas with full CRUD APIs
2. Full-text search with category/form filters
3. Cascade delete protection (cannot delete medicine with linked products)
4. Medicine library web UI (search, filter, add, edit, delete -- both levels)
---
## Data Model
### Medicine Schema
### Medicine Schema (Generic Level)
The generic substance -- what you take. Regimens and cabinet items reference this.
```typescript
// packages/shared/src/types/medicine.ts
export interface Medicine {
id: string;
householdId: string;
name: string;
genericName?: string;
brand?: string;
barcode?: string;
name: string; // Display name, e.g., "Metformin" or "Vitamin D3"
form: MedicineForm;
strength: number;
strengthUnit: StrengthUnit;
strength: number; // e.g., 500
strengthUnit: StrengthUnit; // e.g., 'mg' (weight/count only)
category: MedicineCategory;
activeIngredient?: string;
manufacturer?: string;
notes?: string;
imageUrl?: string;
tags: string[];
source: MedicineSource;
createdBy: string;
createdAt: Date;
updatedAt: Date;
@ -72,45 +66,99 @@ export enum MedicineCategory {
PRESCRIPTION = 'prescription',
OTC = 'otc',
SUPPLEMENT = 'supplement',
VITAMIN = 'vitamin',
HERBAL = 'herbal',
OTHER = 'other',
}
```
export enum MedicineSource {
### MedicineProduct Schema (Purchasable Level)
A specific brand/package you can buy. References a Medicine. Used for price tracking and ordering. Vial products can include concentration data.
```typescript
// packages/shared/src/types/medicine.ts
export interface MedicineProduct {
id: string;
householdId: string;
medicineId: string; // Reference to Medicine
medicineName: string; // Denormalized for display
brand: string; // e.g., "CVS Health", "Kirkland"
manufacturer?: string;
packageSize: number; // e.g., 90 (pills per bottle)
packageUnit: DosageUnit; // e.g., 'pill', 'ml', 'vial'
concentration?: number; // For vials only, e.g., 100
concentrationUnit?: ConcentrationUnit; // For vials only, e.g., 'units/mL'
imageUrl?: string;
notes?: string;
source: MedicineProductSource;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export enum MedicineProductSource {
MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
IMPORT = 'import',
}
export enum ConcentrationUnit {
MG_PER_ML = 'mg/mL',
MCG_PER_ML = 'mcg/mL',
UNITS_PER_ML = 'units/mL',
}
```
### Relationship Diagram
```
Medicine (generic) MedicineProduct (purchasable)
┌─────────────────────┐ ┌──────────────────────────────┐
│ Metformin 500mg tab │◄────────│ CVS Metformin 500mg, 90ct │
│ │◄────────│ Kirkland Metformin 500mg, 60ct│
└─────────────────────┘ └──────────────────────────────┘
▲ ▲
│ │
Referenced by: Referenced by:
- Regimens (Phase 3) - PriceRecords (Phase 4)
- CabinetItems (Phase 2) - CabinetItems (Phase 2, optional)
```
### MongoDB Indexes
```javascript
// Text index for search
{ name: 'text', genericName: 'text', brand: 'text', activeIngredient: 'text', tags: 'text' }
// Compound indexes
// Medicine
{ householdId: 1, name: 'text', tags: 'text' }
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 } // unique within household
{ householdId: 1, name: 1, strength: 1, form: 1 } // near-unique for dedup
{ householdId: 1, name: 1, strength: 1, strengthUnit: 1, form: 1 } // dedup
// MedicineProduct
{ householdId: 1, medicineId: 1 }
{ householdId: 1, brand: 'text' }
```
---
## API Endpoints
### MedicinesModule
### MedicinesModule (Generic Level)
| Method | Path | Description | Auth |
| ------ | -------------------------- | -------------------------------- | ------ |
| GET | `/medicines` | List/search medicines (paginated)| member |
| GET | `/medicines/:id` | Get single medicine | member |
| POST | `/medicines` | Create medicine | member |
| PATCH | `/medicines/:id` | Update medicine | member |
| DELETE | `/medicines/:id` | Soft-delete medicine | admin |
| GET | `/medicines/barcode/:code` | Lookup by barcode | member |
| POST | `/medicines/import` | Bulk import from CSV/JSON | admin |
| Method | Path | Description | Auth |
| ------ | --------------------- | --------------------------------- | ------ |
| GET | `/medicines` | List/search medicines (paginated) | member |
| GET | `/medicines/:id` | Get single medicine | member |
| POST | `/medicines` | Create medicine | member |
| PATCH | `/medicines/:id` | Update medicine | member |
| DELETE | `/medicines/:id` | Soft-delete medicine | admin |
| POST | `/medicines/import` | Bulk import from CSV/JSON | admin |
### MedicineProductsModule (Purchasable Level)
| Method | Path | Description | Auth |
| ------ | --------------------------------------- | ------------------------------------------ | ------ |
| GET | `/medicines/:medicineId/products` | List products for a medicine | member |
| GET | `/medicine-products/:id` | Get single product | member |
| POST | `/medicines/:medicineId/products` | Create product under a medicine | member |
| PATCH | `/medicine-products/:id` | Update product | member |
| DELETE | `/medicine-products/:id` | Soft-delete product | admin |
### Query Parameters for GET `/medicines`
@ -141,74 +189,76 @@ interface PaginatedResponse<T> {
## Tasks
### 1.1 Shared Types & Validation
### 1.1 -- Shared Types & Validation
- Add all types above to `packages/shared/src/types/medicine.ts`
- Add enums to `packages/shared/src/enums/`
- Create Zod schemas:
- `CreateMedicineSchema` — validates create payload
- `UpdateMedicineSchema` — partial, validates update payload
- `MedicineQuerySchema` — validates query params
- [x] Add Medicine types to `packages/shared/src/types/medicine.ts`
- [x] Add MedicineProduct types (same file)
- [x] Add enums to `packages/shared/src/enums/medicine.enums.ts`
- [x] Create Zod schemas:
- `CreateMedicineSchema`, `UpdateMedicineSchema`, `MedicineQuerySchema`
- `CreateMedicineProductSchema`, `UpdateMedicineProductSchema`
### 1.2 Mongoose Schema & Repository
### 1.2 -- Medicine Mongoose Schema & Repository
- `packages/api/src/modules/medicines/medicines.repository.ts`
- `MedicinesRepository` with:
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination
- `findByBarcode(householdId, barcode)`
- [x] `packages/api/src/modules/medicines/medicines.repository.ts`
- [x] `MedicinesRepository` with:
- `findByHousehold(householdId, query)` -- supports text search, filters, cursor pagination
- `findById(id, householdId)`
- `findDuplicate(householdId, name, strength, strengthUnit, form, excludeId?)`
- `create(data)`
- `update(id, householdId, data)`
- `softDelete(id, householdId)`
- `bulkCreate(items[])`
### 1.3 — Service & Routes
### 1.3 -- MedicineProduct Mongoose Schema & Repository
- `MedicinesService` with business logic (dedup check on create, validation)
- `MedicinesRoutes` with Fastify route plugin registering all endpoints
- Register Awilix dependencies via `fp()` plugin
- [x] `packages/api/src/modules/medicine-products/medicine-products.repository.ts`
- [x] `MedicineProductsRepository` with:
- `findByMedicine(householdId, medicineId, query)` -- paginated
- `findById(id, householdId)`
- `countByMedicineId(medicineId)` -- for cascade delete check
- `create(data)`
- `update(id, householdId, data)`
- `softDelete(id, householdId)`
### 1.4 — Barcode Lookup
### 1.4 -- Services & Routes
- `BarcodeLookupService`:
- First check local DB for matching barcode
- Placeholder for external drug database API integration (manual entry fallback)
- Cache results in local DB with `source: 'barcode_lookup'`
- [x] `MedicinesService` with business logic:
- Dedup check on create (same name + strength + strengthUnit + form within household)
- Cascade: when deleting a medicine, check for linked products and block if any exist
- [x] `MedicineProductsService` with business logic:
- Validate medicineId exists on create
- Denormalize medicineName
- [x] Route plugins for both modules, registered via `fp()`
### 1.5 — Import Endpoint
### 1.5 -- Web UI: Medicine Library
- `POST /medicines/import` accepts multipart CSV or JSON file
- Validate each row against `CreateMedicineSchema`
- Return summary: `{ imported: N, skipped: M, errors: [...] }`
- CSV column mapping: `name, genericName, brand, barcode, form, strength, strengthUnit, category, activeIngredient, manufacturer`
### 1.6 — Web UI: Medicine Library
- `/medicines` page:
- Search bar with debounced full-text search
- [x] `/medicines` page:
- Search bar with text search
- Category and form filter dropdowns
- Tag filter chips
- Medicine grid/list view (toggle)
- Each medicine card shows: name, strength + unit, form, brand, category badge
- Add/Edit medicine modal:
- Form fields for all medicine properties
- Barcode field with "Lookup" button
- Import dialog: file upload with preview and error display
- Medicine list view
- Each medicine card shows: name, strength + unit, form, category badge
- Click a medicine to see its detail page with products
- [x] Add/Edit medicine form
- [x] Add/Edit product form (nested under a medicine detail page)
- Brand, manufacturer, package size, concentration (vials only)
- [x] Delete with confirmation (blocked if products exist)
---
## Acceptance Criteria
- [ ] Can create, read, update, delete medicines via API
- [ ] Full-text search returns relevant results
- [ ] Barcode lookup checks local DB first
- [ ] Bulk import processes a CSV with 100+ medicines
- [ ] Web UI allows searching, filtering, adding, and editing medicines
- [ ] All medicine queries are scoped to `householdId`
- [ ] Dedup check prevents creating duplicate medicines (same name + strength + form)
- [x] Can create, read, update, delete medicines (generic level) via API
- [x] Can create, read, update, delete medicine products (purchasable level) via API
- [x] Medicine products are correctly linked to their parent medicine
- [x] Full-text search returns relevant results
- [x] Category and form filters work on the medicines list
- [x] Cascade delete check prevents deleting medicines with linked products
- [x] Web UI shows two-level hierarchy: medicines with nested products
- [x] All queries are scoped to `householdId`
- [x] Dedup check prevents creating duplicate medicines (same name + strength + unit + form)
- [x] Vial products support concentration + concentration unit fields
---
## Estimated Effort
Medium. Straightforward CRUD with search, following the same patterns established in Phase 0.
Medium. Two related CRUD modules with search. The two-level structure adds a bit of complexity over a flat model but keeps the domain clean.

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

View file

@ -49,13 +49,15 @@ export interface Store {
export interface MedicinePriceRecord {
id: string;
householdId: string;
medicineId: string;
medicineProductId: string; // Reference to MedicineProduct (purchasable level)
medicineProductBrand: string; // Denormalized
medicineId: string; // Reference to Medicine (generic level, for cross-brand comparison)
medicineName: string; // Denormalized
storeId: string;
storeName: string; // Denormalized
price: number;
currency: string; // Default from household settings
quantity: number; // How many pills/units for this price
quantity: number; // How many pills/units for this price (package size)
unit: DosageUnit;
pricePerUnit: number; // Computed: price / quantity
date: Date;
@ -139,8 +141,8 @@ export enum RefillListStatus {
{ householdId: 1, tags: 1 }
// MedicinePriceRecord
{ householdId: 1, medicineId: 1, storeId: 1, date: -1 }
{ householdId: 1, medicineId: 1, date: -1 }
{ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
{ householdId: 1, medicineId: 1, date: -1 } // Cross-brand comparison
{ householdId: 1, storeId: 1, date: -1 }
// RefillList