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,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run:*)" "Bash(npm run:*)",
"Bash(ls:*)"
] ]
} }
} }

View file

@ -61,7 +61,7 @@ The API follows a **routes → services → repositories** pattern with Awilix c
Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules. Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules.
### Domain Modules ### Domain Modules
**Medicine domain** (Phases 1-4): `medicines/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `refills/` **Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `refills/`
**Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/` **Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/`
**Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/` **Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/`

View file

@ -74,7 +74,8 @@ MeshiTrack/
│ │ │ │ ├── health/ │ │ │ │ ├── health/
│ │ │ │ ├── users/ │ │ │ │ ├── users/
│ │ │ │ ├── households/ │ │ │ │ ├── households/
│ │ │ │ ├── medicines/ # Phase 1: Medicine catalog │ │ │ │ ├── medicines/ # Phase 1: Medicine catalog (generic level)
│ │ │ │ ├── medicine-products/ # Phase 1: Purchasable brands/packages
│ │ │ │ ├── cabinet/ # Phase 2: Medicine inventory │ │ │ │ ├── cabinet/ # Phase 2: Medicine inventory
│ │ │ │ ├── regimens/ # Phase 3: Medication schedules │ │ │ │ ├── regimens/ # Phase 3: Medication schedules
│ │ │ │ ├── organizer/ # Phase 3: Pill organizer fills │ │ │ │ ├── organizer/ # Phase 3: Pill organizer fills

View file

@ -1,6 +1,6 @@
# Phase 1 — Medicine Library # 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) **Depends on**: Phase 0 (auth, households, shared types)
@ -8,37 +8,31 @@
## Deliverables ## Deliverables
1. `Medicine` MongoDB schema and full CRUD API 1. `Medicine` and `MedicineProduct` MongoDB schemas with full CRUD APIs
2. Full-text search with filters 2. Full-text search with category/form filters
3. Barcode lookup (future: integration with drug database APIs) 3. Cascade delete protection (cannot delete medicine with linked products)
4. Bulk import (CSV/JSON) 4. Medicine library web UI (search, filter, add, edit, delete -- both levels)
5. Medicine library web UI (search, add, edit)
--- ---
## Data Model ## Data Model
### Medicine Schema ### Medicine Schema (Generic Level)
The generic substance -- what you take. Regimens and cabinet items reference this.
```typescript ```typescript
// packages/shared/src/types/medicine.ts // packages/shared/src/types/medicine.ts
export interface Medicine { export interface Medicine {
id: string; id: string;
householdId: string; householdId: string;
name: string; name: string; // Display name, e.g., "Metformin" or "Vitamin D3"
genericName?: string;
brand?: string;
barcode?: string;
form: MedicineForm; form: MedicineForm;
strength: number; strength: number; // e.g., 500
strengthUnit: StrengthUnit; strengthUnit: StrengthUnit; // e.g., 'mg' (weight/count only)
category: MedicineCategory; category: MedicineCategory;
activeIngredient?: string;
manufacturer?: string;
notes?: string; notes?: string;
imageUrl?: string;
tags: string[]; tags: string[];
source: MedicineSource;
createdBy: string; createdBy: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@ -72,46 +66,100 @@ export enum MedicineCategory {
PRESCRIPTION = 'prescription', PRESCRIPTION = 'prescription',
OTC = 'otc', OTC = 'otc',
SUPPLEMENT = 'supplement', SUPPLEMENT = 'supplement',
VITAMIN = 'vitamin',
HERBAL = 'herbal',
OTHER = 'other', 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', MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
IMPORT = 'import', 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 ### MongoDB Indexes
```javascript ```javascript
// Text index for search // Medicine
{ name: 'text', genericName: 'text', brand: 'text', activeIngredient: 'text', tags: 'text' } { householdId: 1, name: 'text', tags: 'text' }
// Compound indexes
{ householdId: 1, category: 1 } { householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 } // unique within household { householdId: 1, name: 1, strength: 1, strengthUnit: 1, form: 1 } // dedup
{ householdId: 1, name: 1, strength: 1, form: 1 } // near-unique for dedup
// MedicineProduct
{ householdId: 1, medicineId: 1 }
{ householdId: 1, brand: 'text' }
``` ```
--- ---
## API Endpoints ## API Endpoints
### MedicinesModule ### MedicinesModule (Generic Level)
| Method | Path | Description | Auth | | Method | Path | Description | Auth |
| ------ | -------------------------- | -------------------------------- | ------ | | ------ | --------------------- | --------------------------------- | ------ |
| GET | `/medicines` | List/search medicines (paginated)| member | | GET | `/medicines` | List/search medicines (paginated) | member |
| GET | `/medicines/:id` | Get single medicine | member | | GET | `/medicines/:id` | Get single medicine | member |
| POST | `/medicines` | Create medicine | member | | POST | `/medicines` | Create medicine | member |
| PATCH | `/medicines/:id` | Update medicine | member | | PATCH | `/medicines/:id` | Update medicine | member |
| DELETE | `/medicines/:id` | Soft-delete medicine | admin | | DELETE | `/medicines/:id` | Soft-delete medicine | admin |
| GET | `/medicines/barcode/:code` | Lookup by barcode | member |
| POST | `/medicines/import` | Bulk import from CSV/JSON | 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` ### Query Parameters for GET `/medicines`
``` ```
@ -141,74 +189,76 @@ interface PaginatedResponse<T> {
## Tasks ## Tasks
### 1.1 Shared Types & Validation ### 1.1 -- Shared Types & Validation
- Add all types above to `packages/shared/src/types/medicine.ts` - [x] Add Medicine types to `packages/shared/src/types/medicine.ts`
- Add enums to `packages/shared/src/enums/` - [x] Add MedicineProduct types (same file)
- Create Zod schemas: - [x] Add enums to `packages/shared/src/enums/medicine.enums.ts`
- `CreateMedicineSchema` — validates create payload - [x] Create Zod schemas:
- `UpdateMedicineSchema` — partial, validates update payload - `CreateMedicineSchema`, `UpdateMedicineSchema`, `MedicineQuerySchema`
- `MedicineQuerySchema` — validates query params - `CreateMedicineProductSchema`, `UpdateMedicineProductSchema`
### 1.2 Mongoose Schema & Repository ### 1.2 -- Medicine Mongoose Schema & Repository
- `packages/api/src/modules/medicines/medicines.repository.ts` - [x] `packages/api/src/modules/medicines/medicines.repository.ts`
- `MedicinesRepository` with: - [x] `MedicinesRepository` with:
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination - `findByHousehold(householdId, query)` -- supports text search, filters, cursor pagination
- `findByBarcode(householdId, barcode)`
- `findById(id, householdId)` - `findById(id, householdId)`
- `findDuplicate(householdId, name, strength, strengthUnit, form, excludeId?)`
- `create(data)` - `create(data)`
- `update(id, householdId, data)` - `update(id, householdId, data)`
- `softDelete(id, householdId)` - `softDelete(id, householdId)`
- `bulkCreate(items[])`
### 1.3 — Service & Routes ### 1.3 -- MedicineProduct Mongoose Schema & Repository
- `MedicinesService` with business logic (dedup check on create, validation) - [x] `packages/api/src/modules/medicine-products/medicine-products.repository.ts`
- `MedicinesRoutes` with Fastify route plugin registering all endpoints - [x] `MedicineProductsRepository` with:
- Register Awilix dependencies via `fp()` plugin - `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`: - [x] `MedicinesService` with business logic:
- First check local DB for matching barcode - Dedup check on create (same name + strength + strengthUnit + form within household)
- Placeholder for external drug database API integration (manual entry fallback) - Cascade: when deleting a medicine, check for linked products and block if any exist
- Cache results in local DB with `source: 'barcode_lookup'` - [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 - [x] `/medicines` page:
- Validate each row against `CreateMedicineSchema` - Search bar with text search
- 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
- Category and form filter dropdowns - Category and form filter dropdowns
- Tag filter chips - Medicine list view
- Medicine grid/list view (toggle) - Each medicine card shows: name, strength + unit, form, category badge
- Each medicine card shows: name, strength + unit, form, brand, category badge - Click a medicine to see its detail page with products
- Add/Edit medicine modal: - [x] Add/Edit medicine form
- Form fields for all medicine properties - [x] Add/Edit product form (nested under a medicine detail page)
- Barcode field with "Lookup" button - Brand, manufacturer, package size, concentration (vials only)
- Import dialog: file upload with preview and error display - [x] Delete with confirmation (blocked if products exist)
--- ---
## Acceptance Criteria ## Acceptance Criteria
- [ ] Can create, read, update, delete medicines via API - [x] Can create, read, update, delete medicines (generic level) via API
- [ ] Full-text search returns relevant results - [x] Can create, read, update, delete medicine products (purchasable level) via API
- [ ] Barcode lookup checks local DB first - [x] Medicine products are correctly linked to their parent medicine
- [ ] Bulk import processes a CSV with 100+ medicines - [x] Full-text search returns relevant results
- [ ] Web UI allows searching, filtering, adding, and editing medicines - [x] Category and form filters work on the medicines list
- [ ] All medicine queries are scoped to `householdId` - [x] Cascade delete check prevents deleting medicines with linked products
- [ ] Dedup check prevents creating duplicate medicines (same name + strength + form) - [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 ## 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 # 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) **Depends on**: Phase 0, Phase 1 (medicines)
@ -10,9 +10,8 @@
1. `CabinetItem` MongoDB schema and full CRUD API 1. `CabinetItem` MongoDB schema and full CRUD API
2. Aggregate quantity view per medicine 2. Aggregate quantity view per medicine
3. Expiry date tracking and warnings 3. Expiry date tracking with visual indicators
4. Low stock alerts (based on configurable thresholds) 4. Medicine cabinet web UI with status indicators
5. Medicine cabinet web UI with status indicators
--- ---
@ -25,19 +24,18 @@
export interface CabinetItem { export interface CabinetItem {
id: string; id: string;
householdId: string; householdId: string;
medicineId: string; medicineId: string; // Reference to Medicine (generic level)
medicineName: string; // Denormalized medicineName: string; // Denormalized
medicineStrength: number; // Denormalized for display medicineStrength: number; // Denormalized for display
medicineStrengthUnit: StrengthUnit; // Denormalized medicineStrengthUnit: StrengthUnit; // Denormalized
medicineForm: MedicineForm; // 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; unit: DosageUnit;
expirationDate?: Date; expirationDate?: Date;
lotNumber?: string;
purchaseDate?: Date;
purchasePrice?: number;
storeId?: string;
storeName?: string; // Denormalized
status: CabinetItemStatus; status: CabinetItemStatus;
notes?: string; notes?: string;
createdBy: string; createdBy: string;
@ -45,23 +43,10 @@ export interface CabinetItem {
updatedAt: Date; 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 { export enum CabinetItemStatus {
ACTIVE = 'active', ACTIVE = 'active',
DEPLETED = 'depleted', DEPLETED = 'depleted',
EXPIRED = 'expired', EXPIRED = 'expired',
DISCARDED = 'discarded',
} }
``` ```
@ -79,8 +64,6 @@ export interface CabinetSummary {
unit: DosageUnit; unit: DosageUnit;
earliestExpiry: Date | null; earliestExpiry: Date | null;
itemCount: number; // How many cabinet items (bottles/boxes) itemCount: number; // How many cabinet items (bottles/boxes)
lowStockThreshold?: number; // From household settings
isLowStock: boolean;
} }
``` ```
@ -89,8 +72,7 @@ export interface CabinetSummary {
```javascript ```javascript
{ householdId: 1, medicineId: 1, status: 1 } { householdId: 1, medicineId: 1, status: 1 }
{ householdId: 1, status: 1 } { householdId: 1, status: 1 }
{ householdId: 1, expirationDate: 1 } // For expiry warnings { householdId: 1, expirationDate: 1 }
{ householdId: 1, 'quantity': 1 }
``` ```
--- ---
@ -107,9 +89,8 @@ export interface CabinetSummary {
| POST | `/cabinet` | Add item to cabinet | member | | POST | `/cabinet` | Add item to cabinet | member |
| PATCH | `/cabinet/:id` | Update item (quantity, notes, etc.) | member | | PATCH | `/cabinet/:id` | Update item (quantity, notes, etc.) | member |
| POST | `/cabinet/:id/adjust` | Adjust quantity (add/subtract without full edit) | 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/expiring-soon` | Items expiring within N days | member |
| GET | `/cabinet/low-stock` | Medicines below threshold quantity | member |
### Query Parameters for GET `/cabinet` ### Query Parameters for GET `/cabinet`
@ -117,7 +98,6 @@ export interface CabinetSummary {
?medicineId=abc123 # Filter by medicine ?medicineId=abc123 # Filter by medicine
&status=active # Filter by status &status=active # Filter by status
&expiringWithin=30 # Days until expiry &expiringWithin=30 # Days until expiry
&sort=-expirationDate|name # Sort field
&cursor=abc123 &cursor=abc123
&limit=20 &limit=20
``` ```
@ -128,7 +108,7 @@ export interface CabinetSummary {
// POST /cabinet/:id/adjust // POST /cabinet/:id/adjust
interface AdjustQuantityRequest { interface AdjustQuantityRequest {
delta: number; // Positive to add, negative to subtract 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: - `CabinetRepository` with:
- `findByHousehold(householdId, query)` — filtered, paginated - `findByHousehold(householdId, query)` — filtered, paginated
- `findById(id, householdId)` - `findById(id, householdId)`
- `findByMedicine(householdId, medicineId)` — all items for a medicine
- `getAggregateSummary(householdId)` — MongoDB aggregation pipeline - `getAggregateSummary(householdId)` — MongoDB aggregation pipeline
- `create(data)` - `create(data)`
- `update(id, householdId, data)` - `update(id, householdId, data)`
- `adjustQuantity(id, householdId, delta)` — atomic `$inc` - `adjustQuantity(id, householdId, delta)` — atomic adjust with floor at 0
- `findExpiringSoon(householdId, withinDays)` - `findExpiringSoon(householdId, withinDays)`
- `delete(id, householdId)` - `softDelete(id, householdId)`
### 2.3 — Cabinet Service ### 2.3 — Cabinet Service
```typescript ```typescript
class CabinetService { class CabinetService {
/** Add item, denormalizing medicine fields */ /** Add item, denormalizing medicine and product fields */
addItem(data: CreateCabinetItem): Promise<CabinetItem>; addItem(data: CreateCabinetItem): Promise<CabinetItem>;
/** Adjust quantity with floor at 0, auto-set depleted status */ /** 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[]>; getSummary(householdId: string): Promise<CabinetSummary[]>;
/** Find items expiring within N days */ /** Find items expiring within N days */
getExpiringSoon(householdId: string, withinDays: number): Promise<CabinetItem[]>; 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): - `/medicines/cabinet` page:
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:
- **Summary view** (default): aggregated per medicine - **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) - Expand to see individual items (bottles/boxes)
- **Detail view**: all individual cabinet items - **Detail view**: all individual cabinet items with status filter
- Each item shows: medicine name, quantity, expiry date, status badge - Each item shows: quantity, unit, expiry date, status badge
- Color-coded expiry: green (>30 days), yellow (7-30 days), red (<7 days), grey (expired) - Color-coded expiry: green (>30 days), yellow (7-30 days), red (<7 days), bold red (expired)
- Low stock badge on medicines below threshold - Quick actions: adjust quantity (+/-), delete
- Quick actions: adjust quantity (+/-), discard - "Add to Cabinet" form with medicine search/select
- "Add to Cabinet" button -> modal: - `/medicines/[id]` detail page:
- Medicine autocomplete (from library) - Inventory section showing cabinet items for that medicine
- Quantity + unit - Adjust and delete actions inline
- Expiration date (optional)
- Lot number (optional)
- Purchase date, price, store (optional)
- `/cabinet/alerts` or notification panel:
- Expiring soon items
- Low stock warnings
--- ---
## Acceptance Criteria ## Acceptance Criteria
- [ ] Can add items to cabinet linked to medicines - [x] Can add items to cabinet linked to medicines
- [ ] Aggregate summary shows total quantity per medicine - [x] Aggregate summary shows total quantity per medicine
- [ ] Quantity adjustments are atomic and floor at 0 - [x] Quantity adjustments are atomic and floor at 0
- [ ] Items auto-transition to `depleted` when quantity reaches 0 - [x] Items auto-transition to `depleted` when quantity reaches 0
- [ ] Items auto-transition to `expired` when past expiration date - [x] Expiring-soon endpoint returns items within N days
- [ ] Expiring-soon endpoint returns items within N days - [x] Web UI shows color-coded expiry indicators
- [ ] Low-stock endpoint compares against configurable thresholds - [x] All cabinet queries are scoped to `householdId`
- [ ] FEFO deduction draws from earliest-expiring items first - [x] Medicine detail page shows inventory for that medicine
- [ ] Web UI shows color-coded expiry indicators - [x] Concentration is denormalized from product for injection vials
- [ ] 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).

View file

@ -49,13 +49,15 @@ export interface Store {
export interface MedicinePriceRecord { export interface MedicinePriceRecord {
id: string; id: string;
householdId: 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 medicineName: string; // Denormalized
storeId: string; storeId: string;
storeName: string; // Denormalized storeName: string; // Denormalized
price: number; price: number;
currency: string; // Default from household settings 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; unit: DosageUnit;
pricePerUnit: number; // Computed: price / quantity pricePerUnit: number; // Computed: price / quantity
date: Date; date: Date;
@ -139,8 +141,8 @@ export enum RefillListStatus {
{ householdId: 1, tags: 1 } { householdId: 1, tags: 1 }
// MedicinePriceRecord // MedicinePriceRecord
{ householdId: 1, medicineId: 1, storeId: 1, date: -1 } { householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
{ householdId: 1, medicineId: 1, date: -1 } { householdId: 1, medicineId: 1, date: -1 } // Cross-brand comparison
{ householdId: 1, storeId: 1, date: -1 } { householdId: 1, storeId: 1, date: -1 }
// RefillList // RefillList

View file

@ -11,6 +11,9 @@ vi.mock('mongoose', () => {
this.paths['createdAt'] = { path: 'createdAt' }; this.paths['createdAt'] = { path: 'createdAt' };
this.paths['updatedAt'] = { path: 'updatedAt' }; this.paths['updatedAt'] = { path: 'updatedAt' };
} }
index() {
return this;
}
} }
const models: Record<string, unknown> = {}; const models: Record<string, unknown> = {};

View file

@ -28,6 +28,9 @@ import householdPlugin from './plugins/household.plugin.js';
import healthRoutes from './modules/health/health.routes.js'; import healthRoutes from './modules/health/health.routes.js';
import usersRoutes from './modules/users/users.routes.js'; import usersRoutes from './modules/users/users.routes.js';
import householdsRoutes from './modules/households/households.routes.js'; import householdsRoutes from './modules/households/households.routes.js';
import medicinesRoutes from './modules/medicines/medicines.routes.js';
import medicineProductsRoutes from './modules/medicine-products/medicine-products.routes.js';
import cabinetRoutes from './modules/cabinet/cabinet.routes.js';
export async function buildApp(opts: { logger?: boolean | object } = {}) { export async function buildApp(opts: { logger?: boolean | object } = {}) {
const app = Fastify({ const app = Fastify({
@ -42,8 +45,16 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
app.setSerializerCompiler(serializerCompiler); app.setSerializerCompiler(serializerCompiler);
// Security & compression // Security & compression
await app.register(helmet); await app.register(helmet, {
await app.register(cors, { origin: config.cors.origin, credentials: true }); crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: false,
});
await app.register(cors, {
origin: config.cors.origin,
credentials: true,
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
await app.register(compress); await app.register(compress);
// Swagger / OpenAPI // Swagger / OpenAPI
@ -89,6 +100,9 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
await app.register(healthRoutes); await app.register(healthRoutes);
await app.register(usersRoutes); await app.register(usersRoutes);
await app.register(householdsRoutes); await app.register(householdsRoutes);
await app.register(medicinesRoutes);
await app.register(medicineProductsRoutes);
await app.register(cabinetRoutes);
// Global error handler // Global error handler
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, request, reply) => {

View file

@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave, mockCountDocuments, mockAggregate } =
vi.hoisted(() => ({
mockFind: vi.fn(),
mockFindOne: vi.fn(),
mockFindOneAndUpdate: vi.fn(),
mockSave: vi.fn(),
mockCountDocuments: vi.fn(),
mockAggregate: vi.fn(),
}));
vi.mock('../../schemas/cabinet-item.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,
});
const countChain = () => ({
exec: mockCountDocuments,
});
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 findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
static countDocuments = vi.fn(() => countChain());
static aggregate = vi.fn(() => aggChain());
}
return { CabinetItemModel: FakeModel };
});
import { CabinetRepository } from './cabinet.repository.js';
describe(CabinetRepository.name, () => {
let repo: CabinetRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new CabinetRepository();
});
describe('findByHousehold', () => {
it('returns paginated items', async () => {
const items = [{ _id: 'ci-1', quantity: 30 }];
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: 'ci-2', quantity: 10 }];
mockFind.mockResolvedValue(items);
const cursor = Buffer.from('ci-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: `ci-${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();
});
it('filters by medicineId', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { medicineId: 'med-1', limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
it('filters by status', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', {
status: 'active' as never,
limit: 20,
});
expect(mockFind).toHaveBeenCalled();
});
it('filters by expiringWithin', async () => {
mockFind.mockResolvedValue([]);
await repo.findByHousehold('hh1', { expiringWithin: 30, limit: 20 });
expect(mockFind).toHaveBeenCalled();
});
});
describe('findById', () => {
it('returns item by id and householdId', async () => {
const item = { _id: 'ci-1', householdId: 'hh1', quantity: 30 };
mockFindOne.mockResolvedValue(item);
const result = await repo.findById('ci-1', 'hh1');
expect(result).toEqual(item);
});
});
describe('getAggregateSummary', () => {
it('returns aggregate data', async () => {
const aggregated = [{ _id: 'med-1', totalQuantity: 60, itemCount: 2 }];
mockAggregate.mockResolvedValue(aggregated);
const result = await repo.getAggregateSummary('hh1');
expect(result).toEqual(aggregated);
});
});
describe('create', () => {
it('creates and returns cabinet item', async () => {
const data = {
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
quantity: 30,
unit: 'tablet' as const,
};
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
return Promise.resolve(this);
});
const result = await repo.create(data, 'hh1', 'user-1');
expect(result).toBeTruthy();
expect(mockSave).toHaveBeenCalled();
});
});
describe('update', () => {
it('updates and returns item', async () => {
const updated = { _id: 'ci-1', quantity: 25 };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.update('ci-1', 'hh1', { quantity: 25 });
expect(result).toEqual(updated);
});
});
describe('adjustQuantity', () => {
it('adjusts quantity and returns updated item', async () => {
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 30, status: 'active' });
const updated = { _id: 'ci-1', quantity: 27, status: 'active' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.adjustQuantity('ci-1', 'hh1', -3);
expect(result).toEqual(updated);
});
it('floors quantity at 0 and sets depleted status', async () => {
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 2, status: 'active' });
const updated = { _id: 'ci-1', quantity: 0, status: 'depleted' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.adjustQuantity('ci-1', 'hh1', -5);
expect(result).toEqual(updated);
});
it('re-activates depleted item when adding stock', async () => {
mockFindOne.mockResolvedValue({ _id: 'ci-1', quantity: 0, status: 'depleted' });
const updated = { _id: 'ci-1', quantity: 10, status: 'active' };
mockFindOneAndUpdate.mockResolvedValue(updated);
const result = await repo.adjustQuantity('ci-1', 'hh1', 10);
expect(result).toEqual(updated);
});
it('returns null if item not found', async () => {
mockFindOne.mockResolvedValue(null);
const result = await repo.adjustQuantity('ci-missing', 'hh1', 5);
expect(result).toBeNull();
});
});
describe('findExpiringSoon', () => {
it('returns items expiring within N days', async () => {
const items = [{ _id: 'ci-1', expirationDate: new Date() }];
mockFind.mockResolvedValue(items);
const result = await repo.findExpiringSoon('hh1', 30);
expect(result).toEqual(items);
});
});
describe('countByMedicineId', () => {
it('returns count', async () => {
mockCountDocuments.mockResolvedValue(3);
const result = await repo.countByMedicineId('med-1');
expect(result).toBe(3);
});
});
describe('softDelete', () => {
it('soft deletes and returns item', async () => {
const deleted = { _id: 'ci-1', isDeleted: true };
mockFindOneAndUpdate.mockResolvedValue(deleted);
const result = await repo.softDelete('ci-1', 'hh1');
expect(result).toEqual(deleted);
});
});
});

View file

@ -0,0 +1,144 @@
import { CabinetItemModel } from '../../schemas/cabinet-item.schema.js';
import type { CabinetItemStatus, CreateCabinetItemInput, UpdateCabinetItemInput } from '@meshitrack/shared';
interface FindByHouseholdQuery {
medicineId?: string;
status?: CabinetItemStatus;
expiringWithin?: number;
cursor?: string;
limit: number;
}
export class CabinetRepository {
public async findByHousehold(householdId: string, query: FindByHouseholdQuery) {
const filter: Record<string, unknown> = { householdId, isDeleted: false };
if (query.medicineId) filter['medicineId'] = query.medicineId;
if (query.status) filter['status'] = query.status;
if (query.expiringWithin) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + query.expiringWithin);
filter['expirationDate'] = { $lte: cutoff, $gt: new Date() };
filter['status'] = 'active';
}
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $gt: id };
}
const limit = query.limit;
const items = await CabinetItemModel.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 CabinetItemModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
}
public async getAggregateSummary(householdId: string) {
return CabinetItemModel.aggregate([
{ $match: { householdId, isDeleted: false, status: 'active' } },
{
$group: {
_id: '$medicineId',
medicineName: { $first: '$medicineName' },
medicineStrength: { $first: '$medicineStrength' },
medicineStrengthUnit: { $first: '$medicineStrengthUnit' },
medicineForm: { $first: '$medicineForm' },
totalQuantity: { $sum: '$quantity' },
unit: { $first: '$unit' },
earliestExpiry: { $min: '$expirationDate' },
itemCount: { $sum: 1 },
},
},
{ $sort: { medicineName: 1 } },
]).exec();
}
public async create(
data: CreateCabinetItemInput & {
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
medicineProductBrand?: string;
concentration?: number;
concentrationUnit?: string;
},
householdId: string,
createdBy: string,
) {
const item = new CabinetItemModel({ ...data, householdId, createdBy });
const saved = await item.save();
return saved.toObject();
}
public async update(id: string, householdId: string, data: UpdateCabinetItemInput) {
return CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
}
public async adjustQuantity(id: string, householdId: string, delta: number) {
const item = await CabinetItemModel.findOne({
_id: id,
householdId,
isDeleted: false,
})
.lean()
.exec();
if (!item) return null;
const newQuantity = Math.max(0, item.quantity + delta);
const newStatus =
newQuantity === 0 ? 'depleted' : item.status === 'depleted' ? 'active' : item.status;
return CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { quantity: newQuantity, status: newStatus } },
{ new: true, lean: true },
).exec();
}
public async findExpiringSoon(householdId: string, withinDays: number) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + withinDays);
return CabinetItemModel.find({
householdId,
isDeleted: false,
status: 'active',
expirationDate: { $lte: cutoff, $gt: new Date() },
})
.sort({ expirationDate: 1 })
.lean()
.exec();
}
public async countByMedicineId(medicineId: string): Promise<number> {
return CabinetItemModel.countDocuments({ medicineId, isDeleted: false }).exec();
}
public async softDelete(id: string, householdId: string) {
return CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
}
}

View file

@ -0,0 +1,382 @@
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 { DosageUnit, MedicineForm, StrengthUnit, CabinetItemStatus } 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 {
mockFindByHousehold,
mockFindById,
mockGetAggregateSummary,
mockCreate,
mockUpdate,
mockAdjustQuantity,
mockFindExpiringSoon,
mockSoftDelete,
mockCountByMedicineId,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockGetAggregateSummary: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockAdjustQuantity: vi.fn(),
mockFindExpiringSoon: vi.fn(),
mockSoftDelete: vi.fn(),
mockCountByMedicineId: vi.fn(),
}));
vi.mock('./cabinet.repository.js', () => ({
CabinetRepository: class {
findByHousehold = mockFindByHousehold;
findById = mockFindById;
getAggregateSummary = mockGetAggregateSummary;
create = mockCreate;
update = mockUpdate;
adjustQuantity = mockAdjustQuantity;
findExpiringSoon = mockFindExpiringSoon;
softDelete = mockSoftDelete;
countByMedicineId = mockCountByMedicineId;
},
}));
const { mockMedicineFindById } = vi.hoisted(() => ({
mockMedicineFindById: vi.fn(),
}));
vi.mock('../medicines/medicines.repository.js', () => ({
MedicinesRepository: class {
findById = mockMedicineFindById;
findByHousehold = vi.fn();
findDuplicate = vi.fn();
create = vi.fn();
update = vi.fn();
softDelete = vi.fn();
},
}));
const { mockProductFindById } = vi.hoisted(() => ({
mockProductFindById: vi.fn(),
}));
vi.mock('../medicine-products/medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
findById = mockProductFindById;
findByMedicine = vi.fn();
create = vi.fn();
update = vi.fn();
softDelete = vi.fn();
countByMedicineId = vi.fn();
},
}));
vi.mock('../medicine-products/medicine-products.service.js', () => ({
MedicineProductsService: class {
listByMedicine = vi.fn();
getById = vi.fn();
create = vi.fn();
update = vi.fn();
delete = vi.fn();
},
}));
vi.mock('../medicines/medicines.service.js', () => ({
MedicinesService: class {
list = vi.fn();
getById = vi.fn();
create = vi.fn();
update = vi.fn();
delete = 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 medicinesRoutes from '../medicines/medicines.routes.js';
import medicineProductsRoutes from '../medicine-products/medicine-products.routes.js';
import cabinetRoutes from './cabinet.routes.js';
function makeFakeCabinetItem(overrides = {}) {
return {
_id: 'ci-1',
householdId: 'hh1',
medicineId: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: StrengthUnit.MG,
medicineForm: MedicineForm.TABLET,
quantity: 30,
unit: DosageUnit.TABLET,
status: CabinetItemStatus.ACTIVE,
createdBy: 'kc-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('cabinet.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(medicinesRoutes);
await instance.register(medicineProductsRoutes);
await instance.register(cabinetRoutes);
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', () => {
it('returns paginated list', async () => {
const item = makeFakeCabinetItem();
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).toHaveLength(1);
expect(body.data[0].medicineName).toBe('Metformin');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const item = makeFakeCabinetItem({
_id: { toString: () => 'ci-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
expirationDate: new Date('2026-06-01T00:00:00.000Z'),
notes: 'Main supply',
medicineProductId: 'prod-1',
medicineProductBrand: 'Glucophage',
});
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]._id).toBe('ci-obj');
expect(body.data[0].expirationDate).toBe('2026-06-01T00:00:00.000Z');
expect(body.data[0].medicineProductBrand).toBe('Glucophage');
});
it('handles string dates in response', async () => {
const item = makeFakeCabinetItem({
expirationDate: '2026-12-31T00:00:00.000Z',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
});
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].expirationDate).toBe('2026-12-31T00:00:00.000Z');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
});
});
describe('GET /api/v1/households/:householdId/cabinet/summary', () => {
it('returns aggregate summary', async () => {
mockGetAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 60,
unit: 'tablet',
earliestExpiry: new Date('2026-06-01T00:00:00.000Z'),
itemCount: 2,
},
]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet/summary',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].totalQuantity).toBe(60);
});
});
describe('GET /api/v1/households/:householdId/cabinet/expiring-soon', () => {
it('returns items expiring within N days', async () => {
mockFindExpiringSoon.mockResolvedValue([makeFakeCabinetItem()]);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet/expiring-soon?days=30',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
});
});
describe('GET /api/v1/households/:householdId/cabinet/:id', () => {
it('returns a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/cabinet/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(30);
});
});
describe('POST /api/v1/households/:householdId/cabinet', () => {
it('creates a cabinet item', async () => {
mockMedicineFindById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockCreate.mockResolvedValue(makeFakeCabinetItem());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet',
headers: authHeaders,
payload: {
medicineId: 'med-1',
quantity: 30,
unit: DosageUnit.TABLET,
},
});
expect(res.statusCode).toBe(201);
expect(res.json().medicineName).toBe('Metformin');
});
});
describe('PATCH /api/v1/households/:householdId/cabinet/:id', () => {
it('updates a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem());
mockUpdate.mockResolvedValue(makeFakeCabinetItem({ quantity: 25 }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/cabinet/ci-1',
headers: authHeaders,
payload: { quantity: 25 },
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(25);
});
});
describe('POST /api/v1/households/:householdId/cabinet/:id/adjust', () => {
it('adjusts quantity', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem({ quantity: 30 }));
mockAdjustQuantity.mockResolvedValue(makeFakeCabinetItem({ quantity: 27 }));
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/cabinet/ci-1/adjust',
headers: authHeaders,
payload: { delta: -3 },
});
expect(res.statusCode).toBe(200);
expect(res.json().quantity).toBe(27);
});
});
describe('DELETE /api/v1/households/:householdId/cabinet/:id', () => {
it('soft deletes a cabinet item', async () => {
mockFindById.mockResolvedValue(makeFakeCabinetItem());
mockSoftDelete.mockResolvedValue(makeFakeCabinetItem({ isDeleted: true }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/cabinet/ci-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
});

View file

@ -0,0 +1,245 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreateCabinetItemSchema,
UpdateCabinetItemSchema,
AdjustQuantitySchema,
CabinetQuerySchema,
CabinetItemResponseSchema,
CabinetItemListResponseSchema,
CabinetSummaryResponseSchema,
} from '@meshitrack/shared';
import { CabinetRepository } from './cabinet.repository.js';
import { CabinetService } from './cabinet.service.js';
type AnyCabinetDoc = {
_id: string | { toString: () => string };
householdId: string;
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
medicineProductId?: string | null;
medicineProductBrand?: string | null;
concentration?: number | null;
concentrationUnit?: string | null;
quantity: number;
unit: string;
expirationDate?: Date | string | null;
status: string;
notes?: string | null;
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 toOptIso(v: Date | string | null | undefined): string | undefined {
/* v8 ignore next */
if (!v) return undefined;
if (typeof v === 'string') return v;
return v.toISOString();
}
function toCabinetItemResponse(doc: AnyCabinetDoc): z.infer<typeof CabinetItemResponseSchema> {
return {
_id: toStr(doc._id),
householdId: doc.householdId,
medicineId: doc.medicineId,
medicineName: doc.medicineName,
medicineStrength: doc.medicineStrength,
medicineStrengthUnit: doc.medicineStrengthUnit,
medicineForm: doc.medicineForm,
...(doc.medicineProductId ? { medicineProductId: doc.medicineProductId } : {}),
...(doc.medicineProductBrand ? { medicineProductBrand: doc.medicineProductBrand } : {}),
...(doc.concentration != null ? { concentration: doc.concentration } : {}),
...(doc.concentrationUnit ? { concentrationUnit: doc.concentrationUnit } : {}),
quantity: doc.quantity,
unit: doc.unit,
...(doc.expirationDate ? { expirationDate: toOptIso(doc.expirationDate) } : {}),
status: doc.status,
...(doc.notes ? { notes: doc.notes } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
cabinetRepository: CabinetRepository;
cabinetService: CabinetService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
cabinetRepository: asClass(CabinetRepository, { lifetime: Lifetime.SINGLETON }),
cabinetService: asClass(CabinetService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
// GET /api/v1/households/:householdId/cabinet — list items
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/cabinet',
schema: {
params: householdParams,
querystring: CabinetQuerySchema,
response: { 200: CabinetItemListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const result = await service.list(request.params.householdId, request.query);
return reply.send({
data: result.data.map(toCabinetItemResponse),
pagination: result.pagination,
});
},
});
// GET /api/v1/households/:householdId/cabinet/summary — aggregate per medicine
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/cabinet/summary',
schema: {
params: householdParams,
response: { 200: CabinetSummaryResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const data = await service.getSummary(request.params.householdId);
return reply.send({ data });
},
});
// GET /api/v1/households/:householdId/cabinet/expiring-soon — items expiring within N days
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/cabinet/expiring-soon',
schema: {
params: householdParams,
querystring: z.object({
days: z.coerce.number().int().min(1).max(365).default(30),
}),
response: {
200: z.object({ data: z.array(CabinetItemResponseSchema) }),
},
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const items = await service.getExpiringSoon(request.params.householdId, request.query.days);
return reply.send({ data: items.map(toCabinetItemResponse) });
},
});
// GET /api/v1/households/:householdId/cabinet/:id — get single item
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/cabinet/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: CabinetItemResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const item = await service.getById(request.params.id, request.params.householdId);
return reply.send(toCabinetItemResponse(item));
},
});
// POST /api/v1/households/:householdId/cabinet — add item
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/cabinet',
schema: {
params: householdParams,
body: CreateCabinetItemSchema,
response: { 201: CabinetItemResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const item = await service.addItem(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toCabinetItemResponse(item));
},
});
// PATCH /api/v1/households/:householdId/cabinet/:id — update item
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/cabinet/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
body: UpdateCabinetItemSchema,
response: { 200: CabinetItemResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const item = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toCabinetItemResponse(item));
},
});
// POST /api/v1/households/:householdId/cabinet/:id/adjust — adjust quantity
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/cabinet/:id/adjust',
schema: {
params: householdParams.extend({ id: z.string() }),
body: AdjustQuantitySchema,
response: { 200: CabinetItemResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const item = await service.adjustQuantity(
request.params.id,
request.params.householdId,
request.body.delta,
);
return reply.send(toCabinetItemResponse(item));
},
});
// DELETE /api/v1/households/:householdId/cabinet/:id — soft delete
app.route({
method: 'DELETE',
url: '/api/v1/households/:householdId/cabinet/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 204: z.undefined() },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
await service.delete(request.params.id, request.params.householdId);
return reply.status(204).send();
},
});
},
{
name: 'cabinet-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,299 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CabinetService } from './cabinet.service.js';
describe(CabinetService.name, () => {
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(),
};
const mockMedicinesRepo = {
findById: vi.fn(),
findByHousehold: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockProductsRepo = {
findById: vi.fn(),
findByMedicine: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
countByMedicineId: vi.fn(),
};
let service: CabinetService;
beforeEach(() => {
vi.clearAllMocks();
service = new CabinetService({
cabinetRepository: mockCabinetRepo as never,
medicinesRepository: mockMedicinesRepo as never,
medicineProductsRepository: mockProductsRepo as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const result = { data: [], pagination: { cursor: null, hasMore: false } };
mockCabinetRepo.findByHousehold.mockResolvedValue(result);
const response = await service.list('hh1', { limit: 20 });
expect(response).toEqual(result);
expect(mockCabinetRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
});
});
describe('getById', () => {
it('returns item when found', async () => {
const item = { _id: 'ci-1', quantity: 30 };
mockCabinetRepo.findById.mockResolvedValue(item);
const result = await service.getById('ci-1', 'hh1');
expect(result).toEqual(item);
});
it('throws NotFoundError when not found', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.getById('ci-missing', 'hh1')).rejects.toThrow('Cabinet item not found');
});
});
describe('getSummary', () => {
it('returns aggregated summary with formatted dates', async () => {
const expiryDate = new Date('2026-06-01T00:00:00.000Z');
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 60,
unit: 'tablet',
earliestExpiry: expiryDate,
itemCount: 2,
},
]);
const result = await service.getSummary('hh1');
expect(result).toHaveLength(1);
expect(result[0].medicineId).toBe('med-1');
expect(result[0].earliestExpiry).toBe('2026-06-01T00:00:00.000Z');
});
it('handles null expiry dates', async () => {
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{
_id: 'med-1',
medicineName: 'Test',
medicineStrength: 10,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
totalQuantity: 30,
unit: 'tablet',
earliestExpiry: null,
itemCount: 1,
},
]);
const result = await service.getSummary('hh1');
expect(result[0].earliestExpiry).toBeNull();
});
});
describe('addItem', () => {
const createInput = {
medicineId: 'med-1',
quantity: 30,
unit: 'tablet' as const,
};
it('creates item with denormalized medicine fields', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
const created = { _id: 'ci-1', ...createInput, medicineName: 'Metformin' };
mockCabinetRepo.create.mockResolvedValue(created);
const result = await service.addItem(createInput, 'hh1', 'user-1');
expect(result).toEqual(created);
expect(mockCabinetRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
medicineName: 'Metformin',
medicineStrength: 500,
medicineStrengthUnit: 'mg',
medicineForm: 'tablet',
}),
'hh1',
'user-1',
);
});
it('throws NotFoundError when medicine not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(service.addItem(createInput, 'hh1', 'user-1')).rejects.toThrow(
'Medicine not found',
);
});
it('denormalizes product brand when medicineProductId given', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockProductsRepo.findById.mockResolvedValue({
_id: 'prod-1',
brand: 'Glucophage',
});
mockCabinetRepo.create.mockResolvedValue({ _id: 'ci-1' });
await service.addItem({ ...createInput, medicineProductId: 'prod-1' }, 'hh1', 'user-1');
expect(mockCabinetRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ medicineProductBrand: 'Glucophage' }),
'hh1',
'user-1',
);
});
it('throws NotFoundError when product not found', async () => {
mockMedicinesRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Metformin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
});
mockProductsRepo.findById.mockResolvedValue(null);
await expect(
service.addItem({ ...createInput, medicineProductId: 'prod-missing' }, 'hh1', 'user-1'),
).rejects.toThrow('Medicine product not found');
});
});
describe('update', () => {
it('updates and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
const updated = { _id: 'ci-1', quantity: 25 };
mockCabinetRepo.update.mockResolvedValue(updated);
const result = await service.update('ci-1', 'hh1', { quantity: 25 });
expect(result).toEqual(updated);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.update('ci-missing', 'hh1', { quantity: 25 })).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when update returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
mockCabinetRepo.update.mockResolvedValue(null);
await expect(service.update('ci-1', 'hh1', { quantity: 25 })).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('adjustQuantity', () => {
it('adjusts quantity and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
const updated = { _id: 'ci-1', quantity: 27 };
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
const result = await service.adjustQuantity('ci-1', 'hh1', -3);
expect(result).toEqual(updated);
});
it('throws BadRequestError when delta is 0', async () => {
await expect(service.adjustQuantity('ci-1', 'hh1', 0)).rejects.toThrow(
'Delta must be non-zero',
);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-missing', 'hh1', 5)).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when adjust returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-1', 'hh1', 5)).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('getExpiringSoon', () => {
it('delegates to repository', async () => {
const items = [{ _id: 'ci-1' }];
mockCabinetRepo.findExpiringSoon.mockResolvedValue(items);
const result = await service.getExpiringSoon('hh1', 30);
expect(result).toEqual(items);
expect(mockCabinetRepo.findExpiringSoon).toHaveBeenCalledWith('hh1', 30);
});
});
describe('delete', () => {
it('soft deletes item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
const result = await service.delete('ci-1', 'hh1');
expect(result.isDeleted).toBe(true);
});
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.delete('ci-missing', 'hh1')).rejects.toThrow('Cabinet item not found');
});
it('throws NotFoundError when softDelete returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
mockCabinetRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('ci-1', 'hh1')).rejects.toThrow('Cabinet item not found');
});
});
});

View file

@ -0,0 +1,121 @@
import type { CabinetRepository } from './cabinet.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type {
CreateCabinetItemInput,
UpdateCabinetItemInput,
CabinetQueryInput,
} from '@meshitrack/shared';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
interface Deps {
cabinetRepository: CabinetRepository;
medicinesRepository: MedicinesRepository;
medicineProductsRepository: MedicineProductsRepository;
}
export class CabinetService {
private readonly cabinetRepository: CabinetRepository;
private readonly medicinesRepository: MedicinesRepository;
private readonly medicineProductsRepository: MedicineProductsRepository;
public constructor({ cabinetRepository, medicinesRepository, medicineProductsRepository }: Deps) {
this.cabinetRepository = cabinetRepository;
this.medicinesRepository = medicinesRepository;
this.medicineProductsRepository = medicineProductsRepository;
}
public async list(householdId: string, query: CabinetQueryInput) {
return this.cabinetRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
const item = await this.cabinetRepository.findById(id, householdId);
if (!item) {
throw new NotFoundError('Cabinet item not found');
}
return item;
}
public async getSummary(householdId: string) {
const results = await this.cabinetRepository.getAggregateSummary(householdId);
return results.map((r: Record<string, unknown>) => ({
medicineId: r._id as string,
medicineName: r.medicineName as string,
medicineStrength: r.medicineStrength as number,
medicineStrengthUnit: r.medicineStrengthUnit as string,
medicineForm: r.medicineForm as string,
totalQuantity: r.totalQuantity as number,
unit: r.unit as string,
earliestExpiry: r.earliestExpiry ? (r.earliestExpiry as Date).toISOString() : null,
itemCount: r.itemCount as number,
}));
}
public async addItem(data: CreateCabinetItemInput, householdId: string, createdBy: string) {
const medicine = await this.medicinesRepository.findById(data.medicineId, householdId);
if (!medicine) {
throw new NotFoundError('Medicine not found');
}
let productBrand: string | undefined;
let concentration: number | undefined;
let concentrationUnit: string | undefined;
if (data.medicineProductId) {
const product = await this.medicineProductsRepository.findById(
data.medicineProductId,
householdId,
);
if (!product) {
throw new NotFoundError('Medicine product not found');
}
productBrand = product.brand;
concentration = product.concentration ?? undefined;
concentrationUnit = product.concentrationUnit ?? undefined;
}
return this.cabinetRepository.create(
{
...data,
medicineName: medicine.name,
medicineStrength: medicine.strength,
medicineStrengthUnit: medicine.strengthUnit,
medicineForm: medicine.form,
medicineProductBrand: productBrand,
concentration,
concentrationUnit,
},
householdId,
createdBy,
);
}
public async update(id: string, householdId: string, data: UpdateCabinetItemInput) {
await this.getById(id, householdId);
const updated = await this.cabinetRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Cabinet item not found');
return updated;
}
public async adjustQuantity(id: string, householdId: string, delta: number) {
if (delta === 0) {
throw new BadRequestError('Delta must be non-zero');
}
await this.getById(id, householdId);
const updated = await this.cabinetRepository.adjustQuantity(id, householdId, delta);
if (!updated) throw new NotFoundError('Cabinet item not found');
return updated;
}
public async getExpiringSoon(householdId: string, withinDays: number) {
return this.cabinetRepository.findExpiringSoon(householdId, withinDays);
}
public async delete(id: string, householdId: string) {
await this.getById(id, householdId);
const deleted = await this.cabinetRepository.softDelete(id, householdId);
if (!deleted) throw new NotFoundError('Cabinet item not found');
return deleted;
}
}

View file

@ -56,6 +56,7 @@ vi.mock('../users/users.repository.js', () => ({
UsersRepository: class { UsersRepository: class {
findByKeycloakId = mockFindByKeycloakId; findByKeycloakId = mockFindByKeycloakId;
update = mockUserUpdate; update = mockUserUpdate;
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
}, },
})); }));
@ -117,6 +118,8 @@ describe('households.routes', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default: auth plugin finds user with hh1 membership (routes with householdId guard pass)
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'], defaultHouseholdId: null });
app = await buildTestApp(); app = await buildTestApp();
}); });
@ -143,6 +146,43 @@ describe('households.routes', () => {
}); });
}); });
describe('POST /api/v1/households (ObjectId/Date conversion)', () => {
it('handles ObjectId and Date objects in response', async () => {
const household = makeFakeHousehold({
_id: { toString: () => 'hh-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
members: [
{
userId: 'kc-1',
role: HouseholdRole.OWNER,
joinedAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
},
],
settings: null,
});
mockCreate.mockResolvedValue(household);
mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null });
mockUserUpdate.mockResolvedValue({});
const res = await app.inject({
method: 'POST',
url: '/api/v1/households',
headers: authHeaders,
payload: { name: 'Test' },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body._id).toBe('hh-obj');
expect(body.createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.members[0].joinedAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.settings.timezone).toBe('UTC');
expect(body.settings.currency).toBe('USD');
expect(body.settings.language).toBe('en');
});
});
describe('GET /api/v1/households/:id', () => { describe('GET /api/v1/households/:id', () => {
it('returns a household', async () => { it('returns a household', async () => {
const household = makeFakeHousehold(); const household = makeFakeHousehold();

View file

@ -93,6 +93,15 @@ describe('HouseholdsService', () => {
); );
}); });
it('aborts transaction on error', async () => {
mockHouseholdsRepo.create.mockRejectedValue(new Error('DB error'));
await expect(service.create({ name: 'Test' }, 'kc-1')).rejects.toThrow('DB error');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('handles case when owner user not found in db', async () => { it('handles case when owner user not found in db', async () => {
const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1' }; const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1' };
mockHouseholdsRepo.create.mockResolvedValue(household); mockHouseholdsRepo.create.mockResolvedValue(household);
@ -153,6 +162,16 @@ describe('HouseholdsService', () => {
await expect(service.update('hh1', { name: 'X' }, 'kc-3')).rejects.toThrow(ForbiddenError); await expect(service.update('hh1', { name: 'X' }, 'kc-3')).rejects.toThrow(ForbiddenError);
}); });
it('throws NotFoundError when repo update returns null', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
mockHouseholdsRepo.update.mockResolvedValue(null);
await expect(service.update('hh1', { name: 'X' }, 'kc-1')).rejects.toThrow(NotFoundError);
});
it('throws ForbiddenError for non-member', async () => { it('throws ForbiddenError for non-member', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({ mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1', _id: 'hh1',
@ -186,6 +205,16 @@ describe('HouseholdsService', () => {
await expect(service.generateInviteCode('hh1', 'kc-3')).rejects.toThrow(ForbiddenError); await expect(service.generateInviteCode('hh1', 'kc-3')).rejects.toThrow(ForbiddenError);
}); });
it('throws NotFoundError when updateInviteCode returns null', async () => {
mockHouseholdsRepo.findById.mockResolvedValue({
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
});
mockHouseholdsRepo.updateInviteCode.mockResolvedValue(null);
await expect(service.generateInviteCode('hh1', 'kc-1')).rejects.toThrow(NotFoundError);
});
}); });
describe('join', () => { describe('join', () => {
@ -236,6 +265,31 @@ describe('HouseholdsService', () => {
await expect(service.join('CODE', 'kc-1')).rejects.toThrow(ConflictError); await expect(service.join('CODE', 'kc-1')).rejects.toThrow(ConflictError);
}); });
it('throws NotFoundError when addMember returns null', async () => {
const household = {
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
};
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
mockHouseholdsRepo.addMember.mockResolvedValue(null);
await expect(service.join('CODE', 'kc-2')).rejects.toThrow(NotFoundError);
});
it('aborts transaction on error during join', async () => {
const household = {
_id: 'hh1',
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
};
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
mockHouseholdsRepo.addMember.mockRejectedValue(new Error('DB error'));
await expect(service.join('CODE', 'kc-2')).rejects.toThrow('DB error');
expect(mockSession.abortTransaction).toHaveBeenCalled();
expect(mockSession.endSession).toHaveBeenCalled();
});
it('handles case when joining user not found in db', async () => { it('handles case when joining user not found in db', async () => {
const household = { const household = {
_id: 'hh1', _id: 'hh1',

View file

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DosageUnit, MedicineProductSource } from '@meshitrack/shared';
const {
mockExec,
_mockLean,
mockFind,
mockFindOne,
mockFindOneAndUpdate,
mockSave,
_mockSort,
_mockLimit,
mockCountDocuments,
} = vi.hoisted(() => {
const mockExec = vi.fn();
const mockLean = vi.fn(() => ({ exec: mockExec }));
const mockLimit = vi.fn(() => ({ lean: mockLean }));
const mockSort = vi.fn(() => ({ limit: mockLimit }));
const mockCountDocuments = vi.fn();
return {
mockExec,
mockLean,
mockFind: vi.fn(() => ({ sort: mockSort })),
mockFindOne: vi.fn(() => ({ lean: mockLean })),
mockFindOneAndUpdate: vi.fn(() => ({ exec: mockExec })),
mockSave: vi.fn(),
mockSort,
mockLimit,
mockCountDocuments,
};
});
vi.mock('../../schemas/medicine-product.schema.js', () => {
class MockMedicineProductModel {
_data: Record<string, unknown>;
constructor(data: Record<string, unknown>) {
this._data = data;
Object.assign(this, data);
}
save() {
mockSave();
return Promise.resolve(this);
}
toObject() {
return { _id: 'mp-new', ...this._data };
}
static find = mockFind;
static findOne = mockFindOne;
static findOneAndUpdate = mockFindOneAndUpdate;
static countDocuments = vi.fn(() => ({ exec: mockCountDocuments }));
}
return { MedicineProductModel: MockMedicineProductModel };
});
import { MedicineProductsRepository } from './medicine-products.repository.js';
describe(MedicineProductsRepository.name, () => {
let repo: MedicineProductsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new MedicineProductsRepository();
});
describe('findByMedicine', () => {
it('returns paginated results', async () => {
const items = [
{ _id: 'mp-1', brand: 'CVS' },
{ _id: 'mp-2', brand: 'Kirkland' },
];
mockExec.mockResolvedValue(items);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
expect(mockFind).toHaveBeenCalledWith({
householdId: 'hh1',
medicineId: 'med-1',
isDeleted: false,
});
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(false);
});
it('detects hasMore when extra item returned', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `mp-${i}`, brand: `Brand ${i}` }));
mockExec.mockResolvedValue(items);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.cursor).toBeTruthy();
});
it('decodes cursor for pagination', async () => {
mockExec.mockResolvedValue([]);
const cursor = Buffer.from('mp-5').toString('base64');
await repo.findByMedicine('hh1', 'med-1', { cursor, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(expect.objectContaining({ _id: { $gt: 'mp-5' } }));
});
it('returns null cursor when no data', async () => {
mockExec.mockResolvedValue([]);
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
expect(result.pagination.cursor).toBeNull();
expect(result.pagination.hasMore).toBe(false);
});
});
describe('findById', () => {
it('finds by id and householdId', async () => {
const product = { _id: 'mp-1', brand: 'CVS' };
mockExec.mockResolvedValue(product);
const result = await repo.findById('mp-1', 'hh1');
expect(mockFindOne).toHaveBeenCalledWith({
_id: 'mp-1',
householdId: 'hh1',
isDeleted: false,
});
expect(result).toEqual(product);
});
});
describe('create', () => {
it('creates a medicine product', async () => {
mockSave.mockResolvedValue({});
const data = {
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
source: MedicineProductSource.MANUAL,
};
const result = await repo.create(data, 'hh1', 'med-1', 'Metformin', 'kc-1');
expect(mockSave).toHaveBeenCalled();
expect(result).toMatchObject({
brand: 'CVS Health',
householdId: 'hh1',
medicineId: 'med-1',
medicineName: 'Metformin',
createdBy: 'kc-1',
});
});
});
describe('update', () => {
it('updates a medicine product', async () => {
mockExec.mockResolvedValue({ _id: 'mp-1', brand: 'Updated' });
const result = await repo.update('mp-1', 'hh1', { brand: 'Updated' });
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'mp-1', householdId: 'hh1', isDeleted: false },
{ $set: { brand: 'Updated' } },
{ new: true, lean: true },
);
expect(result).toEqual({ _id: 'mp-1', brand: 'Updated' });
});
});
describe('softDelete', () => {
it('sets isDeleted to true', async () => {
mockExec.mockResolvedValue({ _id: 'mp-1', isDeleted: true });
await repo.softDelete('mp-1', 'hh1');
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'mp-1', householdId: 'hh1', isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
);
});
});
describe('countByMedicineId', () => {
it('returns count of non-deleted products for medicine', async () => {
mockCountDocuments.mockResolvedValue(3);
const result = await repo.countByMedicineId('med-1');
expect(result).toBe(3);
});
});
});

View file

@ -0,0 +1,74 @@
import { MedicineProductModel } from '../../schemas/medicine-product.schema.js';
import type { CreateMedicineProductInput, UpdateMedicineProductInput } from '@meshitrack/shared';
interface FindByMedicineQuery {
cursor?: string;
limit: number;
}
export class MedicineProductsRepository {
public async findByMedicine(householdId: string, medicineId: string, query: FindByMedicineQuery) {
const filter: Record<string, unknown> = { householdId, medicineId, isDeleted: false };
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $gt: id };
}
const limit = query.limit;
const items = await MedicineProductModel.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 MedicineProductModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
}
public async create(
data: CreateMedicineProductInput,
householdId: string,
medicineId: string,
medicineName: string,
createdBy: string,
) {
const product = new MedicineProductModel({
...data,
householdId,
medicineId,
medicineName,
createdBy,
});
const saved = await product.save();
return saved.toObject();
}
public async update(id: string, householdId: string, data: UpdateMedicineProductInput) {
return MedicineProductModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
}
public async countByMedicineId(medicineId: string): Promise<number> {
return MedicineProductModel.countDocuments({ medicineId, isDeleted: false }).exec();
}
public async softDelete(id: string, householdId: string) {
return MedicineProductModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
}
}

View file

@ -0,0 +1,234 @@
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 { DosageUnit, MedicineProductSource } 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 {
mockFindByMedicine,
mockFindById,
mockCreate,
mockUpdate,
mockSoftDelete,
mockMedicineFindById,
} = vi.hoisted(() => ({
mockFindByMedicine: vi.fn(),
mockFindById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockSoftDelete: vi.fn(),
mockMedicineFindById: vi.fn(),
}));
vi.mock('./medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
findByMedicine = mockFindByMedicine;
findById = mockFindById;
create = mockCreate;
update = mockUpdate;
softDelete = mockSoftDelete;
},
}));
vi.mock('../medicines/medicines.repository.js', () => ({
MedicinesRepository: class {
findById = mockMedicineFindById;
},
}));
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 medicinesRoutes from '../medicines/medicines.routes.js';
import medicineProductsRoutes from './medicine-products.routes.js';
function makeFakeProduct(overrides = {}) {
return {
_id: 'mp-1',
householdId: 'hh1',
medicineId: 'med-1',
medicineName: 'Metformin',
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
source: MedicineProductSource.MANUAL,
createdBy: 'kc-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('medicine-products.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(medicinesRoutes);
await instance.register(medicineProductsRoutes);
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/medicines/:medicineId/products', () => {
it('returns paginated list', async () => {
const product = makeFakeProduct();
mockFindByMedicine.mockResolvedValue({
data: [product],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines/med-1/products',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].brand).toBe('CVS Health');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const product = makeFakeProduct({
_id: { toString: () => 'mp-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
manufacturer: 'Pfizer',
imageUrl: 'https://example.com/img.png',
notes: 'Store in cool place',
});
mockFindByMedicine.mockResolvedValue({
data: [product],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines/med-1/products',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('mp-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].manufacturer).toBe('Pfizer');
expect(body.data[0].imageUrl).toBe('https://example.com/img.png');
expect(body.data[0].notes).toBe('Store in cool place');
});
});
describe('GET /api/v1/households/:householdId/medicine-products/:id', () => {
it('returns a product', async () => {
mockFindById.mockResolvedValue(makeFakeProduct());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicine-products/mp-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().brand).toBe('CVS Health');
});
});
describe('POST /api/v1/households/:householdId/medicines/:medicineId/products', () => {
it('creates a product', async () => {
mockMedicineFindById.mockResolvedValue({ _id: 'med-1', name: 'Metformin' });
mockCreate.mockResolvedValue(makeFakeProduct());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicines/med-1/products',
headers: authHeaders,
payload: {
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
},
});
expect(res.statusCode).toBe(201);
expect(res.json().brand).toBe('CVS Health');
});
});
describe('PATCH /api/v1/households/:householdId/medicine-products/:id', () => {
it('updates a product', async () => {
mockFindById.mockResolvedValue(makeFakeProduct());
mockUpdate.mockResolvedValue(makeFakeProduct({ brand: 'Updated' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/medicine-products/mp-1',
headers: authHeaders,
payload: { brand: 'Updated' },
});
expect(res.statusCode).toBe(200);
expect(res.json().brand).toBe('Updated');
});
});
describe('DELETE /api/v1/households/:householdId/medicine-products/:id', () => {
it('soft deletes a product', async () => {
mockFindById.mockResolvedValue(makeFakeProduct());
mockSoftDelete.mockResolvedValue(makeFakeProduct({ isDeleted: true }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/medicine-products/mp-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
});

View file

@ -0,0 +1,183 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreateMedicineProductSchema,
UpdateMedicineProductSchema,
MedicineProductResponseSchema,
MedicineProductListResponseSchema,
} from '@meshitrack/shared';
import { MedicineProductsRepository } from './medicine-products.repository.js';
import { MedicineProductsService } from './medicine-products.service.js';
type AnyProductDoc = {
_id: string | { toString: () => string };
householdId: string;
medicineId: string;
medicineName: string;
brand: string;
manufacturer?: string | null;
packageSize: number;
packageUnit: string;
concentration?: number | null;
concentrationUnit?: string | null;
imageUrl?: string | null;
notes?: string | null;
source: string;
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 | { toISOString: () => string }): string {
return typeof v === 'string' ? v : v.toISOString();
}
function toProductResponse(doc: AnyProductDoc): z.infer<typeof MedicineProductResponseSchema> {
return {
_id: toStr(doc._id),
householdId: doc.householdId,
medicineId: doc.medicineId,
medicineName: doc.medicineName,
brand: doc.brand,
...(doc.manufacturer ? { manufacturer: doc.manufacturer } : {}),
packageSize: doc.packageSize,
packageUnit: doc.packageUnit,
...(doc.concentration ? { concentration: doc.concentration } : {}),
...(doc.concentrationUnit ? { concentrationUnit: doc.concentrationUnit } : {}),
...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}),
...(doc.notes ? { notes: doc.notes } : {}),
source: doc.source,
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
medicineProductsRepository: MedicineProductsRepository;
medicineProductsService: MedicineProductsService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
medicineProductsRepository: asClass(MedicineProductsRepository, {
lifetime: Lifetime.SINGLETON,
}),
medicineProductsService: asClass(MedicineProductsService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
const paginationQuery = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// GET /api/v1/households/:householdId/medicines/:medicineId/products — list by medicine
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/medicines/:medicineId/products',
schema: {
params: householdParams.extend({ medicineId: z.string() }),
querystring: paginationQuery,
response: { 200: MedicineProductListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const result = await service.listByMedicine(
request.params.householdId,
request.params.medicineId,
request.query,
);
return reply.send({
data: result.data.map(toProductResponse),
pagination: result.pagination,
});
},
});
// GET /api/v1/households/:householdId/medicine-products/:id — get by id
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/medicine-products/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: MedicineProductResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const product = await service.getById(request.params.id, request.params.householdId);
return reply.send(toProductResponse(product));
},
});
// POST /api/v1/households/:householdId/medicines/:medicineId/products — create
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/medicines/:medicineId/products',
schema: {
params: householdParams.extend({ medicineId: z.string() }),
body: CreateMedicineProductSchema,
response: { 201: MedicineProductResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const product = await service.create(
request.body,
request.params.householdId,
request.params.medicineId,
request.user.keycloakId,
);
return reply.status(201).send(toProductResponse(product));
},
});
// PATCH /api/v1/households/:householdId/medicine-products/:id — update
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/medicine-products/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
body: UpdateMedicineProductSchema,
response: { 200: MedicineProductResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const product = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toProductResponse(product));
},
});
// DELETE /api/v1/households/:householdId/medicine-products/:id — soft delete
app.route({
method: 'DELETE',
url: '/api/v1/households/:householdId/medicine-products/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 204: z.undefined() },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
await service.delete(request.params.id, request.params.householdId);
return reply.status(204).send();
},
});
},
{
name: 'medicine-products-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicineProductsService } from './medicine-products.service.js';
import { NotFoundError } from '../../common/errors.js';
import { DosageUnit, MedicineProductSource } from '@meshitrack/shared';
describe(MedicineProductsService.name, () => {
const mockProductsRepo = {
findByMedicine: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockMedicinesRepo = {
findById: vi.fn(),
};
let service: MedicineProductsService;
beforeEach(() => {
vi.clearAllMocks();
service = new MedicineProductsService({
medicineProductsRepository: mockProductsRepo as never,
medicinesRepository: mockMedicinesRepo as never,
});
});
describe('listByMedicine', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockProductsRepo.findByMedicine.mockResolvedValue(expected);
const result = await service.listByMedicine('hh1', 'med-1', { limit: 20 });
expect(mockProductsRepo.findByMedicine).toHaveBeenCalledWith('hh1', 'med-1', { limit: 20 });
expect(result).toEqual(expected);
});
});
describe('getById', () => {
it('returns product when found', async () => {
const product = { _id: 'mp-1', brand: 'CVS' };
mockProductsRepo.findById.mockResolvedValue(product);
const result = await service.getById('mp-1', 'hh1');
expect(result).toEqual(product);
});
it('throws NotFoundError when not found', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const data = {
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
source: MedicineProductSource.MANUAL,
};
it('creates when parent medicine exists', async () => {
mockMedicinesRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Metformin' });
mockProductsRepo.create.mockResolvedValue({
_id: 'mp-1',
...data,
medicineName: 'Metformin',
});
const result = await service.create(data, 'hh1', 'med-1', 'kc-1');
expect(mockMedicinesRepo.findById).toHaveBeenCalledWith('med-1', 'hh1');
expect(mockProductsRepo.create).toHaveBeenCalledWith(
data,
'hh1',
'med-1',
'Metformin',
'kc-1',
);
expect(result._id).toBe('mp-1');
});
it('throws NotFoundError when parent medicine does not exist', async () => {
mockMedicinesRepo.findById.mockResolvedValue(null);
await expect(service.create(data, 'hh1', 'missing', 'kc-1')).rejects.toThrow(NotFoundError);
expect(mockProductsRepo.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('updates a product', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1', brand: 'CVS' });
mockProductsRepo.update.mockResolvedValue({ _id: 'mp-1', brand: 'Updated' });
const result = await service.update('mp-1', 'hh1', { brand: 'Updated' });
expect(result.brand).toBe('Updated');
});
it('throws NotFoundError when product does not exist', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(service.update('missing', 'hh1', { brand: 'X' })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when update returns null', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1' });
mockProductsRepo.update.mockResolvedValue(null);
await expect(service.update('mp-1', 'hh1', { brand: 'X' })).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('soft deletes a product', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1' });
mockProductsRepo.softDelete.mockResolvedValue({ _id: 'mp-1', isDeleted: true });
await service.delete('mp-1', 'hh1');
expect(mockProductsRepo.softDelete).toHaveBeenCalledWith('mp-1', 'hh1');
});
it('throws NotFoundError when not found', async () => {
mockProductsRepo.findById.mockResolvedValue(null);
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockProductsRepo.findById.mockResolvedValue({ _id: 'mp-1' });
mockProductsRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('mp-1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
});

View file

@ -0,0 +1,69 @@
import type { MedicineProductsRepository } from './medicine-products.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { CreateMedicineProductInput, UpdateMedicineProductInput } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
medicineProductsRepository: MedicineProductsRepository;
medicinesRepository: MedicinesRepository;
}
export class MedicineProductsService {
private readonly medicineProductsRepository: MedicineProductsRepository;
private readonly medicinesRepository: MedicinesRepository;
public constructor({ medicineProductsRepository, medicinesRepository }: Deps) {
this.medicineProductsRepository = medicineProductsRepository;
this.medicinesRepository = medicinesRepository;
}
public async listByMedicine(
householdId: string,
medicineId: string,
query: { cursor?: string; limit: number },
) {
return this.medicineProductsRepository.findByMedicine(householdId, medicineId, query);
}
public async getById(id: string, householdId: string) {
const product = await this.medicineProductsRepository.findById(id, householdId);
if (!product) {
throw new NotFoundError('Medicine product not found');
}
return product;
}
public async create(
data: CreateMedicineProductInput,
householdId: string,
medicineId: string,
createdBy: string,
) {
const medicine = await this.medicinesRepository.findById(medicineId, householdId);
if (!medicine) {
throw new NotFoundError('Medicine not found');
}
return this.medicineProductsRepository.create(
data,
householdId,
medicineId,
medicine.name,
createdBy,
);
}
public async update(id: string, householdId: string, data: UpdateMedicineProductInput) {
await this.getById(id, householdId);
const updated = await this.medicineProductsRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Medicine product not found');
return updated;
}
public async delete(id: string, householdId: string) {
await this.getById(id, householdId);
const deleted = await this.medicineProductsRepository.softDelete(id, householdId);
if (!deleted) throw new NotFoundError('Medicine product not found');
return deleted;
}
}

View file

@ -0,0 +1,215 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicineForm, StrengthUnit, MedicineCategory } from '@meshitrack/shared';
const {
mockExec,
_mockLean,
mockFind,
mockFindOne,
mockFindOneAndUpdate,
mockSave,
_mockSort,
_mockLimit,
} = vi.hoisted(() => {
const mockExec = vi.fn();
const mockLean = vi.fn(() => ({ exec: mockExec }));
const mockLimit = vi.fn(() => ({ lean: mockLean }));
const mockSort = vi.fn(() => ({ limit: mockLimit }));
return {
mockExec,
mockLean,
mockFind: vi.fn(() => ({ sort: mockSort })),
mockFindOne: vi.fn(() => ({ lean: mockLean })),
mockFindOneAndUpdate: vi.fn(() => ({ exec: mockExec })),
mockSave: vi.fn(),
mockSort,
mockLimit,
};
});
vi.mock('../../schemas/medicine.schema.js', () => {
class MockMedicineModel {
_data: Record<string, unknown>;
constructor(data: Record<string, unknown>) {
this._data = data;
Object.assign(this, data);
}
save() {
mockSave();
return Promise.resolve(this);
}
toObject() {
return { _id: 'med-new', ...this._data };
}
static find = mockFind;
static findOne = mockFindOne;
static findOneAndUpdate = mockFindOneAndUpdate;
}
return { MedicineModel: MockMedicineModel };
});
import { MedicinesRepository } from './medicines.repository.js';
describe(MedicinesRepository.name, () => {
let repo: MedicinesRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new MedicinesRepository();
});
describe('findByHousehold', () => {
it('returns paginated results', async () => {
const items = [
{ _id: 'med-1', name: 'Aspirin' },
{ _id: 'med-2', name: 'Ibuprofen' },
];
mockExec.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(mockFind).toHaveBeenCalledWith({ householdId: 'hh1', isDeleted: false });
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(false);
});
it('applies partial name search when q is provided', async () => {
mockExec.mockResolvedValue([]);
await repo.findByHousehold('hh1', { q: 'asp', limit: 20 });
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({ name: { $regex: 'asp', $options: 'i' } }),
);
});
it('applies category filter', async () => {
mockExec.mockResolvedValue([]);
await repo.findByHousehold('hh1', { category: MedicineCategory.PRESCRIPTION, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({ category: MedicineCategory.PRESCRIPTION }),
);
});
it('applies form filter', async () => {
mockExec.mockResolvedValue([]);
await repo.findByHousehold('hh1', { form: MedicineForm.TABLET, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(expect.objectContaining({ form: MedicineForm.TABLET }));
});
it('detects hasMore when extra item returned', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `med-${i}`, name: `Med ${i}` }));
mockExec.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('decodes cursor for pagination', async () => {
mockExec.mockResolvedValue([]);
const cursor = Buffer.from('med-5').toString('base64');
await repo.findByHousehold('hh1', { cursor, limit: 20 });
expect(mockFind).toHaveBeenCalledWith(expect.objectContaining({ _id: { $gt: 'med-5' } }));
});
});
describe('findById', () => {
it('finds by id and householdId', async () => {
const medicine = { _id: 'med-1', name: 'Aspirin' };
mockExec.mockResolvedValue(medicine);
const result = await repo.findById('med-1', 'hh1');
expect(mockFindOne).toHaveBeenCalledWith({
_id: 'med-1',
householdId: 'hh1',
isDeleted: false,
});
expect(result).toEqual(medicine);
});
});
describe('findDuplicate', () => {
it('finds medicine with matching fields', async () => {
mockExec.mockResolvedValue({ _id: 'med-1' });
const result = await repo.findDuplicate('hh1', 'Aspirin', 500, 'mg', 'tablet');
expect(mockFindOne).toHaveBeenCalledWith({
householdId: 'hh1',
name: 'Aspirin',
strength: 500,
strengthUnit: 'mg',
form: 'tablet',
isDeleted: false,
});
expect(result).toBeTruthy();
});
it('excludes specified id', async () => {
mockExec.mockResolvedValue(null);
await repo.findDuplicate('hh1', 'Aspirin', 500, 'mg', 'tablet', 'med-1');
expect(mockFindOne).toHaveBeenCalledWith(expect.objectContaining({ _id: { $ne: 'med-1' } }));
});
});
describe('create', () => {
it('creates a medicine', async () => {
mockSave.mockResolvedValue({});
const data = {
name: 'Aspirin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.OTC,
tags: [],
};
const result = await repo.create(data, 'hh1', 'kc-1');
expect(mockSave).toHaveBeenCalled();
expect(result).toMatchObject({ name: 'Aspirin', householdId: 'hh1', createdBy: 'kc-1' });
});
});
describe('update', () => {
it('updates a medicine', async () => {
mockExec.mockResolvedValue({ _id: 'med-1', name: 'Updated' });
const result = await repo.update('med-1', 'hh1', { name: 'Updated' });
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'med-1', householdId: 'hh1', isDeleted: false },
{ $set: { name: 'Updated' } },
{ new: true, lean: true },
);
expect(result).toEqual({ _id: 'med-1', name: 'Updated' });
});
});
describe('softDelete', () => {
it('sets isDeleted to true', async () => {
mockExec.mockResolvedValue({ _id: 'med-1', isDeleted: true });
await repo.softDelete('med-1', 'hh1');
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'med-1', householdId: 'hh1', isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
);
});
});
});

View file

@ -0,0 +1,81 @@
import { MedicineModel } from '../../schemas/medicine.schema.js';
import type {
CreateMedicineInput,
UpdateMedicineInput,
MedicineQueryInput,
} from '@meshitrack/shared';
export class MedicinesRepository {
public async findByHousehold(householdId: string, query: MedicineQueryInput) {
const filter: Record<string, unknown> = { householdId, isDeleted: false };
if (query.category) filter['category'] = query.category;
if (query.form) filter['form'] = query.form;
if (query.q) filter['name'] = { $regex: query.q, $options: 'i' };
if (query.cursor) {
const id = Buffer.from(query.cursor, 'base64').toString();
filter['_id'] = { $gt: id };
}
const limit = query.limit;
const items = await MedicineModel.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 MedicineModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
}
public async findDuplicate(
householdId: string,
name: string,
strength: number,
strengthUnit: string,
form: string,
excludeId?: string,
) {
const filter: Record<string, unknown> = {
householdId,
name,
strength,
strengthUnit,
form,
isDeleted: false,
};
if (excludeId) filter['_id'] = { $ne: excludeId };
return MedicineModel.findOne(filter).lean().exec();
}
public async create(data: CreateMedicineInput, householdId: string, createdBy: string) {
const medicine = new MedicineModel({ ...data, householdId, createdBy });
const saved = await medicine.save();
return saved.toObject();
}
public async update(id: string, householdId: string, data: UpdateMedicineInput) {
return MedicineModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
}
public async softDelete(id: string, householdId: string) {
return MedicineModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
}
}

View file

@ -0,0 +1,247 @@
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 { MedicineForm, StrengthUnit, MedicineCategory } 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 {
mockFindByHousehold,
mockFindById,
mockFindDuplicate,
mockCreate,
mockUpdate,
mockSoftDelete,
mockCountByMedicineId,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockFindDuplicate: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockSoftDelete: vi.fn(),
mockCountByMedicineId: vi.fn(),
}));
vi.mock('./medicines.repository.js', () => ({
MedicinesRepository: class {
findByHousehold = mockFindByHousehold;
findById = mockFindById;
findDuplicate = mockFindDuplicate;
create = mockCreate;
update = mockUpdate;
softDelete = mockSoftDelete;
},
}));
vi.mock('../medicine-products/medicine-products.repository.js', () => ({
MedicineProductsRepository: class {
countByMedicineId = mockCountByMedicineId;
},
}));
vi.mock('../medicine-products/medicine-products.service.js', () => ({
MedicineProductsService: class {
listByMedicine = vi.fn();
getById = vi.fn();
create = vi.fn();
update = vi.fn();
delete = 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 medicinesRoutes from './medicines.routes.js';
import medicineProductsRoutes from '../medicine-products/medicine-products.routes.js';
function makeFakeMedicine(overrides = {}) {
return {
_id: 'med-1',
householdId: 'hh1',
name: 'Metformin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.PRESCRIPTION,
tags: [],
createdBy: 'kc-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('medicines.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(medicineProductsRoutes);
await instance.register(medicinesRoutes);
await instance.ready();
return instance;
}
const authHeaders = { authorization: 'Bearer valid-token' };
beforeEach(async () => {
vi.clearAllMocks();
mockCountByMedicineId.mockResolvedValue(0);
app = await buildTestApp();
});
afterEach(async () => {
if (app) await app.close();
});
describe('GET /api/v1/households/:householdId/medicines', () => {
it('returns paginated list', async () => {
const medicine = makeFakeMedicine();
mockFindByHousehold.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe('Metformin');
expect(body.pagination.hasMore).toBe(false);
});
it('handles ObjectId and Date objects in response', async () => {
const medicine = makeFakeMedicine({
_id: { toString: () => 'med-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
notes: 'Take with food',
});
mockFindByHousehold.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('med-obj');
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.data[0].notes).toBe('Take with food');
});
});
describe('GET /api/v1/households/:householdId/medicines/:id', () => {
it('returns a medicine', async () => {
mockFindById.mockResolvedValue(makeFakeMedicine());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/medicines/med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Metformin');
});
});
describe('POST /api/v1/households/:householdId/medicines', () => {
it('creates a medicine', async () => {
mockFindDuplicate.mockResolvedValue(null);
mockCreate.mockResolvedValue(makeFakeMedicine());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/medicines',
headers: authHeaders,
payload: {
name: 'Metformin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.PRESCRIPTION,
},
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Metformin');
});
});
describe('PATCH /api/v1/households/:householdId/medicines/:id', () => {
it('updates a medicine', async () => {
mockFindById.mockResolvedValue(makeFakeMedicine());
mockFindDuplicate.mockResolvedValue(null);
mockUpdate.mockResolvedValue(makeFakeMedicine({ name: 'Updated' }));
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/medicines/med-1',
headers: authHeaders,
payload: { name: 'Updated' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated');
});
});
describe('DELETE /api/v1/households/:householdId/medicines/:id', () => {
it('soft deletes a medicine', async () => {
mockFindById.mockResolvedValue(makeFakeMedicine());
mockSoftDelete.mockResolvedValue(makeFakeMedicine({ isDeleted: true }));
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/medicines/med-1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
});
});

View file

@ -0,0 +1,165 @@
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreateMedicineSchema,
UpdateMedicineSchema,
MedicineQuerySchema,
MedicineResponseSchema,
MedicineListResponseSchema,
} from '@meshitrack/shared';
import { MedicinesRepository } from './medicines.repository.js';
import { MedicinesService } from './medicines.service.js';
type AnyMedicineDoc = {
_id: string | { toString: () => string };
householdId: string;
name: string;
form: string;
strength: number;
strengthUnit: string;
category: string;
notes?: string | null;
tags: string[];
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 | { toISOString: () => string }): string {
return typeof v === 'string' ? v : v.toISOString();
}
function toMedicineResponse(doc: AnyMedicineDoc): z.infer<typeof MedicineResponseSchema> {
return {
_id: toStr(doc._id),
householdId: doc.householdId,
name: doc.name,
form: doc.form,
strength: doc.strength,
strengthUnit: doc.strengthUnit,
category: doc.category,
...(doc.notes ? { notes: doc.notes } : {}),
tags: doc.tags,
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
medicinesRepository: MedicinesRepository;
medicinesService: MedicinesService;
}
}
export default fp(
async (fastify) => {
fastify.diContainer.register({
medicinesRepository: asClass(MedicinesRepository, { lifetime: Lifetime.SINGLETON }),
medicinesService: asClass(MedicinesService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
// GET /api/v1/households/:householdId/medicines — list/search
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/medicines',
schema: {
params: householdParams,
querystring: MedicineQuerySchema,
response: { 200: MedicineListResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const result = await service.list(request.params.householdId, request.query);
return reply.send({
data: result.data.map(toMedicineResponse),
pagination: result.pagination,
});
},
});
// GET /api/v1/households/:householdId/medicines/:id — get by id
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/medicines/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 200: MedicineResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const medicine = await service.getById(request.params.id, request.params.householdId);
return reply.send(toMedicineResponse(medicine));
},
});
// POST /api/v1/households/:householdId/medicines — create
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/medicines',
schema: {
params: householdParams,
body: CreateMedicineSchema,
response: { 201: MedicineResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const medicine = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(201).send(toMedicineResponse(medicine));
},
});
// PATCH /api/v1/households/:householdId/medicines/:id — update
app.route({
method: 'PATCH',
url: '/api/v1/households/:householdId/medicines/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
body: UpdateMedicineSchema,
response: { 200: MedicineResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const medicine = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
return reply.send(toMedicineResponse(medicine));
},
});
// DELETE /api/v1/households/:householdId/medicines/:id — soft delete
app.route({
method: 'DELETE',
url: '/api/v1/households/:householdId/medicines/:id',
schema: {
params: householdParams.extend({ id: z.string() }),
response: { 204: z.undefined() },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
await service.delete(request.params.id, request.params.householdId);
return reply.status(204).send();
},
});
},
{
name: 'medicines-routes',
dependencies: ['auth-plugin'],
},
);

View file

@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MedicinesService } from './medicines.service.js';
import { NotFoundError, ConflictError } from '../../common/errors.js';
import { MedicineForm, StrengthUnit, MedicineCategory } from '@meshitrack/shared';
describe(MedicinesService.name, () => {
const mockRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
};
const mockProductsRepo = {
countByMedicineId: vi.fn(),
};
let service: MedicinesService;
beforeEach(() => {
vi.clearAllMocks();
service = new MedicinesService({
medicinesRepository: mockRepo as never,
medicineProductsRepository: mockProductsRepo as never,
});
});
describe('list', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockRepo.findByHousehold.mockResolvedValue(expected);
const result = await service.list('hh1', { limit: 20 });
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
expect(result).toEqual(expected);
});
});
describe('getById', () => {
it('returns medicine when found', async () => {
const medicine = { _id: 'med-1', name: 'Aspirin' };
mockRepo.findById.mockResolvedValue(medicine);
const result = await service.getById('med-1', 'hh1');
expect(result).toEqual(medicine);
});
it('throws NotFoundError when not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const data = {
name: 'Aspirin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.OTC,
tags: [],
};
it('creates when no duplicate exists', async () => {
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'med-1', ...data });
const result = await service.create(data, 'hh1', 'kc-1');
expect(mockRepo.findDuplicate).toHaveBeenCalledWith(
'hh1',
'Aspirin',
500,
StrengthUnit.MG,
MedicineForm.TABLET,
);
expect(result._id).toBe('med-1');
});
it('throws ConflictError when duplicate exists', async () => {
mockRepo.findDuplicate.mockResolvedValue({ _id: 'existing' });
await expect(service.create(data, 'hh1', 'kc-1')).rejects.toThrow(ConflictError);
expect(mockRepo.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('updates a medicine', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Aspirin',
strength: 500,
strengthUnit: StrengthUnit.MG,
form: MedicineForm.TABLET,
});
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.update.mockResolvedValue({ _id: 'med-1', name: 'Updated' });
const result = await service.update('med-1', 'hh1', { name: 'Updated' });
expect(result.name).toBe('Updated');
});
it('throws NotFoundError when medicine does not exist', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.update('missing', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when update would create duplicate', async () => {
mockRepo.findById.mockResolvedValue({
_id: 'med-1',
name: 'Aspirin',
strength: 500,
strengthUnit: StrengthUnit.MG,
form: MedicineForm.TABLET,
});
mockRepo.findDuplicate.mockResolvedValue({ _id: 'med-2' });
await expect(service.update('med-1', 'hh1', { name: 'Ibuprofen' })).rejects.toThrow(
ConflictError,
);
});
it('throws NotFoundError when repo update returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
mockRepo.update.mockResolvedValue(null);
await expect(service.update('med-1', 'hh1', { notes: 'Updated' })).rejects.toThrow(
NotFoundError,
);
});
it('skips dedup check when no identity fields change', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
mockRepo.update.mockResolvedValue({ _id: 'med-1', notes: 'Updated notes' });
await service.update('med-1', 'hh1', { notes: 'Updated notes' });
expect(mockRepo.findDuplicate).not.toHaveBeenCalled();
});
});
describe('delete', () => {
it('soft deletes a medicine', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1' });
mockProductsRepo.countByMedicineId.mockResolvedValue(0);
mockRepo.softDelete.mockResolvedValue({ _id: 'med-1', isDeleted: true });
await service.delete('med-1', 'hh1');
expect(mockProductsRepo.countByMedicineId).toHaveBeenCalledWith('med-1');
expect(mockRepo.softDelete).toHaveBeenCalledWith('med-1', 'hh1');
});
it('throws ConflictError when linked products exist', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1' });
mockProductsRepo.countByMedicineId.mockResolvedValue(3);
await expect(service.delete('med-1', 'hh1')).rejects.toThrow(ConflictError);
expect(mockRepo.softDelete).not.toHaveBeenCalled();
});
it('throws NotFoundError when not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockRepo.findById.mockResolvedValue({ _id: 'med-1' });
mockProductsRepo.countByMedicineId.mockResolvedValue(0);
mockRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('med-1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
});

View file

@ -0,0 +1,92 @@
import type { MedicinesRepository } from './medicines.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type {
CreateMedicineInput,
UpdateMedicineInput,
MedicineQueryInput,
} from '@meshitrack/shared';
import { NotFoundError, ConflictError } from '../../common/errors.js';
interface Deps {
medicinesRepository: MedicinesRepository;
medicineProductsRepository: MedicineProductsRepository;
}
export class MedicinesService {
private readonly medicinesRepository: MedicinesRepository;
private readonly medicineProductsRepository: MedicineProductsRepository;
public constructor({ medicinesRepository, medicineProductsRepository }: Deps) {
this.medicinesRepository = medicinesRepository;
this.medicineProductsRepository = medicineProductsRepository;
}
public async list(householdId: string, query: MedicineQueryInput) {
return this.medicinesRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
const medicine = await this.medicinesRepository.findById(id, householdId);
if (!medicine) {
throw new NotFoundError('Medicine not found');
}
return medicine;
}
public async create(data: CreateMedicineInput, householdId: string, createdBy: string) {
const existing = await this.medicinesRepository.findDuplicate(
householdId,
data.name,
data.strength,
data.strengthUnit,
data.form,
);
if (existing) {
throw new ConflictError('A medicine with the same name, strength, and form already exists');
}
return this.medicinesRepository.create(data, householdId, createdBy);
}
public async update(id: string, householdId: string, data: UpdateMedicineInput) {
await this.getById(id, householdId);
if (data.name || data.strength || data.strengthUnit || data.form) {
const current = await this.medicinesRepository.findById(id, householdId);
const name = data.name ?? current!.name;
const strength = data.strength ?? current!.strength;
const strengthUnit = data.strengthUnit ?? current!.strengthUnit;
const form = data.form ?? current!.form;
const duplicate = await this.medicinesRepository.findDuplicate(
householdId,
name,
strength,
strengthUnit,
form,
id,
);
if (duplicate) {
throw new ConflictError('A medicine with the same name, strength, and form already exists');
}
}
const updated = await this.medicinesRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Medicine not found');
return updated;
}
public async delete(id: string, householdId: string) {
await this.getById(id, householdId);
const productCount = await this.medicineProductsRepository.countByMedicineId(id);
if (productCount > 0) {
throw new ConflictError(
`Cannot delete medicine with ${productCount} linked product(s). Delete them first.`,
);
}
const deleted = await this.medicinesRepository.softDelete(id, householdId);
if (!deleted) throw new NotFoundError('Medicine not found');
return deleted;
}
}

View file

@ -20,10 +20,14 @@ vi.mock('jose', () => ({
})); }));
// Mock the users repository module with a real class // Mock the users repository module with a real class
const mockUpsertFromToken = vi.hoisted(() => vi.fn()); const { mockUpsertFromToken, mockFindByKeycloakId } = vi.hoisted(() => ({
mockUpsertFromToken: vi.fn(),
mockFindByKeycloakId: vi.fn(),
}));
vi.mock('./users.repository.js', () => ({ vi.mock('./users.repository.js', () => ({
UsersRepository: class MockUsersRepository { UsersRepository: class MockUsersRepository {
upsertFromToken = mockUpsertFromToken; upsertFromToken = mockUpsertFromToken;
findByKeycloakId = mockFindByKeycloakId;
}, },
})); }));
@ -47,6 +51,55 @@ describe('users.routes', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
});
it('returns 404 when syncFromToken returns null', async () => {
mockUpsertFromToken.mockResolvedValue(null);
const app = await buildTestApp();
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/users/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(404);
await app.close();
});
it('handles ObjectId and Date objects in response', async () => {
const mockUser = {
_id: { toString: () => 'u-obj' },
keycloakId: 'kc-1',
displayName: 'testuser',
email: 'test@example.com',
householdIds: ['hh1'],
defaultHouseholdId: 'hh1',
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
};
mockUpsertFromToken.mockResolvedValue(mockUser);
const app = await buildTestApp();
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/users/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body._id).toBe('u-obj');
expect(body.createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(body.defaultHouseholdId).toBe('hh1');
await app.close();
}); });
it('GET /api/v1/users/me syncs user from token and returns profile', async () => { it('GET /api/v1/users/me syncs user from token and returns profile', async () => {

View file

@ -1,7 +1,7 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix'; import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4'; import { UserResponseSchema } from '@meshitrack/shared';
import { UsersRepository } from './users.repository.js'; import { UsersRepository } from './users.repository.js';
import { UsersService } from './users.service.js'; import { UsersService } from './users.service.js';
import { NotFoundError } from '../../common/errors.js'; import { NotFoundError } from '../../common/errors.js';
@ -56,18 +56,7 @@ export default fp(
url: '/api/v1/users/me', url: '/api/v1/users/me',
config: { skipHousehold: true }, config: { skipHousehold: true },
schema: { schema: {
response: { response: { 200: UserResponseSchema },
200: z.object({
_id: z.string(),
keycloakId: z.string(),
displayName: z.string(),
email: z.string(),
householdIds: z.array(z.string()),
defaultHouseholdId: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
}),
},
}, },
handler: async (request, reply) => { handler: async (request, reply) => {
const service = fastify.diContainer.resolve('usersService'); const service = fastify.diContainer.resolve('usersService');

View file

@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { asValue } from 'awilix';
const { MockJOSEError } = vi.hoisted(() => ({ const { MockJOSEError } = vi.hoisted(() => ({
MockJOSEError: class JOSEError extends Error {}, MockJOSEError: class JOSEError extends Error {},
@ -12,21 +14,38 @@ vi.mock('jose', () => ({
errors: { JOSEError: MockJOSEError }, errors: { JOSEError: MockJOSEError },
})); }));
const { mockFindByKeycloakId, mockUpsertFromToken } = vi.hoisted(() => ({
mockFindByKeycloakId: vi.fn(),
mockUpsertFromToken: vi.fn(),
}));
import authPlugin from './auth.plugin.js'; import authPlugin from './auth.plugin.js';
import * as jose from 'jose'; import * as jose from 'jose';
describe('auth.plugin', () => { describe('auth.plugin', () => {
function buildApp() { async function buildApp() {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
await app.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
app.diContainer.register({
usersRepository: asValue({
findByKeycloakId: mockFindByKeycloakId,
upsertFromToken: mockUpsertFromToken,
}),
});
return app; return app;
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
}); });
it('skips auth for routes marked as public', async () => { it('skips auth for routes marked as public', async () => {
const app = buildApp(); const app = await buildApp();
await app.register(authPlugin); await app.register(authPlugin);
app.get('/public', { config: { public: true } as never }, async () => ({ ok: true })); app.get('/public', { config: { public: true } as never }, async () => ({ ok: true }));
@ -37,7 +56,7 @@ describe('auth.plugin', () => {
}); });
it('throws 401 when no Authorization header', async () => { it('throws 401 when no Authorization header', async () => {
const app = buildApp(); const app = await buildApp();
await app.register(authPlugin); await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true })); app.get('/protected', async () => ({ ok: true }));
@ -49,7 +68,7 @@ describe('auth.plugin', () => {
}); });
it('throws 401 when Authorization header is not Bearer', async () => { it('throws 401 when Authorization header is not Bearer', async () => {
const app = buildApp(); const app = await buildApp();
await app.register(authPlugin); await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true })); app.get('/protected', async () => ({ ok: true }));
@ -66,7 +85,7 @@ describe('auth.plugin', () => {
it('throws 401 when token is invalid', async () => { it('throws 401 when token is invalid', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValue(new MockJOSEError('Invalid token')); vi.mocked(jose.jwtVerify).mockRejectedValue(new MockJOSEError('Invalid token'));
const app = buildApp(); const app = await buildApp();
await app.register(authPlugin); await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true })); app.get('/protected', async () => ({ ok: true }));
@ -88,15 +107,15 @@ describe('auth.plugin', () => {
email: 'test@example.com', email: 'test@example.com',
preferred_username: 'testuser', preferred_username: 'testuser',
realm_access: { roles: ['member'] }, realm_access: { roles: ['member'] },
householdIds: ['hh1'],
iss: 'http://localhost:8080/realms/meshitrack', iss: 'http://localhost:8080/realms/meshitrack',
aud: 'meshitrack-api', aud: 'meshitrack-api',
}, },
protectedHeader: { alg: 'RS256' }, protectedHeader: { alg: 'RS256' },
key: {} as never, key: {} as never,
} as never); } as never);
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
const app = buildApp(); const app = await buildApp();
await app.register(authPlugin); await app.register(authPlugin);
let capturedUser: unknown; let capturedUser: unknown;
@ -122,18 +141,37 @@ describe('auth.plugin', () => {
}); });
}); });
it('rethrows non-JOSE errors as-is', async () => {
vi.mocked(jose.jwtVerify).mockRejectedValue(new Error('Network failure'));
const app = await buildApp();
await app.register(authPlugin);
app.get('/protected', async () => ({ ok: true }));
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer some-token' },
});
expect(res.statusCode).toBe(500);
});
it('handles missing optional fields in JWT payload', async () => { it('handles missing optional fields in JWT payload', async () => {
vi.mocked(jose.jwtVerify).mockResolvedValue({ vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { payload: {
// sub, email, preferred_username, realm_access, householdIds all missing // sub, email, preferred_username, realm_access all missing
iss: 'http://localhost:8080/realms/meshitrack', iss: 'http://localhost:8080/realms/meshitrack',
aud: 'meshitrack-api', aud: 'meshitrack-api',
}, },
protectedHeader: { alg: 'RS256' }, protectedHeader: { alg: 'RS256' },
key: {} as never, key: {} as never,
} as never); } as never);
mockFindByKeycloakId.mockResolvedValue(null);
mockUpsertFromToken.mockResolvedValue({ householdIds: [] });
const app = buildApp(); const app = await buildApp();
await app.register(authPlugin); await app.register(authPlugin);
let capturedUser: unknown; let capturedUser: unknown;

View file

@ -3,6 +3,7 @@ import * as jose from 'jose';
import config from '../config/configuration.js'; import config from '../config/configuration.js';
import type { AuthUser } from '../common/types.js'; import type { AuthUser } from '../common/types.js';
import { UnauthorizedError } from '../common/errors.js'; import { UnauthorizedError } from '../common/errors.js';
import type { UsersRepository } from '../modules/users/users.repository.js';
let jwks: jose.JWTVerifyGetKey | undefined; let jwks: jose.JWTVerifyGetKey | undefined;
@ -49,12 +50,25 @@ export default fp(
audience: config.keycloak.clientId, audience: config.keycloak.clientId,
}); });
const keycloakId = payload.sub ?? '';
const email = (payload['email'] as string) ?? '';
const displayName = (payload['preferred_username'] as string) ?? '';
const roles = (payload['realm_access'] as Record<string, string[]>)?.['roles'] ?? [];
// Look up user from DB to get fresh householdIds (application state
// belongs in the database, not baked into the JWT).
const usersRepository = fastify.diContainer.resolve<UsersRepository>('usersRepository');
let dbUser = await usersRepository.findByKeycloakId(keycloakId);
if (!dbUser) {
dbUser = await usersRepository.upsertFromToken(keycloakId, email, displayName);
}
const user: AuthUser = { const user: AuthUser = {
keycloakId: payload.sub ?? '', keycloakId,
email: (payload['email'] as string) ?? '', email,
displayName: (payload['preferred_username'] as string) ?? '', displayName,
roles: (payload['realm_access'] as Record<string, string[]>)?.['roles'] ?? [], roles,
householdIds: (payload['householdIds'] as string[]) ?? [], householdIds: dbUser?.householdIds ?? [],
}; };
request.user = user; request.user = user;

View file

@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { asValue } from 'awilix';
// Mock jose for the auth plugin dependency // Mock jose for the auth plugin dependency
vi.mock('jose', () => ({ vi.mock('jose', () => ({
@ -10,19 +12,33 @@ vi.mock('jose', () => ({
email: 'test@example.com', email: 'test@example.com',
preferred_username: 'testuser', preferred_username: 'testuser',
realm_access: { roles: ['member'] }, realm_access: { roles: ['member'] },
householdIds: ['hh1'],
}, },
protectedHeader: { alg: 'RS256' }, protectedHeader: { alg: 'RS256' },
key: {}, key: {},
}), }),
})); }));
const { mockFindByKeycloakId } = vi.hoisted(() => ({
mockFindByKeycloakId: vi.fn(),
}));
import authPlugin from './auth.plugin.js'; import authPlugin from './auth.plugin.js';
import householdPlugin from './household.plugin.js'; import householdPlugin from './household.plugin.js';
describe('household.plugin', () => { describe('household.plugin', () => {
async function buildApp() { async function buildApp() {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
await app.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
app.diContainer.register({
usersRepository: asValue({
findByKeycloakId: mockFindByKeycloakId,
upsertFromToken: vi.fn().mockResolvedValue({ householdIds: ['hh1'] }),
}),
});
await app.register(authPlugin); await app.register(authPlugin);
await app.register(householdPlugin); await app.register(householdPlugin);
return app; return app;
@ -30,6 +46,7 @@ describe('household.plugin', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'] });
}); });
it('skips household check for public routes', async () => { it('skips household check for public routes', async () => {

View file

@ -0,0 +1,48 @@
import mongoose from 'mongoose';
import {
CabinetItemStatus,
ConcentrationUnit,
DosageUnit,
MedicineForm,
StrengthUnit,
} from '@meshitrack/shared';
const cabinetItemSchema = new mongoose.Schema(
{
householdId: { type: String, required: true },
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 },
medicineProductId: { type: String },
medicineProductBrand: { type: String },
concentration: { type: Number },
concentrationUnit: { type: String, enum: Object.values(ConcentrationUnit) },
quantity: { type: Number, required: true, min: 0 },
unit: { type: String, enum: Object.values(DosageUnit), required: true },
expirationDate: { type: Date },
status: {
type: String,
enum: Object.values(CabinetItemStatus),
default: CabinetItemStatus.ACTIVE,
},
notes: { type: String },
createdBy: { type: String, required: true },
isDeleted: { type: Boolean, default: false },
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
},
);
cabinetItemSchema.index({ householdId: 1, medicineId: 1, status: 1 });
cabinetItemSchema.index({ householdId: 1, status: 1 });
cabinetItemSchema.index({ householdId: 1, expirationDate: 1 });
export const CabinetItemModel = mongoose.model('CabinetItem', cabinetItemSchema);
export type CabinetItemDocument = mongoose.InferSchemaType<typeof cabinetItemSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { MedicineProductModel } from './medicine-product.schema.js';
describe(MedicineProductModel.name, () => {
it('is a valid mongoose model', () => {
expect(MedicineProductModel.modelName).toBe('MedicineProduct');
});
it('has expected schema paths', () => {
const paths = Object.keys(MedicineProductModel.schema.paths);
expect(paths).toContain('householdId');
expect(paths).toContain('medicineId');
expect(paths).toContain('medicineName');
expect(paths).toContain('brand');
expect(paths).toContain('packageSize');
expect(paths).toContain('packageUnit');
expect(paths).toContain('source');
expect(paths).toContain('createdBy');
expect(paths).toContain('isDeleted');
expect(paths).toContain('createdAt');
expect(paths).toContain('updatedAt');
});
});

View file

@ -0,0 +1,38 @@
import mongoose from 'mongoose';
import { ConcentrationUnit, DosageUnit, MedicineProductSource } from '@meshitrack/shared';
const medicineProductSchema = new mongoose.Schema(
{
householdId: { type: String, required: true },
medicineId: { type: String, required: true },
medicineName: { type: String, required: true },
brand: { type: String, required: true },
manufacturer: { type: String },
packageSize: { type: Number, required: true },
packageUnit: { type: String, enum: Object.values(DosageUnit), required: true },
concentration: { type: Number },
concentrationUnit: { type: String, enum: Object.values(ConcentrationUnit) },
imageUrl: { type: String },
notes: { type: String },
source: {
type: String,
enum: Object.values(MedicineProductSource),
default: MedicineProductSource.MANUAL,
},
createdBy: { type: String, required: true },
isDeleted: { type: Boolean, default: false },
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
},
);
medicineProductSchema.index({ householdId: 1, medicineId: 1 });
medicineProductSchema.index({ householdId: 1, brand: 'text' });
export const MedicineProductModel = mongoose.model('MedicineProduct', medicineProductSchema);
export type MedicineProductDocument = mongoose.InferSchemaType<typeof medicineProductSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { MedicineModel } from './medicine.schema.js';
describe(MedicineModel.name, () => {
it('is a valid mongoose model', () => {
expect(MedicineModel.modelName).toBe('Medicine');
});
it('has expected schema paths', () => {
const paths = Object.keys(MedicineModel.schema.paths);
expect(paths).toContain('householdId');
expect(paths).toContain('name');
expect(paths).toContain('form');
expect(paths).toContain('strength');
expect(paths).toContain('strengthUnit');
expect(paths).toContain('category');
expect(paths).toContain('tags');
expect(paths).toContain('createdBy');
expect(paths).toContain('isDeleted');
expect(paths).toContain('createdAt');
expect(paths).toContain('updatedAt');
});
});

View file

@ -0,0 +1,37 @@
import mongoose from 'mongoose';
import { MedicineForm, StrengthUnit, MedicineCategory } from '@meshitrack/shared';
const medicineSchema = new mongoose.Schema(
{
householdId: { type: String, required: true },
name: { type: String, required: true },
form: { type: String, enum: Object.values(MedicineForm), required: true },
strength: { type: Number, required: true },
strengthUnit: { type: String, enum: Object.values(StrengthUnit), required: true },
category: { type: String, enum: Object.values(MedicineCategory), required: true },
notes: { type: String },
tags: { type: [String], default: [] },
createdBy: { type: String, required: true },
isDeleted: { type: Boolean, default: false },
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
},
);
medicineSchema.index(
{ householdId: 1, name: 'text', tags: 'text' },
{ name: 'medicine_text_search' },
);
medicineSchema.index({ householdId: 1, category: 1 });
medicineSchema.index(
{ householdId: 1, name: 1, strength: 1, strengthUnit: 1, form: 1 },
{ name: 'medicine_dedup' },
);
export const MedicineModel = mongoose.model('Medicine', medicineSchema);
export type MedicineDocument = mongoose.InferSchemaType<typeof medicineSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { CabinetItemStatus } from './cabinet.enums.js';
describe(CabinetItemStatus.name, () => {
it('has exactly 3 values', () => {
expect(Object.values(CabinetItemStatus)).toHaveLength(3);
});
it.each([
['ACTIVE', 'active'],
['DEPLETED', 'depleted'],
['EXPIRED', 'expired'],
])('%s = %s', (key, value) => {
expect(CabinetItemStatus[key as keyof typeof CabinetItemStatus]).toBe(value);
});
});

View file

@ -0,0 +1,5 @@
export enum CabinetItemStatus {
ACTIVE = 'active',
DEPLETED = 'depleted',
EXPIRED = 'expired',
}

View file

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

View file

@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import {
MedicineForm,
StrengthUnit,
MedicineCategory,
MedicineProductSource,
DosageUnit,
ConcentrationUnit,
allowedUnitsForForm,
defaultUnitForForm,
} from './medicine.enums.js';
describe(MedicineForm.name, () => {
it('has expected values', () => {
expect(MedicineForm.TABLET).toBe('tablet');
expect(MedicineForm.CAPSULE).toBe('capsule');
expect(MedicineForm.LIQUID).toBe('liquid');
expect(MedicineForm.INJECTION).toBe('injection');
expect(MedicineForm.OTHER).toBe('other');
});
it('has exactly 5 forms', () => {
expect(Object.values(MedicineForm)).toHaveLength(5);
});
});
describe(StrengthUnit.name, () => {
it('has expected values', () => {
expect(StrengthUnit.MG).toBe('mg');
expect(StrengthUnit.IU).toBe('IU');
expect(StrengthUnit.PERCENT).toBe('%');
});
it('has exactly 7 units', () => {
expect(Object.values(StrengthUnit)).toHaveLength(7);
});
});
describe(MedicineCategory.name, () => {
it('has expected values', () => {
expect(MedicineCategory.PRESCRIPTION).toBe('prescription');
expect(MedicineCategory.OTC).toBe('otc');
expect(MedicineCategory.SUPPLEMENT).toBe('supplement');
});
it('has exactly 4 categories', () => {
expect(Object.values(MedicineCategory)).toHaveLength(4);
});
});
describe(MedicineProductSource.name, () => {
it('has expected values', () => {
expect(MedicineProductSource.MANUAL).toBe('manual');
expect(MedicineProductSource.IMPORT).toBe('import');
});
it('has exactly 2 sources', () => {
expect(Object.values(MedicineProductSource)).toHaveLength(2);
});
});
describe(DosageUnit.name, () => {
it('has expected values', () => {
expect(DosageUnit.TABLET).toBe('tablet');
expect(DosageUnit.CAPSULE).toBe('capsule');
expect(DosageUnit.ML).toBe('ml');
expect(DosageUnit.VIAL).toBe('vial');
expect(DosageUnit.DOSE).toBe('dose');
});
it('has exactly 5 units', () => {
expect(Object.values(DosageUnit)).toHaveLength(5);
});
});
describe(ConcentrationUnit.name, () => {
it('has expected values', () => {
expect(ConcentrationUnit.MG_PER_ML).toBe('mg/mL');
expect(ConcentrationUnit.MCG_PER_ML).toBe('mcg/mL');
expect(ConcentrationUnit.UNITS_PER_ML).toBe('units/mL');
});
it('has exactly 3 units', () => {
expect(Object.values(ConcentrationUnit)).toHaveLength(3);
});
});
describe('allowedUnitsForForm', () => {
it('returns tablet for tablet form', () => {
expect(allowedUnitsForForm(MedicineForm.TABLET)).toEqual([DosageUnit.TABLET]);
});
it('returns capsule for capsule form', () => {
expect(allowedUnitsForForm(MedicineForm.CAPSULE)).toEqual([DosageUnit.CAPSULE]);
});
it('returns ml for liquid form', () => {
expect(allowedUnitsForForm(MedicineForm.LIQUID)).toEqual([DosageUnit.ML]);
});
it('returns vial and ml for injection form', () => {
expect(allowedUnitsForForm(MedicineForm.INJECTION)).toEqual([DosageUnit.VIAL, DosageUnit.ML]);
});
it('returns dose for other form', () => {
expect(allowedUnitsForForm(MedicineForm.OTHER)).toEqual([DosageUnit.DOSE]);
});
});
describe('defaultUnitForForm', () => {
it('returns the first allowed unit', () => {
expect(defaultUnitForForm(MedicineForm.TABLET)).toBe(DosageUnit.TABLET);
expect(defaultUnitForForm(MedicineForm.INJECTION)).toBe(DosageUnit.VIAL);
});
});

View file

@ -0,0 +1,64 @@
export enum MedicineForm {
TABLET = 'tablet',
CAPSULE = 'capsule',
LIQUID = 'liquid',
INJECTION = 'injection',
OTHER = 'other',
}
export enum StrengthUnit {
MG = 'mg',
MCG = 'mcg',
G = 'g',
ML = 'ml',
IU = 'IU',
PERCENT = '%',
OTHER = 'other',
}
export enum MedicineCategory {
PRESCRIPTION = 'prescription',
OTC = 'otc',
SUPPLEMENT = 'supplement',
OTHER = 'other',
}
export enum MedicineProductSource {
MANUAL = 'manual',
IMPORT = 'import',
}
export enum DosageUnit {
TABLET = 'tablet',
CAPSULE = 'capsule',
ML = 'ml',
VIAL = 'vial',
DOSE = 'dose',
}
export enum ConcentrationUnit {
MG_PER_ML = 'mg/mL',
MCG_PER_ML = 'mcg/mL',
UNITS_PER_ML = 'units/mL',
}
/** Returns the allowed DosageUnit values for a given MedicineForm. */
export function allowedUnitsForForm(form: MedicineForm): DosageUnit[] {
switch (form) {
case MedicineForm.TABLET:
return [DosageUnit.TABLET];
case MedicineForm.CAPSULE:
return [DosageUnit.CAPSULE];
case MedicineForm.LIQUID:
return [DosageUnit.ML];
case MedicineForm.INJECTION:
return [DosageUnit.VIAL, DosageUnit.ML];
case MedicineForm.OTHER:
return [DosageUnit.DOSE];
}
}
/** Returns the default DosageUnit for a given MedicineForm. */
export function defaultUnitForForm(form: MedicineForm): DosageUnit {
return allowedUnitsForForm(form)[0];
}

View file

@ -0,0 +1,41 @@
import type { CabinetItemStatus } from '../enums/cabinet.enums.js';
import type {
ConcentrationUnit,
DosageUnit,
MedicineForm,
StrengthUnit,
} from '../enums/medicine.enums.js';
export interface CabinetItem {
id: string;
householdId: string;
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
medicineForm: MedicineForm;
medicineProductId?: string;
medicineProductBrand?: string;
concentration?: number;
concentrationUnit?: ConcentrationUnit;
quantity: number;
unit: DosageUnit;
expirationDate?: Date;
status: CabinetItemStatus;
notes?: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface CabinetSummary {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
totalQuantity: number;
unit: string;
earliestExpiry: string | null;
itemCount: number;
}

View file

@ -1,3 +1,5 @@
export * from './user.js'; export * from './user.js';
export * from './household.js'; export * from './household.js';
export * from './common.js'; export * from './common.js';
export * from './medicine.js';
export * from './cabinet.js';

View file

@ -0,0 +1,42 @@
import type {
MedicineForm,
StrengthUnit,
MedicineCategory,
MedicineProductSource,
DosageUnit,
ConcentrationUnit,
} from '../enums/medicine.enums.js';
export interface Medicine {
id: string;
householdId: string;
name: string;
form: MedicineForm;
strength: number;
strengthUnit: StrengthUnit;
category: MedicineCategory;
notes?: string;
tags: string[];
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface MedicineProduct {
id: string;
householdId: string;
medicineId: string;
medicineName: string;
brand: string;
manufacturer?: string;
packageSize: number;
packageUnit: DosageUnit;
concentration?: number;
concentrationUnit?: ConcentrationUnit;
imageUrl?: string;
notes?: string;
source: MedicineProductSource;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import { DosageUnit } from '../enums/medicine.enums.js';
import { CabinetItemStatus } from '../enums/cabinet.enums.js';
import {
CreateCabinetItemSchema,
UpdateCabinetItemSchema,
AdjustQuantitySchema,
CabinetQuerySchema,
} from './cabinet.schemas.js';
describe('CreateCabinetItemSchema', () => {
const validItem = {
medicineId: 'med-1',
quantity: 30,
unit: DosageUnit.TABLET,
};
it('accepts valid minimal input', () => {
const result = CreateCabinetItemSchema.safeParse(validItem);
expect(result.success).toBe(true);
});
it('accepts valid full input', () => {
const result = CreateCabinetItemSchema.safeParse({
...validItem,
medicineProductId: 'prod-1',
expirationDate: '2026-12-31T00:00:00.000Z',
notes: 'Main supply',
});
expect(result.success).toBe(true);
});
it('rejects missing medicineId', () => {
const result = CreateCabinetItemSchema.safeParse({ quantity: 10, unit: DosageUnit.TABLET });
expect(result.success).toBe(false);
});
it('rejects negative quantity', () => {
const result = CreateCabinetItemSchema.safeParse({ ...validItem, quantity: -1 });
expect(result.success).toBe(false);
});
it('accepts quantity of 0', () => {
const result = CreateCabinetItemSchema.safeParse({ ...validItem, quantity: 0 });
expect(result.success).toBe(true);
});
});
describe('UpdateCabinetItemSchema', () => {
it('accepts partial update', () => {
const result = UpdateCabinetItemSchema.safeParse({ quantity: 25 });
expect(result.success).toBe(true);
});
it('accepts status update', () => {
const result = UpdateCabinetItemSchema.safeParse({ status: CabinetItemStatus.EXPIRED });
expect(result.success).toBe(true);
});
it('accepts empty object', () => {
const result = UpdateCabinetItemSchema.safeParse({});
expect(result.success).toBe(true);
});
it('rejects invalid status', () => {
const result = UpdateCabinetItemSchema.safeParse({ status: 'invalid' });
expect(result.success).toBe(false);
});
});
describe('AdjustQuantitySchema', () => {
it('accepts positive delta', () => {
const result = AdjustQuantitySchema.safeParse({ delta: 5 });
expect(result.success).toBe(true);
});
it('accepts negative delta', () => {
const result = AdjustQuantitySchema.safeParse({ delta: -3, reason: 'Took a dose' });
expect(result.success).toBe(true);
});
it('rejects missing delta', () => {
const result = AdjustQuantitySchema.safeParse({});
expect(result.success).toBe(false);
});
it('trims reason', () => {
const result = AdjustQuantitySchema.safeParse({ delta: 1, reason: ' test ' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe('test');
}
});
});
describe('CabinetQuerySchema', () => {
it('applies default limit', () => {
const result = CabinetQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('accepts medicineId filter', () => {
const result = CabinetQuerySchema.safeParse({ medicineId: 'med-1' });
expect(result.success).toBe(true);
});
it('accepts status filter', () => {
const result = CabinetQuerySchema.safeParse({ status: CabinetItemStatus.ACTIVE });
expect(result.success).toBe(true);
});
it('accepts expiringWithin filter', () => {
const result = CabinetQuerySchema.safeParse({ expiringWithin: 30 });
expect(result.success).toBe(true);
});
it('coerces limit from string', () => {
const result = CabinetQuerySchema.safeParse({ limit: '50' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(50);
}
});
it('rejects limit over 100', () => {
const result = CabinetQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
it('rejects expiringWithin over 365', () => {
const result = CabinetQuerySchema.safeParse({ expiringWithin: 400 });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,88 @@
import { z } from 'zod/v4';
import { CabinetItemStatus } from '../enums/cabinet.enums.js';
import { DosageUnit } from '../enums/medicine.enums.js';
// --- CabinetItem ---
export const CreateCabinetItemSchema = z.object({
medicineId: z.string().min(1),
medicineProductId: z.string().min(1).optional(),
quantity: z.number().nonnegative(),
unit: z.nativeEnum(DosageUnit),
expirationDate: z.iso.datetime().optional(),
notes: z.string().max(1000).trim().optional(),
});
export const UpdateCabinetItemSchema = z.object({
quantity: z.number().nonnegative().optional(),
unit: z.nativeEnum(DosageUnit).optional(),
expirationDate: z.iso.datetime().optional(),
status: z.nativeEnum(CabinetItemStatus).optional(),
notes: z.string().max(1000).trim().optional(),
});
export const AdjustQuantitySchema = z.object({
delta: z.number(),
reason: z.string().max(500).trim().optional(),
});
export const CabinetQuerySchema = z.object({
medicineId: z.string().optional(),
status: z.nativeEnum(CabinetItemStatus).optional(),
expiringWithin: z.coerce.number().int().min(1).max(365).optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const CabinetItemResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
medicineId: z.string(),
medicineName: z.string(),
medicineStrength: z.number(),
medicineStrengthUnit: z.string(),
medicineForm: z.string(),
medicineProductId: z.string().optional(),
medicineProductBrand: z.string().optional(),
concentration: z.number().optional(),
concentrationUnit: z.string().optional(),
quantity: z.number(),
unit: z.string(),
expirationDate: z.string().optional(),
status: z.string(),
notes: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const CabinetItemListResponseSchema = z.object({
data: z.array(CabinetItemResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export const CabinetSummaryItemSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
medicineStrength: z.number(),
medicineStrengthUnit: z.string(),
medicineForm: z.string(),
totalQuantity: z.number(),
unit: z.string(),
earliestExpiry: z.string().nullable(),
itemCount: z.number(),
});
export const CabinetSummaryResponseSchema = z.object({
data: z.array(CabinetSummaryItemSchema),
});
// Type exports
export type CreateCabinetItemInput = z.infer<typeof CreateCabinetItemSchema>;
export type UpdateCabinetItemInput = z.infer<typeof UpdateCabinetItemSchema>;
export type AdjustQuantityInput = z.infer<typeof AdjustQuantitySchema>;
export type CabinetQueryInput = z.infer<typeof CabinetQuerySchema>;

View file

@ -1,2 +1,4 @@
export * from './user.schemas.js'; export * from './user.schemas.js';
export * from './household.schemas.js'; export * from './household.schemas.js';
export * from './medicine.schemas.js';
export * from './cabinet.schemas.js';

View file

@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest';
import {
CreateMedicineSchema,
UpdateMedicineSchema,
MedicineQuerySchema,
CreateMedicineProductSchema,
UpdateMedicineProductSchema,
} from './medicine.schemas.js';
import {
MedicineForm,
StrengthUnit,
MedicineCategory,
DosageUnit,
MedicineProductSource,
} from '../enums/medicine.enums.js';
describe('CreateMedicineSchema', () => {
const validMedicine = {
name: 'Metformin',
form: MedicineForm.TABLET,
strength: 500,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.PRESCRIPTION,
};
it('accepts valid medicine', () => {
const result = CreateMedicineSchema.safeParse(validMedicine);
expect(result.success).toBe(true);
});
it('accepts medicine with all optional fields', () => {
const result = CreateMedicineSchema.safeParse({
...validMedicine,
notes: 'Take with food',
tags: ['daily', 'morning'],
});
expect(result.success).toBe(true);
});
it('defaults tags to empty array', () => {
const result = CreateMedicineSchema.safeParse(validMedicine);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.tags).toEqual([]);
}
});
it('rejects empty name', () => {
const result = CreateMedicineSchema.safeParse({ ...validMedicine, name: '' });
expect(result.success).toBe(false);
});
it('rejects zero strength', () => {
const result = CreateMedicineSchema.safeParse({ ...validMedicine, strength: 0 });
expect(result.success).toBe(false);
});
it('rejects negative strength', () => {
const result = CreateMedicineSchema.safeParse({ ...validMedicine, strength: -1 });
expect(result.success).toBe(false);
});
it('rejects invalid form', () => {
const result = CreateMedicineSchema.safeParse({ ...validMedicine, form: 'gummy' });
expect(result.success).toBe(false);
});
it('rejects invalid category', () => {
const result = CreateMedicineSchema.safeParse({ ...validMedicine, category: 'magic' });
expect(result.success).toBe(false);
});
it('trims name whitespace', () => {
const result = CreateMedicineSchema.safeParse({ ...validMedicine, name: ' Metformin ' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Metformin');
}
});
});
describe('UpdateMedicineSchema', () => {
it('accepts empty object (all fields optional)', () => {
const result = UpdateMedicineSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts partial update', () => {
const result = UpdateMedicineSchema.safeParse({ name: 'Updated Name' });
expect(result.success).toBe(true);
});
it('rejects invalid strength', () => {
const result = UpdateMedicineSchema.safeParse({ strength: -5 });
expect(result.success).toBe(false);
});
});
describe('MedicineQuerySchema', () => {
it('accepts empty query with defaults', () => {
const result = MedicineQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('accepts full query', () => {
const result = MedicineQuerySchema.safeParse({
q: 'metformin',
category: MedicineCategory.PRESCRIPTION,
form: MedicineForm.TABLET,
cursor: 'abc123',
limit: 50,
});
expect(result.success).toBe(true);
});
it('coerces limit from string', () => {
const result = MedicineQuerySchema.safeParse({ limit: '10' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(10);
}
});
it('rejects limit above 100', () => {
const result = MedicineQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
});
describe('CreateMedicineProductSchema', () => {
const validProduct = {
brand: 'CVS Health',
packageSize: 90,
packageUnit: DosageUnit.TABLET,
};
it('accepts valid product', () => {
const result = CreateMedicineProductSchema.safeParse(validProduct);
expect(result.success).toBe(true);
});
it('defaults source to manual', () => {
const result = CreateMedicineProductSchema.safeParse(validProduct);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.source).toBe(MedicineProductSource.MANUAL);
}
});
it('accepts product with all optional fields', () => {
const result = CreateMedicineProductSchema.safeParse({
...validProduct,
manufacturer: 'CVS Pharmacy',
imageUrl: 'https://example.com/image.jpg',
notes: 'Generic equivalent',
source: MedicineProductSource.IMPORT,
});
expect(result.success).toBe(true);
});
it('rejects empty brand', () => {
const result = CreateMedicineProductSchema.safeParse({ ...validProduct, brand: '' });
expect(result.success).toBe(false);
});
it('rejects zero package size', () => {
const result = CreateMedicineProductSchema.safeParse({ ...validProduct, packageSize: 0 });
expect(result.success).toBe(false);
});
it('rejects invalid URL', () => {
const result = CreateMedicineProductSchema.safeParse({
...validProduct,
imageUrl: 'not-a-url',
});
expect(result.success).toBe(false);
});
});
describe('UpdateMedicineProductSchema', () => {
it('accepts empty object', () => {
const result = UpdateMedicineProductSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts partial update', () => {
const result = UpdateMedicineProductSchema.safeParse({
brand: 'Kirkland',
packageSize: 60,
});
expect(result.success).toBe(true);
});
});

View file

@ -0,0 +1,123 @@
import { z } from 'zod/v4';
import {
MedicineForm,
StrengthUnit,
MedicineCategory,
MedicineProductSource,
DosageUnit,
ConcentrationUnit,
} from '../enums/medicine.enums.js';
// --- Medicine (generic level) ---
export const CreateMedicineSchema = z.object({
name: z.string().min(1).max(200).trim(),
form: z.nativeEnum(MedicineForm),
strength: z.number().positive(),
strengthUnit: z.nativeEnum(StrengthUnit),
category: z.nativeEnum(MedicineCategory),
notes: z.string().max(1000).trim().optional(),
tags: z.array(z.string().min(1).max(50).trim()).max(20).default([]),
});
export const UpdateMedicineSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
form: z.nativeEnum(MedicineForm).optional(),
strength: z.number().positive().optional(),
strengthUnit: z.nativeEnum(StrengthUnit).optional(),
category: z.nativeEnum(MedicineCategory).optional(),
notes: z.string().max(1000).trim().optional(),
tags: z.array(z.string().min(1).max(50).trim()).max(20).optional(),
});
export const MedicineQuerySchema = z.object({
q: z.string().optional(),
category: z.nativeEnum(MedicineCategory).optional(),
form: z.nativeEnum(MedicineForm).optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const MedicineResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
name: z.string(),
form: z.string(),
strength: z.number(),
strengthUnit: z.string(),
category: z.string(),
notes: z.string().optional(),
tags: z.array(z.string()),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const MedicineListResponseSchema = z.object({
data: z.array(MedicineResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
// --- MedicineProduct (purchasable level) ---
export const CreateMedicineProductSchema = z.object({
brand: z.string().min(1).max(200).trim(),
manufacturer: z.string().max(200).trim().optional(),
packageSize: z.number().positive(),
packageUnit: z.nativeEnum(DosageUnit),
concentration: z.number().positive().optional(),
concentrationUnit: z.nativeEnum(ConcentrationUnit).optional(),
imageUrl: z.url().optional(),
notes: z.string().max(1000).trim().optional(),
source: z.nativeEnum(MedicineProductSource).default(MedicineProductSource.MANUAL),
});
export const UpdateMedicineProductSchema = z.object({
brand: z.string().min(1).max(200).trim().optional(),
manufacturer: z.string().max(200).trim().optional(),
packageSize: z.number().positive().optional(),
packageUnit: z.nativeEnum(DosageUnit).optional(),
concentration: z.number().positive().optional(),
concentrationUnit: z.nativeEnum(ConcentrationUnit).optional(),
imageUrl: z.url().optional(),
notes: z.string().max(1000).trim().optional(),
});
export const MedicineProductResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
medicineId: z.string(),
medicineName: z.string(),
brand: z.string(),
manufacturer: z.string().optional(),
packageSize: z.number(),
packageUnit: z.string(),
concentration: z.number().optional(),
concentrationUnit: z.string().optional(),
imageUrl: z.string().optional(),
notes: z.string().optional(),
source: z.string(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const MedicineProductListResponseSchema = z.object({
data: z.array(MedicineProductResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
// Type exports
export type CreateMedicineInput = z.infer<typeof CreateMedicineSchema>;
export type UpdateMedicineInput = z.infer<typeof UpdateMedicineSchema>;
export type MedicineQueryInput = z.infer<typeof MedicineQuerySchema>;
export type CreateMedicineProductInput = z.infer<typeof CreateMedicineProductSchema>;
export type UpdateMedicineProductInput = z.infer<typeof UpdateMedicineProductSchema>;

View file

@ -10,5 +10,17 @@ export const CreateUserSchema = z.object({
export const UpdateUserSchema = CreateUserSchema.partial().omit({ keycloakId: true }); export const UpdateUserSchema = CreateUserSchema.partial().omit({ keycloakId: true });
export const UserResponseSchema = z.object({
_id: z.string(),
keycloakId: z.string(),
displayName: z.string(),
email: z.string(),
householdIds: z.array(z.string()),
defaultHouseholdId: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>; export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>; export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type UserResponse = z.infer<typeof UserResponseSchema>;

View file

@ -1,7 +1,28 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
import path from 'path';
const sharedSrc = path.resolve(import.meta.dirname, '../shared/src');
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
transpilePackages: ['@meshitrack/shared'], turbopack: {},
webpack: (config) => {
// The shared package source uses ESM `.js` extensions on imports (e.g. `./enums/index.js`).
// When Next.js resolves via tsconfig paths to the raw `.ts` source, webpack needs to
// know that `.js` imports inside that directory should resolve to `.ts` files.
config.resolve = config.resolve ?? {};
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
'.js': ['.ts', '.js'],
};
// Ensure the shared source directory is included in the module resolution
config.resolve.alias = {
...config.resolve.alias,
'@meshitrack/shared': sharedSrc,
};
return config;
},
}; };
export default nextConfig; export default nextConfig;

View file

@ -6,29 +6,9 @@ export default function DashboardPage() {
<h1 className="text-2xl font-bold mb-4">Dashboard</h1> <h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<DashboardCard <DashboardCard
title="Product Library" title="Medicines"
description="Manage your food products and nutrition data" description="Manage your medicines, products and inventory"
href="/products" href="/medicines"
/>
<DashboardCard
title="Recipes"
description="Create and manage recipes with auto-nutrition"
href="/recipes"
/>
<DashboardCard
title="Pantry"
description="Track what's in your fridge and pantry"
href="/pantry"
/>
<DashboardCard
title="Meal Plans"
description="Plan your weekly meals and hit nutrition targets"
href="/meal-plans"
/>
<DashboardCard
title="Shopping Lists"
description="Create shopping lists and track prices"
href="/shopping"
/> />
<DashboardCard <DashboardCard
title="Settings" title="Settings"

View file

@ -0,0 +1,692 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import {
listCabinetItems,
getCabinetSummary,
createCabinetItem,
adjustCabinetItemQuantity,
deleteCabinetItem,
} from '@/services/cabinet';
import { listMedicines } from '@/services/medicines';
import {
DosageUnit,
CabinetItemStatus,
allowedUnitsForForm,
defaultUnitForForm,
} from '@meshitrack/shared';
import type { MedicineForm, CreateCabinetItemInput } from '@meshitrack/shared';
type CabinetItem = {
_id: string;
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
medicineProductId?: string;
medicineProductBrand?: string;
concentration?: number;
concentrationUnit?: string;
quantity: number;
unit: string;
expirationDate?: string;
status: string;
notes?: string;
};
type SummaryItem = {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
totalQuantity: number;
unit: string;
earliestExpiry: string | null;
itemCount: number;
};
type MedicineOption = {
_id: string;
name: string;
strength: number;
strengthUnit: string;
form: string;
};
const FORM_LABELS: Record<string, string> = {
tablet: 'Tablet',
capsule: 'Capsule',
liquid: 'Liquid',
injection: 'Injection',
other: 'Other',
};
const UNIT_LABELS: Record<string, string> = {
tablet: 'tablets',
capsule: 'capsules',
ml: 'mL',
vial: 'vials',
dose: 'doses',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
depleted: 'Depleted',
expired: 'Expired',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-700',
depleted: 'bg-gray-100 text-gray-600',
expired: 'bg-red-100 text-red-700',
};
function getExpiryColor(expirationDate?: string): string {
if (!expirationDate) return '';
const now = new Date();
const expiry = new Date(expirationDate);
const daysUntil = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntil <= 0) return 'text-red-600 font-semibold';
if (daysUntil <= 7) return 'text-red-500';
if (daysUntil <= 30) return 'text-yellow-600';
return 'text-green-600';
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString();
}
function daysUntilExpiry(dateStr?: string): string {
if (!dateStr) return '';
const now = new Date();
const expiry = new Date(dateStr);
const days = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (days <= 0) return '(expired)';
if (days === 1) return '(1 day)';
return `(${days} days)`;
}
export function CabinetTab({ householdId }: { householdId: string }) {
const [view, setView] = useState<'summary' | 'detail'>('summary');
const [summaryItems, setSummaryItems] = useState<SummaryItem[]>([]);
const [cabinetItems, setCabinetItems] = useState<CabinetItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [filterStatus, setFilterStatus] = useState('');
const [expandedMedicine, setExpandedMedicine] = useState<string | null>(null);
const [expandedItems, setExpandedItems] = useState<CabinetItem[]>([]);
const [expandLoading, setExpandLoading] = useState(false);
const fetchData = useCallback(async () => {
if (!householdId) return;
setLoading(true);
try {
if (view === 'summary') {
const result = await getCabinetSummary(householdId);
setSummaryItems(result.data);
if (expandedMedicine) {
const expanded = await listCabinetItems(householdId, {
medicineId: expandedMedicine,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(expanded.data);
if (expanded.data.length === 0) {
setExpandedMedicine(null);
}
}
} else {
const result = await listCabinetItems(householdId, {
status: (filterStatus as CabinetItemStatus) || undefined,
limit: 50,
});
setCabinetItems(result.data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load cabinet');
} finally {
setLoading(false);
}
}, [householdId, view, filterStatus, expandedMedicine]);
useEffect(() => {
if (householdId) {
fetchData();
}
}, [householdId, fetchData]);
async function handleExpand(medicineId: string) {
if (!householdId) return;
if (expandedMedicine === medicineId) {
setExpandedMedicine(null);
setExpandedItems([]);
return;
}
setExpandedMedicine(medicineId);
setExpandLoading(true);
try {
const result = await listCabinetItems(householdId, {
medicineId,
status: CabinetItemStatus.ACTIVE,
limit: 50,
});
setExpandedItems(result.data);
} catch {
setExpandedItems([]);
} finally {
setExpandLoading(false);
}
}
async function handleAdjust(itemId: string, delta: number) {
if (!householdId) return;
try {
await adjustCabinetItemQuantity(householdId, itemId, { delta });
fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to adjust quantity');
}
}
async function handleDelete(itemId: string) {
if (!householdId || !confirm('Delete this item permanently?')) return;
try {
await deleteCabinetItem(householdId, itemId);
fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<div />
<button
onClick={() => 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' : 'Add to Cabinet'}
</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>
)}
{showForm && (
<AddToCabinetForm
householdId={householdId}
onCreated={() => {
setShowForm(false);
fetchData();
}}
onCancel={() => setShowForm(false)}
/>
)}
<div className="mb-4 flex items-center gap-3">
<div className="flex rounded-lg border overflow-hidden">
<button
onClick={() => setView('summary')}
className={`px-4 py-2 text-sm font-medium transition-colors ${view === 'summary' ? 'bg-primary-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}
>
Summary
</button>
<button
onClick={() => setView('detail')}
className={`px-4 py-2 text-sm font-medium transition-colors ${view === 'detail' ? 'bg-primary-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}
>
All Items
</button>
</div>
{view === 'detail' && (
<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(CabinetItemStatus).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</option>
))}
</select>
)}
</div>
{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-20" />
))}
</div>
) : view === 'summary' ? (
<SummaryView
items={summaryItems}
expandedMedicine={expandedMedicine}
expandedItems={expandedItems}
expandLoading={expandLoading}
onExpand={handleExpand}
onAdjust={handleAdjust}
onDelete={handleDelete}
/>
) : (
<DetailView items={cabinetItems} onAdjust={handleAdjust} onDelete={handleDelete} />
)}
</div>
);
}
function SummaryView({
items,
expandedMedicine,
expandedItems,
expandLoading,
onExpand,
onAdjust,
onDelete,
}: {
items: SummaryItem[];
expandedMedicine: string | null;
expandedItems: CabinetItem[];
expandLoading: boolean;
onExpand: (id: string) => void;
onAdjust: (id: string, delta: number) => void;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
Your cabinet is empty. Add medicines above.
</div>
);
}
return (
<div className="space-y-3">
{items.map((item) => (
<div key={item.medicineId}>
<button
onClick={() => onExpand(item.medicineId)}
className="w-full rounded-xl border bg-white p-4 shadow-sm text-left hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900">{item.medicineName}</h3>
<p className="text-sm text-gray-500">
{item.medicineStrength} {item.medicineStrengthUnit}{' '}
{FORM_LABELS[item.medicineForm] ?? item.medicineForm}
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-lg font-semibold text-gray-900">
{item.totalQuantity} {UNIT_LABELS[item.unit] ?? item.unit}
</p>
<p className="text-xs text-gray-500">
{item.itemCount} {item.itemCount === 1 ? 'item' : 'items'}
</p>
</div>
{item.earliestExpiry && (
<div className={`text-right text-sm ${getExpiryColor(item.earliestExpiry)}`}>
<p>Exp: {formatDate(item.earliestExpiry)}</p>
<p className="text-xs">{daysUntilExpiry(item.earliestExpiry)}</p>
</div>
)}
<svg
className={`h-5 w-5 text-gray-400 transition-transform ${expandedMedicine === item.medicineId ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</button>
{expandedMedicine === item.medicineId && (
<div className="ml-6 mt-2 space-y-2">
{expandLoading ? (
<div className="animate-pulse rounded-lg border bg-gray-50 p-3 h-14" />
) : expandedItems.length === 0 ? (
<p className="text-sm text-gray-500 p-2">No active items</p>
) : (
expandedItems.map((ci) => (
<CabinetItemCard key={ci._id} item={ci} onAdjust={onAdjust} onDelete={onDelete} />
))
)}
</div>
)}
</div>
))}
</div>
);
}
function DetailView({
items,
onAdjust,
onDelete,
}: {
items: CabinetItem[];
onAdjust: (id: string, delta: number) => void;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
No items match your filter.
</div>
);
}
return (
<div className="space-y-3">
{items.map((item) => (
<CabinetItemCard
key={item._id}
item={item}
showMedicineName
onAdjust={onAdjust}
onDelete={onDelete}
/>
))}
</div>
);
}
function CabinetItemCard({
item,
showMedicineName = false,
onAdjust,
onDelete,
}: {
item: CabinetItem;
showMedicineName?: boolean;
onAdjust: (id: string, delta: number) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="rounded-lg border bg-white p-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
{showMedicineName && <h4 className="font-medium text-gray-900">{item.medicineName}</h4>}
<div className="flex items-center gap-2 text-sm text-gray-600">
{showMedicineName && (
<span>
{item.medicineStrength} {item.medicineStrengthUnit}{' '}
{FORM_LABELS[item.medicineForm] ?? item.medicineForm}
</span>
)}
{item.medicineProductBrand && (
<span className="text-gray-400">({item.medicineProductBrand})</span>
)}
{item.concentration != null && item.concentrationUnit && (
<span className="text-gray-500">
{item.concentration} {item.concentrationUnit}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-sm">
<span className="font-medium">
{item.quantity} {UNIT_LABELS[item.unit] ?? item.unit}
</span>
{item.expirationDate && (
<span className={getExpiryColor(item.expirationDate)}>
Exp: {formatDate(item.expirationDate)} {daysUntilExpiry(item.expirationDate)}
</span>
)}
</div>
{item.notes && <p className="text-xs text-gray-400 mt-1">{item.notes}</p>}
</div>
<div className="flex items-center gap-2 ml-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[item.status] ?? STATUS_COLORS['active']}`}
>
{STATUS_LABELS[item.status] ?? item.status}
</span>
{item.status === 'active' && (
<div className="flex items-center gap-1">
<button
onClick={() => onAdjust(item._id, -1)}
className="rounded border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 transition-colors"
title="Take 1"
>
-1
</button>
<button
onClick={() => onAdjust(item._id, 1)}
className="rounded border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 transition-colors"
title="Add 1"
>
+1
</button>
</div>
)}
<button
onClick={() => onDelete(item._id)}
className="rounded p-1 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>
);
}
function AddToCabinetForm({
householdId,
onCreated,
onCancel,
}: {
householdId: string;
onCreated: () => void;
onCancel: () => void;
}) {
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
const [formData, setFormData] = useState<CreateCabinetItemInput>({
medicineId: '',
quantity: 0,
unit: DosageUnit.TABLET,
});
const [expirationDate, setExpirationDate] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [medicineSearch, setMedicineSearch] = useState('');
useEffect(() => {
async function fetchMedicines() {
try {
const result = await listMedicines(householdId, {
q: medicineSearch || undefined,
limit: 50,
});
setMedicines(result.data);
} catch {
/* empty */
}
}
fetchMedicines();
}, [householdId, medicineSearch]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!formData.medicineId) {
setError('Please select a medicine');
return;
}
setSubmitting(true);
try {
const payload: CreateCabinetItemInput = {
...formData,
quantity: Number(formData.quantity),
};
if (expirationDate) {
payload.expirationDate = new Date(expirationDate).toISOString();
}
if (notes.trim()) {
payload.notes = notes.trim();
}
await createCabinetItem(householdId, payload);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add item');
} 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">Add to Cabinet</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-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Medicine</label>
<input
type="text"
value={medicineSearch}
onChange={(e) => setMedicineSearch(e.target.value)}
placeholder="Search medicines..."
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 mb-2"
/>
<select
value={formData.medicineId}
onChange={(e) => {
const selectedMed = medicines.find((m) => m._id === e.target.value);
const medForm = selectedMed?.form as MedicineForm | undefined;
const defUnit = medForm ? defaultUnitForForm(medForm) : DosageUnit.TABLET;
setFormData({ ...formData, medicineId: e.target.value, unit: defUnit });
}}
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"
required
>
<option value="">Select a medicine</option>
{medicines.map((med) => (
<option key={med._id} value={med._id}>
{med.name} ({med.strength} {med.strengthUnit}, {FORM_LABELS[med.form] ?? med.form})
</option>
))}
</select>
{medicines.length === 0 && (
<p className="text-xs text-gray-400 mt-1">
No medicines found.{' '}
<Link href="/medicines" className="text-primary-600 underline">
Add medicines first
</Link>
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
<input
type="number"
required
min={0}
step="any"
value={formData.quantity || ''}
onChange={(e) => setFormData({ ...formData, quantity: Number(e.target.value) })}
placeholder="30"
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-sm font-medium text-gray-700 mb-1">Unit</label>
<select
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: 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"
>
{(() => {
const selectedMed = medicines.find((m) => m._id === formData.medicineId);
const units = selectedMed
? allowedUnitsForForm(selectedMed.form as MedicineForm)
: Object.values(DosageUnit);
return units.map((u) => (
<option key={u} value={u}>
{UNIT_LABELS[u] ?? u}
</option>
));
})()}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Expiration Date (optional)
</label>
<input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(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-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 about this item"
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 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 ? 'Adding...' : 'Add to Cabinet'}
</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>
);
}

View file

@ -0,0 +1,358 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { listMedicines, createMedicine, deleteMedicine } from '@/services/medicines';
import { MedicineForm, StrengthUnit, MedicineCategory } from '@meshitrack/shared';
import type { CreateMedicineInput } from '@meshitrack/shared';
type Medicine = {
_id: string;
name: string;
form: string;
strength: number;
strengthUnit: string;
category: string;
tags: string[];
};
const FORM_LABELS: Record<string, string> = {
tablet: 'Tablet',
capsule: 'Capsule',
liquid: 'Liquid',
injection: 'Injection',
other: 'Other',
};
const CATEGORY_LABELS: Record<string, string> = {
prescription: 'Prescription',
otc: 'OTC',
supplement: 'Supplement',
other: 'Other',
};
const CATEGORY_COLORS: Record<string, string> = {
prescription: 'bg-blue-100 text-blue-700',
otc: 'bg-green-100 text-green-700',
supplement: 'bg-purple-100 text-purple-700',
other: 'bg-gray-100 text-gray-700',
};
export function LibraryTab({ householdId }: { householdId: string }) {
const [medicines, setMedicines] = useState<Medicine[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [search, setSearch] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [filterForm, setFilterForm] = useState('');
const fetchMedicines = useCallback(async () => {
if (!householdId) return;
setLoading(true);
try {
const result = await listMedicines(householdId, {
q: search || undefined,
category: filterCategory || undefined,
form: filterForm || undefined,
limit: 50,
});
setMedicines(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load medicines');
} finally {
setLoading(false);
}
}, [householdId, search, filterCategory, filterForm]);
useEffect(() => {
if (householdId) {
fetchMedicines();
}
}, [householdId, fetchMedicines]);
async function handleDelete(id: string, name: string) {
if (!householdId || !confirm(`Delete "${name}"?`)) return;
try {
await deleteMedicine(householdId, id);
setMedicines((prev) => prev.filter((m) => m._id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<div />
<button
onClick={() => 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' : 'Add Medicine'}
</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>
)}
{showForm && (
<CreateMedicineForm
householdId={householdId}
onCreated={() => {
setShowForm(false);
fetchMedicines();
}}
onCancel={() => setShowForm(false)}
/>
)}
<div className="mb-4 flex flex-wrap items-center gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search medicines..."
className="w-full max-w-md rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(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 Categories</option>
{Object.values(MedicineCategory).map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c] ?? c}
</option>
))}
</select>
<select
value={filterForm}
onChange={(e) => setFilterForm(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 Forms</option>
{Object.values(MedicineForm).map((f) => (
<option key={f} value={f}>
{FORM_LABELS[f] ?? f}
</option>
))}
</select>
</div>
{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-20" />
))}
</div>
) : medicines.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
{search || filterCategory || filterForm
? 'No medicines found matching your filters.'
: 'No medicines yet. Add your first one above.'}
</div>
) : (
<div className="space-y-3">
{medicines.map((med) => (
<div
key={med._id}
className="rounded-xl border bg-white p-4 shadow-sm flex items-center justify-between"
>
<Link href={`/medicines/${med._id}`} className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div>
<h3 className="font-medium text-gray-900">{med.name}</h3>
<p className="text-sm text-gray-500">
{med.strength} {med.strengthUnit} {FORM_LABELS[med.form] ?? med.form}
</p>
</div>
</div>
</Link>
<div className="flex items-center gap-3 ml-4">
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${CATEGORY_COLORS[med.category] ?? CATEGORY_COLORS['other']}`}
>
{CATEGORY_LABELS[med.category] ?? med.category}
</span>
<button
onClick={() => handleDelete(med._id, med.name)}
className="rounded p-1 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>
);
}
function CreateMedicineForm({
householdId,
onCreated,
onCancel,
}: {
householdId: string;
onCreated: () => void;
onCancel: () => void;
}) {
const [formData, setFormData] = useState<CreateMedicineInput>({
name: '',
form: MedicineForm.TABLET,
strength: 0,
strengthUnit: StrengthUnit.MG,
category: MedicineCategory.OTC,
tags: [],
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await createMedicine(householdId, {
...formData,
name: formData.name.trim(),
strength: Number(formData.strength),
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create medicine');
} 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">Add Medicine</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-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">Name</label>
<input
type="text"
required
maxLength={200}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. Metformin"
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-sm font-medium text-gray-700 mb-1">Form</label>
<select
value={formData.form}
onChange={(e) => setFormData({ ...formData, form: e.target.value as MedicineForm })}
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(MedicineForm).map((f) => (
<option key={f} value={f}>
{FORM_LABELS[f] ?? f}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Strength</label>
<input
type="number"
required
min={0.01}
step="any"
value={formData.strength || ''}
onChange={(e) => setFormData({ ...formData, strength: Number(e.target.value) })}
placeholder="500"
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-sm font-medium text-gray-700 mb-1">Unit</label>
<select
value={formData.strengthUnit}
onChange={(e) =>
setFormData({ ...formData, strengthUnit: e.target.value as StrengthUnit })
}
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(StrengthUnit).map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={formData.category}
onChange={(e) =>
setFormData({ ...formData, category: e.target.value as MedicineCategory })
}
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(MedicineCategory).map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c] ?? c}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
<input
type="text"
maxLength={1000}
value={formData.notes ?? ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value || undefined })}
placeholder="Any additional notes"
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 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 ? 'Creating...' : 'Create Medicine'}
</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>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { CabinetTab } from '../CabinetTab';
export default function CabinetPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-64 rounded-lg bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</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 medicines.
</p>
</div>
</div>
);
}
return <CabinetTab householdId={householdId} />;
}

View file

@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { LibraryTab } from '../LibraryTab';
export default function LibraryPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Library</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-64 rounded-lg bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Library</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 medicines.
</p>
</div>
</div>
);
}
return <LibraryTab householdId={householdId} />;
}

View file

@ -0,0 +1,79 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
export default function MedicinesPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return <PageSkeleton />;
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicines</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 medicines.
</p>
</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<SectionCard
title="Library"
description="Manage your medicines and their products"
href="/medicines/library"
/>
<SectionCard
title="Cabinet"
description="Track your medicine inventory, quantities and expiry dates"
href="/medicines/cabinet"
/>
</div>
</div>
);
}
function SectionCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
return (
<Link
href={href}
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
</Link>
);
}
function PageSkeleton() {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
<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>
</div>
);
}

View file

@ -1,16 +1,305 @@
'use client';
import { useState } from 'react';
import { useApi } from '@/lib/useApi';
import {
createHousehold,
joinHousehold,
getHousehold,
updateHousehold,
generateInviteCode,
} from '@/services/households';
export default function SettingsPage() { export default function SettingsPage() {
const { householdId, isLoading, refreshProfile } = useApi();
if (isLoading) {
return <SettingsLoading />;
}
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-4">Settings</h1> <h1 className="text-2xl font-bold mb-4">Settings</h1>
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
<HouseholdSection householdId={householdId} onHouseholdChanged={() => refreshProfile()} />
<AccountSection />
</div>
</div>
);
}
function SettingsLoading() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Settings</h1>
<div className="max-w-2xl">
<div className="animate-pulse rounded-xl border bg-white p-6 shadow-sm h-48" />
</div>
</div>
);
}
function HouseholdSection({
householdId,
onHouseholdChanged,
}: {
householdId: string | null;
onHouseholdChanged: () => void;
}) {
const [householdName, setHouseholdName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [currentHousehold, setCurrentHousehold] = useState<{
name: string;
inviteCode: string;
members: { userId: string; role: string }[];
} | null>(null);
const [loadedHousehold, setLoadedHousehold] = useState(false);
const [editingName, setEditingName] = useState(false);
const [editedName, setEditedName] = useState('');
const [editNameError, setEditNameError] = useState('');
const [savingName, setSavingName] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [regenerateError, setRegenerateError] = useState('');
async function loadHousehold() {
if (!householdId || loadedHousehold) return;
try {
const hh = await getHousehold(householdId);
setCurrentHousehold(hh);
} catch {
// Household may not be accessible yet
}
setLoadedHousehold(true);
}
if (householdId && !loadedHousehold) {
loadHousehold();
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await createHousehold(householdName.trim());
setHouseholdName('');
onHouseholdChanged();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create household');
} finally {
setSubmitting(false);
}
}
async function handleJoin(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await joinHousehold(inviteCode.trim());
setInviteCode('');
onHouseholdChanged();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join household');
} finally {
setSubmitting(false);
}
}
async function handleSaveName() {
if (!householdId || !currentHousehold) return;
const trimmed = editedName.trim();
if (!trimmed) {
setEditNameError('Name cannot be empty');
return;
}
if (trimmed === currentHousehold.name) {
setEditingName(false);
setEditNameError('');
return;
}
setEditNameError('');
setSavingName(true);
try {
const updated = await updateHousehold(householdId, { name: trimmed });
setCurrentHousehold(updated);
setEditingName(false);
} catch (err) {
setEditNameError(err instanceof Error ? err.message : 'Failed to update name');
} finally {
setSavingName(false);
}
}
async function handleRegenerateInviteCode() {
if (!householdId) return;
setRegenerateError('');
setRegenerating(true);
try {
const updated = await generateInviteCode(householdId);
setCurrentHousehold(updated);
} catch (err) {
setRegenerateError(err instanceof Error ? err.message : 'Failed to regenerate invite code');
} finally {
setRegenerating(false);
}
}
if (householdId) {
return (
<section className="rounded-xl border bg-white p-6 shadow-sm"> <section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Household</h2> <h2 className="text-lg font-semibold mb-4">Household</h2>
<p className="text-sm text-gray-500"> {currentHousehold ? (
Household management will be available here. Create a household, invite members, or <div className="space-y-3">
switch between households. <div>
</p> <span className="text-sm text-gray-500">Name:</span>{' '}
{editingName ? (
<span className="inline-flex items-center gap-2">
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
maxLength={100}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<button
type="button"
onClick={handleSaveName}
disabled={savingName}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{savingName ? 'Saving...' : 'Save'}
</button>
<button
type="button"
onClick={() => {
setEditingName(false);
setEditNameError('');
}}
disabled={savingName}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Cancel
</button>
</span>
) : (
<span className="inline-flex items-center gap-2">
<span className="font-medium">{currentHousehold.name}</span>
<button
type="button"
onClick={() => {
setEditedName(currentHousehold.name);
setEditNameError('');
setEditingName(true);
}}
className="rounded-lg border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Edit
</button>
</span>
)}
{editNameError && <p className="mt-1 text-xs text-red-600">{editNameError}</p>}
</div>
<div>
<span className="text-sm text-gray-500">Invite Code:</span>{' '}
<span className="inline-flex items-center gap-2">
<code className="rounded bg-gray-100 px-2 py-1 text-sm font-mono">
{currentHousehold.inviteCode}
</code>
<button
type="button"
onClick={handleRegenerateInviteCode}
disabled={regenerating}
className="rounded-lg border px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
{regenerating ? 'Regenerating...' : 'Regenerate'}
</button>
</span>
{regenerateError && <p className="mt-1 text-xs text-red-600">{regenerateError}</p>}
</div>
<div>
<span className="text-sm text-gray-500">Members:</span>{' '}
<span className="font-medium">{currentHousehold.members.length}</span>
</div>
</div>
) : (
<p className="text-sm text-gray-500">Loading household details...</p>
)}
</section> </section>
);
}
return (
<section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Household</h2>
<p className="text-sm text-gray-500 mb-4">
You are not part of any household yet. Create one or join using an invite code.
</p>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<div className="space-y-6">
<form onSubmit={handleCreate} className="space-y-3">
<h3 className="text-sm font-medium text-gray-700">Create a new household</h3>
<div className="flex gap-3">
<input
type="text"
value={householdName}
onChange={(e) => setHouseholdName(e.target.value)}
placeholder="Household name"
required
maxLength={100}
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<button
type="submit"
disabled={submitting || !householdName.trim()}
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"
>
Create
</button>
</div>
</form>
<div className="border-t" />
<form onSubmit={handleJoin} className="space-y-3">
<h3 className="text-sm font-medium text-gray-700">Join with invite code</h3>
<div className="flex gap-3">
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Invite code"
required
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
<button
type="submit"
disabled={submitting || !inviteCode.trim()}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Join
</button>
</div>
</form>
</div>
</section>
);
}
function AccountSection() {
const keycloakUrl = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'http://localhost:8080';
const realm = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'meshitrack';
return (
<section className="rounded-xl border bg-white p-6 shadow-sm"> <section className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Account</h2> <h2 className="text-lg font-semibold mb-4">Account</h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
@ -18,15 +307,13 @@ export default function SettingsPage() {
profile. profile.
</p> </p>
<a <a
href={`${process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'http://localhost:8080'}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'meshitrack'}/account`} href={`${keycloakUrl}/realms/${realm}/account`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-4 inline-block rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors" className="mt-4 inline-block rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
> >
Manage Keycloak Account Manage Keycloak Account
</a> </a>
</section> </section>
</div>
</div>
); );
} }

View file

@ -1,15 +1,18 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Providers } from '@/components/Providers';
import '@/styles/globals.css'; import '@/styles/globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'MeshiTrack', title: 'MeshiTrack',
description: 'Nutrition & Pantry Management Platform', description: 'Medicine & Nutrition Management Platform',
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body className="min-h-screen bg-gray-50">{children}</body> <body className="min-h-screen bg-gray-50">
<Providers>{children}</Providers>
</body>
</html> </html>
); );
} }

View file

@ -0,0 +1,8 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View file

@ -2,11 +2,7 @@ import Link from 'next/link';
const navItems = [ const navItems = [
{ label: 'Dashboard', href: '/dashboard' }, { label: 'Dashboard', href: '/dashboard' },
{ label: 'Products', href: '/products' }, { label: 'Medicines', href: '/medicines' },
{ label: 'Recipes', href: '/recipes' },
{ label: 'Pantry', href: '/pantry' },
{ label: 'Meal Plans', href: '/meal-plans' },
{ label: 'Shopping', href: '/shopping' },
{ label: 'Settings', href: '/settings' }, { label: 'Settings', href: '/settings' },
]; ];

View file

@ -1,25 +1,47 @@
import { auth } from '@/lib/auth'; 'use client';
export async function TopBar() { import Link from 'next/link';
const session = await auth(); import useSWR from 'swr';
const name = session?.user?.name ?? 'Unknown'; import { useApi } from '@/lib/useApi';
import { getHousehold } from '@/services/households';
export function TopBar() {
const { householdId, profile, isLoading } = useApi();
const name = profile?.displayName ?? 'Unknown';
const initial = name.charAt(0).toUpperCase(); const initial = name.charAt(0).toUpperCase();
const householdId = session?.householdIds?.[0] ?? null;
const { data: household } = useSWR(householdId ? `household-${householdId}` : null, () =>
getHousehold(householdId!),
);
if (isLoading) {
return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div className="h-6 w-32 animate-pulse rounded bg-gray-200" />
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
</header>
);
}
return ( return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6"> <header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{householdId ? ( {householdId ? (
<span className="rounded-md border px-3 py-1 font-medium text-gray-700"> <span className="rounded-md border px-3 py-1 font-medium text-gray-700">
{householdId} {household?.name ?? householdId}
</span> </span>
) : ( ) : (
<span className="rounded-md border px-3 py-1 text-gray-400">No household</span> <Link
href="/settings"
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-1 text-amber-700 hover:bg-amber-100 transition-colors"
>
No household - Create one
</Link>
)} )}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{name}</span> <span className="text-sm text-gray-600">{name}</span>
<div className="h-8 w-8 rounded-full bg-primary-200 flex items-center justify-center text-sm font-medium text-primary-800"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-200 text-sm font-medium text-primary-800">
{initial} {initial}
</div> </div>
</div> </div>

View file

@ -31,21 +31,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: '/login', signIn: '/login',
}, },
callbacks: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account }) {
if (account) { if (account) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.refreshToken = account.refresh_token; token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at; token.expiresAt = account.expires_at;
// householdIds is injected into the ID token by the Keycloak protocol mapper
token.householdIds = (profile as Record<string, unknown>)?.['householdIds'] as
| string[]
| undefined;
} }
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
session.accessToken = token.accessToken as string; session.accessToken = token.accessToken as string;
session.householdIds = (token.householdIds as string[] | undefined) ?? [];
return session; return session;
}, },
}, },

View file

@ -0,0 +1,41 @@
'use client';
import { useSession } from 'next-auth/react';
import useSWR from 'swr';
import { apiClient } from '@/services/api-client';
import type { UserResponse } from '@meshitrack/shared';
/**
* Provides authentication state and user profile (including householdIds).
*
* The access token comes from the NextAuth session (Keycloak JWT).
* Household membership is fetched from the API (`GET /users/me`) via SWR
* so it is always fresh -- no stale JWT claims.
*/
export function useApi() {
const { data: session, status } = useSession();
// Set API client token synchronously so SWR fetches have credentials.
if (session?.accessToken) {
apiClient.accessToken = session.accessToken;
}
const shouldFetch = status === 'authenticated' && apiClient.hasToken;
const {
data: profile,
mutate: refreshProfile,
isLoading: profileLoading,
} = useSWR<UserResponse>(shouldFetch ? 'user-profile' : null, () =>
apiClient.get<UserResponse>('/users/me'),
);
return {
householdId: profile?.householdIds?.[0] ?? null,
householdIds: profile?.householdIds ?? [],
isLoading: status === 'loading' || (shouldFetch && profileLoading),
isAuthenticated: status === 'authenticated',
profile: profile ?? null,
refreshProfile,
};
}

View file

@ -7,6 +7,10 @@ class ApiClient {
this._accessToken = token; this._accessToken = token;
} }
public get hasToken(): boolean {
return this._accessToken !== null;
}
private getHeaders(): Record<string, string> { private getHeaders(): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -19,15 +23,26 @@ class ApiClient {
return headers; return headers;
} }
private async handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message: string;
try {
const body = await res.json();
message = body.message || `Request failed: ${res.status}`;
} catch {
message = `Request failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return res.json();
}
public async get<T>(url: string): Promise<T> { public async get<T>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, { const res = await fetch(`${BASE_URL}${url}`, {
headers: this.getHeaders(), headers: this.getHeaders(),
}); });
if (!res.ok) { return this.handleResponse<T>(res);
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
} }
public async post<T>(url: string, body?: unknown): Promise<T> { public async post<T>(url: string, body?: unknown): Promise<T> {
@ -36,11 +51,7 @@ class ApiClient {
headers: this.getHeaders(), headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
if (!res.ok) { return this.handleResponse<T>(res);
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
} }
public async patch<T>(url: string, body: unknown): Promise<T> { public async patch<T>(url: string, body: unknown): Promise<T> {
@ -49,24 +60,19 @@ class ApiClient {
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { return this.handleResponse<T>(res);
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
return res.json();
} }
public async delete<T = void>(url: string): Promise<T> { public async delete<T = void>(url: string): Promise<T> {
const headers: Record<string, string> = {};
if (this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`;
}
const res = await fetch(`${BASE_URL}${url}`, { const res = await fetch(`${BASE_URL}${url}`, {
method: 'DELETE', method: 'DELETE',
headers: this.getHeaders(), headers,
}); });
if (!res.ok) { return this.handleResponse<T>(res);
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `Request failed: ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
} }
} }

View file

@ -0,0 +1,86 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
CabinetItemResponseSchema,
CabinetItemListResponseSchema,
CabinetSummaryResponseSchema,
CreateCabinetItemInput,
UpdateCabinetItemInput,
AdjustQuantityInput,
} from '@meshitrack/shared';
type CabinetItemResponse = z.infer<typeof CabinetItemResponseSchema>;
type CabinetItemListResponse = z.infer<typeof CabinetItemListResponseSchema>;
type CabinetSummaryResponse = z.infer<typeof CabinetSummaryResponseSchema>;
export async function listCabinetItems(
householdId: string,
query?: {
medicineId?: string;
status?: string;
expiringWithin?: number;
cursor?: string;
limit?: number;
},
): Promise<CabinetItemListResponse> {
const params = new URLSearchParams();
if (query?.medicineId) params.set('medicineId', query.medicineId);
if (query?.status) params.set('status', query.status);
if (query?.expiringWithin) params.set('expiringWithin', String(query.expiringWithin));
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<CabinetItemListResponse>(
`/households/${householdId}/cabinet${qs ? `?${qs}` : ''}`,
);
}
export async function getCabinetItem(
householdId: string,
id: string,
): Promise<CabinetItemResponse> {
return apiClient.get<CabinetItemResponse>(`/households/${householdId}/cabinet/${id}`);
}
export async function getCabinetSummary(householdId: string): Promise<CabinetSummaryResponse> {
return apiClient.get<CabinetSummaryResponse>(`/households/${householdId}/cabinet/summary`);
}
export async function getExpiringSoon(
householdId: string,
days = 30,
): Promise<{ data: CabinetItemResponse[] }> {
return apiClient.get<{ data: CabinetItemResponse[] }>(
`/households/${householdId}/cabinet/expiring-soon?days=${days}`,
);
}
export async function createCabinetItem(
householdId: string,
data: CreateCabinetItemInput,
): Promise<CabinetItemResponse> {
return apiClient.post<CabinetItemResponse>(`/households/${householdId}/cabinet`, data);
}
export async function updateCabinetItem(
householdId: string,
id: string,
data: UpdateCabinetItemInput,
): Promise<CabinetItemResponse> {
return apiClient.patch<CabinetItemResponse>(`/households/${householdId}/cabinet/${id}`, data);
}
export async function adjustCabinetItemQuantity(
householdId: string,
id: string,
data: AdjustQuantityInput,
): Promise<CabinetItemResponse> {
return apiClient.post<CabinetItemResponse>(
`/households/${householdId}/cabinet/${id}/adjust`,
data,
);
}
export async function deleteCabinetItem(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/cabinet/${id}`);
}

View file

@ -0,0 +1,28 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type { HouseholdResponseSchema, UpdateHouseholdInput } from '@meshitrack/shared';
type HouseholdResponse = z.infer<typeof HouseholdResponseSchema>;
export async function createHousehold(name: string): Promise<HouseholdResponse> {
return apiClient.post<HouseholdResponse>('/households', { name });
}
export async function getHousehold(id: string): Promise<HouseholdResponse> {
return apiClient.get<HouseholdResponse>(`/households/${id}`);
}
export async function updateHousehold(
id: string,
data: UpdateHouseholdInput,
): Promise<HouseholdResponse> {
return apiClient.patch<HouseholdResponse>(`/households/${id}`, data);
}
export async function generateInviteCode(id: string): Promise<HouseholdResponse> {
return apiClient.post<HouseholdResponse>(`/households/${id}/invite`);
}
export async function joinHousehold(inviteCode: string): Promise<HouseholdResponse> {
return apiClient.post<HouseholdResponse>('/households/join', { inviteCode });
}

View file

@ -0,0 +1,96 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
MedicineResponseSchema,
MedicineListResponseSchema,
MedicineProductResponseSchema,
MedicineProductListResponseSchema,
CreateMedicineInput,
UpdateMedicineInput,
CreateMedicineProductInput,
UpdateMedicineProductInput,
} from '@meshitrack/shared';
type MedicineResponse = z.infer<typeof MedicineResponseSchema>;
type MedicineListResponse = z.infer<typeof MedicineListResponseSchema>;
type MedicineProductResponse = z.infer<typeof MedicineProductResponseSchema>;
type MedicineProductListResponse = z.infer<typeof MedicineProductListResponseSchema>;
export async function listMedicines(
householdId: string,
query?: { q?: string; category?: string; form?: string; cursor?: string; limit?: number },
): Promise<MedicineListResponse> {
const params = new URLSearchParams();
if (query?.q) params.set('q', query.q);
if (query?.category) params.set('category', query.category);
if (query?.form) params.set('form', query.form);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<MedicineListResponse>(
`/households/${householdId}/medicines${qs ? `?${qs}` : ''}`,
);
}
export async function getMedicine(householdId: string, id: string): Promise<MedicineResponse> {
return apiClient.get<MedicineResponse>(`/households/${householdId}/medicines/${id}`);
}
export async function createMedicine(
householdId: string,
data: CreateMedicineInput,
): Promise<MedicineResponse> {
return apiClient.post<MedicineResponse>(`/households/${householdId}/medicines`, data);
}
export async function updateMedicine(
householdId: string,
id: string,
data: UpdateMedicineInput,
): Promise<MedicineResponse> {
return apiClient.patch<MedicineResponse>(`/households/${householdId}/medicines/${id}`, data);
}
export async function deleteMedicine(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/medicines/${id}`);
}
export async function listMedicineProducts(
householdId: string,
medicineId: string,
query?: { cursor?: string; limit?: number },
): Promise<MedicineProductListResponse> {
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<MedicineProductListResponse>(
`/households/${householdId}/medicines/${medicineId}/products${qs ? `?${qs}` : ''}`,
);
}
export async function createMedicineProduct(
householdId: string,
medicineId: string,
data: CreateMedicineProductInput,
): Promise<MedicineProductResponse> {
return apiClient.post<MedicineProductResponse>(
`/households/${householdId}/medicines/${medicineId}/products`,
data,
);
}
export async function updateMedicineProduct(
householdId: string,
id: string,
data: UpdateMedicineProductInput,
): Promise<MedicineProductResponse> {
return apiClient.patch<MedicineProductResponse>(
`/households/${householdId}/medicine-products/${id}`,
data,
);
}
export async function deleteMedicineProduct(householdId: string, id: string): Promise<void> {
return apiClient.delete(`/households/${householdId}/medicine-products/${id}`);
}

View file

@ -15,9 +15,7 @@
"allowJs": true, "allowJs": true,
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"]
"@meshitrack/shared": ["../shared/src"],
"@meshitrack/shared/*": ["../shared/src/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],