361 lines
11 KiB
Markdown
361 lines
11 KiB
Markdown
|
|
# Phase 4 — Pharmacies, Prices & Refills
|
||
|
|
|
||
|
|
**Goal**: Track where you buy medicines, compare prices across pharmacies, and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
|
||
|
|
|
||
|
|
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Deliverables
|
||
|
|
|
||
|
|
1. `Store` MongoDB schema and CRUD API (shared infrastructure)
|
||
|
|
2. `PriceRecord` schema for medicine price tracking
|
||
|
|
3. Price history and store comparison
|
||
|
|
4. Refill alerts based on burn rate
|
||
|
|
5. Refill list generation
|
||
|
|
6. Web UI: stores, price history, refill management
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Data Model
|
||
|
|
|
||
|
|
### Store Schema (Shared — used by both medicine and food domains)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// packages/shared/src/types/store.ts
|
||
|
|
export interface Store {
|
||
|
|
id: string;
|
||
|
|
householdId: string;
|
||
|
|
name: string;
|
||
|
|
address?: string;
|
||
|
|
location?: {
|
||
|
|
lat: number;
|
||
|
|
lng: number;
|
||
|
|
};
|
||
|
|
url?: string;
|
||
|
|
notes?: string;
|
||
|
|
tags: string[]; // e.g., 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
|
||
|
|
isActive: boolean;
|
||
|
|
createdBy: string;
|
||
|
|
createdAt: Date;
|
||
|
|
updatedAt: Date;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### PriceRecord Schema (Medicine)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// packages/shared/src/types/medicine-price.ts
|
||
|
|
export interface MedicinePriceRecord {
|
||
|
|
id: string;
|
||
|
|
householdId: string;
|
||
|
|
medicineId: string;
|
||
|
|
medicineName: string; // Denormalized
|
||
|
|
storeId: string;
|
||
|
|
storeName: string; // Denormalized
|
||
|
|
price: number;
|
||
|
|
currency: string; // Default from household settings
|
||
|
|
quantity: number; // How many pills/units for this price
|
||
|
|
unit: DosageUnit;
|
||
|
|
pricePerUnit: number; // Computed: price / quantity
|
||
|
|
date: Date;
|
||
|
|
isInsurancePrice: boolean; // With insurance vs retail
|
||
|
|
notes?: string;
|
||
|
|
createdBy: string;
|
||
|
|
createdAt: Date;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### RefillAlert (Computed, not stored)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// packages/shared/src/types/refill.ts
|
||
|
|
export interface RefillAlert {
|
||
|
|
medicineId: string;
|
||
|
|
medicineName: string;
|
||
|
|
medicineStrength: number;
|
||
|
|
medicineStrengthUnit: StrengthUnit;
|
||
|
|
daysUntilEmpty: number;
|
||
|
|
dailyConsumption: number;
|
||
|
|
currentStock: number;
|
||
|
|
suggestedQuantity: number; // Enough for N days (configurable, default 30)
|
||
|
|
lastKnownPrice?: {
|
||
|
|
price: number;
|
||
|
|
pricePerUnit: number;
|
||
|
|
storeName: string;
|
||
|
|
storeId: string;
|
||
|
|
date: Date;
|
||
|
|
};
|
||
|
|
cheapestOption?: {
|
||
|
|
price: number;
|
||
|
|
pricePerUnit: number;
|
||
|
|
storeName: string;
|
||
|
|
storeId: string;
|
||
|
|
date: Date;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RefillList {
|
||
|
|
id: string;
|
||
|
|
householdId: string;
|
||
|
|
name: string;
|
||
|
|
items: RefillListItem[];
|
||
|
|
status: RefillListStatus;
|
||
|
|
preferredStoreId?: string;
|
||
|
|
totalEstimatedCost?: number;
|
||
|
|
createdBy: string;
|
||
|
|
createdAt: Date;
|
||
|
|
updatedAt: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RefillListItem {
|
||
|
|
id: string;
|
||
|
|
medicineId: string;
|
||
|
|
medicineName: string;
|
||
|
|
quantity: number;
|
||
|
|
unit: DosageUnit;
|
||
|
|
estimatedPrice?: number;
|
||
|
|
actualPrice?: number;
|
||
|
|
checked: boolean;
|
||
|
|
checkedAt?: Date;
|
||
|
|
addedToCabinet: boolean;
|
||
|
|
storeId?: string;
|
||
|
|
notes?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export enum RefillListStatus {
|
||
|
|
ACTIVE = 'active',
|
||
|
|
SHOPPING = 'shopping',
|
||
|
|
COMPLETED = 'completed',
|
||
|
|
ARCHIVED = 'archived',
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### MongoDB Indexes
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// Store
|
||
|
|
{ householdId: 1, name: 1 }
|
||
|
|
{ householdId: 1, tags: 1 }
|
||
|
|
|
||
|
|
// MedicinePriceRecord
|
||
|
|
{ householdId: 1, medicineId: 1, storeId: 1, date: -1 }
|
||
|
|
{ householdId: 1, medicineId: 1, date: -1 }
|
||
|
|
{ householdId: 1, storeId: 1, date: -1 }
|
||
|
|
|
||
|
|
// RefillList
|
||
|
|
{ householdId: 1, status: 1 }
|
||
|
|
{ householdId: 1, createdAt: -1 }
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## API Endpoints
|
||
|
|
|
||
|
|
### StoresModule (Shared)
|
||
|
|
|
||
|
|
| Method | Path | Description | Auth |
|
||
|
|
| ------ | ------------- | ------------------------- | ------ |
|
||
|
|
| GET | `/stores` | List stores for household | member |
|
||
|
|
| GET | `/stores/:id` | Get single store | member |
|
||
|
|
| POST | `/stores` | Add a store | member |
|
||
|
|
| PATCH | `/stores/:id` | Update store | member |
|
||
|
|
| DELETE | `/stores/:id` | Deactivate store | admin |
|
||
|
|
|
||
|
|
### MedicinePricesModule
|
||
|
|
|
||
|
|
| Method | Path | Description | Auth |
|
||
|
|
| ------ | -------------------------------------- | --------------------------------- | ------ |
|
||
|
|
| POST | `/medicine-prices` | Record a price | member |
|
||
|
|
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
|
||
|
|
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
|
||
|
|
| GET | `/medicine-prices/analytics` | Spending analytics | member |
|
||
|
|
|
||
|
|
### RefillsModule
|
||
|
|
|
||
|
|
| Method | Path | Description | Auth |
|
||
|
|
| ------ | ----------------------------------- | ---------------------------------------- | ------ |
|
||
|
|
| GET | `/refills/alerts` | Get refill alerts (medicines running low)| member |
|
||
|
|
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
|
||
|
|
| GET | `/refills/lists` | List refill lists | member |
|
||
|
|
| GET | `/refills/lists/:id` | Get refill list | member |
|
||
|
|
| PATCH | `/refills/lists/:id` | Update refill list | member |
|
||
|
|
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
|
||
|
|
| POST | `/refills/lists/:id/add-to-cabinet` | Move checked items to cabinet | member |
|
||
|
|
| GET | `/refills/lists/:id/store-comparison`| Best store for this list | member |
|
||
|
|
|
||
|
|
### Query Parameters
|
||
|
|
|
||
|
|
```
|
||
|
|
# GET /stores
|
||
|
|
?tags=pharmacy # Filter by tags
|
||
|
|
&search=walgreens # Name search
|
||
|
|
|
||
|
|
# GET /medicine-prices/history/:medicineId
|
||
|
|
?storeId=abc123 # Filter by store
|
||
|
|
&startDate=2026-01-01 # Date range
|
||
|
|
&endDate=2026-03-27
|
||
|
|
&limit=50
|
||
|
|
|
||
|
|
# GET /refills/alerts
|
||
|
|
?thresholdDays=7 # Alert when <= N days of stock remain (default: 7)
|
||
|
|
&userId=abc123 # Filter by user's regimens
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Tasks
|
||
|
|
|
||
|
|
### 4.1 — Shared Types & Validation
|
||
|
|
|
||
|
|
- Add store types to `packages/shared/src/types/store.ts`
|
||
|
|
- Add medicine price types to `packages/shared/src/types/medicine-price.ts`
|
||
|
|
- Add refill types to `packages/shared/src/types/refill.ts`
|
||
|
|
- Zod schemas for all create/update operations
|
||
|
|
|
||
|
|
### 4.2 — Stores CRUD (Shared Infrastructure)
|
||
|
|
|
||
|
|
- `packages/api/src/modules/stores/`
|
||
|
|
- Standard CRUD, scoped to `householdId`
|
||
|
|
- Tag-based filtering (pharmacy, grocery, online, etc.)
|
||
|
|
- This module is used by both medicine and food domains
|
||
|
|
|
||
|
|
### 4.3 — Medicine Price Service
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
class MedicinePriceService {
|
||
|
|
/** Record a price, computing pricePerUnit */
|
||
|
|
recordPrice(data: CreateMedicinePriceRecord): Promise<MedicinePriceRecord>;
|
||
|
|
|
||
|
|
/** Get price history for a medicine, optionally filtered by store */
|
||
|
|
getPriceHistory(medicineId: string, householdId: string, options?: {
|
||
|
|
storeId?: string;
|
||
|
|
startDate?: Date;
|
||
|
|
endDate?: Date;
|
||
|
|
limit?: number;
|
||
|
|
}): Promise<MedicinePriceRecord[]>;
|
||
|
|
|
||
|
|
/** Compare current prices across stores for a medicine */
|
||
|
|
compareStores(medicineId: string, householdId: string): Promise<StoreComparison[]>;
|
||
|
|
|
||
|
|
/** Estimate price based on most recent record */
|
||
|
|
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise<number | null>;
|
||
|
|
|
||
|
|
/** Spending analytics over time */
|
||
|
|
getAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<MedicineSpendingAnalytics>;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.4 — Refill Alert Service
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
class RefillAlertService {
|
||
|
|
/**
|
||
|
|
* For each medicine in the user's active regimens:
|
||
|
|
* 1. Get burn rate from BurnRateService (Phase 3)
|
||
|
|
* 2. If daysUntilEmpty <= thresholdDays, create alert
|
||
|
|
* 3. Attach last known price + cheapest store option
|
||
|
|
* 4. Calculate suggested quantity (enough for configurable days, default 30)
|
||
|
|
*/
|
||
|
|
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.5 — Refill Lists
|
||
|
|
|
||
|
|
- CRUD for refill lists
|
||
|
|
- `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts
|
||
|
|
- Each item can have an estimated price (from price history) and an actual price (entered when purchased)
|
||
|
|
- Store comparison: for each item, find cheapest store based on recent price records
|
||
|
|
|
||
|
|
### 4.6 — Refill to Cabinet Flow
|
||
|
|
|
||
|
|
- `POST /refills/lists/:id/add-to-cabinet`:
|
||
|
|
- For each checked item with `addedToCabinet: false`:
|
||
|
|
- Create a `CabinetItem` (status: active, purchaseDate: today)
|
||
|
|
- If `actualPrice` was entered, create a `MedicinePriceRecord`
|
||
|
|
- Mark `addedToCabinet: true`
|
||
|
|
- Return summary: `{ addedCount, priceRecordsCreated }`
|
||
|
|
|
||
|
|
### 4.7 — Price Analytics
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface MedicineSpendingAnalytics {
|
||
|
|
/** Total spending per period */
|
||
|
|
spendingOverTime: { period: string; total: number }[];
|
||
|
|
|
||
|
|
/** Most expensive medicines */
|
||
|
|
topBySpending: {
|
||
|
|
medicineId: string;
|
||
|
|
medicineName: string;
|
||
|
|
totalSpent: number;
|
||
|
|
avgPricePerUnit: number;
|
||
|
|
}[];
|
||
|
|
|
||
|
|
/** Per-store spending */
|
||
|
|
spendingByStore: {
|
||
|
|
storeId: string;
|
||
|
|
storeName: string;
|
||
|
|
totalSpent: number;
|
||
|
|
purchaseCount: number;
|
||
|
|
}[];
|
||
|
|
|
||
|
|
/** Price trend alerts (significant increases) */
|
||
|
|
priceAlerts: {
|
||
|
|
medicineId: string;
|
||
|
|
medicineName: string;
|
||
|
|
storeName: string;
|
||
|
|
previousPrice: number;
|
||
|
|
currentPrice: number;
|
||
|
|
changePercent: number;
|
||
|
|
}[];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.8 — Web UI: Pharmacies & Prices
|
||
|
|
|
||
|
|
- `/stores` page:
|
||
|
|
- Store list with CRUD
|
||
|
|
- Filter by tags (pharmacy, grocery, etc.)
|
||
|
|
- Per-store summary: total spent, last visit, item count
|
||
|
|
- `/medicine-prices` page:
|
||
|
|
- Medicine search -> price history line chart (per store, color-coded)
|
||
|
|
- Store comparison table for selected medicine
|
||
|
|
- Spending over time bar chart
|
||
|
|
- Price alert panel
|
||
|
|
|
||
|
|
### 4.9 — Web UI: Refill Management
|
||
|
|
|
||
|
|
- `/refills` page:
|
||
|
|
- **Alerts section**: medicines running low
|
||
|
|
- Card per medicine: name, days remaining, suggested quantity, cheapest store
|
||
|
|
- "Generate Refill List" button -> creates list from all alerts
|
||
|
|
- **Refill lists section**:
|
||
|
|
- Active lists at top, completed/archived below
|
||
|
|
- List detail view:
|
||
|
|
- Items with checkbox, medicine name, quantity, estimated price
|
||
|
|
- Check off: optionally enter actual price
|
||
|
|
- Store comparison panel
|
||
|
|
- "Done Shopping" -> prompts "Add items to cabinet?"
|
||
|
|
- **Dashboard widget**: refill alert count badge, medicines needing refill soon
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Acceptance Criteria
|
||
|
|
|
||
|
|
- [ ] Can create and manage stores with tags
|
||
|
|
- [ ] Can record medicine prices and view price history
|
||
|
|
- [ ] Store comparison shows cheapest option per medicine
|
||
|
|
- [ ] Refill alerts correctly identify medicines running low based on burn rate
|
||
|
|
- [ ] Refill lists can be auto-generated from alerts
|
||
|
|
- [ ] Checked refill items can be added to cabinet in one action
|
||
|
|
- [ ] Price analytics show spending trends
|
||
|
|
- [ ] Store infrastructure is reusable for food tracking (Phase 9)
|
||
|
|
- [ ] All queries scoped to `householdId`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Estimated Effort
|
||
|
|
|
||
|
|
Medium-large. Store/price infrastructure, refill alert logic, and the refill-to-cabinet flow involve significant work. The store comparison and analytics add moderate complexity.
|