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.