Setup initial project

This commit is contained in:
Aerilyn Weber 2026-03-27 14:50:34 +09:00
commit db79af06f7
119 changed files with 20761 additions and 0 deletions

127
docs/PLAN.md Normal file
View file

@ -0,0 +1,127 @@
# MeshiTrack — Large-Scale Project Plan
> **Nutrition, Medicine & Pantry Management Platform**
> A self-hosted, multi-user app for medicine tracking, nutrition tracking, pantry/fridge management, recipe management, meal planning, and price surveillance across stores.
## Project Summary
MeshiTrack helps households manage medicines and food. Medicine tracking comes first as the simpler domain: catalog medicines, track inventory in a medicine cabinet, define daily regimens, batch-dispense via a pill organizer, compare pharmacy prices, and get automatic refill alerts. Food tracking follows the same architectural patterns: product catalog, recipes, pantry tracking, meal planning, grocery lists, and price comparison. Both domains share infrastructure (auth, households, stores).
## Tech Stack
| Layer | Technology |
| ------------------- | --------------------------------------- |
| **Backend** | Fastify 5 (TypeScript, ESM) |
| **DI Container** | Awilix 13 + @fastify/awilix |
| **Frontend** | Next.js 16 (React 19, TypeScript) |
| **Styling** | Tailwind CSS 4 (CSS-first config) |
| **Database** | MongoDB (Mongoose 9) |
| **Auth** | Keycloak (OIDC), jose 6 (JWT) |
| **Validation** | Zod 4 (shared schemas) |
| **Shared Code** | TypeScript package (types, Zod schemas) |
| **Testing** | Vitest 4 (unit + integration) |
| **Mobile (future)** | React Native |
| **LLM** | Abstracted interface (provider TBD) |
| **Deployment** | Docker Compose (self-hosted) |
| **Monorepo** | Turborepo 2 |
| **Runtime** | Node.js 22+ (ESM-only) |
## Phase Overview
### Medicine Tracking (Phases 1-4)
| Phase | Name | Description | Doc |
| ----- | ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 0 | Foundation & Infrastructure | Repo scaffolding, Docker, auth | [phase-0-foundation.md](phases/phase-0-foundation.md) |
| 1 | Medicine Library | Searchable medicine catalog with dosage/form info | [phase-1-medicine-library.md](phases/phase-1-medicine-library.md) |
| 2 | Medicine Cabinet | Inventory tracking with quantity, expiry, low-stock alerts | [phase-2-medicine-cabinet.md](phases/phase-2-medicine-cabinet.md) |
| 3 | Regimens & Pill Organizer | Daily medication schedules, batch-dispense, burn rate | [phase-3-regimens-pill-organizer.md](phases/phase-3-regimens-pill-organizer.md) |
| 4 | Pharmacies, Prices & Refills | Shared store infrastructure, price tracking, refill alerts | [phase-4-pharmacies-prices-refills.md](phases/phase-4-pharmacies-prices-refills.md) |
### Food Tracking (Phases 5-9)
| Phase | Name | Description | Doc |
| ----- | ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 5 | Product Library | Searchable food product catalog with nutrition data | [phase-5-product-library.md](phases/phase-5-product-library.md) |
| 6 | Recipe Management | Recipe CRUD, nutrition auto-calculation, import | [phase-6-recipes.md](phases/phase-6-recipes.md) |
| 7 | Pantry & Fridge Tracking | Track item lifecycle, freshness, spoilage estimation | [phase-7-pantry.md](phases/phase-7-pantry.md) |
| 8 | Meal Planning & Waste Reduction| Suggest meals from pantry, nutrition targets, weekly planning | [phase-8-meal-planning.md](phases/phase-8-meal-planning.md) |
| 9 | Grocery & Price Tracking | Shopping lists, price analytics, store comparison (reuses Phase 4 stores) | [phase-9-grocery.md](phases/phase-9-grocery.md) |
### Shared (Phase 10)
| Phase | Name | Description | Doc |
| ----- | ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 10 | LLM Integration & Smart Features | Wire up LLM providers, enable smart features across both domains | [phase-10-llm.md](phases/phase-10-llm.md) |
## Cross-Cutting Concerns
See [cross-cutting.md](cross-cutting.md) for API versioning, pagination, audit trails, real-time events, testing strategy, and mobile readiness.
## Architecture Decisions
See [architecture.md](architecture.md) for key decisions and rationale.
## Monorepo Structure (Target)
```
MeshiTrack/
├── docs/ # This documentation
├── packages/
│ ├── api/ # Fastify backend
│ │ ├── src/
│ │ │ ├── modules/
│ │ │ │ ├── health/
│ │ │ │ ├── users/
│ │ │ │ ├── households/
│ │ │ │ ├── medicines/ # Phase 1: Medicine catalog
│ │ │ │ ├── cabinet/ # Phase 2: Medicine inventory
│ │ │ │ ├── regimens/ # Phase 3: Medication schedules
│ │ │ │ ├── organizer/ # Phase 3: Pill organizer fills
│ │ │ │ ├── stores/ # Phase 4: Shared store infrastructure
│ │ │ │ ├── medicine-prices/ # Phase 4: Medicine price tracking
│ │ │ │ ├── refills/ # Phase 4: Refill alerts & lists
│ │ │ │ ├── products/ # Phase 5: Food product catalog
│ │ │ │ ├── recipes/ # Phase 6: Recipe management
│ │ │ │ ├── pantry/ # Phase 7: Food inventory
│ │ │ │ ├── meal-plans/ # Phase 8: Meal planning
│ │ │ │ ├── grocery/ # Phase 9: Grocery shopping
│ │ │ │ └── llm/ # Phase 10: LLM integration
│ │ │ ├── plugins/ # Fastify plugins (auth, mongoose, etc.)
│ │ │ ├── schemas/ # Mongoose schemas
│ │ │ ├── common/ # Error classes, shared types
│ │ │ └── config/
│ │ ├── vitest.config.ts
│ │ └── package.json
│ ├── web/ # Next.js frontend
│ │ ├── src/
│ │ │ ├── app/ # App Router pages
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/ # API client layer
│ │ │ └── styles/
│ │ └── package.json
│ └── shared/ # Shared TypeScript types & validation
│ ├── src/
│ │ ├── types/
│ │ ├── enums/
│ │ └── validation/ # Zod 4 schemas
│ └── package.json
├── docker/
│ ├── docker-compose.yml
│ ├── keycloak/ # Realm export, themes
│ └── mongo/ # Init scripts
├── .github/
│ └── workflows/
├── turbo.json # Turborepo config
├── package.json # Root workspace config
└── tsconfig.base.json
```
## Verification Strategy
- **Per-phase**: each phase ends with a working `docker compose up` that demos the new feature
- **Integration**: Postman/Bruno collection maintained alongside API development
- **E2E smoke test (medicine)**: automated script that creates a user, adds medicines, stocks cabinet, creates regimen, fills organizer, checks refill alerts
- **E2E smoke test (food)**: automated script that adds products, creates a recipe, stocks the pantry, generates a meal plan, and creates a shopping list
- **Performance**: MongoDB indexes reviewed per phase; query profiling before phase sign-off

233
docs/architecture.md Normal file
View file

@ -0,0 +1,233 @@
# Architecture Decisions
Key architectural decisions for MeshiTrack with rationale.
---
## ADR-001: Monorepo with Shared Types Package
**Decision**: Use a Turborepo/Nx monorepo with `packages/api`, `packages/web`, and `packages/shared`.
**Rationale**:
- Type-safe API contracts: DTOs and validation schemas (Zod) are defined once in `shared`, consumed by both API and web
- Future React Native app imports directly from `shared` — no duplication
- Atomic commits across API + web when contracts change
- Turborepo handles caching and parallel builds efficiently
**Trade-offs**:
- Slightly more complex initial setup vs separate repos
- Needs careful dependency management between packages
---
## ADR-002: Keycloak for Authentication
**Decision**: Use Keycloak as an external OIDC identity provider rather than custom JWT auth.
**Rationale**:
- Production-grade OIDC/OAuth2 out of the box
- Built-in user management admin UI — no need to build user registration/password reset flows
- Social login support if needed later
- Household multi-tenancy via custom token claims (custom protocol mapper injects `householdIds[]` into JWT)
- Self-hosted, aligns with deployment strategy
**Trade-offs**:
- Heavier infrastructure (JVM-based, ~512MB RAM)
- Steeper learning curve for Keycloak config vs simple Passport.js
- Adds complexity to Docker Compose
---
## ADR-003: MongoDB over PostgreSQL
**Decision**: Use MongoDB as the primary database.
**Rationale**:
- Flexible schema suits the product catalog (products have varying nutrition fields, optional barcodes, tags)
- Embedded documents reduce need for joins (recipe ingredients embed product references, pantry items embed freshness rules)
- Time-series-like data (price records) works well with MongoDB's TTL indexes and time-series collections
- Native JSON — no ORM impedance mismatch with TypeScript objects
- Good Atlas Search capabilities for full-text product search
**Trade-offs**:
- Less strict referential integrity vs PostgreSQL (mitigated by application-level validation)
- Aggregation pipeline can be complex for reporting queries
- Need to be intentional about data modeling to avoid unbounded array growth
---
## ADR-004: Household-Scoped Multi-Tenancy
**Decision**: All domain data (products, recipes, pantry items, shopping lists) is scoped to a `householdId`. Users belong to one or more households.
**Rationale**:
- Enables sharing: family members share a pantry, shopping list, and recipe collection
- Clean data isolation between households within the same MongoDB instance
- Every query includes `householdId` filter — implemented via a Fastify preHandler hook that validates it from the URI param (`:householdId`) against the user's `householdIds[]` JWT claim
**Data model**:
```
User { keycloakId, displayName, householdIds[], defaultHouseholdId }
Household { name, memberIds[], ownerUserId, inviteCode, settings }
```
**Trade-offs**:
- Slightly more complex than single-user: need to manage household membership, invitations, role checks
- Every query must include household filter (enforced by middleware, not developer discipline)
---
## ADR-005: LLM Abstraction Layer (Provider Pattern)
**Decision**: Define an `ILlmProvider` interface in Phase 1, implement concrete providers in Phase 6. All LLM-dependent features have manual fallback paths.
**Rationale**:
- Avoids blocking core functionality on LLM availability or cost decisions
- The LLM landscape changes rapidly — abstraction allows swapping providers without touching feature code
- Users who don't want LLM features get a fully functional app
- Each feature (product recognition, recipe parsing, meal suggestions) calls the interface; a `NoOpLlmProvider` returns graceful "not available" responses until real providers are wired
**Interface sketch**:
```typescript
interface ILlmProvider {
extractNutrition(input: string | Buffer): Promise<NutritionData | null>;
parseRecipe(text: string): Promise<ParsedRecipe | null>;
parseRecipeFromUrl(url: string): Promise<ParsedRecipe | null>;
parseReceipt(image: Buffer): Promise<ParsedReceipt | null>;
suggestMealPlan(context: MealPlanContext): Promise<MealPlanSuggestion | null>;
parseNaturalLanguage(text: string): Promise<StructuredAction | null>;
}
```
**Trade-offs**:
- Delayed gratification — smart features come last
- Interface may need revision as we learn what each feature actually needs (acceptable — iterate)
---
## ADR-006: Docker Compose for Self-Hosted Deployment
**Decision**: Primary deployment target is Docker Compose on a single server or VPS.
**Rationale**:
- Aligns with self-hosted preference
- Simple to operate: `docker compose up -d` starts everything
- Easy backup: MongoDB volume + Keycloak DB volume
- Can scale vertically on a single machine for household-sized workloads
**Services in Docker Compose**:
```
services:
mongodb # Data store
keycloak # Auth
api # NestJS backend
web # Next.js frontend
mongo-express # Dev-only: DB admin UI
```
**Trade-offs**:
- No horizontal scaling (acceptable for household app)
- Single point of failure (mitigated by container restart policies)
- Need manual backup strategy (cron + mongodump)
---
## ADR-007: React Native for Future Mobile App
**Decision**: When mobile is needed, build with React Native to share code with the Next.js web frontend.
**Rationale**:
- Shares TypeScript types and validation from `packages/shared`
- React component patterns and hooks can be adapted (not 1:1, but concepts transfer)
- Single team can maintain web + mobile with same language
- Large ecosystem, good Android support
**Preparation (done now)**:
- All business logic and types live in `packages/shared`, not in `packages/web`
- API is the single source of truth — web is a thin UI layer
- No server-side rendering dependencies in shared code
**Trade-offs**:
- React Native doesn't share actual UI components with Next.js (different render targets)
- May need a `packages/mobile` that depends on `packages/shared`
---
## ADR-008: Denormalized Nutrition on Recipes
**Decision**: When a recipe is saved, compute `totalNutrition` and `perServingNutrition` from ingredients and store them directly on the recipe document.
**Rationale**:
- Avoids expensive aggregation lookups on every recipe read
- Recipe pages load fast — nutrition data is pre-computed
- Recipes are read far more often than edited
**Recalculation triggers**:
- Recipe ingredient list is edited → recalculate
- A product's nutrition data is updated → background job recalculates affected recipes
**Trade-offs**:
- Data can become stale if a product is updated but recipe recalc fails (mitigated by eventual consistency via background job)
- Slight write amplification on product nutrition updates
---
## ADR-009: Fastify + Awilix over NestJS
**Decision**: Replace NestJS with Fastify 5 + Awilix (DI) + fastify-plugin architecture. Replace Jest with Vitest.
**Rationale**:
- **ESM-native**: NestJS is locked to CommonJS. Fastify 5, Awilix 13, and Vitest 4 are all ESM-first, aligning with the Node.js ecosystem direction.
- **No decorators**: NestJS relies on TypeScript experimental decorators and `emitDecoratorMetadata`, which are non-standard and incompatible with `verbatimModuleSyntax`. Fastify + Awilix use plain functions and constructor injection.
- **Performance**: Fastify is consistently the fastest Node.js HTTP framework. No reflection overhead.
- **Simpler mental model**: Fastify's plugin system is composable and explicit. Dependencies are declared, not magically resolved via decorators.
- **Better testing**: Fastify's `app.inject()` tests routes without HTTP overhead. Vitest is faster than Jest and natively supports ESM.
- **Lighter dependency tree**: NestJS pulls in 40+ packages. Fastify core is 3 packages.
**Migration pattern**:
```
NestJS → Fastify + Awilix
@Module → fp() plugin (fastify-plugin)
@Controller → Route definitions inside plugin
@Injectable → Plain class + Awilix registration
@InjectModel → Import Mongoose model directly
class-validator → Zod schemas (fastify-type-provider-zod)
@UseGuards → onRequest/preHandler hooks
Jest → Vitest
```
**Stack versions** (as of migration):
- Fastify 5.8, Awilix 13, @fastify/awilix 8.2
- fastify-type-provider-zod 6.1 (Zod v4 support)
- jose 6.2 (ESM-only JWT, replaces passport-jwt)
- Vitest 4.1, TypeScript 6.0, Mongoose 9.3
**Trade-offs**:
- NestJS has more opinionated structure — new developers may need to learn Fastify's plugin model
- No built-in CLI scaffolding (acceptable — our module structure is documented)
- Awilix DI is less "magical" than NestJS — requires explicit registration (this is actually a benefit)

225
docs/cross-cutting.md Normal file
View file

@ -0,0 +1,225 @@
# Cross-Cutting Concerns
Aspects that span all phases and must be maintained consistently throughout development.
---
## API Versioning
- All endpoints prefixed with `/api/v1/`
- Version is part of the URL, not headers
- When breaking changes are needed, introduce `/api/v2/` alongside v1
- Deprecation: v1 endpoints log warnings 3 months before removal
---
## Pagination
All list endpoints use **cursor-based pagination**:
```typescript
// Request
GET /api/v1/products?cursor=abc123&limit=20
// Response
{
"data": [...],
"pagination": {
"cursor": "def456", // Pass as next cursor, null = no more pages
"hasMore": true,
"total": 150 // Total count (optional, can be expensive)
}
}
```
- Default `limit`: 20, max: 100
- Cursor is an opaque string (encoded `_id` or composite sort key)
- Prefer cursor over offset/skip for MongoDB performance
---
## Audit Trail
Every domain document includes:
```typescript
{
createdAt: Date; // Set on creation, never modified
updatedAt: Date; // Updated on every modification
createdBy: string; // userId who created
}
```
For sensitive operations (deletes, status transitions, admin actions), maintain an `AuditLog` collection:
```typescript
interface AuditLog {
id: string;
householdId: string;
userId: string;
action: string; // 'product.delete', 'pantry.transition', etc.
entityType: string; // 'Product', 'PantryItem', etc.
entityId: string;
changes?: Record<string, { from: any; to: any }>;
timestamp: Date;
}
```
Implemented as a NestJS interceptor that logs after successful mutations.
---
## Error Handling
Consistent error response format across all endpoints:
```typescript
interface ApiError {
statusCode: number;
error: string; // HTTP status text
message: string; // Human-readable message
details?: any; // Validation errors, etc.
timestamp: string;
path: string;
}
```
Global exception filter in NestJS catches:
- `ValidationException` → 400
- `UnauthorizedException` → 401
- `ForbiddenException` → 403
- `NotFoundException` → 404
- `ConflictException` → 409 (e.g., duplicate barcode)
- `TooManyRequestsException` → 429 (LLM rate limit)
- Unhandled errors → 500 (log stack trace, return generic message)
---
## Real-Time (WebSocket)
NestJS `@WebSocketGateway` with Socket.IO:
- **Authentication**: validate JWT on connection
- **Rooms**: one room per `householdId` — all household members receive household events
- **Namespaces**: optional per-feature namespaces (`/pantry`, `/shopping`)
Events introduced per phase:
| Phase | Events |
| ----- | ------------------------------------------------------------------------------------------------ |
| 3 | `pantry:item-added`, `pantry:item-updated`, `pantry:freshness-alert` |
| 5 | `shopping:item-checked`, `shopping:item-added`, `shopping:item-removed`, `shopping:list-updated` |
| 4 | `meal-plan:updated` |
Frontend pattern:
- Connect on app mount, join household room
- Use React context/Zustand store to distribute events to components
- Optimistic UI updates with server reconciliation
---
## Testing Strategy
### Unit Tests (per module, per phase)
- **Services**: business logic, calculations (nutrition calculator, freshness calculator, suggestion scoring)
- **Guards/Interceptors**: auth, household scoping
- **Mocking**: MongoDB operations mocked for service tests
- **Framework**: Jest
### Integration Tests (per module)
- **In-memory MongoDB** (`mongodb-memory-server`) or test containers
- Test full request → service → database → response cycle
- **Framework**: Supertest + Jest
### E2E Tests (per phase milestone)
- Full Docker Compose stack
- Automated script that exercises the happy path:
1. Register/login user
2. Create household
3. Add products
4. Create recipe
5. Stock pantry
6. Generate meal plan
7. Create shopping list
8. Check off items → add to pantry
- **Framework**: Supertest or Playwright (for web UI)
### Frontend Tests
- **Component tests**: React Testing Library
- **Hook tests**: `@testing-library/react-hooks`
- **E2E**: Playwright for critical flows (login, add product, create recipe)
### Test Coverage Targets
| Type | Target |
| ----------- | -------------------- |
| Unit | 80%+ |
| Integration | Key flows covered |
| E2E | Happy path per phase |
---
## Mobile Readiness
Design decisions to facilitate React Native development later:
1. **All business logic in API**: the web frontend is a thin UI layer. Mobile will consume the same API.
2. **Shared types package**: `packages/shared` is platform-agnostic TypeScript. Mobile imports it directly.
3. **No SSR dependencies in shared code**: avoid Next.js-specific imports in `packages/shared`.
4. **API client layer**: `packages/web/src/services/api.ts` wraps fetch/axios with auth. Mobile will have its own but same pattern.
5. **WebSocket events**: same events work on mobile (Socket.IO has React Native support).
6. **Auth**: Keycloak has React Native OIDC libraries (`react-native-app-auth`).
7. **Image handling**: API accepts standard multipart uploads — works from any client.
8. **Push notifications**: Phase 3 starts with in-app notifications. Mobile phase adds Firebase Cloud Messaging (FCM) as a notification channel.
When the mobile phase begins:
- Add `packages/mobile` (React Native via Expo or bare workflow)
- Share from `packages/shared`
- Build mobile-optimized UI for key flows: pantry check, shopping list, quick add
---
## Security
- **Authentication**: All API routes require valid Keycloak JWT (except health check)
- **Authorization**: Household-scoped — users can only access data for their households
- **Input validation**: Zod schemas validate all inputs; NestJS validation pipe rejects invalid requests
- **Rate limiting**: per-IP and per-household (configurable)
- **CORS**: restricted to known origins
- **Helmet**: HTTP security headers via `@nestjs/helmet`
- **Secrets**: environment variables, never committed; `.env.example` documents required vars
- **Image uploads**: validate file type and size; store in local volume or S3-compatible storage
- **MongoDB**: authenticated access, least-privilege user for the app
---
## Performance
- **MongoDB indexes**: reviewed and optimized per phase (documented in each phase doc)
- **Query profiling**: enable MongoDB slow query log in dev; review before phase sign-off
- **Caching**: consider Redis for:
- Product search results (short TTL)
- Freshness rule lookups (rarely change)
- LLM response caching (identical inputs)
- **Denormalization**: nutrition on recipes, product names on pantry items — reduces lookups
- **Pagination**: cursor-based, no `skip()` for large collections
- **Compression**: gzip responses via NestJS middleware
---
## Observability (Future)
Not in initial phases, but plan for:
- **Structured logging**: Pino or Winston with JSON format
- **Health checks**: `/api/v1/health` returns status of MongoDB, Keycloak connectivity
- **Metrics**: Prometheus-compatible endpoint (NestJS has plugins)
- **Tracing**: OpenTelemetry for request tracing across services
- **Error tracking**: Sentry integration (optional, self-hosted instance possible)

View file

@ -0,0 +1,259 @@
# Coding Conventions & Style Guide — MeshiTrack
> Cross-cutting conventions that apply to all packages in the monorepo.
## General Rules
### No emojis
Never use emoji characters anywhere in this codebase: not in source code, UI text, console output, log messages, comments, or documentation. Use plain text instead.
### Language: TypeScript everywhere
- All packages use TypeScript with `strict: true`
- No `.js` files in source (except config files: `jest.config.js`, `next.config.js`)
- All files use `.ts` or `.tsx` extension
### Formatting: Prettier
```json
// .prettierrc
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
}
```
### Linting: ESLint
Use a flat config (`eslint.config.js`) with TypeScript support:
- `@typescript-eslint/recommended`
- `@typescript-eslint/no-explicit-any` → error
- `@typescript-eslint/no-unused-vars` → error (with `_` prefix exception)
- `import/order` → enforce consistent import ordering
### Import ordering
```typescript
// 1. Node built-ins
import { readFile } from 'fs/promises';
// 2. External packages
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
// 3. Monorepo packages
import type { Product } from '@meshitrack/shared';
import { ProductCategory } from '@meshitrack/shared';
// 4. Internal (relative) imports
import { ProductsRepository } from './products.repository';
import type { ProductQueryDto } from './dto/product-query.dto';
```
Separate each group with a blank line.
## Naming Conventions
### Files
| Kind | Pattern | Example |
| ----------------- | -------------------------------------- | --------------------------- |
| Module | `kebab-case.module.ts` | `products.module.ts` |
| Controller | `kebab-case.controller.ts` | `products.controller.ts` |
| Service | `kebab-case.service.ts` | `products.service.ts` |
| Repository | `kebab-case.repository.ts` | `products.repository.ts` |
| Schema (Mongoose) | `kebab-case.schema.ts` | `product.schema.ts` |
| DTO | `kebab-case.dto.ts` | `create-product.dto.ts` |
| Guard | `kebab-case.guard.ts` | `keycloak-auth.guard.ts` |
| Decorator | `kebab-case.decorator.ts` | `current-user.decorator.ts` |
| Interface | `kebab-case.interface.ts` | `llm-provider.interface.ts` |
| React component | `PascalCase.tsx` | `ProductCard.tsx` |
| React hook | `use-kebab-case.ts` | `use-products.ts` |
| Test file | `*.spec.ts` (API) / `*.test.tsx` (Web) | `products.service.spec.ts` |
| Zod schema | `kebab-case.schemas.ts` | `product.schemas.ts` |
| Enum file | `kebab-case.enums.ts` | `product.enums.ts` |
### Code
| Kind | Style | Example |
| ------------------------------------- | -------------------------------- | -------------------------------------- |
| Class | PascalCase | `ProductsService`, `KeycloakAuthGuard` |
| Interface | PascalCase + I prefix (optional) | `ILlmProvider` or `LlmProvider` |
| Type alias | PascalCase | `ProductQuery`, `CreateProductInput` |
| Enum | PascalCase | `ProductCategory` |
| Enum member | UPPER_SNAKE | `ProductCategory.DAIRY` |
| Function | camelCase | `calculateNutrition()` |
| Variable | camelCase | `servingSize`, `totalCalories` |
| Constant | UPPER_SNAKE | `LLM_PROVIDER`, `MAX_RETRY_COUNT` |
| Private field (backing getter/setter) | `_camelCase` | `_accessToken`, `_householdId` |
| React component | PascalCase | `ProductCard`, `NutritionBadge` |
| React hook | camelCase | `useProducts`, `usePantryItems` |
| CSS class (Tailwind) | kebab-case | via Tailwind utilities |
## Code Organization Rules
### API (NestJS)
1. **One module per feature** — self-contained folder with controller, service, repository, schemas, DTOs, tests
2. **No cross-module imports of internal files** — only import from exported module API
3. **Services contain business logic** — controllers are thin, repositories handle data access
4. **Throw NestJS exceptions**`NotFoundException`, `ConflictException`, etc.
5. **Use custom decorators for request context**`@CurrentUser()`, `@CurrentHousehold()`
### Web (Next.js)
1. **App Router conventions**`page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`
2. **Server Components by default** — only add `'use client'` when interactive
3. **Feature components** in `components/features/` — organized by domain (products, pantry, etc.)
4. **UI components** in `components/ui/` — generic, reusable, no domain logic
5. **Hooks** in `hooks/` — custom hooks for data fetching, state management
6. **Services** in `services/` — API client wrappers
### Shared
1. **Only pure TypeScript** — no Node.js, no browser, no framework code
2. **Types, enums, Zod schemas, pure utils** — nothing else
3. **Barrel exports** — every directory has `index.ts`
4. **Use `type` modifier for type-only exports/imports**
## Getters and Setters
Use TypeScript `get`/`set` accessors when a property simply exposes or lightly wraps a private backing field. Use a plain method only when the operation is async, takes multiple parameters, or has meaningful side effects beyond assignment.
```typescript
// Good:Simple exposure of a private field — use accessor
class ApiClient {
private _accessToken: string | null = null;
set accessToken(token: string) {
this._accessToken = token;
}
get accessToken(): string | null {
return this._accessToken;
}
}
// Good:Side effects / async / multiple params — use a method
class TokenManager {
async setTokenFromCode(code: string, redirectUri: string) {
const token = await exchangeCode(code, redirectUri);
this._accessToken = token;
}
}
```
Backing fields use the `_camelCase` prefix to avoid name collisions with the accessor.
## Error Messages
### API error response format
```json
{
"statusCode": 404,
"error": "Not Found",
"message": "Product abc123 not found in household xyz",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/api/v1/products/abc123"
}
```
### Rules
- Messages are **end-user readable** — no stack traces, no internal IDs in production
- Include **resource type and identifier** in not-found messages
- Log full error details server-side with a correlation ID
- Never leak database field names or query details
## Git Conventions
### Branch naming
```
feature/MESH-001-product-crud
fix/MESH-042-barcode-duplicate
chore/update-dependencies
docs/phase-2-recipe-notes
```
### Commit messages (Conventional Commits)
```
feat(products): add barcode lookup via Open Food Facts
fix(pantry): correct freshness calculation for opened items
docs: update phase-2 recipe instructions
chore(deps): bump @nestjs/core to 10.4.0
test(api): add integration tests for product search
refactor(api): extract repository pattern from service
```
### PR template
```markdown
## What
Brief description of the change.
## Why
Link to issue or explanation of the need.
## How
Key implementation decisions.
## Testing
- [ ] Unit tests added/updated
- [ ] Integration tests (if DB changes)
- [ ] Manual testing done
## Checklist
- [ ] Types updated in `packages/shared`
- [ ] Zod schemas match new fields
- [ ] API docs (Swagger decorators) updated
- [ ] No `any` types introduced
```
## Logging
### Use NestJS Logger
```typescript
import { Logger } from '@nestjs/common';
@Injectable()
export class ProductsService {
private readonly logger = new Logger(ProductsService.name);
async create(householdId: string, userId: string, dto: CreateProductDto) {
this.logger.log(`Creating product "${dto.name}" for household ${householdId}`);
// ...
this.logger.debug(`Product created with ID ${product.id}`);
return product;
}
}
```
### Log levels
- `error` — unexpected failures, unhandled exceptions
- `warn` — recoverable issues, deprecated usage, slow queries
- `log` — significant operations (create, delete, status transitions)
- `debug` — detailed flow information (only shown in dev)
- `verbose` — very detailed (disabled by default)
### Never log:
- Passwords, tokens, API keys
- Full request bodies with sensitive user data
- PII (unless specifically needed and compliant with privacy policy)

331
docs/instructions/docker.md Normal file
View file

@ -0,0 +1,331 @@
# Docker & Docker Compose Best Practices — MeshiTrack
> Instruction file for containerization and local development environment.
## Docker Compose Architecture
```
┌─────────────┐ ┌───────────────┐ ┌──────────┐
│ web:3000 │────→│ api:3001 │────→│ mongodb │
│ (Next.js) │ │ (NestJS) │ │ :27017 │
└─────────────┘ └───────┬───────┘ └──────────┘
┌───────▼───────┐
│ keycloak │
│ :8080 │
└───────────────┘
```
## docker-compose.yml
```yaml
version: '3.8'
services:
mongodb:
image: mongo:7
container_name: meshitrack-mongodb
restart: unless-stopped
ports:
- '27017:27017'
environment:
MONGO_INITDB_ROOT_USERNAME: meshitrack
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-devpassword}
MONGO_INITDB_DATABASE: meshitrack
volumes:
- mongo-data:/data/db
- ./mongo/init-replica.js:/docker-entrypoint-initdb.d/init-replica.js:ro
command: ['--replSet', 'rs0', '--bind_ip_all']
healthcheck:
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:24.0
container_name: meshitrack-keycloak
restart: unless-stopped
ports:
- '8080:8080'
environment:
KC_DB: dev-mem
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin}
command: start-dev --import-realm
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
healthcheck:
test:
[
'CMD-SHELL',
"exec 3<>/dev/tcp/127.0.0.1/8080; echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; cat <&3 | grep -q '200'",
]
interval: 15s
timeout: 5s
retries: 10
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
target: development
container_name: meshitrack-api
restart: unless-stopped
ports:
- '3001:3001'
depends_on:
mongodb:
condition: service_healthy
keycloak:
condition: service_healthy
environment:
NODE_ENV: development
PORT: 3001
MONGODB_URI: mongodb://meshitrack:${MONGO_PASSWORD:-devpassword}@mongodb:27017/meshitrack?authSource=admin&replicaSet=rs0
KEYCLOAK_URL: http://keycloak:8080
KEYCLOAK_REALM: meshitrack
KEYCLOAK_CLIENT_ID: meshitrack-api
volumes:
- ../packages/api/src:/app/packages/api/src:ro # Hot reload
- ../packages/shared/src:/app/packages/shared/src:ro
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
target: development
container_name: meshitrack-web
restart: unless-stopped
ports:
- '3000:3000'
depends_on:
- api
environment:
NODE_ENV: development
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
NEXT_PUBLIC_KEYCLOAK_URL: http://localhost:8080
NEXT_PUBLIC_KEYCLOAK_REALM: meshitrack
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: meshitrack-web
volumes:
- ../packages/web/src:/app/packages/web/src:ro
# Dev-only services
mongo-express:
image: mongo-express
container_name: meshitrack-mongo-express
ports:
- '8081:8081'
depends_on:
mongodb:
condition: service_healthy
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: meshitrack
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASSWORD:-devpassword}
ME_CONFIG_MONGODB_URL: mongodb://meshitrack:${MONGO_PASSWORD:-devpassword}@mongodb:27017/
profiles:
- dev
volumes:
mongo-data:
```
## Dockerfile Best Practices
### Multi-stage builds
```dockerfile
# docker/Dockerfile.api
# ---- Base ----
FROM node:20-alpine AS base
WORKDIR /app
RUN corepack enable
# ---- Dependencies ----
FROM base AS dependencies
COPY package.json package-lock.json ./
COPY packages/api/package.json ./packages/api/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci --workspace=packages/shared --workspace=packages/api
# ---- Build ----
FROM dependencies AS build
COPY packages/shared/ ./packages/shared/
COPY packages/api/ ./packages/api/
COPY tsconfig.base.json ./
RUN npm run build --workspace=packages/shared
RUN npm run build --workspace=packages/api
# ---- Production ----
FROM base AS production
COPY --from=build /app/packages/api/dist ./packages/api/dist
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/api/package.json ./packages/api/
COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=build /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3001
CMD ["node", "packages/api/dist/main.js"]
# ---- Development ----
FROM dependencies AS development
COPY packages/shared/ ./packages/shared/
COPY packages/api/ ./packages/api/
COPY tsconfig.base.json ./
RUN npm run build --workspace=packages/shared
EXPOSE 3001
CMD ["npm", "run", "dev", "--workspace=packages/api"]
```
### Key principles
1. **Layer ordering**: Copy `package.json` files first, then `npm ci`, then source code. This ensures dependency layers are cached unless `package.json` changes.
2. **Multi-stage targets**: Use `--target=development` for dev (with hot reload), `--target=production` for deploy (minimal image).
3. **Alpine images**: Use `node:20-alpine` for smaller images (~180MB vs ~1GB).
4. **.dockerignore**: Always include to prevent sending unnecessary files to the build context:
```
node_modules
.git
.next
dist
*.md
docs/
.env*
```
## Environment Variables
### .env.example (committed to repo)
```env
# MongoDB
MONGO_PASSWORD=devpassword
# Keycloak
KC_ADMIN_PASSWORD=admin
# API
API_PORT=3001
MONGODB_URI=mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin
# Web
NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1
NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080
NEXT_PUBLIC_KEYCLOAK_REALM=meshitrack
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=meshitrack-web
# LLM (Phase 6)
LLM_PROVIDER_TYPE=noop
# OPENAI_API_KEY=sk-...
# LLM_MONTHLY_BUDGET_USD=20.00
```
### .env (gitignored, developer-specific)
Never commit `.env`. Each developer copies `.env.example` to `.env` and fills in secrets.
### In Docker Compose, use variable substitution
```yaml
environment:
MONGO_PASSWORD: ${MONGO_PASSWORD:-devpassword} # Fallback for dev
```
## Health Checks
Every service should have a health check so `depends_on: condition: service_healthy` works:
```yaml
# MongoDB
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
# API (requires /api/v1/health endpoint in code)
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/v1/health"]
interval: 10s
timeout: 5s
retries: 5
```
## Volume Management
### Named volumes for persistence
```yaml
volumes:
mongo-data: # Survives container recreations
```
### Bind mounts for hot reload in development
```yaml
volumes:
- ../packages/api/src:/app/packages/api/src:ro # Read-only for safety
```
Use `:ro` (read-only) for source code mounts — the container shouldn't modify your source files.
## MongoDB Replica Set for Transactions
MongoDB transactions require a replica set. For local development, initialize a single-node replica:
```javascript
// docker/mongo/init-replica.js
// This runs on first start via /docker-entrypoint-initdb.d/
try {
rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'mongodb:27017' }] });
} catch (e) {
if (e.codeName !== 'AlreadyInitialized') throw e;
}
```
## Useful Commands
```bash
# Start all services
docker compose up -d
# Start with dev tools (mongo-express)
docker compose --profile dev up -d
# View logs
docker compose logs -f api
# Rebuild after dependency changes
docker compose build --no-cache api
# Reset database
docker compose down -v # -v removes volumes
docker compose up -d
# Enter a running container
docker compose exec api sh
docker compose exec mongodb mongosh -u meshitrack -p devpassword
# Backup MongoDB
docker compose exec mongodb mongodump --uri="mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin" --archive | gzip > backup-$(date +%Y%m%d).gz
```
## Production Considerations
- Use separate `docker-compose.prod.yml` with:
- `target: production` for API and web builds
- No bind mounts
- No dev services (mongo-express)
- Proper resource limits
- External volumes for MongoDB data
- Log drivers configured
- Consider adding:
- **Traefik** or **nginx** as reverse proxy with TLS
- **Watchtower** for auto-updating container images
- Automated backup cron container for MongoDB

View file

@ -0,0 +1,324 @@
# Fastify Best Practices — MeshiTrack API
> Instruction file for developing the Fastify backend (`packages/api`).
## Module Organization
### One route plugin per domain feature
Each business domain gets its own folder under `src/modules/`:
```
src/modules/
├── health/
│ ├── health.routes.ts
│ └── health.routes.test.ts
├── users/
│ ├── users.routes.ts
│ ├── users.service.ts
│ ├── users.repository.ts
│ └── users.routes.test.ts
├── households/
│ ├── households.routes.ts
│ ├── households.service.ts
│ ├── households.repository.ts
│ └── households.routes.test.ts
├── products/
│ ├── products.routes.ts
│ ├── products.service.ts
│ ├── products.repository.ts
│ └── products.routes.test.ts
└── ...
```
### Route plugins
Each module exports a Fastify plugin using `fastify-plugin` (`fp()`):
```typescript
import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { ProductsRepository } from './products.repository.js';
import { ProductsService } from './products.service.js';
export default fp(
async (fastify) => {
// Register DI
fastify.diContainer.register({
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
app.route({
method: 'GET',
url: '/api/v1/products',
schema: {
querystring: ListProductsQuerySchema,
response: { 200: ProductListResponseSchema },
},
handler: async (request, reply) => {
const service = request.diScope.resolve<ProductsService>('productsService');
const result = await service.list(request.householdId, request.query);
return reply.send(result);
},
});
},
{ name: 'products-routes' },
);
```
## Dependency Injection with Awilix
### Constructor injection via destructuring
Awilix injects dependencies by matching constructor parameter names:
```typescript
export class ProductsService {
private readonly productsRepository: ProductsRepository;
constructor({ productsRepository }: { productsRepository: ProductsRepository }) {
this.productsRepository = productsRepository;
}
}
```
### Registration
Register classes in the route plugin that owns them:
```typescript
import { asClass, asValue, Lifetime } from 'awilix';
fastify.diContainer.register({
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }),
});
```
### Resolving per-request
Use `request.diScope.resolve()` in handlers:
```typescript
handler: async (request) => {
const service = request.diScope.resolve<ProductsService>('productsService');
return service.findById(request.params.id);
};
```
### Lifetime rules
- **SINGLETON** for stateless services and repositories (default choice)
- **SCOPED** only when you need per-request state (e.g., transaction context)
- Never use **TRANSIENT** unless you have a specific reason
## Request Validation with Zod
### Use `fastify-type-provider-zod`
Set up the Zod type provider at app level:
```typescript
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
```
### Schema definitions
Define schemas in `packages/shared` and import them in route definitions:
```typescript
app.route({
method: 'POST',
url: '/api/v1/products',
schema: {
body: CreateProductSchema,
response: { 201: ProductResponseSchema },
},
handler: async (request, reply) => {
// request.body is fully typed from CreateProductSchema
const product = await service.create(request.householdId, request.body);
return reply.status(201).send(product);
},
});
```
## Plugin Architecture
### Use `fastify-plugin` for shared plugins
Plugins that need to be visible to sibling routes must use `fp()`:
```typescript
import fp from 'fastify-plugin';
export default fp(
async (fastify) => {
// decorations/hooks registered here are visible to all routes
},
{ name: 'my-plugin', dependencies: ['other-plugin'] },
);
```
### Plugin ordering matters
Register plugins in this order in `main.ts`:
1. Security plugins (`@fastify/helmet`, `@fastify/cors`)
2. Compression (`@fastify/compress`)
3. Swagger (`@fastify/swagger`, `@fastify/swagger-ui`)
4. DI container (`@fastify/awilix`)
5. Database (`mongoose.plugin`)
6. Auth (`auth.plugin`)
7. Household guard (`household.plugin`)
8. Route modules (health, users, households, etc.)
## Error Handling
### Custom AppError hierarchy
```typescript
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly error: string,
public readonly details?: unknown,
) {
super(message);
}
}
// Subclasses: NotFoundError, UnauthorizedError, ForbiddenError, ConflictError, BadRequestError
```
### Throw from services, catch in global handler
Services throw `AppError` subclasses. The global error handler in `main.ts` maps them to `ApiError` response shape:
```typescript
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
statusCode: error.statusCode,
error: error.error,
message: error.message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
// ... handle Zod validation errors, unexpected errors
});
```
## Route Configuration
### Marking routes as public
```typescript
app.route({
method: 'GET',
url: '/api/v1/health',
config: { public: true },
// ...
});
```
### Skipping household validation
```typescript
app.route({
method: 'GET',
url: '/api/v1/users/me',
config: { skipHousehold: true },
// ...
});
```
## Repository Pattern
### Keep Mongoose queries in repositories
```typescript
export class ProductsRepository {
async findByHousehold(householdId: string, cursor?: string, limit = 20) {
const query: Record<string, unknown> = { householdId };
if (cursor) query['_id'] = { $gt: cursor };
return ProductModel.find(query)
.sort({ _id: 1 })
.limit(limit + 1)
.lean()
.exec();
}
}
```
### Always use `.lean().exec()`
Every read query must use `.lean().exec()` for performance:
```typescript
// Good
const product = await ProductModel.findById(id).lean().exec();
// Bad — returns full Mongoose document with all overhead
const product = await ProductModel.findById(id);
```
## Testing with Vitest
### Use `app.inject()` for route tests
Fastify's built-in `inject()` method tests routes without starting a real HTTP server:
```typescript
import { describe, it, expect } from 'vitest';
describe('Products Routes', () => {
it('GET /api/v1/products returns products for household', async () => {
const app = await buildTestApp();
const response = await app.inject({
method: 'GET',
url: '/api/v1/products',
headers: {
authorization: 'Bearer <test-jwt>',
'x-household-id': 'test-household-id',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toHaveProperty('items');
});
});
```
### Service unit tests with manual DI
No test module builder needed — just pass mock dependencies:
```typescript
import { describe, it, expect, vi } from 'vitest';
describe('ProductsService', () => {
const mockRepo = {
findByHousehold: vi.fn(),
create: vi.fn(),
};
const service = new ProductsService({ productsRepository: mockRepo as any });
it('should create a product', async () => {
mockRepo.create.mockResolvedValue({ id: '1', name: 'Chicken' });
const result = await service.create('hh1', { name: 'Chicken' });
expect(result).toEqual({ id: '1', name: 'Chicken' });
expect(mockRepo.create).toHaveBeenCalledWith('hh1', { name: 'Chicken' });
});
});
```

View file

@ -0,0 +1,347 @@
# Keycloak Integration Best Practices — MeshiTrack
> Instruction file for Keycloak setup, configuration, and Fastify/Next.js integration.
## Realm Configuration
### Realm: `meshitrack`
Export a realm JSON for reproducible setup across environments. Store in `docker/keycloak/realm-export.json`.
### Clients
| Client ID | Type | Access | Purpose |
| ---------------- | ----------- | ---------------- | ------------------------ |
| `meshitrack-web` | Public | PKCE (no secret) | Frontend (Next.js) login |
| `meshitrack-api` | Bearer-only | Confidential | Backend token validation |
### Client Configuration: `meshitrack-web`
```json
{
"clientId": "meshitrack-web",
"publicClient": true,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"redirectUris": ["http://localhost:3000/*", "https://meshitrack.example.com/*"],
"webOrigins": ["http://localhost:3000", "https://meshitrack.example.com"],
"attributes": {
"pkce.code.challenge.method": "S256"
}
}
```
### Client Configuration: `meshitrack-api`
```json
{
"clientId": "meshitrack-api",
"publicClient": false,
"bearerOnly": true,
"standardFlowEnabled": false
}
```
## Realm Roles
| Role | Description |
| -------- | ------------------------------------------- |
| `admin` | Can manage household settings, delete items |
| `member` | Standard access: CRUD on own data |
Assign default role `member` to all new users.
## Custom Token Claims (Household Mapping)
### User Attributes
Each Keycloak user gets custom attributes:
- `householdIds`: JSON array string, e.g. `["household-uuid-1", "household-uuid-2"]`
- `defaultHouseholdId`: single UUID string
### Protocol Mapper: Household Claims
Create a protocol mapper on the `meshitrack-web` client (or realm level):
```json
{
"name": "household-ids-mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"claim.name": "householdIds",
"user.attribute": "householdIds",
"jsonType.label": "JSON",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"multivalued": "false"
}
}
```
This injects `householdIds` directly into the JWT access token, so the API can read it without a separate database call.
## Fastify Integration
### JWT Validation with jose
Use the `jose` library for JWKS-based JWT verification — lightweight, ESM-native, no Passport overhead.
```bash
npm install jose --workspace=packages/api
```
```typescript
// plugins/auth.plugin.ts
import fp from 'fastify-plugin';
import * as jose from 'jose';
import config from '../config/configuration.js';
import type { AuthUser } from '../common/types.js';
import { UnauthorizedError } from '../common/errors.js';
let jwks: jose.JWTVerifyGetKey | undefined;
function getJwks(): jose.JWTVerifyGetKey {
if (!jwks) {
const issuerUrl = `${config.keycloak.url}/realms/${config.keycloak.realm}`;
jwks = jose.createRemoteJWKSet(new URL(`${issuerUrl}/protocol/openid-connect/certs`));
}
return jwks;
}
export default fp(
async (fastify) => {
fastify.decorateRequest('user', null as unknown as AuthUser);
fastify.addHook('onRequest', async (request) => {
// Skip auth for routes marked as public via route config
const routeConfig = request.routeOptions.config as Record<string, unknown> | undefined;
if (routeConfig?.['public'] === true) return;
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
const issuerUrl = `${config.keycloak.url}/realms/${config.keycloak.realm}`;
const { payload } = await jose.jwtVerify(token, getJwks(), {
issuer: issuerUrl,
audience: config.keycloak.clientId,
});
request.user = {
keycloakId: payload.sub ?? '',
email: (payload['email'] as string) ?? '',
displayName: (payload['preferred_username'] as string) ?? '',
roles: (payload['realm_access'] as Record<string, string[]>)?.['roles'] ?? [],
householdIds: (payload['householdIds'] as string[]) ?? [],
};
});
},
{ name: 'auth-plugin' },
);
```
### Route Configuration for Public/Protected
Use Fastify route config to mark endpoints as public:
```typescript
// Public endpoint — no auth required
app.route({
method: 'GET',
url: '/api/v1/health',
config: { public: true },
handler: async () => ({ status: 'ok' }),
});
// Protected endpoint (default — auth hook enforces JWT)
app.route({
method: 'GET',
url: '/api/v1/users/me',
config: { skipHousehold: true }, // auth required, household check skipped
handler: async (request) => {
/* request.user is populated */
},
});
```
### User Sync on First Login
When a user first authenticates, sync their Keycloak profile to the local MongoDB `User` document via the route handler:
```typescript
// modules/users/users.routes.ts
app.route({
method: 'GET',
url: '/api/v1/users/me',
config: { skipHousehold: true },
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('usersService');
const user = await service.syncFromToken(request.user);
return reply.send(user);
},
});
// modules/users/users.service.ts
export class UsersService {
constructor({ usersRepository }: { usersRepository: UsersRepository }) {
this.usersRepository = usersRepository;
}
async syncFromToken(user: AuthUser) {
return this.usersRepository.upsertFromToken(user.keycloakId, user.email, user.displayName);
}
}
```
## Next.js Integration
### Using next-auth v5 with Keycloak provider
```typescript
// lib/auth.ts
import NextAuth from 'next-auth';
import Keycloak from 'next-auth/providers/keycloak';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
token.householdIds = (profile as any)?.householdIds;
}
// Handle token refresh
if (Date.now() < (token.expiresAt as number) * 1000) {
return token;
}
return await refreshAccessToken(token);
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.householdIds = token.householdIds as string[];
return session;
},
},
});
async function refreshAccessToken(token: any) {
try {
const response = await fetch(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}),
},
);
const refreshed = await response.json();
return {
...token,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
expiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
};
} catch {
return { ...token, error: 'RefreshAccessTokenError' };
}
}
```
### Proxy for route protection
Next.js 16 uses `proxy.ts` instead of `middleware.ts`:
```typescript
// proxy.ts
export { auth as proxy } from '@/lib/auth';
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
```
## Test Users
Create in realm export for development:
| Username | Password | Roles | Households |
| ----------- | ---------- | ------------- | ---------------------- |
| `testuser1` | `test1234` | member, admin | `["household-test-1"]` |
| `testuser2` | `test1234` | member | `["household-test-1"]` |
| `testuser3` | `test1234` | member | `["household-test-2"]` |
## Token Lifetime Configuration
| Setting | Dev Value | Prod Recommendation |
| ---------------------- | --------- | ------------------- |
| Access Token Lifespan | 30 min | 5 min |
| Refresh Token Lifespan | 1 day | 30 min |
| SSO Session Idle | 1 day | 30 min |
| SSO Session Max | 7 days | 8 hours |
Configure in Keycloak Admin → Realm Settings → Tokens.
## Keycloak Admin API (for household management)
When a user creates a household or invites members, you may need to update Keycloak user attributes via the Admin API:
```typescript
// services/keycloak-admin.service.ts
import KcAdminClient from '@keycloak/keycloak-admin-client';
export class KeycloakAdminService {
private kcAdmin: KcAdminClient;
constructor() {
this.kcAdmin = new KcAdminClient({
baseUrl: process.env['KEYCLOAK_URL'],
realmName: process.env['KEYCLOAK_REALM'],
});
}
async authenticate() {
await this.kcAdmin.auth({
grantType: 'client_credentials',
clientId: 'meshitrack-api',
clientSecret: process.env['KEYCLOAK_CLIENT_SECRET']!,
});
}
async updateUserHouseholds(keycloakId: string, householdIds: string[]) {
await this.kcAdmin.users.update(
{ id: keycloakId },
{ attributes: { householdIds: [JSON.stringify(householdIds)] } },
);
}
}
```
## Common Pitfalls
1. **CORS issues**: Keycloak's public URL must be accessible from the browser. In Docker, the browser connects to `localhost:8080`, but the API connects to `keycloak:8080`. Use `KC_HOSTNAME_URL` in production.
2. **Token clock skew**: Ensure system clocks are synced between API server and Keycloak. Use NTP.
3. **Realm export not importing**: The import only works on first startup. To re-import, delete the Keycloak data volume.
4. **HTTPS in production**: Always use HTTPS for Keycloak in production. Use `KC_PROXY=edge` with a reverse proxy.

View file

@ -0,0 +1,392 @@
# MongoDB & Mongoose Best Practices — MeshiTrack
> Instruction file for database design and Mongoose usage across the project.
## Schema Design Principles
### Embed when possible, reference when necessary
MongoDB favors denormalization. Use this decision tree:
- **Embed** (subdocument) when:
- Data belongs exclusively to the parent (e.g., `NutritionInfo` inside `Product`)
- Data is always read together with the parent
- The embedded array is bounded and small (< 100 items)
- **Reference** (ObjectId) when:
- Data is shared across multiple documents (e.g., `Product` referenced by `Recipe`, `PantryItem`, `ShoppingItem`)
- The referenced document is large or changes independently
- You need to query the referenced document on its own
### MeshiTrack schema strategy
| Schema | Embedded Data | Referenced Data |
| ------------ | --------------------------------------- | ---------------------------------- |
| Product | `nutrition: NutritionInfo` (embed) | — |
| Recipe | `ingredients[]`, `steps[]` (embed) | `ingredients[].productId` (ref) |
| | `totalNutrition`, `perServingNutrition` | |
| PantryItem | `freshnessEstimate` (embed) | `productId` (ref), `storeId` (ref) |
| ShoppingList | `items[]` (embed) | `items[].productId` (ref) |
| MealPlan | `days[].meals[]` (embed) | `meals[].recipeId` (ref) |
| PriceRecord | — | `productId` (ref), `storeId` (ref) |
### Denormalize names for display
Store `productName` alongside `productId` so list views don't require joins:
```typescript
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true })
productId: mongoose.Types.ObjectId;
@Prop({ required: true })
productName: string; // Denormalized from Product.name
```
Update denormalized names when the source changes (background job).
## Mongoose Schema Definitions
### Use NestJS decorators for schema definitions
```typescript
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type ProductDocument = HydratedDocument<Product>;
@Schema({
timestamps: true, // Auto-manages createdAt, updatedAt
collection: 'products', // Explicit collection name
toJSON: { virtuals: true }, // Include virtuals in JSON output
})
export class Product {
@Prop({ required: true, index: true })
householdId: string;
@Prop({ required: true, trim: true })
name: string;
@Prop({ trim: true })
brand?: string;
@Prop({ unique: false, sparse: true })
barcode?: string;
@Prop({ required: true, enum: ProductCategory })
category: string;
@Prop({ type: NutritionInfoSchema })
nutrition: NutritionInfo;
@Prop([String])
tags: string[];
@Prop()
deletedAt?: Date; // Soft delete
@Prop({ required: true })
createdBy: string;
}
export const ProductSchema = SchemaFactory.createForClass(Product);
```
### Define subdocument schemas separately
```typescript
@Schema({ _id: false }) // No separate _id for embedded subdocuments
export class NutritionInfo {
@Prop({ required: true, min: 0 })
calories: number;
@Prop({ required: true, min: 0 })
protein: number;
@Prop({ required: true, min: 0 })
carbs: number;
@Prop({ required: true, min: 0 })
fat: number;
@Prop({ min: 0 })
fiber?: number;
@Prop({ min: 0 })
sugar?: number;
@Prop({ min: 0 })
sodium?: number;
}
export const NutritionInfoSchema = SchemaFactory.createForClass(NutritionInfo);
```
## Indexing Strategy
### Every query pattern needs an index
Design indexes based on the queries your app actually runs, not just the schema structure.
### Compound indexes: put equality fields first, range/sort fields last
```javascript
// Good: householdId (equality) + status (equality) + urgency (sort/filter)
{ householdId: 1, status: 1, 'freshnessEstimate.urgency': 1 }
// Bad: sorting field first
{ 'freshnessEstimate.urgency': 1, householdId: 1, status: 1 }
```
### Text indexes for search
```typescript
// Define after schema creation
ProductSchema.index(
{ name: 'text', brand: 'text', tags: 'text' },
{ weights: { name: 10, brand: 5, tags: 3 } }, // Name matches rank higher
);
```
Only **one** text index per collection. If you need multiple text search patterns, use Atlas Search or a separate search service.
### Required indexes per collection
```javascript
// Products
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 }
{ name: 'text', brand: 'text', tags: 'text' }
// Recipes
{ householdId: 1 }
{ householdId: 1, 'ingredients.productId': 1 }
{ name: 'text', tags: 'text', cuisine: 'text' }
// PantryItems
{ householdId: 1, status: 1, 'freshnessEstimate.estimatedExpiryDate': 1 }
{ householdId: 1, storageLocation: 1, status: 1 }
{ householdId: 1, productId: 1, status: 1 }
// PriceRecords
{ householdId: 1, productId: 1, storeId: 1, date: -1 }
{ householdId: 1, productId: 1, date: -1 }
// ShoppingLists
{ householdId: 1, status: 1 }
// FreshnessRules
{ category: 1, storageLocation: 1 }
```
### Register indexes in schema files
```typescript
// After schema class definition
ProductSchema.index({ householdId: 1, category: 1 });
ProductSchema.index({ householdId: 1, barcode: 1 }, { sparse: true });
ProductSchema.index(
{ name: 'text', brand: 'text', tags: 'text' },
{ weights: { name: 10, brand: 5, tags: 3 } },
);
```
## Query Best Practices
### Always filter by householdId first
Every single data query MUST include `householdId`. Enforce this in the repository layer:
```typescript
// Every repository method takes householdId as the first parameter
async findAll(householdId: string, filter: any = {}): Promise<Product[]> {
return this.model
.find({ householdId, deletedAt: null, ...filter })
.lean()
.exec();
}
```
### Use `.lean()` for read operations
```typescript
// Returns plain JS objects — 2-5x faster than hydrated documents
const products = await this.model.find(filter).lean().exec();
```
Only skip `.lean()` when you need Mongoose document methods (`.save()`, virtuals, middleware).
### Use `.exec()` on all queries
```typescript
// Always end with .exec()
const product = await this.model.findById(id).lean().exec();
```
### Cursor-based pagination (not offset)
```typescript
async findPaginated(
householdId: string,
cursor: string | null,
limit: number = 20,
): Promise<{ data: Product[]; nextCursor: string | null }> {
const filter: any = { householdId, deletedAt: null };
if (cursor) {
filter._id = { $gt: new Types.ObjectId(cursor) };
}
const docs = await this.model
.find(filter)
.sort({ _id: 1 })
.limit(limit + 1) // Fetch one extra to determine hasMore
.lean()
.exec();
const hasMore = docs.length > limit;
const data = hasMore ? docs.slice(0, limit) : docs;
const nextCursor = hasMore ? data[data.length - 1]._id.toString() : null;
return { data, nextCursor };
}
```
### Use aggregation pipelines for analytics
```typescript
// Example: Waste stats
async getWasteStats(householdId: string, startDate: Date, endDate: Date) {
return this.model.aggregate([
{
$match: {
householdId,
updatedAt: { $gte: startDate, $lte: endDate },
status: { $in: ['consumed', 'discarded'] },
},
},
{
$group: {
_id: '$status',
count: { $sum: 1 },
},
},
]).exec();
}
```
## Soft Deletes
### Use `deletedAt` field, filter in repository
```typescript
@Prop({ type: Date, default: null })
deletedAt: Date | null;
// Repository always filters
async findAll(householdId: string): Promise<Product[]> {
return this.model.find({ householdId, deletedAt: null }).lean().exec();
}
// Soft delete
async softDelete(id: string, householdId: string): Promise<void> {
await this.model.updateOne(
{ _id: id, householdId },
{ $set: { deletedAt: new Date() } },
).exec();
}
```
## Transactions
Only use transactions when updating multiple documents that must be atomic:
```typescript
async transferItem(fromPantry: string, toRecipe: string): Promise<void> {
const session = await this.connection.startSession();
try {
session.startTransaction();
// ... multiple operations with { session }
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}
```
**Note**: MongoDB transactions require a replica set. For local development, use a single-node replica set in Docker.
## Connection Management
### Configure connection in AppModule
```typescript
MongooseModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('MONGODB_URI'),
maxPoolSize: 10, // Connection pool size
serverSelectionTimeoutMS: 5000, // Fail fast on connection issues
socketTimeoutMS: 45000,
retryWrites: true,
}),
inject: [ConfigService],
});
```
### Monitor connection events
```typescript
MongooseModule.forRootAsync({
useFactory: () => ({
uri: process.env.MONGODB_URI,
onConnectionCreate: (connection) => {
connection.on('connected', () => console.log('MongoDB connected'));
connection.on('disconnected', () => console.warn('MongoDB disconnected'));
connection.on('error', (err) => console.error('MongoDB error', err));
return connection;
},
}),
});
```
## Data Validation
### Schema-level validation for data integrity
```typescript
@Prop({
required: true,
min: 0,
max: 99999,
validate: {
validator: (v: number) => v >= 0,
message: 'Calories cannot be negative',
},
})
calories: number;
```
### Application-level validation for business rules
Don't rely solely on Mongoose validation. Validate in the service layer with meaningful error messages:
```typescript
if (ingredient.quantity <= 0) {
throw new BadRequestException('Ingredient quantity must be positive');
}
```
## Backup Strategy (Docker/Self-Hosted)
```bash
# Backup: run inside the mongodb container or from host
mongodump --uri="mongodb://meshitrack:password@localhost:27017/meshitrack?authSource=admin" --out=/backup/$(date +%Y%m%d)
# Restore
mongorestore --uri="mongodb://meshitrack:password@localhost:27017/meshitrack?authSource=admin" /backup/20260325
# Automate with cron on the host or a Docker sidecar
```

470
docs/instructions/nextjs.md Normal file
View file

@ -0,0 +1,470 @@
# Next.js Best Practices — MeshiTrack Web
> Instruction file for developing the Next.js frontend (`packages/web`).
> Uses **App Router** (not Pages Router), **TypeScript**, and **Tailwind CSS**.
## Project Structure
```
packages/web/src/
├── app/ # App Router — file-based routing
│ ├── layout.tsx # Root layout (html, body, providers)
│ ├── page.tsx # Dashboard / home page
│ ├── loading.tsx # Root loading state
│ ├── error.tsx # Root error boundary
│ ├── not-found.tsx # 404 page
│ ├── (auth)/ # Route group: unauthenticated pages
│ │ ├── login/page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/ # Route group: authenticated pages
│ │ ├── layout.tsx # Sidebar + topbar layout
│ │ ├── products/
│ │ │ ├── page.tsx # Product list
│ │ │ ├── [id]/page.tsx # Product detail
│ │ │ └── loading.tsx
│ │ ├── recipes/
│ │ ├── pantry/
│ │ ├── meal-plans/
│ │ ├── shopping-lists/
│ │ └── settings/
│ └── api/ # Route Handlers (if needed for BFF patterns)
├── components/ # Shared React components
│ ├── ui/ # Generic UI components (Button, Modal, Card, etc.)
│ ├── forms/ # Form components
│ ├── layout/ # Navigation, Sidebar, TopBar
│ └── features/ # Feature-specific composed components
│ ├── products/
│ ├── recipes/
│ ├── pantry/
│ └── shopping/
├── hooks/ # Custom React hooks
├── services/ # API client layer
│ ├── api-client.ts # Configured fetch/axios wrapper
│ ├── products.service.ts
│ ├── recipes.service.ts
│ └── ...
├── lib/ # Utility functions, constants
├── styles/ # Global styles, Tailwind config
└── types/ # Frontend-specific types (import shared types from @meshitrack/shared)
```
## Server vs Client Components
### Default to Server Components
Every component in the App Router is a **Server Component** by default. Keep it that way unless the component needs:
- Browser APIs (`window`, `document`, `localStorage`)
- React hooks (`useState`, `useEffect`, `useRef`, etc.)
- Event handlers (`onClick`, `onChange`, etc.)
- Browser-only libraries
### Mark Client Components explicitly with `'use client'`
```typescript
'use client';
import { useState } from 'react';
export function ProductSearchBar({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
// ...interactive UI
}
```
### Composition pattern: Server parent, Client children
```typescript
// app/(dashboard)/products/page.tsx — Server Component
import { ProductSearchBar } from '@/components/features/products/ProductSearchBar';
import { ProductList } from '@/components/features/products/ProductList';
export default async function ProductsPage() {
// Can fetch data directly on the server
const initialProducts = await fetchProducts();
return (
<div>
<h1>Product Library</h1>
<ProductSearchBar /> {/* Client Component */}
<ProductList initialData={initialProducts} /> {/* Client Component for interactivity */}
</div>
);
}
```
### Never import server-only code in Client Components
If a utility should only run on the server, use the `server-only` package:
```typescript
import 'server-only';
export async function getServerConfig() {
// This will error if accidentally imported from a Client Component
}
```
## Data Fetching
### In Server Components: fetch directly
```typescript
// app/(dashboard)/products/page.tsx
export default async function ProductsPage() {
const res = await fetch(`${process.env.API_URL}/api/v1/products`, {
headers: { Authorization: `Bearer ${await getToken()}` },
cache: 'no-store', // Always fresh for user-specific data
});
const data = await res.json();
return <ProductGrid products={data.data} />;
}
```
### In Client Components: use SWR or React Query
We recommend **SWR** for most data fetching in Client Components:
```typescript
'use client';
import useSWR from 'swr';
import { apiClient } from '@/services/api-client';
export function PantryDashboard() {
const { data, error, isLoading, mutate } = useSWR(
'/api/v1/pantry?sort=-freshnessEstimate.daysRemaining',
apiClient.get,
);
if (isLoading) return <PantrySkeleton />;
if (error) return <ErrorDisplay error={error} />;
return <PantryGrid items={data.data} onUpdate={() => mutate()} />;
}
```
### Parallel data fetching
When a page needs multiple independent data sources, fetch in parallel:
```typescript
export default async function DashboardPage() {
const [pantryData, mealPlanData, shoppingData] = await Promise.all([
fetchExpiringSoon(),
fetchCurrentMealPlan(),
fetchActiveShoppingLists(),
]);
return (
<>
<ExpiringItems items={pantryData} />
<CurrentMealPlan plan={mealPlanData} />
<ActiveShoppingLists lists={shoppingData} />
</>
);
}
```
## Loading & Error States
### Use `loading.tsx` for route-level loading
```typescript
// app/(dashboard)/products/loading.tsx
export default function Loading() {
return <ProductGridSkeleton />;
}
```
### Use `error.tsx` for route-level error boundaries
```typescript
// app/(dashboard)/products/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
```
### Use `<Suspense>` for granular loading within a page
```typescript
import { Suspense } from 'react';
export default function PantryPage() {
return (
<div>
<h1>Pantry</h1>
<Suspense fallback={<FreshnessAlertsSkeleton />}>
<FreshnessAlerts />
</Suspense>
<Suspense fallback={<PantryGridSkeleton />}>
<PantryGrid />
</Suspense>
</div>
);
}
```
## API Client Layer
### Centralized API client with auth
```typescript
// services/api-client.ts
import { getSession } from '@/lib/auth';
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
class ApiClient {
private async getHeaders(): Promise<HeadersInit> {
const session = await getSession();
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${session?.accessToken}`,
};
}
async get<T>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
headers: await this.getHeaders(),
});
if (!res.ok) throw await this.handleError(res);
return res.json();
}
async post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
method: 'POST',
headers: await this.getHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) throw await this.handleError(res);
return res.json();
}
// ... patch, delete, upload methods
}
export const apiClient = new ApiClient();
```
### Feature-specific service files
```typescript
// services/products.service.ts
import { apiClient } from './api-client';
import type { Product, PaginatedResponse, CreateProductDto } from '@meshitrack/shared';
export const productsService = {
list: (params?: Record<string, string>) =>
apiClient.get<PaginatedResponse<Product>>(`/products?${new URLSearchParams(params)}`),
getById: (id: string) => apiClient.get<Product>(`/products/${id}`),
create: (data: CreateProductDto) => apiClient.post<Product>('/products', data),
update: (id: string, data: Partial<CreateProductDto>) =>
apiClient.patch<Product>(`/products/${id}`, data),
};
```
## Layouts & Navigation
### Root layout: providers and global UI
```typescript
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}
```
### Dashboard layout: sidebar + topbar
```typescript
// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex-1 flex flex-col">
<TopBar />
<main className="flex-1 overflow-auto p-6">
{children}
</main>
</div>
</div>
);
}
```
### Use route groups `(folder)` for shared layouts
Route groups (parenthesized folder names) don't affect the URL:
- `(auth)` — login, register pages with minimal layout
- `(dashboard)` — all authenticated pages with full navigation
## Forms
### Use controlled forms with validation
```typescript
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateProductSchema } from '@meshitrack/shared';
export function ProductForm({ onSubmit }: { onSubmit: (data: CreateProductInput) => void }) {
const form = useForm({
resolver: zodResolver(CreateProductSchema),
defaultValues: { name: '', category: '', servingSize: 0, ... },
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('name')} />
{form.formState.errors.name && <span>{form.formState.errors.name.message}</span>}
{/* ... */}
</form>
);
}
```
### Optimistic updates for real-time feel
```typescript
const { trigger, isMutating } = useSWRMutation('/api/v1/pantry/item/transition', apiClient.post);
async function handleConsume(itemId: string) {
// Optimistically update local data
mutate(
(currentData) => ({
...currentData,
data: currentData.data.map((item) =>
item.id === itemId ? { ...item, status: 'consumed' } : item,
),
}),
false,
);
// Then send to server
await trigger({ itemId, status: 'consumed' });
}
```
## Shared Types from `@meshitrack/shared`
### Import types from the shared package
```typescript
import type { Product, NutritionInfo, ProductCategory } from '@meshitrack/shared';
import { ServingUnit, ProductSource } from '@meshitrack/shared';
```
### Never duplicate types in the web package
If a type is used in both API and web, it **must** live in `packages/shared`. The web package only defines frontend-specific types (e.g., UI state, component props).
## Authentication (Keycloak)
### Use `next-auth` or `keycloak-js` for OIDC
For App Router, `next-auth` v5 with the Keycloak provider is recommended:
```typescript
// lib/auth.ts
import NextAuth from 'next-auth';
import Keycloak from 'next-auth/providers/keycloak';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
return session;
},
},
});
```
### Protect routes with middleware
```typescript
// proxy.ts (Next.js 16 renamed middleware.ts → proxy.ts)
export { auth as proxy } from '@/lib/auth';
export const config = {
matcher: ['/(dashboard)/:path*'], // Protect all dashboard routes
};
```
## Performance Tips
- **Use `next/image`** for all images — automatic optimization, lazy loading, responsive sizing
- **Use `next/link`** for all internal navigation — prefetching, client-side transitions
- **Lazy load heavy components** with `dynamic()`:
```typescript
import dynamic from 'next/dynamic';
const PriceChart = dynamic(() => import('@/components/features/prices/PriceChart'), {
loading: () => <ChartSkeleton />,
});
```
- **Keep Client Components as small as possible** — push `'use client'` boundary as far down the tree as you can
- **Use `React.memo`** for list items that render frequently (e.g., pantry items, shopping items)
## Testing
- **Component tests**: React Testing Library
```typescript
import { render, screen } from '@testing-library/react';
import { ProductCard } from '@/components/features/products/ProductCard';
test('displays product name and calories', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
expect(screen.getByText('165 kcal')).toBeInTheDocument();
});
```
- **E2E tests**: Playwright for critical flows
- **Mock API calls** in tests using MSW (Mock Service Worker)

View file

@ -0,0 +1,515 @@
# Testing Best Practices — MeshiTrack
> Instruction file for testing strategy, tools, and patterns across the monorepo.
## Testing Stack
| Layer | Tool | Package |
| ----------------- | ------------------------------ | ----------------- |
| Unit tests (API) | Vitest | `packages/api` |
| Unit tests (Web) | Vitest | `packages/web` |
| Component tests | React Testing Library | `packages/web` |
| Integration tests | Vitest + mongodb-memory-server | `packages/api` |
| E2E tests | Playwright | root/e2e |
| API mocking (web) | MSW (Mock Service Worker) | `packages/web` |
| Shared validation | Vitest | `packages/shared` |
## Directory Structure
```
packages/api/
├── src/
│ └── modules/products/
│ ├── products.routes.test.ts # Route tests (inject)
│ ├── products.service.test.ts # Service unit tests
│ └── products.integration.test.ts # Integration (mongodb-memory-server)
└── vitest.config.ts
packages/web/
├── src/
│ └── components/features/products/
│ ├── ProductCard.tsx
│ └── __tests__/
│ └── ProductCard.test.tsx
└── e2e/ # or root-level
├── playwright.config.ts
└── tests/
└── products.spec.ts
```
## Unit Testing (Fastify API)
### Test one thing at a time
Each test file tests a single class or route module. Mock all dependencies.
### Use `ClassName.name` for `describe` labels
Use the constructor's `.name` property instead of string literals for `describe` block labels. This keeps test output accurate after refactors and avoids stale string mismatches:
```typescript
// correct
describe(NotFoundError.name, () => { ... });
// avoid
describe('NotFoundError', () => { ... });
```
### Service test pattern
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProductsService } from './products.service.js';
import { ProductsRepository } from './products.repository.js';
describe('ProductsService', () => {
const mockRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
findByBarcode: vi.fn(),
};
let service: ProductsService;
beforeEach(() => {
vi.clearAllMocks();
service = new ProductsService({ productsRepository: mockRepo as any });
});
describe('create', () => {
it('should create a product with household scope', async () => {
const dto = {
name: 'Chicken Breast',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
const expected = { id: '1', householdId: 'hh1', createdBy: 'user1', ...dto };
mockRepo.create.mockResolvedValue(expected);
const result = await service.create('hh1', 'user1', dto);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Chicken Breast',
householdId: 'hh1',
createdBy: 'user1',
}),
);
expect(result.id).toBe('1');
});
it('should throw ConflictError for duplicate barcode', async () => {
mockRepo.findByBarcode.mockResolvedValue({ id: 'existing' });
await expect(service.create('hh1', 'user1', { ...dto, barcode: '123456' })).rejects.toThrow(
ConflictError,
);
});
});
});
```
### Route test pattern (using Fastify inject)
```typescript
import { describe, it, expect } from 'vitest';
import Fastify from 'fastify';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import productRoutes from './products.routes.js';
describe('Products Routes', () => {
async function buildTestApp() {
const app = Fastify({ logger: false });
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// Register mocked auth/DI as needed
await app.register(productRoutes);
return app;
}
it('GET /api/v1/products returns 200', async () => {
const app = await buildTestApp();
const response = await app.inject({
method: 'GET',
url: '/api/v1/products',
headers: {
authorization: 'Bearer <test-jwt>',
'x-household-id': 'test-household-id',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toHaveProperty('items');
});
});
```
## Integration Testing (Fastify API)
### Use `mongodb-memory-server` for real MongoDB
```typescript
import { MongoMemoryServer } from 'mongodb-memory-server';
import mongoose from 'mongoose';
import Fastify from 'fastify';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { fastifyAwilixPlugin } from '@fastify/awilix';
describe('ProductsModule Integration', () => {
let app: ReturnType<typeof Fastify>;
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
const uri = mongod.getUri();
app = Fastify({ logger: false });
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(fastifyAwilixPlugin, { disposeOnClose: true, disposeOnResponse: true, strictBooleanEnforced: true });
// Connect mongoose to in-memory MongoDB
await mongoose.connect(uri);
// Register route modules (with test auth mock)
await app.register(productRoutes);
return { app, mongod };
}
afterAll(async () => {
await app.close();
await mongoose.disconnect();
await mongod.stop();
});
it('POST /api/v1/products → creates and returns product', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/products',
payload: {
name: 'Chicken Breast',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
},
headers: {
authorization: 'Bearer <test-jwt>',
'x-household-id': 'test-hh',
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.name).toBe('Chicken Breast');
expect(body.id).toBeDefined();
});
it('GET /api/v1/products → returns paginated results', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/products',
headers: {
authorization: 'Bearer <test-jwt>',
'x-household-id': 'test-hh',
},
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toBeInstanceOf(Array);
expect(body.pagination).toHaveProperty('hasMore');
});
});
```
## Component Testing (Next.js Web)
### React Testing Library patterns
```typescript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from '../ProductCard';
const mockProduct = {
id: '1',
name: 'Chicken Breast',
category: 'meat',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
describe('ProductCard', () => {
it('displays product name and calories', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
expect(screen.getByText(/165 kcal/)).toBeInTheDocument();
});
it('calls onEdit when edit button clicked', async () => {
const onEdit = vi.fn();
render(<ProductCard product={mockProduct} onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(onEdit).toHaveBeenCalledWith('1');
});
});
```
### Query priority (from React Testing Library docs)
1. `getByRole` — accessible role + name (best)
2. `getByLabelText` — form fields
3. `getByPlaceholderText` — if no label
4. `getByText` — text content
5. `getByTestId` — last resort
### Avoid testing implementation details
```typescript
// Bad: testing internal state
expect(component.state.isOpen).toBe(true);
// Good: testing visible behavior
expect(screen.getByRole('dialog')).toBeVisible();
```
## API Mocking with MSW
### Setup MSW for web tests
```typescript
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('*/api/v1/products', () => {
return HttpResponse.json({
data: [
{ id: '1', name: 'Chicken', category: 'meat', nutrition: { calories: 165 } },
{ id: '2', name: 'Rice', category: 'grains', nutrition: { calories: 130 } },
],
pagination: { cursor: null, hasMore: false },
});
}),
http.post('*/api/v1/products', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
## E2E Testing with Playwright
### Configuration
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e/tests',
baseURL: 'http://localhost:3000',
webServer: [
{
command: 'docker compose up -d && npm run dev',
url: 'http://localhost:3000',
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
],
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
```
### Test pattern
```typescript
import { test, expect } from '@playwright/test';
test.describe('Product Library', () => {
test.beforeEach(async ({ page }) => {
// Login via Keycloak (use API to get token, set cookie)
await loginAsTestUser(page);
});
test('can create a new product', async ({ page }) => {
await page.goto('/products');
await page.click('button:has-text("Add Product")');
await page.fill('[name="name"]', 'Test Product');
await page.selectOption('[name="category"]', 'meat');
await page.fill('[name="servingSize"]', '100');
await page.fill('[name="nutrition.calories"]', '200');
await page.click('button:has-text("Save")');
await expect(page.getByText('Test Product')).toBeVisible();
});
test('can search products by name', async ({ page }) => {
await page.goto('/products');
await page.fill('[placeholder="Search products..."]', 'chicken');
await expect(page.getByText('Chicken Breast')).toBeVisible();
await expect(page.getByText('Rice')).not.toBeVisible();
});
});
```
## Shared Package Testing
### Test Zod schemas directly
```typescript
import { CreateProductSchema, NutritionInfoSchema } from '../validation';
describe('CreateProductSchema', () => {
it('accepts valid product input', () => {
const input = {
name: 'Chicken Breast',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
expect(CreateProductSchema.safeParse(input).success).toBe(true);
});
it('rejects negative calories', () => {
const input = {
name: 'Bad Product',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: -10, protein: 0, carbs: 0, fat: 0 },
};
const result = CreateProductSchema.safeParse(input);
expect(result.success).toBe(false);
});
it('trims whitespace from name', () => {
const input = {
name: ' Chicken Breast ',
category: 'meat',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
};
const result = CreateProductSchema.parse(input);
expect(result.name).toBe('Chicken Breast');
});
});
```
## Test Data Factories
### Create reusable test data builders
```typescript
// test/factories/product.factory.ts
import { faker } from '@faker-js/faker';
import { ProductCategory, ServingUnit } from '@meshitrack/shared';
export function buildProduct(overrides: Partial<Product> = {}): Product {
return {
id: faker.string.uuid(),
householdId: 'test-household-1',
name: faker.food.ingredient(),
brand: faker.company.name(),
category: faker.helpers.arrayElement(Object.values(ProductCategory)),
servingSize: faker.number.int({ min: 1, max: 500 }),
servingUnit: faker.helpers.arrayElement(Object.values(ServingUnit)),
nutrition: buildNutrition(),
tags: [],
createdBy: 'test-user-1',
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
export function buildNutrition(overrides: Partial<NutritionInfo> = {}): NutritionInfo {
return {
calories: faker.number.int({ min: 0, max: 900 }),
protein: faker.number.float({ min: 0, max: 60, fractionDigits: 1 }),
carbs: faker.number.float({ min: 0, max: 100, fractionDigits: 1 }),
fat: faker.number.float({ min: 0, max: 50, fractionDigits: 1 }),
...overrides,
};
}
```
## Coverage Targets
All packages enforce coverage thresholds via `vitest.config.ts`. CI will fail if coverage drops below these levels.
| Scope | Lines | Functions | Branches | Statements |
| ----------------- | ----- | --------- | -------- | ---------- |
| `packages/api` | 100% | 100% | 90% | 100% |
| `packages/shared` | 100% | 100% | 90% | 100% |
| `packages/web` | TBD | TBD | TBD | TBD |
### Coverage Provider
- **V8** (`@vitest/coverage-v8`) — native V8 engine coverage, fast, zero-config
- Reports: `text`, `lcov`, `json-summary`, `html`
- Reports directory: `./coverage` (gitignored)
### Excluding Code from Coverage
Use `/* v8 ignore start */` / `/* v8 ignore stop */` for code that cannot be unit-tested:
- Entry-point bootstrap blocks (`main.ts` top-level `if`)
- Mongoose schema defaults that only run at document creation
- Framework-internal error handler branches (Zod validation, response serialization)
### Best Practices
- Mark untestable lines with `/* v8 ignore */` comments explaining why
- Keep thresholds at 100% for lines/functions/statements — this forces new code to include tests
- Branch threshold at 90% accommodates config ternaries and null-coalescing guards
- Run `npm run test:cov` before merging to verify thresholds
## Running Tests
```bash
# All tests (via turbo)
npm run test
# Specific package
npm run test -w packages/api
# With coverage (specific package)
npm run test:cov -w packages/api
# All packages with coverage (via turbo)
npm run test:cov
# Watch mode (development)
npm run test -- --watch -w packages/api
```

View file

@ -0,0 +1,291 @@
# Turborepo Monorepo Best Practices — MeshiTrack
> Instruction file for managing the monorepo workspace.
## Workspace Structure
```
MeshiTrack/
├── packages/
│ ├── api/ # NestJS backend → @meshitrack/api
│ ├── web/ # Next.js frontend → @meshitrack/web
│ └── shared/ # Shared types & DTOs → @meshitrack/shared
├── docker/ # Docker configs (not a package)
├── docs/ # Documentation (not a package)
├── turbo.json # Turborepo pipeline config
├── package.json # Root workspace config
├── tsconfig.base.json
├── .eslintrc.js
├── .prettierrc
└── .gitignore
```
## Root package.json
```json
{
"name": "meshitrack",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck",
"clean": "turbo run clean"
},
"devDependencies": {
"turbo": "^2.x",
"typescript": "^5.x",
"eslint": "^9.x",
"prettier": "^3.x"
}
}
```
## turbo.json Configuration
```json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
}
}
}
```
### Key concepts
- **`dependsOn: ["^build"]`**: Before running a task in a package, first build all its dependencies. This ensures `shared` is built before `api` or `web` run.
- **`outputs`**: What Turborepo caches. If outputs haven't changed, Turborepo replays from cache.
- **`persistent: true`**: For long-running dev servers that shouldn't be cached.
- **`cache: false`**: Disables caching for tasks that should always run.
## Package Dependencies
### Shared package is the foundation
```
@meshitrack/shared@meshitrack/api
@meshitrack/web
```
Both `api` and `web` depend on `shared`, but never on each other.
### Reference shared in package.json
```json
// packages/api/package.json
{
"name": "@meshitrack/api",
"dependencies": {
"@meshitrack/shared": "workspace:*"
}
}
// packages/web/package.json
{
"name": "@meshitrack/web",
"dependencies": {
"@meshitrack/shared": "workspace:*"
}
}
```
### Import from shared
```typescript
// In packages/api or packages/web
import { Product, NutritionInfo, ProductCategory } from '@meshitrack/shared';
import { CreateProductSchema } from '@meshitrack/shared/validation';
```
## Shared Package Setup
### packages/shared/package.json
```json
{
"name": "@meshitrack/shared",
"version": "0.0.1",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./validation": {
"types": "./dist/validation/index.d.ts",
"default": "./dist/validation/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.x"
},
"devDependencies": {
"typescript": "^5.x"
}
}
```
### packages/shared/tsconfig.json
```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"]
}
```
### Shared package must be platform-agnostic
Rules for code in `packages/shared`:
- **No** Node.js imports (`fs`, `path`, `http`, etc.)
- **No** NestJS decorators or imports
- **No** Next.js imports
- **No** DOM/browser APIs
- Only pure TypeScript: types, interfaces, enums, Zod schemas, utility functions
## TypeScript Configuration
### Root tsconfig.base.json
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"incremental": true
},
"exclude": ["node_modules", "dist"]
}
```
Each package extends this and overrides as needed (e.g., `web` uses `"jsx": "preserve"`, `"module": "esnext"`).
## Development Workflow
### Start all packages in dev mode
```bash
npm run dev
# Turborepo runs: shared (build) → then api (dev) + web (dev) in parallel
```
### Build all packages
```bash
npm run build
# Turborepo builds shared first, then api and web in parallel
```
### Run tasks for a specific package
```bash
npx turbo run dev --filter=@meshitrack/api
npx turbo run test --filter=@meshitrack/web
```
### Add a dependency to a specific package
```bash
cd packages/api
npm install @nestjs/schedule
# Or from root:
npm install @nestjs/schedule --workspace=packages/api
```
## Caching
### Turborepo remote caching (optional)
For CI, consider enabling remote caching to share build cache across machines:
```bash
npx turbo login
npx turbo link
```
Or use a self-hosted cache server for full self-hosted setup.
### Local caching
Turborepo caches locally in `node_modules/.cache/turbo` by default. It's fast and requires no setup.
## CI Integration
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx turbo run build lint test typecheck
```
## Common Pitfalls
1. **Forgetting to build shared**: If `api` or `web` can't find types from `shared`, run `npm run build` from root. The `dependsOn: ["^build"]` config handles this in Turborepo tasks.
2. **Circular dependencies**: Never import from `api` in `web` or vice versa. Only import from `shared`.
3. **Version mismatches**: Keep TypeScript versions aligned across all packages. Pin in root `devDependencies`.
4. **Large `node_modules`**: Use `npm` workspaces hoisting. Most dependencies are installed at root level. Only package-specific versions go in package-level `node_modules`.

View file

@ -0,0 +1,360 @@
# TypeScript & Zod Best Practices — MeshiTrack
> Instruction file for TypeScript configuration, shared types, and Zod validation schemas in the monorepo.
## TypeScript Configuration
### Strict mode everywhere
All packages use `strict: true` (via `tsconfig.base.json`). This enables:
- `strictNullChecks` — forces handling of `null`/`undefined`
- `noImplicitAny` — requires explicit types when inference fails
- `strictPropertyInitialization` — ensures class properties are initialized
### Project-specific overrides
```json
// packages/api/tsconfig.json — inherits ESM from base
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "ES2022"
}
}
// packages/web/tsconfig.json — bundler module resolution for Next.js
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": false,
"jsx": "preserve",
"noEmit": true,
"paths": {
"@/*": ["./src/*"],
"@meshitrack/shared": ["../shared/src"]
}
}
}
// packages/shared/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true
}
}
```
### ESM-first module system
All packages use `"type": "module"` and `"module": "nodenext"` (from base). Key rules:
- **Always use `.js` extensions** on relative imports (TypeScript resolves `.ts` from `.js` in nodenext)
- **Use `import type` for type-only imports** (`verbatimModuleSyntax: true` enforces this)
- **No `require()`** — use `import` exclusively
- **No `esModuleInterop`** — use namespace imports for CJS packages if needed
## Type Design Principles
### 1. Types represent domain concepts
```typescript
// Good: clearly represents the domain
export interface Product {
id: string;
householdId: string;
name: string;
nutrition: NutritionInfo;
}
// Bad: generic/vague naming
export interface Item {
id: string;
hId: string;
n: string;
data: any;
}
```
### 2. Use enums for fixed sets of values
```typescript
export enum ProductCategory {
DAIRY = 'dairy',
MEAT = 'meat',
VEGETABLES = 'vegetables',
// ...
}
// Use string values for readability in DB and API responses
```
### 3. Use discriminated unions for status-dependent data
```typescript
export type PantryItemState =
| { status: 'sealed'; purchaseDate: Date }
| { status: 'opened'; purchaseDate: Date; openedDate: Date }
| { status: 'prepared'; purchaseDate: Date; openedDate: Date; preparedDate: Date }
| { status: 'consumed'; consumedDate: Date }
| { status: 'discarded'; discardedDate: Date; reason?: string };
```
### 4. Use `Pick`, `Omit`, `Partial` for derived types
```typescript
// Create DTO from entity
export type CreateProductInput = Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'createdBy'>;
export type UpdateProductInput = Partial<CreateProductInput>;
// API response (without internal fields)
export type ProductResponse = Omit<Product, 'deletedAt'>;
```
### 5. Use branded types for IDs (optional but recommended)
```typescript
// Prevents accidentally passing a ProductId where a HouseholdId is expected
declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };
export type ProductId = Brand<string, 'ProductId'>;
export type HouseholdId = Brand<string, 'HouseholdId'>;
export type UserId = Brand<string, 'UserId'>;
```
### 6. Never use `any` — use `unknown` if the type is truly unknown
```typescript
// Bad
function parse(data: any): Product { ... }
// Good
function parse(data: unknown): Product {
// Validate/narrow first
const validated = ProductSchema.parse(data);
return validated;
}
```
## Shared Package Organization
```
packages/shared/src/
├── index.ts # Re-exports everything
├── types/
│ ├── index.ts
│ ├── product.ts # Product, NutritionInfo
│ ├── recipe.ts # Recipe, RecipeIngredient, RecipeStep
│ ├── pantry.ts # PantryItem, FreshnessEstimate
│ ├── meal-plan.ts # MealPlan, PlannedMeal
│ ├── shopping-list.ts # ShoppingList, ShoppingItem
│ ├── store.ts # Store
│ ├── price.ts # PriceRecord
│ ├── user.ts # User, Household
│ ├── freshness.ts # FreshnessRule
│ └── common.ts # PaginatedResponse, ApiError
├── enums/
│ ├── index.ts
│ ├── product.enums.ts # ProductCategory, ServingUnit, ProductSource
│ ├── pantry.enums.ts # StorageLocation, ItemStatus, FreshnessUrgency
│ ├── recipe.enums.ts # NutritionWarning
│ ├── meal-plan.enums.ts # MealType, MealPlanStatus
│ └── roles.enums.ts # HouseholdRole
├── validation/
│ ├── index.ts
│ ├── product.schemas.ts
│ ├── recipe.schemas.ts
│ ├── pantry.schemas.ts
│ └── ...
└── utils/
├── index.ts
├── unit-conversion.ts # Serving unit conversions
└── nutrition.ts # Nutrition calculation helpers
```
## Zod Validation Schemas
### Co-locate schemas with types
Each type file has a corresponding validation file:
```typescript
// validation/product.schemas.ts
import { z } from 'zod/v4';
import { ProductCategory, ServingUnit, ProductSource } from '../enums/index.js';
// Nutrition info sub-schema
export const NutritionInfoSchema = z.object({
calories: z.number().nonnegative(),
protein: z.number().nonnegative(),
carbs: z.number().nonnegative(),
fat: z.number().nonnegative(),
fiber: z.number().nonnegative().optional(),
sugar: z.number().nonnegative().optional(),
sodium: z.number().nonnegative().optional(),
saturatedFat: z.number().nonnegative().optional(),
cholesterol: z.number().nonnegative().optional(),
});
// Create product schema
export const CreateProductSchema = z.object({
name: z.string().min(1).max(200).trim(),
brand: z.string().max(200).trim().optional(),
barcode: z.string().max(50).optional(),
category: z.enum(ProductCategory),
servingSize: z.number().positive(),
servingUnit: z.enum(ServingUnit),
nutrition: NutritionInfoSchema,
tags: z.array(z.string().max(50)).max(20).default([]),
imageUrl: z.url().optional(),
});
// Update product schema (all fields optional)
export const UpdateProductSchema = CreateProductSchema.partial();
// Query params schema
export const ProductQuerySchema = z.object({
q: z.string().optional(),
category: z.enum(ProductCategory).optional(),
tags: z.string().optional(), // Comma-separated
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.string().optional(),
});
// Infer TypeScript types from Zod schemas
export type CreateProductInput = z.infer<typeof CreateProductSchema>;
export type UpdateProductInput = z.infer<typeof UpdateProductSchema>;
export type ProductQuery = z.infer<typeof ProductQuerySchema>;
```
### Schema design rules
1. **Always `trim()` strings** — prevents " Chicken " vs "Chicken" issues
2. **Set reasonable `max()` lengths** — prevents abuse
3. **Use `nonnegative()` for nutrition values** — calories can't be negative
4. **Use `z.coerce.number()`** for query params — they arrive as strings
5. **Always set `.default()` for optional arrays** — prevents `undefined` issues
6. **Use `z.enum()`** for TypeScript enums (Zod v4 unified `z.enum` handles both string arrays and TS enums)
7. **Use `z.email()`, `z.url()`, `z.uuid()`** as top-level validators (Zod v4 style)
### Using Zod schemas in Fastify
The `fastify-type-provider-zod` plugin auto-validates request schemas:
```typescript
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { CreateProductSchema, type CreateProductInput } from '@meshitrack/shared';
const app = fastify.withTypeProvider<ZodTypeProvider>();
app.route({
method: 'POST',
url: '/api/v1/products',
schema: {
body: CreateProductSchema,
response: { 201: ProductResponseSchema },
},
handler: async (request, reply) => {
// request.body is fully typed as CreateProductInput
const product = await service.create(request.householdId, request.body);
return reply.status(201).send(product);
},
});
```
Validation errors are automatically caught by the global error handler.
### Using Zod schemas in Next.js
```typescript
// Form validation with react-hook-form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateProductSchema, type CreateProductInput } from '@meshitrack/shared';
const form = useForm<CreateProductInput>({
resolver: zodResolver(CreateProductSchema),
});
```
## Utility Types for API Responses
```typescript
// types/common.ts
export interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null;
hasMore: boolean;
total?: number;
};
}
export interface ApiError {
statusCode: number;
error: string;
message: string;
details?: Record<string, string[]>;
timestamp: string;
path: string;
}
export interface ApiSuccess<T> {
data: T;
message?: string;
}
```
## Null vs Undefined Convention
- **`undefined`**: field is not provided / not applicable (use in DTO inputs)
- **`null`**: field is explicitly empty / cleared (use in database documents)
- **In Zod**: use `.optional()` for undefined, `.nullable()` for null, `.nullish()` for both
```typescript
// Input: optional means "not provided"
brand: z.string().optional(); // string | undefined
// Database: null means "explicitly cleared"
brand: z.string().nullable(); // string | null
// API response: could be either
brand: z.string().nullish(); // string | null | undefined
```
## Import/Export Convention
### Barrel exports in each directory
```typescript
// types/index.ts — use .js extensions for ESM
export * from './product.js';
export * from './recipe.js';
export * from './pantry.js';
// ...
// Root index.ts
export * from './types/index.js';
export * from './enums/index.js';
export {} from /* specific schemas */ './validation/index.js';
```
### Use `import type` for types-only
```typescript
// When importing only types, use `import type` (required by verbatimModuleSyntax)
import type { Product, NutritionInfo } from '@meshitrack/shared';
// When importing values (enums, schemas, functions), use regular import
import { ProductCategory, CreateProductSchema } from '@meshitrack/shared';
```

View file

@ -0,0 +1,222 @@
# Phase 0 — Foundation & Infrastructure
**Goal**: Repo scaffolding, Docker environment, and authentication. After this phase, a developer can clone the repo, run `docker compose up`, and see a working authenticated "Hello World" page.
---
## Deliverables
1. Monorepo initialized with Turborepo
2. NestJS API with health endpoint
3. Next.js web app with landing page
4. Shared types package building and importable
5. Docker Compose with all services running
6. Keycloak configured with realm, roles, and test users
7. Auth guard protecting API routes
8. User and Household schemas with basic CRUD
9. Dev seed script
10. Linting and formatting
---
## Tasks
### 0.1 — Monorepo Setup
- Initialize root `package.json` with workspaces: `packages/*`
- Add Turborepo config (`turbo.json`) with pipelines: `build`, `dev`, `lint`, `test`
- Create three packages:
- `packages/shared` — TypeScript library, compiled with `tsc`
- `packages/api` — NestJS app (`@nestjs/cli` scaffold)
- `packages/web` — Next.js app (`create-next-app` with App Router, TypeScript, Tailwind CSS)
- Root `tsconfig.base.json` with strict settings, extended by each package
- Configure package references so `api` and `web` depend on `shared`
### 0.2 — Shared Types (Initial)
Define in `packages/shared/src/`:
```typescript
// enums/roles.ts
export enum HouseholdRole {
OWNER = 'owner',
ADMIN = 'admin',
MEMBER = 'member',
}
// types/user.ts
export interface User {
id: string;
keycloakId: string;
displayName: string;
email: string;
householdIds: string[];
defaultHouseholdId: string;
createdAt: Date;
updatedAt: Date;
}
// types/household.ts
export interface Household {
id: string;
name: string;
ownerUserId: string;
members: HouseholdMember[];
inviteCode: string;
settings: HouseholdSettings;
createdAt: Date;
updatedAt: Date;
}
export interface HouseholdMember {
userId: string;
role: HouseholdRole;
joinedAt: Date;
}
export interface HouseholdSettings {
timezone: string;
currency: string;
language: string;
}
```
- Add Zod validation schemas for create/update DTOs
- Export everything from `index.ts`
### 0.3 — Docker Compose
Create `docker/docker-compose.yml`:
```yaml
services:
mongodb:
image: mongo:7
ports: ['27017:27017']
volumes: [mongo-data:/data/db]
environment:
MONGO_INITDB_ROOT_USERNAME: meshitrack
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
keycloak:
image: quay.io/keycloak/keycloak:24.0
ports: ['8080:8080']
environment:
KC_DB: dev-mem # Dev mode, in-memory DB
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
command: start-dev --import-realm
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
ports: ['3001:3001']
depends_on: [mongodb, keycloak]
environment:
MONGODB_URI: mongodb://meshitrack:${MONGO_PASSWORD}@mongodb:27017/meshitrack?authSource=admin
KEYCLOAK_URL: http://keycloak:8080
KEYCLOAK_REALM: meshitrack
KEYCLOAK_CLIENT_ID: meshitrack-api
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
ports: ['3000:3000']
depends_on: [api]
environment:
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
NEXT_PUBLIC_KEYCLOAK_URL: http://localhost:8080
NEXT_PUBLIC_KEYCLOAK_REALM: meshitrack
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: meshitrack-web
mongo-express:
image: mongo-express
ports: ['8081:8081']
depends_on: [mongodb]
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: meshitrack
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASSWORD}
ME_CONFIG_MONGODB_URL: mongodb://meshitrack:${MONGO_PASSWORD}@mongodb:27017/
profiles: [dev]
volumes:
mongo-data:
```
- Create `.env.example` with all required variables
- Create `Dockerfile.api` and `Dockerfile.web` (multi-stage builds)
### 0.4 — Keycloak Configuration
- Export a Keycloak realm JSON (`docker/keycloak/realm-export.json`) with:
- Realm: `meshitrack`
- Client: `meshitrack-web` (public, PKCE) for frontend
- Client: `meshitrack-api` (bearer-only) for backend validation
- Realm roles: `admin`, `member`
- Custom protocol mapper: `household-mapper` that maps user attribute `householdIds` to JWT claim
- Test users: `testuser1` / `testuser2` with passwords
### 0.5 — NestJS API Bootstrap
- `packages/api/src/main.ts`: bootstrap NestJS with:
- Global prefix `/api/v1`
- CORS configured for `http://localhost:3000`
- Swagger/OpenAPI docs at `/api/docs`
- Validation pipe (class-validator + class-transformer)
- Modules:
- `AuthModule`: Keycloak strategy (`passport-openidconnect` or `nest-keycloak-connect`), `@AuthGuard` decorator, extract user + householdId from JWT
- `UsersModule`: `User` Mongoose schema, sync user on first login (upsert from Keycloak token)
- `HouseholdsModule`: `Household` Mongoose schema, CRUD endpoints
- `POST /households` — create (creator becomes owner)
- `GET /households/:householdId` — get (members only)
- `POST /households/:householdId/invite` — generate invite code
- `POST /households/join` — join via invite code
- `PATCH /households/:householdId` — update settings (admin/owner)
- `HealthModule`: `GET /api/v1/health` — returns `{ status: 'ok', version, uptime }`
- Common:
- `HouseholdPlugin`: Fastify preHandler hook that reads `:householdId` from the URI, validates it against the user's `householdIds[]` JWT claim, and injects it into `request.householdId`
- `CurrentUser` param decorator
- `CurrentHousehold` param decorator
- Global exception filter with consistent error response shape
### 0.6 — Next.js Web Bootstrap
- Configure `next-auth` or `keycloak-js` for OIDC login flow
- Pages:
- `/login` — redirects to Keycloak
- `/` — dashboard (protected, shows "Welcome, {name}" + household selector)
- `/settings` — household management (create, invite, switch)
- Layout: navigation sidebar (placeholder links for future phases), top bar with user avatar + household switcher
- API client service (`packages/web/src/services/api-client.ts`): fetch wrapper that attaches the JWT `Authorization` header; household context is passed via URL (e.g. `/api/v1/households/:householdId/products`)
### 0.7 — Dev Seed Script
- `packages/api/src/scripts/seed.ts`:
- Create 2 test users (matching Keycloak test users)
- Create 1 household with both users as members
- Log credentials and household ID to console
---
## Acceptance Criteria
- [ ] `docker compose up` starts all services without errors
- [ ] Navigating to `http://localhost:3000` redirects to Keycloak login
- [ ] After login, dashboard shows user name and household
- [ ] `GET /api/v1/health` returns 200
- [ ] `GET /api/v1/households/:id` returns 401 without token, 200 with valid token
- [ ] `packages/shared` types are importable from both `api` and `web`
---
## Dependencies
None — this is the foundation phase.
## Estimated Effort
Medium-large. Mostly boilerplate and configuration, but Keycloak setup requires careful attention.

View file

@ -0,0 +1,214 @@
# 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.
**Depends on**: Phase 0 (auth, households, shared types)
---
## Deliverables
1. `Medicine` MongoDB schema and full CRUD API
2. Full-text search with filters
3. Barcode lookup (future: integration with drug database APIs)
4. Bulk import (CSV/JSON)
5. Medicine library web UI (search, add, edit)
---
## Data Model
### Medicine Schema
```typescript
// packages/shared/src/types/medicine.ts
export interface Medicine {
id: string;
householdId: string;
name: string;
genericName?: string;
brand?: string;
barcode?: string;
form: MedicineForm;
strength: number;
strengthUnit: StrengthUnit;
category: MedicineCategory;
activeIngredient?: string;
manufacturer?: string;
notes?: string;
imageUrl?: string;
tags: string[];
source: MedicineSource;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export enum MedicineForm {
TABLET = 'tablet',
CAPSULE = 'capsule',
LIQUID = 'liquid',
CREAM = 'cream',
INJECTION = 'injection',
INHALER = 'inhaler',
PATCH = 'patch',
DROPS = 'drops',
POWDER = 'powder',
SUPPOSITORY = 'suppository',
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',
VITAMIN = 'vitamin',
HERBAL = 'herbal',
OTHER = 'other',
}
export enum MedicineSource {
MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
IMPORT = 'import',
}
```
### MongoDB Indexes
```javascript
// Text index for search
{ name: 'text', genericName: 'text', brand: 'text', activeIngredient: 'text', tags: 'text' }
// Compound indexes
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 } // unique within household
{ householdId: 1, name: 1, strength: 1, form: 1 } // near-unique for dedup
```
---
## API Endpoints
### MedicinesModule
| Method | Path | Description | Auth |
| ------ | -------------------------- | -------------------------------- | ------ |
| GET | `/medicines` | List/search medicines (paginated)| member |
| GET | `/medicines/:id` | Get single medicine | member |
| POST | `/medicines` | Create medicine | member |
| PATCH | `/medicines/:id` | Update medicine | member |
| DELETE | `/medicines/:id` | Soft-delete medicine | admin |
| GET | `/medicines/barcode/:code` | Lookup by barcode | member |
| POST | `/medicines/import` | Bulk import from CSV/JSON | admin |
### Query Parameters for GET `/medicines`
```
?q=metformin # Full-text search
&category=prescription # Filter by category
&form=tablet # Filter by form
&tags=daily,morning # Filter by tags (AND)
&cursor=abc123 # Cursor-based pagination
&limit=20 # Page size (max 100)
&sort=name|-updatedAt # Sort field, prefix - for desc
```
### Response Shape
```typescript
interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null; // null = last page
hasMore: boolean;
total: number;
};
}
```
---
## Tasks
### 1.1 — Shared Types & Validation
- Add all types above to `packages/shared/src/types/medicine.ts`
- Add enums to `packages/shared/src/enums/`
- Create Zod schemas:
- `CreateMedicineSchema` — validates create payload
- `UpdateMedicineSchema` — partial, validates update payload
- `MedicineQuerySchema` — validates query params
### 1.2 — Mongoose Schema & Repository
- `packages/api/src/modules/medicines/medicines.repository.ts`
- `MedicinesRepository` with:
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination
- `findByBarcode(householdId, barcode)`
- `findById(id, householdId)`
- `create(data)`
- `update(id, householdId, data)`
- `softDelete(id, householdId)`
- `bulkCreate(items[])`
### 1.3 — Service & Routes
- `MedicinesService` with business logic (dedup check on create, validation)
- `MedicinesRoutes` with Fastify route plugin registering all endpoints
- Register Awilix dependencies via `fp()` plugin
### 1.4 — Barcode Lookup
- `BarcodeLookupService`:
- First check local DB for matching barcode
- Placeholder for external drug database API integration (manual entry fallback)
- Cache results in local DB with `source: 'barcode_lookup'`
### 1.5 — Import Endpoint
- `POST /medicines/import` accepts multipart CSV or JSON file
- Validate each row against `CreateMedicineSchema`
- Return summary: `{ imported: N, skipped: M, errors: [...] }`
- CSV column mapping: `name, genericName, brand, barcode, form, strength, strengthUnit, category, activeIngredient, manufacturer`
### 1.6 — Web UI: Medicine Library
- `/medicines` page:
- Search bar with debounced full-text search
- Category and form filter dropdowns
- Tag filter chips
- Medicine grid/list view (toggle)
- Each medicine card shows: name, strength + unit, form, brand, category badge
- Add/Edit medicine modal:
- Form fields for all medicine properties
- Barcode field with "Lookup" button
- Import dialog: file upload with preview and error display
---
## Acceptance Criteria
- [ ] Can create, read, update, delete medicines via API
- [ ] Full-text search returns relevant results
- [ ] Barcode lookup checks local DB first
- [ ] Bulk import processes a CSV with 100+ medicines
- [ ] Web UI allows searching, filtering, adding, and editing medicines
- [ ] All medicine queries are scoped to `householdId`
- [ ] Dedup check prevents creating duplicate medicines (same name + strength + form)
---
## Estimated Effort
Medium. Straightforward CRUD with search, following the same patterns established in Phase 0.

315
docs/phases/phase-10-llm.md Normal file
View file

@ -0,0 +1,315 @@
# Phase 10 — LLM Integration & Smart Features
**Goal**: Wire up the `ILlmProvider` interface (defined in Phase 5) to real LLM backends. Enable all the smart features that have had placeholder endpoints throughout Phases 5-9. Add new natural language interaction capabilities for both medicine and food domains.
**Depends on**: All previous phases (feature endpoints already exist)
---
## Deliverables
1. Concrete `ILlmProvider` implementations (OpenAI, Anthropic, Ollama)
2. Config-driven provider selection
3. LLM request/response logging and cost tracking
4. Rate limiting and budget controls
5. Smart features fully wired:
- Product recognition (photo → nutrition)
- Receipt parsing (photo → store + items + prices)
- Recipe import (text/URL → structured recipe)
- Natural language pantry entry
- LLM-powered meal plan suggestions
6. Prompt templates and versioning
---
## Architecture
### Provider Selection
```
Environment variable: LLM_PROVIDER_TYPE=openai|anthropic|ollama|noop
LlmModule registers the provider dynamically:
@Module({})
export class LlmModule {
static forRoot(): DynamicModule {
return {
providers: [{
provide: LLM_PROVIDER,
useFactory: (config: ConfigService) => {
switch (config.get('LLM_PROVIDER_TYPE')) {
case 'openai': return new OpenAiProvider(config);
case 'anthropic': return new AnthropicProvider(config);
case 'ollama': return new OllamaProvider(config);
default: return new NoOpLlmProvider();
}
},
inject: [ConfigService],
}],
exports: [LLM_PROVIDER],
};
}
}
```
### Provider Implementations
Each provider implements `ILlmProvider` and handles:
- API authentication (keys from env)
- Model selection (configurable per provider)
- Request/response mapping to/from vendor format
- Error handling and retries (exponential backoff)
- Timeout management
```typescript
// packages/api/src/modules/llm/providers/
├── noop.provider.ts # Returns null for everything (already exists from Phase 5)
├── openai.provider.ts # GPT-4o / GPT-4o-mini
├── anthropic.provider.ts # Claude 3.5 Sonnet / Haiku
└── ollama.provider.ts # Local models (Llama 3, Mistral, etc.)
```
### Environment Configuration
```env
# Provider selection
LLM_PROVIDER_TYPE=openai
# OpenAI
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o
OPENAI_VISION_MODEL=gpt-4o # For image inputs
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Ollama
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3
OLLAMA_VISION_MODEL=llava
# Budget
LLM_MONTHLY_BUDGET_USD=20.00
LLM_RATE_LIMIT_RPM=30 # Requests per minute per household
```
---
## Tasks
### 10.1 — OpenAI Provider
```typescript
class OpenAiProvider implements ILlmProvider {
// Uses OpenAI Node.js SDK
// Text endpoints: chat completions with JSON mode
// Vision endpoints: chat completions with image_url or base64 content
// Structured output: use function calling or response_format: json_schema
}
```
- `extractNutrition`: Send product text/image → prompt asks for structured nutrition JSON
- `parseRecipe`: Send recipe text → prompt extracts name, servings, ingredients[], steps[]
- `parseRecipeFromUrl`: Fetch URL content first, then parse as text
- `parseReceipt`: Send receipt image → prompt extracts store, date, line items with prices
- `suggestMealPlan`: Send pantry summary + targets + preferences → get 7-day plan
- `parseNaturalLanguage`: Send user text → extract intent + entities (add product, log purchase, etc.)
### 10.2 — Anthropic Provider
```typescript
class AnthropicProvider implements ILlmProvider {
// Uses Anthropic SDK
// Similar structure to OpenAI but with Claude-specific API format
// Vision: send image as base64 in messages
// Structured output: use tool_use for JSON extraction
}
```
### 10.3 — Ollama Provider
```typescript
class OllamaProvider implements ILlmProvider {
// Uses Ollama REST API (http://host:11434/api/generate or /api/chat)
// Text: standard chat endpoint
// Vision: requires multimodal model (llava, bakllava)
// Note: local models may be less accurate — adjust prompts for simpler output
// No cost tracking needed (self-hosted)
}
```
### 10.4 — Prompt Templates
Create versioned prompt templates in `packages/api/src/modules/llm/prompts/`:
```typescript
// prompts/extract-nutrition.ts
export const EXTRACT_NUTRITION_PROMPT = {
version: '1.0',
system: `You are a nutrition data extraction assistant. Given a food product description or image, extract nutritional information. Return ONLY valid JSON matching the schema below. If you cannot determine a value, use null. Be conservative with estimates.`,
schema: {
name: 'string',
brand: 'string | null',
servingSize: 'number',
servingUnit: 'g | ml | oz | piece',
nutrition: {
calories: 'number',
protein: 'number (grams)',
carbs: 'number (grams)',
fat: 'number (grams)',
fiber: 'number | null',
sugar: 'number | null',
sodium: 'number | null (mg)',
},
},
};
// prompts/parse-recipe.ts
// prompts/parse-receipt.ts
// prompts/suggest-meal-plan.ts
// prompts/parse-natural-language.ts
```
- Each prompt has a `version` for tracking which prompt produced which results
- Prompts are provider-agnostic (providers may wrap them differently)
### 10.5 — LLM Logging & Cost Tracking
```typescript
// Schema
export interface LlmLog {
id: string;
householdId: string;
userId: string;
provider: string; // 'openai' | 'anthropic' | 'ollama'
model: string;
feature: string; // 'extract_nutrition' | 'parse_recipe' | etc.
promptVersion: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number; // Computed from token count + model pricing
latencyMs: number;
success: boolean;
errorMessage?: string;
createdAt: Date;
}
```
- All provider calls are wrapped in a logging decorator/interceptor
- Cost computed from known per-model pricing (configurable)
- Monthly cost aggregation endpoint for household admins
### 10.6 — Rate Limiting & Budget Controls
```typescript
class LlmBudgetGuard {
/**
* Before each LLM call:
* 1. Check per-household rate limit (requests per minute)
* 2. Check monthly budget: sum costUsd for current month vs LLM_MONTHLY_BUDGET_USD
* 3. If exceeded, throw BudgetExceededException (HTTP 429)
*/
async canProceed(householdId: string): Promise<boolean>;
}
```
- Rate limiting: Redis-backed or in-memory (household-level RPM)
- Budget: MongoDB aggregation on `LlmLog` collection
### 10.7 — Wire Up All Feature Endpoints
Each of these endpoints already exists as a placeholder from earlier phases. Now they get real LLM calls:
| Feature | Endpoint (existing) | Phase | LLM Method |
| --------------------- | ----------------------------------- | ----- | ---------------------- |
| Product recognition | `POST /products/smart-add` | 5 | `extractNutrition()` |
| Recipe import (text) | `POST /recipes/import-text` | 6 | `parseRecipe()` |
| Recipe import (URL) | `POST /recipes/import-url` | 6 | `parseRecipeFromUrl()` |
| Receipt parsing | `POST /prices/parse-receipt` | 9 | `parseReceipt()` |
| Meal plan suggestions | `POST /meal-plans/suggest-with-llm` | 8 | `suggestMealPlan()` |
### 10.8 — Natural Language Input (New Feature)
New universal endpoint:
```
POST /api/v1/nlp/parse
Body: { text: string }
Response: { intent: string, action: StructuredAction, confidence: number }
```
Supported intents:
- `add_pantry_item`: "I bought 2 lbs of chicken at Costco for $12" → create pantry item + price record
- `add_product`: "Add whole milk, 240ml serving, 150 cal, 8g protein, 12g carbs, 8g fat" → create product
- `check_expiry`: "What's expiring this week?" → redirect to pantry query
- `find_recipe`: "What can I make with chicken and rice?" → trigger suggestion engine
- `add_to_list`: "Add eggs and butter to my shopping list" → add items to active list
Each recognized intent maps to an existing API operation, executed automatically or returned as a confirmation prompt.
### 10.9 — Web UI: LLM Settings & Features
- `/settings/llm` page (admin only):
- Provider selection display
- Monthly cost usage bar
- Rate limit configuration
- LLM log viewer (recent calls, success/failure, latency, cost)
- Enhance existing UI with LLM-powered features:
- Product add modal: "Smart Add" tab with camera/text → LLM pre-fill
- Recipe page: "Import from text" and "Import from URL" now functional
- Shopping list: "Scan receipt" button with camera
- Dashboard: natural language input bar ("What should I cook tonight?")
### 10.10 — Docker: Ollama Service (Optional)
If user wants local LLM, add to Docker Compose:
```yaml
ollama:
image: ollama/ollama
ports: ['11434:11434']
volumes: [ollama-models:/root/.ollama]
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
profiles: [llm-local]
```
---
## Acceptance Criteria
- [ ] Can switch LLM provider via environment variable
- [ ] OpenAI provider successfully extracts nutrition from product photo
- [ ] Recipe import from text returns a valid structured recipe
- [ ] Receipt parsing extracts store, items, and prices from receipt image
- [ ] All LLM calls are logged with token counts and cost
- [ ] Rate limiting prevents exceeding configured RPM
- [ ] Monthly budget guard blocks calls when budget is exceeded
- [ ] Natural language input correctly identifies intents and executes actions
- [ ] NoOp provider still works gracefully when no LLM is configured
- [ ] LLM settings page shows usage and cost statistics
---
## Estimated Effort
Large. Multiple provider implementations, prompt engineering, testing across different models, cost tracking infrastructure, and NLP intent parsing are all significant.
---
## Notes
- Prompt engineering is iterative — expect to refine prompts based on real-world testing
- Different providers/models will have varying accuracy — consider model-specific prompt tuning
- Local models (Ollama) will be less accurate but free — document quality trade-offs
- Consider caching LLM results for identical inputs (e.g., same barcode photo → same product)

View file

@ -0,0 +1,252 @@
# 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.
**Depends on**: Phase 0, Phase 1 (medicines)
---
## Deliverables
1. `CabinetItem` MongoDB schema and full CRUD API
2. Aggregate quantity view per medicine
3. Expiry date tracking and warnings
4. Low stock alerts (based on configurable thresholds)
5. Medicine cabinet web UI with status indicators
---
## Data Model
### CabinetItem Schema
```typescript
// packages/shared/src/types/cabinet.ts
export interface CabinetItem {
id: string;
householdId: string;
medicineId: string;
medicineName: string; // Denormalized
medicineStrength: number; // Denormalized for display
medicineStrengthUnit: StrengthUnit; // Denormalized
medicineForm: MedicineForm; // Denormalized
quantity: number;
unit: DosageUnit;
expirationDate?: Date;
lotNumber?: string;
purchaseDate?: Date;
purchasePrice?: number;
storeId?: string;
storeName?: string; // Denormalized
status: CabinetItemStatus;
notes?: string;
createdBy: string;
createdAt: 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 {
ACTIVE = 'active',
DEPLETED = 'depleted',
EXPIRED = 'expired',
DISCARDED = 'discarded',
}
```
### CabinetSummary (Computed, not stored)
```typescript
// Aggregate view — total per medicine across all cabinet items
export interface CabinetSummary {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
medicineForm: MedicineForm;
totalQuantity: number;
unit: DosageUnit;
earliestExpiry: Date | null;
itemCount: number; // How many cabinet items (bottles/boxes)
lowStockThreshold?: number; // From household settings
isLowStock: boolean;
}
```
### MongoDB Indexes
```javascript
{ householdId: 1, medicineId: 1, status: 1 }
{ householdId: 1, status: 1 }
{ householdId: 1, expirationDate: 1 } // For expiry warnings
{ householdId: 1, 'quantity': 1 }
```
---
## API Endpoints
### CabinetModule
| Method | Path | Description | Auth |
| ------ | --------------------------- | ----------------------------------------------- | ------ |
| GET | `/cabinet` | List cabinet items (filtered, paginated) | member |
| GET | `/cabinet/summary` | Aggregate quantities per medicine | member |
| GET | `/cabinet/:id` | Get single cabinet item | member |
| POST | `/cabinet` | Add item to cabinet | member |
| PATCH | `/cabinet/:id` | Update item (quantity, notes, etc.) | member |
| POST | `/cabinet/:id/adjust` | Adjust quantity (add/subtract without full edit) | member |
| DELETE | `/cabinet/:id` | Hard delete (admin) | admin |
| GET | `/cabinet/expiring-soon` | Items expiring within N days | member |
| GET | `/cabinet/low-stock` | Medicines below threshold quantity | member |
### Query Parameters for GET `/cabinet`
```
?medicineId=abc123 # Filter by medicine
&status=active # Filter by status
&expiringWithin=30 # Days until expiry
&sort=-expirationDate|name # Sort field
&cursor=abc123
&limit=20
```
### Adjust Quantity Request
```typescript
// POST /cabinet/:id/adjust
interface AdjustQuantityRequest {
delta: number; // Positive to add, negative to subtract
reason?: string; // e.g., "Correcting count", "Dropped a pill"
}
```
---
## Tasks
### 2.1 — Shared Types & Validation
- Add cabinet types to `packages/shared/src/types/cabinet.ts`
- Zod schemas:
- `CreateCabinetItemSchema`
- `UpdateCabinetItemSchema`
- `AdjustQuantitySchema`
- `CabinetQuerySchema`
### 2.2 — Mongoose Schema & Repository
- `packages/api/src/modules/cabinet/cabinet.repository.ts`
- `CabinetRepository` with:
- `findByHousehold(householdId, query)` — filtered, paginated
- `findById(id, householdId)`
- `findByMedicine(householdId, medicineId)` — all items for a medicine
- `getAggregateSummary(householdId)` — MongoDB aggregation pipeline
- `create(data)`
- `update(id, householdId, data)`
- `adjustQuantity(id, householdId, delta)` — atomic `$inc`
- `findExpiringSoon(householdId, withinDays)`
- `delete(id, householdId)`
### 2.3 — Cabinet Service
```typescript
class CabinetService {
/** Add item, denormalizing medicine fields */
addItem(data: CreateCabinetItem): Promise<CabinetItem>;
/** Adjust quantity with floor at 0, auto-set depleted status */
adjustQuantity(id: string, householdId: string, delta: number, reason?: string): Promise<CabinetItem>;
/** Get aggregate summary with low stock flags */
getSummary(householdId: string): Promise<CabinetSummary[]>;
/** Find items expiring within N days */
getExpiringSoon(householdId: string, withinDays: number): Promise<CabinetItem[]>;
/** Find medicines below low stock threshold */
getLowStock(householdId: string): Promise<CabinetSummary[]>;
/**
* Deduct quantity from cabinet items for a medicine (used by Pill Organizer in Phase 3).
* Uses FEFO (First Expiry, First Out) — draws from items with earliest expiry first.
* Returns actual quantity deducted (may be less than requested if insufficient).
*/
deductStock(householdId: string, medicineId: string, quantity: number): Promise<DeductionResult>;
/** Reverse a deduction (used by Pill Organizer undo) */
restoreStock(householdId: string, cabinetItemId: string, quantity: number): Promise<CabinetItem>;
}
interface DeductionResult {
totalDeducted: number;
requested: number;
isShort: boolean;
deductions: {
cabinetItemId: string;
quantityTaken: number;
remainingInItem: number;
}[];
}
```
### 2.4 — Expiry Check Job
- Scheduled job (daily at 6 AM, configurable):
1. Query all active cabinet items with `expirationDate <= today`
2. Update status to `expired`
3. Create in-app notifications for expired items
4. Query items expiring within 7 days, create warning notifications
### 2.5 — Web UI: Medicine Cabinet
- `/cabinet` page:
- **Summary view** (default): aggregated per medicine
- Medicine name, total quantity, earliest expiry, low stock indicator
- Expand to see individual items (bottles/boxes)
- **Detail view**: all individual cabinet items
- Each item shows: medicine name, quantity, expiry date, status badge
- Color-coded expiry: green (>30 days), yellow (7-30 days), red (<7 days), grey (expired)
- Low stock badge on medicines below threshold
- Quick actions: adjust quantity (+/-), discard
- "Add to Cabinet" button -> modal:
- Medicine autocomplete (from library)
- Quantity + unit
- Expiration date (optional)
- Lot number (optional)
- Purchase date, price, store (optional)
- `/cabinet/alerts` or notification panel:
- Expiring soon items
- Low stock warnings
---
## Acceptance Criteria
- [ ] Can add items to cabinet linked to medicines
- [ ] Aggregate summary shows total quantity per medicine
- [ ] Quantity adjustments are atomic and floor at 0
- [ ] Items auto-transition to `depleted` when quantity reaches 0
- [ ] Items auto-transition to `expired` when past expiration date
- [ ] Expiring-soon endpoint returns items within N days
- [ ] Low-stock endpoint compares against configurable thresholds
- [ ] FEFO deduction draws from earliest-expiring items first
- [ ] Web UI shows color-coded expiry indicators
- [ ] All cabinet queries are scoped to `householdId`
---
## Estimated Effort
Medium. CRUD with aggregation pipeline, FEFO logic, and scheduled expiry job. Simpler than food pantry tracking (no freshness estimation).

View file

@ -0,0 +1,354 @@
# Phase 3 — Regimens & Pill Organizer
**Goal**: Define daily medication schedules (regimens) and batch-dispense from the medicine cabinet into a pill organizer. This is the core convenience feature: instead of tracking individual pill consumption daily, users fill their organizer for N days in a single action.
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet)
---
## Deliverables
1. `Regimen` MongoDB schema and CRUD API
2. `OrganizerFill` schema and fill/undo API
3. Pill organizer fill flow with shortage detection
4. Burn rate calculation (days until empty per medicine)
5. Regimen and pill organizer web UI
---
## Data Model
### Regimen Schema
```typescript
// packages/shared/src/types/regimen.ts
export interface Regimen {
id: string;
householdId: string;
userId: string; // Regimens are per-person
name: string; // e.g., "Daily medications", "Morning routine"
isActive: boolean;
medications: RegimenMedication[];
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RegimenMedication {
medicineId: string;
medicineName: string; // Denormalized
medicineStrength: number; // Denormalized
medicineStrengthUnit: StrengthUnit; // Denormalized
medicineForm: MedicineForm; // Denormalized
dosage: number; // e.g., 2 (pills per dose)
dosageUnit: DosageUnit;
frequency: DosageFrequency;
customFrequencyPerDay?: number; // When frequency is 'custom'
timeOfDay?: TimeOfDay;
instructions?: string; // e.g., "Take with food", "Do not crush"
}
export enum DosageFrequency {
DAILY = 'daily', // 1x per day
TWICE_DAILY = 'twice_daily', // 2x per day
THREE_TIMES_DAILY = 'three_times_daily', // 3x per day
WEEKLY = 'weekly', // 1x per week
EVERY_OTHER_DAY = 'every_other_day',
AS_NEEDED = 'as_needed', // Excluded from organizer fill calculations
CUSTOM = 'custom', // Uses customFrequencyPerDay
}
export enum TimeOfDay {
MORNING = 'morning',
AFTERNOON = 'afternoon',
EVENING = 'evening',
BEDTIME = 'bedtime',
}
```
### OrganizerFill Schema
```typescript
// packages/shared/src/types/organizer-fill.ts
export interface OrganizerFill {
id: string;
householdId: string;
userId: string;
regimenId: string;
regimenName: string; // Denormalized
numberOfDays: number; // Flexible: 1, 6, 7, 14, etc.
fillDate: Date;
items: OrganizerFillItem[];
status: OrganizerFillStatus;
notes?: string;
createdAt: Date;
updatedAt: Date;
}
export interface OrganizerFillItem {
medicineId: string;
medicineName: string;
quantityNeeded: number; // Total pills needed for N days
quantityTaken: number; // Actual pills taken from cabinet
wasShort: boolean; // quantityTaken < quantityNeeded
shortage: number; // quantityNeeded - quantityTaken (0 if not short)
deductions: OrganizerDeduction[];
}
export interface OrganizerDeduction {
cabinetItemId: string;
quantityTaken: number;
}
export enum OrganizerFillStatus {
COMPLETED = 'completed', // All medicines fully dispensed
PARTIAL = 'partial', // Some medicines were short
REVERSED = 'reversed', // Fill was undone
}
```
### BurnRate (Computed, not stored)
```typescript
// Calculated from active regimens + cabinet stock
export interface BurnRate {
medicineId: string;
medicineName: string;
dailyConsumption: number; // Total pills per day across all regimens
totalInCabinet: number;
daysUntilEmpty: number | null; // null if dailyConsumption is 0
earliestExpiry: Date | null;
}
```
### MongoDB Indexes
```javascript
// Regimen
{ householdId: 1, userId: 1, isActive: 1 }
{ householdId: 1, 'medications.medicineId': 1 }
// OrganizerFill
{ householdId: 1, userId: 1, fillDate: -1 }
{ householdId: 1, regimenId: 1, fillDate: -1 }
{ householdId: 1, status: 1 }
```
---
## API Endpoints
### RegimensModule
| Method | Path | Description | Auth |
| ------ | ----------------------- | ------------------------------------ | ------ |
| GET | `/regimens` | List user's regimens | member |
| GET | `/regimens/:id` | Get single regimen | member |
| POST | `/regimens` | Create regimen | member |
| PATCH | `/regimens/:id` | Update regimen | member |
| DELETE | `/regimens/:id` | Delete regimen | member |
| GET | `/regimens/burn-rate` | Burn rate for all active regimens | member |
### OrganizerModule
| Method | Path | Description | Auth |
| ------ | ----------------------------- | -------------------------------------------- | ------ |
| GET | `/organizer/fills` | List fill history (paginated) | member |
| GET | `/organizer/fills/:id` | Get single fill details | member |
| POST | `/organizer/preview` | Preview a fill (shows quantities, shortages) | member |
| POST | `/organizer/fill` | Execute a fill (deduct from cabinet) | member |
| POST | `/organizer/fills/:id/undo` | Reverse a fill (restore cabinet quantities) | member |
### Preview Request/Response
```typescript
// POST /organizer/preview
interface OrganizerPreviewRequest {
regimenId: string;
numberOfDays: number; // Default: 7
}
interface OrganizerPreviewResponse {
regimenName: string;
numberOfDays: number;
items: {
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityAvailable: number;
isShort: boolean;
shortage: number;
cabinetBreakdown: {
cabinetItemId: string;
expirationDate: Date | null;
quantityToTake: number;
}[];
}[];
canFillCompletely: boolean;
hasShortages: boolean;
}
```
### Fill Request
```typescript
// POST /organizer/fill
interface OrganizerFillRequest {
regimenId: string;
numberOfDays: number;
allowPartial: boolean; // If false, reject when any medicine is short
notes?: string;
}
```
---
## Tasks
### 3.1 — Shared Types & Validation
- Add regimen types to `packages/shared/src/types/regimen.ts`
- Add organizer fill types to `packages/shared/src/types/organizer-fill.ts`
- Zod schemas:
- `CreateRegimenSchema`
- `UpdateRegimenSchema`
- `OrganizerPreviewSchema`
- `OrganizerFillSchema`
### 3.2 — Regimen CRUD
- `RegimensRepository` and `RegimensService`
- Standard CRUD scoped to `householdId` + `userId`
- On create/update: validate that all `medicineId` references exist in the medicine library
- Denormalize medicine fields (name, strength, unit, form)
### 3.3 — Frequency Multiplier Logic
```typescript
/**
* Calculate total pills needed for N days based on frequency.
*
* daily: dosage * numberOfDays
* twice_daily: dosage * 2 * numberOfDays
* three_times_daily: dosage * 3 * numberOfDays
* weekly: dosage * ceil(numberOfDays / 7)
* every_other_day: dosage * ceil(numberOfDays / 2)
* as_needed: 0 (excluded from organizer fills)
* custom: dosage * customFrequencyPerDay * numberOfDays
*/
function calculateQuantityNeeded(
medication: RegimenMedication,
numberOfDays: number,
): number;
```
### 3.4 — Organizer Fill Service
```typescript
class OrganizerService {
/**
* Preview: calculate what would happen without deducting.
* For each medicine in the regimen:
* 1. Calculate quantity needed (via frequency multiplier)
* 2. Check cabinet stock (via CabinetService.getAggregateSummary)
* 3. Plan FEFO deductions (earliest expiry first)
* 4. Flag shortages
*/
preview(householdId: string, regimenId: string, numberOfDays: number): Promise<OrganizerPreviewResponse>;
/**
* Fill: execute the preview plan.
* 1. Re-validate stock (may have changed since preview)
* 2. If allowPartial=false and any shortage, reject
* 3. Call CabinetService.deductStock for each medicine
* 4. Create OrganizerFill record
* 5. Return fill details
*/
fill(householdId: string, userId: string, request: OrganizerFillRequest): Promise<OrganizerFill>;
/**
* Undo: reverse a fill.
* 1. Verify fill is not already reversed
* 2. For each deduction, call CabinetService.restoreStock
* 3. Mark fill as reversed
*/
undoFill(householdId: string, fillId: string): Promise<OrganizerFill>;
}
```
### 3.5 — Burn Rate Calculation
```typescript
class BurnRateService {
/**
* For each medicine across all active regimens for a user:
* 1. Sum daily consumption: dosage * daily_frequency_multiplier
* 2. Get total cabinet stock for that medicine
* 3. daysUntilEmpty = floor(totalInCabinet / dailyConsumption)
* 4. Include earliest expiry date from cabinet
*
* Note: 'as_needed' frequency is excluded from burn rate.
* Note: If multiple users in household have regimens, each sees their own burn rate.
*/
calculateBurnRates(householdId: string, userId: string): Promise<BurnRate[]>;
}
```
### 3.6 — Web UI: Regimens
- `/regimens` page:
- List of user's regimens with active/inactive toggle
- Each regimen shows: name, medication count, active status
- Expand/click to see all medications with dosage details
- Add/Edit regimen form:
- Name, active toggle
- Medications list:
- Medicine autocomplete (from library)
- Dosage (number + unit)
- Frequency dropdown
- Time of day (optional)
- Instructions (optional)
- Add/remove medications
### 3.7 — Web UI: Pill Organizer
- `/organizer` page:
- **Fill organizer** section:
- Select regimen dropdown
- Number of days input (default: 7, adjustable)
- "Preview" button -> shows:
- Per-medicine breakdown: needed vs available
- Shortage warnings (highlighted)
- Which cabinet items will be drawn from (FEFO order)
- "Fill" button -> executes the fill, shows confirmation
- Option for partial fill when shortages exist
- **Burn rate** section:
- Table: medicine name, daily consumption, total in cabinet, days until empty
- Color-coded: green (>14 days), yellow (7-14 days), red (<7 days)
- Links to refill alerts (Phase 4)
- **Fill history** section:
- Recent fills with date, regimen, day count, status
- Expand to see per-medicine details
- "Undo" button on recent fills (with confirmation)
---
## Acceptance Criteria
- [ ] Can create and manage regimens with multiple medications
- [ ] Frequency multiplier correctly calculates quantities for all frequency types
- [ ] Preview accurately shows needed quantities and shortages
- [ ] Fill deducts from cabinet using FEFO (earliest expiry first)
- [ ] Partial fills work when `allowPartial` is true
- [ ] Fill is rejected when `allowPartial` is false and any medicine is short
- [ ] Undo fully restores cabinet quantities
- [ ] Undo is idempotent (cannot undo an already-reversed fill)
- [ ] Burn rate correctly accounts for all active regimens
- [ ] `as_needed` frequency is excluded from fill calculations and burn rate
- [ ] All queries scoped to `householdId`; regimens additionally scoped to `userId`
---
## Estimated Effort
Medium-large. The fill/undo transactional logic with FEFO, shortage handling, and burn rate calculations are the most complex parts. UI is moderately complex with the preview/fill flow.

View file

@ -0,0 +1,360 @@
# 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.

View file

@ -0,0 +1,254 @@
# Phase 5 — Product Library
**Goal**: A searchable catalog of food products with nutrition data, reusable across the entire food tracking domain. Products are the atomic building blocks for recipes, pantry items, and shopping lists.
**Depends on**: Phase 0 (auth, households, shared types). Can reuse Store infrastructure from Phase 4.
---
## Deliverables
1. `Product` MongoDB schema and full CRUD API
2. Full-text search with filters
3. Barcode lookup via Open Food Facts
4. Bulk import (CSV/JSON)
5. Product library web UI (search, add, edit)
6. LLM provider interface (`ILlmProvider`) with no-op implementation
7. "Smart Add" endpoint placeholder
---
## Data Model
### Product Schema
```typescript
// packages/shared/src/types/product.ts
export interface Product {
id: string;
householdId: string;
name: string;
brand?: string;
barcode?: string;
category: ProductCategory;
servingSize: number;
servingUnit: ServingUnit;
nutrition: NutritionInfo;
tags: string[];
imageUrl?: string;
isPublic: boolean; // Visible to all households (for shared catalog)
source: ProductSource; // 'manual' | 'barcode_lookup' | 'llm' | 'import'
createdBy: string; // userId
createdAt: Date;
updatedAt: Date;
}
export interface NutritionInfo {
calories: number; // kcal per serving
protein: number; // grams
carbs: number; // grams
fat: number; // grams
fiber?: number; // grams
sugar?: number; // grams
sodium?: number; // mg
saturatedFat?: number; // grams
cholesterol?: number; // mg
}
export enum ProductCategory {
DAIRY = 'dairy',
MEAT = 'meat',
POULTRY = 'poultry',
SEAFOOD = 'seafood',
FRUITS = 'fruits',
VEGETABLES = 'vegetables',
GRAINS = 'grains',
LEGUMES = 'legumes',
NUTS_SEEDS = 'nuts_seeds',
OILS_FATS = 'oils_fats',
CONDIMENTS = 'condiments',
SPICES = 'spices',
BEVERAGES = 'beverages',
SNACKS = 'snacks',
FROZEN = 'frozen',
CANNED = 'canned',
BAKERY = 'bakery',
DELI = 'deli',
SUPPLEMENTS = 'supplements',
OTHER = 'other',
}
export enum ServingUnit {
GRAMS = 'g',
MILLILITERS = 'ml',
OUNCES = 'oz',
CUPS = 'cup',
TABLESPOONS = 'tbsp',
TEASPOONS = 'tsp',
PIECES = 'piece',
SLICES = 'slice',
}
export enum ProductSource {
MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
LLM = 'llm',
IMPORT = 'import',
}
```
### MongoDB Indexes
```javascript
// Text index for search
{ name: 'text', brand: 'text', tags: 'text' }
// Compound indexes
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 } // unique within household
{ householdId: 1, name: 1, brand: 1 } // near-unique for dedup
```
---
## API Endpoints
### ProductsModule
| Method | Path | Description | Auth |
| ------ | ------------------------- | ------------------------------------------- | ------ |
| GET | `/products` | List/search products (paginated) | member |
| GET | `/products/:id` | Get single product | member |
| POST | `/products` | Create product | member |
| PATCH | `/products/:id` | Update product | member |
| DELETE | `/products/:id` | Soft-delete product | admin |
| GET | `/products/barcode/:code` | Lookup by barcode (local → Open Food Facts) | member |
| POST | `/products/import` | Bulk import from CSV/JSON | admin |
| POST | `/products/smart-add` | LLM-powered add from text/image | member |
### Query Parameters for GET `/products`
```
?q=chicken # Full-text search
&category=meat # Filter by category
&tags=organic,fresh # Filter by tags (AND)
&cursor=abc123 # Cursor-based pagination
&limit=20 # Page size (max 100)
&sort=name|-updatedAt # Sort field, prefix - for desc
```
### Response Shape
```typescript
interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null; // null = last page
hasMore: boolean;
total: number;
};
}
```
---
## Tasks
### 5.1 — Shared Types & Validation
- Add all types above to `packages/shared/src/types/product.ts`
- Add enums to `packages/shared/src/enums/`
- Create Zod schemas:
- `CreateProductSchema` — validates create payload
- `UpdateProductSchema` — partial, validates update payload
- `ProductQuerySchema` — validates query params
### 5.2 — Mongoose Schema & Repository
- `packages/api/src/modules/products/schemas/product.schema.ts`
- `ProductRepository` with:
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination
- `findByBarcode(householdId, barcode)`
- `create(data)`
- `update(id, householdId, data)`
- `softDelete(id, householdId)`
- `bulkCreate(items[])`
### 5.3 — Barcode Lookup Service
- `BarcodeService`:
- First check local DB for matching barcode
- If not found, query Open Food Facts API (`https://world.openfoodfacts.org/api/v2/product/{barcode}`)
- Map OFF response to `Product` shape
- Cache results in local DB with `source: 'barcode_lookup'`
### 5.4 — LLM Provider Interface
- `packages/api/src/modules/llm/interfaces/llm-provider.interface.ts`:
```typescript
export interface ILlmProvider {
extractNutrition(input: {
text?: string;
image?: Buffer;
}): Promise<NutritionExtractionResult | null>;
parseRecipe(text: string): Promise<ParsedRecipe | null>;
parseRecipeFromUrl(url: string): Promise<ParsedRecipe | null>;
parseReceipt(image: Buffer): Promise<ParsedReceipt | null>;
suggestMealPlan(context: MealPlanContext): Promise<MealPlanSuggestion | null>;
parseNaturalLanguage(text: string): Promise<StructuredAction | null>;
}
export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
```
- `NoOpLlmProvider`: implements interface, returns `null` for all methods, logs a warning
- `LlmModule`: provides `LLM_PROVIDER` via factory, selectable by env var `LLM_PROVIDER_TYPE`
### 5.5 — Smart Add Endpoint
- `POST /products/smart-add` accepts `{ text?: string, image?: file }`
- Calls `ILlmProvider.extractNutrition()`
- If LLM returns data, pre-fill a product and return to client for review (not auto-saved)
- If LLM unavailable (`NoOpLlmProvider`), return `{ available: false, message: 'LLM not configured' }`
### 5.6 — Import Endpoint
- `POST /products/import` accepts multipart CSV or JSON file
- Validate each row against `CreateProductSchema`
- Return summary: `{ imported: N, skipped: M, errors: [...] }`
- CSV column mapping: `name, brand, barcode, category, servingSize, servingUnit, calories, protein, carbs, fat, ...`
### 5.7 — Web UI: Product Library
- `/products` page:
- Search bar with debounced full-text search
- Category filter dropdown
- Tag filter chips
- Product grid/list view (toggle)
- Each product card shows: name, brand, category icon, calories/serving
- Add/Edit product modal:
- Form fields for all product properties
- Nutrition input section with per-serving values
- Barcode field with "Lookup" button
- "Smart Add" tab (text input or image upload)
- Import dialog: file upload with preview and error display
---
## Acceptance Criteria
- [ ] Can create, read, update, delete products via API
- [ ] Full-text search returns relevant results
- [ ] Barcode lookup fetches from Open Food Facts when not in local DB
- [ ] Bulk import processes a CSV with 100+ products
- [ ] Web UI allows searching, filtering, adding, and editing products
- [ ] `ILlmProvider` interface is defined and injectable
- [ ] Smart Add endpoint returns graceful "not available" with NoOp provider
- [ ] All product queries are scoped to `householdId`
---
## Estimated Effort
Medium. Straightforward CRUD with search; barcode integration adds some complexity.

View file

@ -0,0 +1,237 @@
# Phase 6 — Recipe Management
**Goal**: Enter, import, and manage recipes. Auto-calculate nutrition from product library ingredients. Highlight nutritional warnings. This phase ties into the product library (Phase 5) and will be consumed by meal planning (Phase 8).
**Depends on**: Phase 0, Phase 5
---
## Deliverables
1. `Recipe` MongoDB schema and full CRUD API
2. Automatic nutrition calculation from ingredient list
3. Nutritional warning generation
4. Recipe scaling (adjust servings)
5. Recipe import via LLM (plain text → structured)
6. Recipe editor web UI with live nutrition sidebar
---
## Data Model
### Recipe Schema
```typescript
// packages/shared/src/types/recipe.ts
export interface Recipe {
id: string;
householdId: string;
name: string;
description?: string;
servings: number;
prepTime?: number; // minutes
cookTime?: number; // minutes
totalTime?: number; // minutes (auto-calculated or manual)
ingredients: RecipeIngredient[];
steps: RecipeStep[];
tags: string[]; // e.g., 'vegetarian', 'quick', 'meal-prep'
cuisine?: string; // e.g., 'Italian', 'Japanese'
imageUrl?: string;
source?: RecipeSource;
totalNutrition: NutritionInfo; // Denormalized, computed on save
perServingNutrition: NutritionInfo; // Denormalized, computed on save
warnings: NutritionWarning[]; // Computed on save
isFavorite: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RecipeIngredient {
productId: string; // Reference to Product
productName: string; // Denormalized for display
quantity: number;
unit: ServingUnit;
preparation?: string; // e.g., 'diced', 'minced', 'melted'
isOptional: boolean;
nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
}
export interface RecipeStep {
order: number;
instruction: string;
duration?: number; // minutes
tip?: string;
}
export interface RecipeSource {
type: 'manual' | 'url' | 'llm_import' | 'text_import';
url?: string;
importedAt?: Date;
}
export enum NutritionWarning {
HIGH_CALORIES = 'high_calories', // > 800 kcal/serving
HIGH_SODIUM = 'high_sodium', // > 1500mg/serving
HIGH_SUGAR = 'high_sugar', // > 25g/serving
HIGH_SATURATED_FAT = 'high_saturated_fat', // > 13g/serving
LOW_PROTEIN = 'low_protein', // < 10g/serving
LOW_FIBER = 'low_fiber', // < 3g/serving
HIGH_CHOLESTEROL = 'high_cholesterol', // > 200mg/serving
}
```
### MongoDB Indexes
```javascript
{ householdId: 1, name: 'text', tags: 'text', cuisine: 'text' }
{ householdId: 1, 'ingredients.productId': 1 } // Find recipes using a product
{ householdId: 1, tags: 1 }
{ householdId: 1, isFavorite: 1 }
```
---
## API Endpoints
### RecipesModule
| Method | Path | Description | Auth |
| ------ | -------------------------------- | --------------------------------------- | ------ |
| GET | `/recipes` | List/search recipes (paginated) | member |
| GET | `/recipes/:id` | Get single recipe | member |
| POST | `/recipes` | Create recipe | member |
| PATCH | `/recipes/:id` | Update recipe | member |
| DELETE | `/recipes/:id` | Soft-delete recipe | admin |
| POST | `/recipes/:id/scale` | Get scaled version (preview, not saved) | member |
| POST | `/recipes/import-text` | Import from plain text via LLM | member |
| POST | `/recipes/import-url` | Import from URL via LLM | member |
| GET | `/recipes/by-product/:productId` | Find recipes using a specific product | member |
### Query Parameters for GET `/recipes`
```
?q=pasta # Full-text search
&tags=vegetarian,quick # Filter by tags
&cuisine=Italian # Filter by cuisine
&maxCalories=600 # Filter by per-serving calories
&isFavorite=true # Favorites only
&cursor=abc123
&limit=20
```
---
## Tasks
### 6.1 — Shared Types & Validation
- Add recipe types to `packages/shared/src/types/recipe.ts`
- Zod schemas:
- `CreateRecipeSchema` — ingredients must reference valid productIds
- `UpdateRecipeSchema` — partial
- `ScaleRecipeSchema``{ targetServings: number }`
- `ImportRecipeTextSchema``{ text: string }`
- `ImportRecipeUrlSchema``{ url: string }`
### 6.2 — Nutrition Calculation Service
- `NutritionCalculatorService`:
```typescript
class NutritionCalculatorService {
/**
* For each ingredient:
* 1. Lookup the product by productId
* 2. Convert ingredient quantity/unit to product's servingUnit
* 3. Calculate nutrition proportionally: (ingredient_qty / serving_size) * nutrition_per_serving
* 4. Sum across all ingredients → totalNutrition
* 5. Divide by servings → perServingNutrition
*/
calculateRecipeNutrition(
ingredients: RecipeIngredient[],
servings: number,
): RecipeNutritionResult;
/**
* Check per-serving nutrition against warning thresholds
*/
generateWarnings(perServingNutrition: NutritionInfo): NutritionWarning[];
}
```
- Unit conversion helper: handle common conversions (g ↔ oz, ml ↔ cups, etc.)
- Not all conversions are possible (density-dependent) — log warning, use best approximation
- This is explicitly **informative, not clinical-grade accurate**
### 6.3 — Recipe CRUD with Auto-Calculation
- On `POST /recipes` and `PATCH /recipes/:id`:
1. Validate ingredients exist in product library
2. Call `NutritionCalculatorService.calculateRecipeNutrition()`
3. Call `NutritionCalculatorService.generateWarnings()`
4. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
- On product nutrition update (Phase 5 edit), trigger background recalculation:
- Find all recipes where `ingredients[].productId == updatedProductId`
- Recalculate each recipe's nutrition
### 6.4 — Recipe Scaling
- `POST /recipes/:id/scale` with `{ targetServings: number }`:
- Returns a scaled **preview** (not persisted) with adjusted ingredient quantities and recalculated nutrition
- `scaledQuantity = originalQuantity * (targetServings / originalServings)`
### 6.5 — Recipe Import (LLM)
- `POST /recipes/import-text`:
- Accepts `{ text: string }` (pasted recipe)
- Calls `ILlmProvider.parseRecipe(text)`
- LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }`
- Service attempts to match ingredient names to existing products (fuzzy match by name)
- Returns structured recipe for user review — unmatched ingredients flagged for manual product creation
- `POST /recipes/import-url`:
- Calls `ILlmProvider.parseRecipeFromUrl(url)`
- Same flow as text import
- With `NoOpLlmProvider`: returns `{ available: false }`
### 6.6 — Web UI: Recipe Management
- `/recipes` page:
- Search bar, tag and cuisine filters
- Recipe card grid: image, name, time, calories/serving, warning badges
- Favorites tab
- `/recipes/new` and `/recipes/:id/edit`:
- Recipe metadata form (name, description, servings, times, cuisine, tags)
- Ingredient editor:
- Autocomplete from product library
- Quantity + unit inputs
- "Add ingredient" button, drag-to-reorder
- Per-ingredient nutrition shown inline
- Steps editor: ordered text areas, optional duration per step
- **Live nutrition sidebar**: updates as ingredients are added/changed
- Shows total and per-serving macros
- Warning badges with explanations
- "Scale" button: adjust servings in sidebar to see scaled amounts
- `/recipes/:id` detail page:
- Full recipe view with ingredients, steps, nutrition panel
- "Import from text" and "Import from URL" buttons in recipe list page
---
## Acceptance Criteria
- [ ] Can create a recipe with ingredients linked to products
- [ ] Nutrition is automatically calculated and stored on the recipe
- [ ] Warnings are generated for recipes exceeding thresholds
- [ ] Recipe scaling returns correctly adjusted quantities
- [ ] Editing a product's nutrition triggers recipe recalculation
- [ ] Text/URL import endpoint delegates to LLM provider interface
- [ ] Web UI shows live nutrition as ingredients are added
- [ ] Full-text search finds recipes by name, tags, cuisine
---
## Estimated Effort
Medium. Nutrition calculation logic and unit conversion require careful implementation. UI is moderately complex with the live sidebar.

View file

@ -0,0 +1,346 @@
# Phase 7 — Pantry & Fridge Tracking
**Goal**: Track the lifecycle of physical food items — purchase, opening, preparation, consumption, or disposal. Estimate freshness/spoilage timelines. Provide a real-time dashboard of what's in the household's storage.
**Depends on**: Phase 0, Phase 5
---
## Deliverables
1. `PantryItem` and `FreshnessRule` MongoDB schemas
2. Full CRUD API with status transition workflow
3. Freshness estimation and urgency scoring
4. Scheduled freshness check job (cron) with in-app notifications
5. Pantry dashboard web UI with color-coded freshness
6. Waste analysis history
---
## Data Model
### PantryItem Schema
```typescript
// packages/shared/src/types/pantry.ts
export interface PantryItem {
id: string;
householdId: string;
productId: string;
productName: string; // Denormalized
storageLocation: StorageLocation;
quantity: number;
unit: ServingUnit;
purchaseDate: Date;
expirationDate?: Date; // From packaging, if known
openedDate?: Date;
preparedDate?: Date;
status: ItemStatus;
freshnessEstimate: FreshnessEstimate;
notes?: string;
purchasePrice?: number; // Links to grocery tracking (Phase 9)
storeId?: string; // Where it was bought
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export enum StorageLocation {
PANTRY = 'pantry',
FRIDGE = 'fridge',
FREEZER = 'freezer',
COUNTER = 'counter',
}
export enum ItemStatus {
SEALED = 'sealed',
OPENED = 'opened',
PREPARED = 'prepared',
CONSUMED = 'consumed',
DISCARDED = 'discarded',
EXPIRED = 'expired',
}
export interface FreshnessEstimate {
estimatedExpiryDate: Date; // Computed from rules
daysRemaining: number; // Computed
urgency: FreshnessUrgency; // Computed
source: 'packaging' | 'rule' | 'manual';
}
export enum FreshnessUrgency {
FRESH = 'fresh', // > 5 days
USE_SOON = 'use_soon', // 2-5 days
URGENT = 'urgent', // 0-2 days
CHECK = 'check', // Past estimated date, may still be ok
EXPIRED = 'expired', // Way past date
}
```
### FreshnessRule Schema
```typescript
// packages/shared/src/types/freshness.ts
export interface FreshnessRule {
id: string;
householdId?: string; // null = system default
category: ProductCategory;
storageLocation: StorageLocation;
shelfLifeDays: number; // When sealed
openedLifeDays: number; // After opening
freezerLifeDays?: number; // If moved to freezer
spoilageSignsToCheck: string[]; // e.g., ['smell', 'discoloration', 'texture change']
tips?: string; // Storage tips
source: 'system' | 'household'; // System defaults vs household overrides
}
```
### Status Transition Rules
```
┌──────────┐
│ SEALED │
└─────┬─────┘
┌────────┼────────┐
▼ ▼ ▼
┌─────────┐ ┌──────┐ ┌──────────┐
│ OPENED │ │CONSUMED│ │DISCARDED │
└────┬────┘ └──────┘ └──────────┘
┌────┼────────┐
▼ ▼ ▼
┌──────┐┌──────────┐┌──────────┐
│PREPARED│ │CONSUMED │ │DISCARDED │
└───┬──┘ └──────────┘└──────────┘
├──────────┐
▼ ▼
┌──────────┐┌──────────┐
│ CONSUMED ││ DISCARDED│
└──────────┘└──────────┘
```
Valid transitions:
- `sealed → opened | consumed | discarded`
- `opened → prepared | consumed | discarded`
- `prepared → consumed | discarded`
- Any status → `expired` (set by system cron)
### MongoDB Indexes
```javascript
{ householdId: 1, status: 1, 'freshnessEstimate.urgency': 1 }
{ householdId: 1, storageLocation: 1, status: 1 }
{ householdId: 1, productId: 1, status: 1 }
{ householdId: 1, 'freshnessEstimate.estimatedExpiryDate': 1 } // For cron job
{ 'freshnessRule.category': 1, 'freshnessRule.storageLocation': 1 } // For rule lookup
```
---
## API Endpoints
### PantryModule
| Method | Path | Description | Auth |
| ------ | -------------------------- | ------------------------------------------------------------- | ------ |
| GET | `/pantry` | List pantry items (filtered, paginated) | member |
| GET | `/pantry/:id` | Get single item | member |
| POST | `/pantry` | Add item to pantry | member |
| PATCH | `/pantry/:id` | Update item details | member |
| POST | `/pantry/:id/transition` | Change status (open, consume, discard, etc.) | member |
| POST | `/pantry/batch-transition` | Bulk status change (e.g., mark all as consumed after cooking) | member |
| DELETE | `/pantry/:id` | Hard delete (admin) | admin |
| GET | `/pantry/expiring-soon` | Items expiring within N days | member |
| GET | `/pantry/stats` | Waste analysis summary | member |
### FreshnessRulesModule
| Method | Path | Description | Auth |
| ------ | ---------------------- | -------------------------------------------- | ------ |
| GET | `/freshness-rules` | List rules (system + household overrides) | member |
| POST | `/freshness-rules` | Create household override | admin |
| PATCH | `/freshness-rules/:id` | Update household rule | admin |
| DELETE | `/freshness-rules/:id` | Remove household override (revert to system) | admin |
### Query Parameters for GET `/pantry`
```
?storageLocation=fridge # Filter by location
&status=sealed,opened # Filter by status (comma-separated)
&urgency=urgent,use_soon # Filter by freshness urgency
&productId=abc123 # Filter by product
&sort=-freshnessEstimate.daysRemaining # Sort by urgency (most urgent first)
&cursor=abc123
&limit=20
```
---
## Tasks
### 7.1 — Shared Types & Validation
- Add pantry types to `packages/shared/src/types/pantry.ts`
- Add freshness types to `packages/shared/src/types/freshness.ts`
- Zod schemas:
- `CreatePantryItemSchema`
- `UpdatePantryItemSchema`
- `TransitionPantryItemSchema``{ status: ItemStatus, date?: Date, notes?: string }`
- `CreateFreshnessRuleSchema`
### 7.2 — Freshness Rule Seed Data
- Seed `FreshnessRule` collection with defaults based on USDA/StillTasty guidelines:
| Category | Location | Sealed (days) | Opened (days) | Freezer (days) |
| ---------- | -------- | ------------- | ------------- | -------------- |
| Dairy | Fridge | 14 | 7 | 90 |
| Meat | Fridge | 3 | 2 | 180 |
| Poultry | Fridge | 2 | 1 | 270 |
| Seafood | Fridge | 2 | 1 | 180 |
| Fruits | Counter | 7 | 3 | 270 |
| Vegetables | Fridge | 7 | 4 | 270 |
| Grains | Pantry | 180 | 90 | 365 |
| Legumes | Pantry | 365 | 7 | 365 |
| Bakery | Counter | 5 | 3 | 90 |
| ... | ... | ... | ... | ... |
- Household can override any rule
### 7.3 — Freshness Calculation Service
```typescript
class FreshnessService {
/**
* Given a pantry item and its applicable freshness rule:
* 1. If packaging expirationDate exists, use it
* 2. Else compute: purchaseDate + shelfLifeDays (sealed) or openedDate + openedLifeDays (opened)
* 3. If in freezer, use freezerLifeDays from purchaseDate
* 4. Calculate daysRemaining = estimatedExpiryDate - today
* 5. Map to urgency: >5 = FRESH, 2-5 = USE_SOON, 0-2 = URGENT, <0 = CHECK/EXPIRED
*/
calculateFreshness(item: PantryItem, rule: FreshnessRule): FreshnessEstimate;
/**
* Find the most specific rule: household override > system default
* Match by category + storageLocation
*/
findApplicableRule(
householdId: string,
category: ProductCategory,
location: StorageLocation,
): FreshnessRule;
}
```
### 7.4 — Status Transition Service
```typescript
class PantryTransitionService {
/**
* Validate transition is allowed, apply side effects:
* - sealed → opened: set openedDate, recalculate freshness with openedLifeDays
* - * → consumed: record consumption date, update quantity
* - * → discarded: record discard date, log for waste analysis
*/
transition(item: PantryItem, newStatus: ItemStatus, metadata?: TransitionMetadata): PantryItem;
}
```
### 7.5 — Freshness Cron Job
- NestJS `@Cron('0 6 * * *')` (daily at 6 AM, configurable):
1. Query all active pantry items (status: sealed/opened/prepared)
2. Recalculate freshness estimates
3. Items past expiry → update status to `expired`
4. Items in `urgent` or `check` → create in-app notifications
- Notification model (simple for now, expand for push in mobile phase):
```typescript
export interface Notification {
id: string;
householdId: string;
userId?: string; // null = all household members
type: 'freshness_warning' | 'item_expired';
title: string;
body: string;
relatedEntityId: string; // PantryItem ID
isRead: boolean;
createdAt: Date;
}
```
### 7.6 — Waste Analysis
- `GET /pantry/stats` returns:
```typescript
interface WasteStats {
period: { start: Date; end: Date };
totalItemsConsumed: number;
totalItemsDiscarded: number;
wastePercentage: number; // discarded / (consumed + discarded) * 100
topWastedCategories: { category: ProductCategory; count: number }[];
topWastedProducts: { productId: string; productName: string; count: number }[];
trendVsPreviousPeriod: number; // % change
}
```
- Query parameters: `?period=week|month|quarter|year`
### 7.7 — WebSocket Events (Initial)
- Set up NestJS `@WebSocketGateway` with household-scoped rooms
- Events:
- `pantry:item-added` — when a new item is added
- `pantry:item-updated` — when item status changes
- `pantry:freshness-alert` — when cron detects urgent items
- Frontend subscribes on pantry page for real-time updates across household members
### 7.8 — Web UI: Pantry Dashboard
- `/pantry` page:
- **Storage tabs**: Fridge | Freezer | Pantry | Counter | All
- **View modes**: Grid (cards) | List (table)
- Each item shows:
- Product name, quantity
- Freshness indicator: color-coded chip (green/yellow/orange/red)
- Days remaining
- Status badge
- Quick-action buttons: Open | Consume | Discard
- **Sort**: by urgency (default), name, purchase date
- **Filter**: by urgency level, category
- Floating "Add Item" button → modal:
- Product autocomplete (from library)
- Storage location picker
- Purchase date (default today)
- Expiration date (optional, from packaging)
- Quantity + unit
- `/pantry/stats` page:
- Waste percentage gauge
- Top wasted categories bar chart
- Trend line chart (weekly waste over time)
- **Notification bell** in top bar: shows freshness warnings, mark as read
---
## Acceptance Criteria
- [ ] Can add items to pantry linked to products
- [ ] Freshness estimate is calculated on creation and updates
- [ ] Status transitions follow valid workflow rules
- [ ] Daily cron job flags expiring items and creates notifications
- [ ] Pantry dashboard shows items color-coded by freshness urgency
- [ ] Waste stats endpoint returns correct aggregation
- [ ] WebSocket broadcasts pantry changes to household members
- [ ] Freshness rules can be overridden per household
- [ ] Items sorted by urgency show most critical first
---
## Estimated Effort
Medium-large. Freshness logic, cron job, notifications, and WebSocket add significant complexity beyond basic CRUD.

View file

@ -0,0 +1,269 @@
# Phase 8 — Meal Planning & Waste Reduction
**Goal**: Enable weekly meal planning with automatic nutrition tracking vs targets. The core feature is a **suggestion engine** that recommends recipes prioritizing ingredients already in the pantry (especially those expiring soon), reducing food waste while maintaining nutritional balance.
**Depends on**: Phase 5 (products), Phase 6 (recipes), Phase 7 (pantry)
---
## Deliverables
1. `MealPlan` and `NutritionTarget` MongoDB schemas
2. Meal plan CRUD API with daily/weekly views
3. Recipe suggestion engine (algorithmic, not LLM-dependent)
4. Nutrition targets per user with daily tracking
5. Shopping gap analysis (what's needed beyond pantry)
6. Meal plan web UI with weekly calendar and suggestion panel
---
## Data Model
### MealPlan Schema
```typescript
// packages/shared/src/types/meal-plan.ts
export interface MealPlan {
id: string;
householdId: string;
weekStartDate: Date; // Monday of the planning week
days: MealPlanDay[];
status: MealPlanStatus;
shoppingListId?: string; // Auto-generated shopping list (Phase 9 link)
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface MealPlanDay {
date: Date;
meals: PlannedMeal[];
dailyNutritionTotal: NutritionInfo; // Computed
}
export interface PlannedMeal {
id: string; // UUID for drag-and-drop reference
type: MealType;
recipeId?: string; // Linked recipe
recipeName: string; // Denormalized
servings: number;
customName?: string; // For non-recipe meals
customNutrition?: NutritionInfo; // Manual override for non-recipe meals
perServingNutrition: NutritionInfo; // From recipe or custom
notes?: string;
}
export enum MealType {
BREAKFAST = 'breakfast',
LUNCH = 'lunch',
DINNER = 'dinner',
SNACK = 'snack',
}
export enum MealPlanStatus {
DRAFT = 'draft',
ACTIVE = 'active',
COMPLETED = 'completed',
}
```
### NutritionTarget Schema
```typescript
// packages/shared/src/types/nutrition-target.ts
export interface NutritionTarget {
id: string;
userId: string;
householdId: string;
dailyCalories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG?: number;
sodiumMg?: number;
sugarG?: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
```
### MongoDB Indexes
```javascript
{ householdId: 1, weekStartDate: 1 } // unique per household per week
{ householdId: 1, status: 1 }
{ householdId: 1, 'days.meals.recipeId': 1 } // Find plans using a recipe
```
---
## API Endpoints
### MealPlanModule
| Method | Path | Description | Auth |
| ------ | ------------------------------ | ---------------------------------------------- | ------ |
| GET | `/meal-plans` | List meal plans (paginated) | member |
| GET | `/meal-plans/current` | Get current week's plan | member |
| GET | `/meal-plans/:id` | Get specific plan | member |
| POST | `/meal-plans` | Create new week plan | member |
| PATCH | `/meal-plans/:id` | Update plan (add/move/remove meals) | member |
| DELETE | `/meal-plans/:id` | Delete plan (draft only) | admin |
| POST | `/meal-plans/:id/activate` | Set plan as active | member |
| GET | `/meal-plans/:id/shopping-gap` | What's needed beyond current pantry | member |
| GET | `/meal-plans/suggestions` | Get recipe suggestions for current pantry | member |
| POST | `/meal-plans/suggest-with-llm` | LLM-powered meal plan generation (placeholder) | member |
### NutritionTargetModule
| Method | Path | Description | Auth |
| ------ | ------------------------ | -------------------------------- | ------ |
| GET | `/nutrition-targets` | Get current user's active target | member |
| POST | `/nutrition-targets` | Set nutrition targets | member |
| PATCH | `/nutrition-targets/:id` | Update targets | member |
---
## Tasks
### 8.1 — Shared Types & Validation
- Add all types above to `packages/shared`
- Zod schemas for create/update operations
- Meal plan day validation (7 days per plan, valid dates)
### 8.2 — Meal Plan CRUD
- Standard CRUD with computed `dailyNutritionTotal` per day:
- Sum `perServingNutrition * servings` for all meals in each day
- On meal plan creation: default to 7 empty days (MondaySunday)
- On update: support granular operations:
- `addMeal(dayIndex, meal)`
- `removeMeal(dayIndex, mealId)`
- `moveMeal(fromDay, toDay, mealId)` — for drag-and-drop
- `updateMeal(dayIndex, mealId, updates)`
### 8.3 — Recipe Suggestion Engine (Core Algorithm)
This is the **key differentiating feature** — algorithmic, no LLM required.
```typescript
class RecipeSuggestionService {
/**
* Score and rank recipes based on pantry state and user preferences.
*
* Input:
* - Current pantry items (with freshness urgency)
* - Recipe catalog for the household
* - User's nutrition targets (optional)
* - Already planned meals this week (to avoid repetition)
*
* Scoring per recipe:
* ingredientCoverageScore (0-40 pts): % of ingredients available in pantry
* freshnessUrgencyScore (0-30 pts): bonus for using urgent/use-soon items
* nutritionBalanceScore (0-20 pts): how well it complements the day's existing meals vs targets
* varietyScore (0-10 pts): penalty for recently planned recipes
*
* Output per suggestion:
* - recipe (id, name, perServingNutrition)
* - score (total)
* - availableIngredients[]: items from pantry that match
* - missingIngredients[]: items not in pantry (with estimated cost from Phase 9 if available)
* - urgentIngredients[]: pantry items with urgency=urgent that this recipe would use
* - reasoning: human-readable explanation of why this recipe was suggested
*/
suggestRecipes(context: SuggestionContext): Promise<RecipeSuggestion[]>;
}
```
**Ingredient matching logic**:
- Match recipe ingredient's `productId` against pantry items with `status: sealed|opened`
- Check quantity: is there enough? (approximate — compare units, flag if unclear)
- Prefer items with higher freshness urgency
**Scoring weights** (configurable per household):
```typescript
const DEFAULT_WEIGHTS = {
ingredientCoverage: 40,
freshnessUrgency: 30,
nutritionBalance: 20,
variety: 10,
};
```
### 8.4 — Shopping Gap Analysis
- `GET /meal-plans/:id/shopping-gap`:
- For each recipe in the meal plan, list required ingredients
- Cross-reference with current pantry (available quantity vs needed quantity)
- Return:
```typescript
interface ShoppingGap {
coveredByPantry: ShoppingGapItem[]; // Already have enough
needToBuy: ShoppingGapItem[]; // Partially or fully missing
pantryItemsUsed: PantryItemUsage[]; // Which pantry items will be consumed
}
interface ShoppingGapItem {
productId: string;
productName: string;
totalNeeded: { quantity: number; unit: ServingUnit };
availableInPantry: { quantity: number; unit: ServingUnit };
shortfall: { quantity: number; unit: ServingUnit };
usedInRecipes: string[]; // Recipe names
}
```
- This output feeds directly into Phase 9's auto-generated shopping lists
### 8.5 — LLM Suggestion Placeholder
- `POST /meal-plans/suggest-with-llm`:
- Builds a context object: pantry summary, nutrition targets, dietary preferences
- Calls `ILlmProvider.suggestMealPlan(context)`
- With `NoOpLlmProvider`: returns `{ available: false }`
- When wired (Phase 10): returns a full week meal plan draft
### 8.6 — Web UI: Meal Planning
- `/meal-plans` page:
- **Weekly calendar grid**: 7 columns (MonSun) × 4 rows (Breakfast, Lunch, Dinner, Snack)
- Each cell: drop zone for recipes, shows meal name + calorie badge
- **Drag-and-drop**: drag recipes from suggestion panel or between cells
- **Daily nutrition summary row** at bottom: calories, protein, carbs, fat bars
- Color-coded vs user's nutrition targets (under = blue, on-target = green, over = red)
- **Week navigation**: previous/next week arrows
- **Suggestion panel** (sidebar or drawer):
- "Suggestions based on your pantry" — ranked list from suggestion engine
- Each suggestion shows: recipe name, match score, "Uses: [urgent items]", "Need to buy: [missing items]"
- Click to expand: full ingredient match breakdown
- "Add to plan" button → pick day + meal type
- **Shopping gap tab**: shows what's needed beyond pantry, "Generate shopping list" button (Phase 9 integration)
- `/nutrition-targets` settings:
- Daily macro targets form (calories, protein, carbs, fat)
- Preset templates: "Maintenance", "Weight loss", "Muscle gain", "Custom"
- Visual preview: donut chart of macro ratios
---
## Acceptance Criteria
- [ ] Can create a weekly meal plan and add meals to specific days/slots
- [ ] Daily nutrition totals are computed and displayed
- [ ] Suggestion engine returns ranked recipes based on pantry state
- [ ] Suggestions prioritize recipes using soon-to-expire pantry items
- [ ] Shopping gap analysis correctly identifies missing ingredients
- [ ] Drag-and-drop works in the weekly calendar UI
- [ ] Nutrition targets can be set per user
- [ ] Daily nutrition bars show progress vs targets
- [ ] Variety scoring penalizes recently used recipes
---
## Estimated Effort
Large. The suggestion engine scoring algorithm, shopping gap analysis, and calendar UI with drag-and-drop are all significant features.

View file

@ -0,0 +1,357 @@
# Phase 9 — Grocery & Price Tracking
**Goal**: Track food shopping across stores, compare prices over time, optimize where to buy. Auto-generate shopping lists from meal plans (Phase 8) or manually. Close the loop: when items are purchased, add them to the pantry (Phase 7). Reuses Store infrastructure from Phase 4.
**Depends on**: Phase 0, Phase 4 (stores), Phase 5 (products), Phase 7 (pantry), Phase 8 (meal planning)
---
## Deliverables
1. `Store`, `PriceRecord`, `ShoppingList` MongoDB schemas
2. Shopping list CRUD with real-time sync (WebSocket)
3. Auto-generate shopping lists from meal plan gaps
4. Price entry and history tracking
5. Price analytics: cheapest store per product, per shopping list, trends
6. Shopping → Pantry flow (checked items → add to pantry)
7. Web UI: shopping lists, price history charts, store comparison
---
## Data Model
### Store Schema
```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., 'organic', 'bulk', 'discount'
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
```
### PriceRecord Schema
```typescript
// packages/shared/src/types/price.ts
export interface PriceRecord {
id: string;
householdId: string;
productId: string;
productName: string; // Denormalized
storeId: string;
storeName: string; // Denormalized
price: number;
currency: string; // Default from household settings
quantity: number; // How much for this price
unit: ServingUnit;
pricePerUnit: number; // Computed: price / quantity (normalized)
date: Date;
receiptImageUrl?: string;
notes?: string;
createdBy: string;
createdAt: Date;
}
```
### ShoppingList Schema
```typescript
// packages/shared/src/types/shopping-list.ts
export interface ShoppingList {
id: string;
householdId: string;
name: string;
items: ShoppingItem[];
status: ShoppingListStatus;
createdFrom?: ShoppingListSource;
mealPlanId?: string;
totalEstimatedCost?: number; // Sum of estimated prices
preferredStoreId?: string;
completedAt?: Date;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface ShoppingItem {
id: string; // UUID for real-time sync reference
productId?: string; // Linked product (optional for custom items)
customName?: string; // For items not in product library
quantity: number;
unit: ServingUnit;
checked: boolean;
checkedAt?: Date;
checkedBy?: string; // userId who checked it off
estimatedPrice?: number; // From price history
actualPrice?: number; // Entered when checked off
storeId?: string; // Preferred store for this item
notes?: string;
category?: ProductCategory; // For grouping in shopping aisle order
addedToPantry: boolean; // Tracks if item was added to pantry after purchase
}
export enum ShoppingListStatus {
ACTIVE = 'active',
SHOPPING = 'shopping', // Currently at the store
COMPLETED = 'completed',
ARCHIVED = 'archived',
}
export interface ShoppingListSource {
type: 'meal_plan' | 'manual' | 'pantry_restock';
referenceId?: string; // MealPlan ID, etc.
}
```
### MongoDB Indexes
```javascript
// PriceRecord
{ householdId: 1, productId: 1, storeId: 1, date: -1 } // Price history per product per store
{ householdId: 1, productId: 1, date: -1 } // Price history per product (all stores)
{ householdId: 1, storeId: 1, date: -1 } // All purchases at a store
{ date: 1, expireAfterSeconds: 63072000 } // Optional TTL: 2 years
// ShoppingList
{ householdId: 1, status: 1 }
{ householdId: 1, createdAt: -1 }
// Store
{ householdId: 1, name: 1 }
```
---
## API Endpoints
### StoresModule
| Method | Path | Description | Auth |
| ------ | ------------- | ------------------------- | ------ |
| GET | `/stores` | List stores for household | member |
| POST | `/stores` | Add a store | member |
| PATCH | `/stores/:id` | Update store | member |
| DELETE | `/stores/:id` | Deactivate store | admin |
### PriceRecordsModule
| Method | Path | Description | Auth |
| ------ | ---------------------------- | ------------------------------------- | ------ |
| POST | `/prices` | Record a price | member |
| POST | `/prices/bulk` | Record multiple prices (from receipt) | member |
| GET | `/prices/history/:productId` | Price history for a product | member |
| GET | `/prices/compare/:productId` | Compare stores for a product | member |
| GET | `/prices/analytics` | Aggregated price analytics | member |
| POST | `/prices/parse-receipt` | LLM receipt parsing (placeholder) | member |
### ShoppingListsModule
| Method | Path | Description | Auth |
| ------ | ---------------------------------------- | ---------------------------------------------- | ------ |
| GET | `/shopping-lists` | List shopping lists | member |
| GET | `/shopping-lists/:id` | Get shopping list | member |
| POST | `/shopping-lists` | Create shopping list | member |
| PATCH | `/shopping-lists/:id` | Update list metadata | member |
| DELETE | `/shopping-lists/:id` | Delete list | admin |
| POST | `/shopping-lists/:id/items` | Add item to list | member |
| PATCH | `/shopping-lists/:id/items/:itemId` | Update item (check off, change qty, set price) | member |
| DELETE | `/shopping-lists/:id/items/:itemId` | Remove item from list | member |
| POST | `/shopping-lists/from-meal-plan/:planId` | Auto-generate from meal plan gap analysis | member |
| POST | `/shopping-lists/:id/add-to-pantry` | Move checked items to pantry | member |
| GET | `/shopping-lists/:id/store-comparison` | Best store(s) for this list | member |
---
## Tasks
### 9.1 — Shared Types & Validation
- Add all types above to `packages/shared`
- Zod schemas for all create/update operations
### 9.2 — Stores CRUD
- Standard CRUD, straightforward
### 9.3 — Price Record Service
```typescript
class PriceService {
/** Record a price, computing pricePerUnit */
recordPrice(data: CreatePriceRecord): Promise<PriceRecord>;
/** Get price history for a product, optionally filtered by store */
getPriceHistory(
productId: string,
householdId: string,
options?: {
storeId?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
},
): Promise<PriceRecord[]>;
/** Compare current prices across stores for a product */
compareStores(productId: string, householdId: string): Promise<StoreComparison[]>;
/** Estimate price for a product based on recent history */
estimatePrice(productId: string, householdId: string, storeId?: string): Promise<number | null>;
/** Detect significant price changes */
detectPriceChanges(householdId: string): Promise<PriceAlert[]>;
}
```
### 9.4 — Shopping List CRUD & Real-Time Sync
- Standard CRUD
- **WebSocket integration**: shopping list room per list ID
- Events: `shopping:item-checked`, `shopping:item-added`, `shopping:item-removed`, `shopping:item-updated`
- Enables multiple household members to shop simultaneously with real-time checkoff sync
- Optimistic updates on frontend with server reconciliation
### 9.5 — Auto-Generate from Meal Plan
- `POST /shopping-lists/from-meal-plan/:planId`:
1. Call Phase 8's shopping gap analysis for the meal plan
2. For each item in `needToBuy`:
- Create a `ShoppingItem` linked to the product
- Call `PriceService.estimatePrice()` to pre-fill estimated price
- Set `category` for store aisle grouping
3. Optionally group by cheapest store per item
4. Return the created shopping list
### 9.6 — Shopping → Pantry Flow
- `POST /shopping-lists/:id/add-to-pantry`:
- For each checked (purchased) item with `addedToPantry: false`:
- Create a `PantryItem` in Phase 7 (status: sealed, purchaseDate: today)
- If `actualPrice` was entered, create a `PriceRecord`
- Mark `addedToPantry: true`
- Return summary: `{ addedCount, priceRecordsCreated }`
### 9.7 — Price Analytics
- `GET /prices/analytics`:
```typescript
interface PriceAnalytics {
/** Average basket cost per store over the last N trips */
averageBasketByStore: {
storeId: string;
storeName: string;
avgTotal: number;
tripCount: number;
}[];
/** Products with significant price increases */
priceAlerts: PriceAlert[];
/** Total spending per period */
spendingOverTime: { period: string; total: number }[];
/** Most expensive categories */
spendingByCategory: { category: ProductCategory; total: number; avgPerItem: number }[];
}
interface PriceAlert {
productId: string;
productName: string;
storeId: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
date: Date;
}
```
### 9.8 — Store Comparison for Shopping List
- `GET /shopping-lists/:id/store-comparison`:
- For each item in the list, find the cheapest recent price per store
- Calculate total list cost per store
- Suggest: "Buy everything at Store A: $X" vs "Split between stores: $Y"
- Consider: is the savings worth going to multiple stores?
```typescript
interface StoreComparisonResult {
singleStoreOptions: {
storeId: string;
storeName: string;
estimatedTotal: number;
itemsCovered: number; // Not all stores carry all products
itemsMissing: string[];
}[];
splitStoreOption?: {
stores: { storeId: string; storeName: string; items: string[]; subtotal: number }[];
estimatedTotal: number;
savingsVsBestSingleStore: number;
};
}
```
### 9.9 — Receipt Parsing Placeholder
- `POST /prices/parse-receipt`:
- Accepts image upload
- Calls `ILlmProvider.parseReceipt(image)`
- Expected return: `{ storeName, date, items[]: { name, price, quantity } }`
- Match items to products (fuzzy), match store to stores
- With `NoOpLlmProvider`: returns `{ available: false }`
### 9.10 — Web UI: Grocery Management
- `/shopping-lists` page:
- Active lists at top, completed/archived below
- "New List" button (manual or from meal plan)
- Each list card: name, item count, estimated cost, completion %
- `/shopping-lists/:id` page (the "shopping mode"):
- Items grouped by category (aisle order)
- Each item: checkbox, name, quantity, estimated price
- Check off: expand to enter actual price (optional)
- Real-time sync indicator ("2 members shopping")
- "Done Shopping" button → prompts "Add items to pantry?"
- `/stores` page:
- Store list with CRUD
- Per-store: total spent, last visit, product count
- `/prices` page (analytics):
- Product search → price history line chart (per store, color-coded)
- Store comparison table
- Spending over time bar chart
- Price alerts panel
- **Shopping list widget on dashboard**: shows active lists with quick-check functionality
---
## Acceptance Criteria
- [ ] Can create shopping lists manually and from meal plans
- [ ] Shopping list items sync in real-time across household members via WebSocket
- [ ] Can record prices and view price history per product
- [ ] Store comparison recommends cheapest store for a shopping list
- [ ] Checked off items can be added to pantry with one action
- [ ] Price analytics show spending trends and alerts
- [ ] Auto-generated lists from meal plans correctly reflect shopping gap
- [ ] Receipt parsing endpoint delegates to LLM provider
---
## Estimated Effort
Large. Real-time shopping sync, price analytics aggregations, store comparison algorithm, and the shopping-to-pantry flow involve significant logic and UI.