Setup initial project
This commit is contained in:
commit
db79af06f7
119 changed files with 20761 additions and 0 deletions
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules
|
||||
.next
|
||||
dist
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
.env*
|
||||
.git
|
||||
docs/
|
||||
coverage
|
||||
*.md
|
||||
27
.env.example
Normal file
27
.env.example
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# MongoDB
|
||||
MONGO_PASSWORD=devpassword
|
||||
|
||||
# Keycloak
|
||||
KC_ADMIN_PASSWORD=admin
|
||||
|
||||
# API
|
||||
PORT=3001
|
||||
MONGODB_URI=mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin&replicaSet=rs0
|
||||
SEED_MONGODB_URI=mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin&directConnection=true
|
||||
KEYCLOAK_URL=http://localhost:8080
|
||||
KEYCLOAK_ISSUER_URL=http://localhost:8080
|
||||
KEYCLOAK_REALM=meshitrack
|
||||
KEYCLOAK_CLIENT_ID=meshitrack-api
|
||||
KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# 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
|
||||
KEYCLOAK_CLIENT_SECRET=dev-web-client-secret
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=dev-secret-change-in-production
|
||||
|
||||
# LLM (Phase 6)
|
||||
LLM_PROVIDER_TYPE=noop
|
||||
49
.github/copilot-instructions.md
vendored
Normal file
49
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# MeshiTrack — Copilot Instructions
|
||||
|
||||
This is a nutrition management application built as a TypeScript monorepo.
|
||||
|
||||
## Project Documentation
|
||||
|
||||
Before writing code, always consult the relevant documentation in the `docs/` directory:
|
||||
|
||||
- **`docs/PLAN.md`** — Project overview, tech stack, and phase roadmap
|
||||
- **`docs/architecture.md`** — Architecture Decision Records (ADRs)
|
||||
- **`docs/cross-cutting.md`** — API versioning, pagination, error handling, security, observability
|
||||
|
||||
## Phase Specifications
|
||||
|
||||
Each phase has a detailed spec with schemas, endpoints, and business logic:
|
||||
|
||||
- **`docs/phases/phase-0-foundation.md`** — Monorepo setup, Docker, Keycloak, auth
|
||||
- **`docs/phases/phase-1-product-library.md`** — Product CRUD, barcode lookup, LLM interface
|
||||
- **`docs/phases/phase-2-recipes.md`** — Recipes, nutrition calculation, LLM import
|
||||
- **`docs/phases/phase-3-pantry.md`** — Pantry tracking, freshness, notifications
|
||||
- **`docs/phases/phase-4-meal-planning.md`** — Meal plans, nutrition targets, suggestion engine
|
||||
- **`docs/phases/phase-5-grocery.md`** — Shopping lists, price tracking, store comparison
|
||||
- **`docs/phases/phase-6-llm.md`** — LLM provider implementations, NLP parsing
|
||||
|
||||
## Coding Instructions (MUST READ)
|
||||
|
||||
The `docs/instructions/` directory contains best practices and conventions that **must** be followed:
|
||||
|
||||
- **`docs/instructions/conventions.md`** — Naming, formatting, linting, git conventions
|
||||
- **`docs/instructions/fastify.md`** — Fastify plugin structure, Awilix DI, validation, hooks, repository pattern
|
||||
- **`docs/instructions/nextjs.md`** — App Router, Server/Client Components, data fetching, forms
|
||||
- **`docs/instructions/mongodb.md`** — Schema design, indexing, queries, pagination
|
||||
- **`docs/instructions/typescript-zod.md`** — Type design, Zod schemas, shared package rules
|
||||
- **`docs/instructions/turborepo.md`** — Monorepo workspace config, build pipelines, dependencies
|
||||
- **`docs/instructions/docker.md`** — Compose services, Dockerfiles, env vars, health checks
|
||||
- **`docs/instructions/keycloak.md`** — Auth integration, JWT claims, guards, token refresh
|
||||
- **`docs/instructions/testing.md`** — Vitest unit/integration/E2E test patterns, coverage targets
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **All domain types and Zod schemas live in `packages/shared`** — never duplicate types across packages.
|
||||
2. **Every data query must filter by `householdId`** — this is the multi-tenancy boundary.
|
||||
3. **Use Server Components by default in Next.js** — only add `'use client'` when interactivity is needed.
|
||||
4. **Use the repository pattern in Fastify** — routes → services → repositories (Awilix DI).
|
||||
5. **Use `.lean().exec()` on all Mongoose read queries**.
|
||||
6. **No `any` types** — use `unknown` and validate with Zod if the type is truly unknown.
|
||||
7. **Cursor-based pagination** — never use `skip()` for large collections.
|
||||
8. **ESM everywhere** — `"type": "module"`, `.js` extensions on imports, `import type` for type-only imports.
|
||||
9. **Zod v4** — import from `'zod/v4'`, use `z.enum()` for enums, `z.email()` / `z.url()` as top-level.
|
||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
node_modules
|
||||
dist
|
||||
.next
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
coverage
|
||||
.nyc_output
|
||||
mongo-data
|
||||
|
||||
# Compiled output that may land in source dirs
|
||||
packages/*/src/**/*.js
|
||||
packages/*/src/**/*.js.map
|
||||
packages/*/src/**/*.d.ts
|
||||
packages/*/src/**/*.d.ts.map
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
9
.prettierrc
Normal file
9
.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
14
.vscode/settings.json
vendored
Normal file
14
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"eslint.workingDirectories": ["packages/api", "packages/shared", "packages/web"],
|
||||
"chat.tools.terminal.enableAutoApprove": true,
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"/^npm run \\w+$/": true
|
||||
}
|
||||
}
|
||||
124
CLAUDE.md
Normal file
124
CLAUDE.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
MeshiTrack is a self-hosted medicine & nutrition management platform. It is a TypeScript monorepo with three packages: `packages/api` (Fastify backend), `packages/web` (Next.js frontend), and `packages/shared` (types + Zod schemas used by both). Medicine tracking is implemented first (Phases 1-4), followed by food tracking (Phases 5-9).
|
||||
|
||||
## Commands
|
||||
|
||||
### Root (all packages via Turborepo)
|
||||
```bash
|
||||
npm run dev # Start all services in dev mode
|
||||
npm run build # Build all packages (shared → api/web)
|
||||
npm run test # Run all tests
|
||||
npm run test:cov # Run all tests with coverage
|
||||
npm run lint # Lint all packages
|
||||
npm run lint-fix # Auto-fix lint issues
|
||||
npm run typecheck # Type-check all packages
|
||||
npm run clean # Remove build artifacts
|
||||
npm run seed # Seed the database (delegates to packages/api)
|
||||
```
|
||||
|
||||
### Single package
|
||||
```bash
|
||||
npm run test -w packages/api # Run API tests
|
||||
npm run test:cov -w packages/api # API tests with coverage
|
||||
npm run test -- --watch -w packages/api # Watch mode
|
||||
npm run dev -w packages/api # API dev server only
|
||||
```
|
||||
|
||||
### API package (packages/api)
|
||||
```bash
|
||||
npm run dev # tsx watch src/main.ts
|
||||
npm run build # tsc
|
||||
npm run seed # tsx src/scripts/seed.ts
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d # Start all services
|
||||
docker compose -f docker/docker-compose.yml down # Stop all services
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
- **`packages/shared`** — Single source of truth for all domain types, enums, and Zod v4 schemas. Consumed by both `api` and `web`. Must be pure TypeScript with no Node.js, browser, or framework dependencies.
|
||||
- **`packages/api`** — Fastify 5 backend. ESM-only, TypeScript strict. Uses Awilix for DI, Mongoose 9 for MongoDB, jose 6 for JWT verification.
|
||||
- **`packages/web`** — Next.js 16 (React 19) frontend. App Router, Tailwind CSS 4.
|
||||
|
||||
### API Layer Architecture (Fastify + Awilix)
|
||||
The API follows a **routes → services → repositories** pattern with Awilix constructor-injection DI:
|
||||
|
||||
- Each domain feature lives in `src/modules/<feature>/` with files: `*.routes.ts`, `*.service.ts`, `*.repository.ts`
|
||||
- Route plugins use `fastify-plugin` (`fp()`) to export routes and register Awilix dependencies
|
||||
- Services receive dependencies via destructured constructor: `constructor({ productsRepository }: { productsRepository: ProductsRepository })`
|
||||
- Resolve services per-request via `request.diScope.resolve<T>('serviceName')`
|
||||
- All Mongoose read queries must use `.lean().exec()`
|
||||
|
||||
Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules.
|
||||
|
||||
### Domain Modules
|
||||
**Medicine domain** (Phases 1-4): `medicines/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `refills/`
|
||||
**Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/`
|
||||
**Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/`
|
||||
|
||||
### Multi-tenancy
|
||||
Every domain document is scoped to a `householdId`. A Fastify `preHandler` hook validates the `householdId` from the URI against the user's `householdIds[]` JWT claim. **Every data query must filter by `householdId`.**
|
||||
|
||||
Routes can opt out with `config: { public: true }` (skips auth) or `config: { skipHousehold: true }` (skips household validation).
|
||||
|
||||
### Auth
|
||||
Keycloak is the OIDC provider. The API verifies JWTs via `jose`. The custom Keycloak protocol mapper injects `householdIds[]` into the JWT claims.
|
||||
|
||||
### Shared Package Rules
|
||||
- All domain types and Zod schemas live here — never duplicate types across packages
|
||||
- Import from `'zod/v4'` (not `'zod'`)
|
||||
- Use `z.enum()` for enums, `z.email()` / `z.url()` as top-level calls
|
||||
- Every directory has a barrel `index.ts`
|
||||
- Use `import type` for type-only imports
|
||||
|
||||
### Pagination
|
||||
All list endpoints use cursor-based pagination. **Never use `skip()`** on MongoDB queries. Response shape:
|
||||
```typescript
|
||||
{ data: T[], pagination: { cursor: string | null, hasMore: boolean, total?: number } }
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `ForbiddenError`, etc.). The global Fastify error handler maps them to the standard `ApiError` response shape (`statusCode`, `error`, `message`, `timestamp`, `path`).
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **No `any` types** — use `unknown` + Zod validation at boundaries
|
||||
2. **ESM everywhere** — `"type": "module"`, `.js` extensions on all imports, `import type` for type-only
|
||||
3. **Cursor-based pagination only** — never `skip()` for large collections
|
||||
4. **Zod v4** — import from `'zod/v4'`
|
||||
5. **`.lean().exec()`** on all Mongoose read queries
|
||||
6. **`householdId` filter** on every domain query — this is the multi-tenancy boundary
|
||||
7. **No emojis** — never use emoji characters in source code, UI text, console output, or documentation
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests**: Vitest, co-located with source files as `*.routes.test.ts` / `*.service.test.ts`
|
||||
- **Integration tests**: Vitest + `mongodb-memory-server` (`*.integration.test.ts`)
|
||||
- **Component tests**: React Testing Library (`__tests__/*.test.tsx`)
|
||||
- **E2E**: Playwright (root-level `e2e/`)
|
||||
- **Use `ClassName.name` for `describe` labels** (not string literals)
|
||||
- Coverage targets: 100% lines/functions/statements for `packages/api` and `packages/shared`; 90% branches
|
||||
- Mark untestable lines with `/* v8 ignore */`
|
||||
|
||||
## Documentation
|
||||
|
||||
Before writing code, consult the relevant docs:
|
||||
- `docs/instructions/` — coding conventions, Fastify patterns, Next.js patterns, MongoDB, Zod/TypeScript, testing, Docker, Keycloak, Turborepo
|
||||
- `docs/phases/` — per-phase specs with schemas, endpoints, and business logic
|
||||
- `docs/architecture.md` — ADRs explaining key technology choices
|
||||
- `docs/cross-cutting.md` — API versioning, pagination, security, audit trail
|
||||
|
||||
## Git Conventions
|
||||
|
||||
Branches: `feature/MESH-001-description`, `fix/MESH-042-description`, `chore/description`
|
||||
|
||||
Commits follow Conventional Commits: `feat(products): add barcode lookup`, `fix(pantry): correct freshness calc`
|
||||
66
deploy.ps1
Normal file
66
deploy.ps1
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Rebuild and redeploy MeshiTrack Docker images.
|
||||
|
||||
.PARAMETER Services
|
||||
One or more services to rebuild. Defaults to 'api' and 'web'.
|
||||
Valid values: api, web
|
||||
|
||||
.PARAMETER NoCache
|
||||
Pass --no-cache to docker compose build.
|
||||
|
||||
.PARAMETER Target
|
||||
Build target: 'development' (default) or 'production'.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1
|
||||
.\deploy.ps1 -Services api
|
||||
.\deploy.ps1 -NoCache
|
||||
.\deploy.ps1 -Target production -NoCache
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('api', 'web')]
|
||||
[string[]]$Services = @('api', 'web'),
|
||||
|
||||
[switch]$NoCache,
|
||||
|
||||
[ValidateSet('development', 'production')]
|
||||
[string]$Target = 'development'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ComposeFile = Join-Path $PSScriptRoot 'docker\docker-compose.yml'
|
||||
|
||||
if (-not (Test-Path $ComposeFile)) {
|
||||
Write-Error "docker-compose.yml not found at: $ComposeFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$BuildArgs = @('compose', '-f', $ComposeFile, 'build', '--build-arg', "target=$Target")
|
||||
if ($NoCache) {
|
||||
$BuildArgs += '--no-cache'
|
||||
}
|
||||
$BuildArgs += $Services
|
||||
|
||||
Write-Host "Building: $($Services -join ', ') [target=$Target$(if ($NoCache) { ', no-cache' })]"
|
||||
& docker @BuildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "docker compose build failed (exit $LASTEXITCODE)"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$UpArgs = @('compose', '-f', $ComposeFile, 'up', '-d', '--force-recreate')
|
||||
$UpArgs += $Services
|
||||
|
||||
Write-Host "Deploying: $($Services -join ', ')"
|
||||
& docker @UpArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "docker compose up failed (exit $LASTEXITCODE)"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Done. Running containers:"
|
||||
& docker compose -f $ComposeFile ps
|
||||
10
docker/.dockerignore
Normal file
10
docker/.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules
|
||||
.next
|
||||
dist
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
.env*
|
||||
.git
|
||||
docs/
|
||||
coverage
|
||||
*.md
|
||||
40
docker/Dockerfile.api
Normal file
40
docker/Dockerfile.api
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# ---- Base ----
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# ---- Dependencies ----
|
||||
FROM base AS dependencies
|
||||
COPY package.json package-lock.json* .npmrc* ./
|
||||
COPY packages/api/package.json ./packages/api/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
RUN npm install --workspace=packages/shared --workspace=packages/api 2>/dev/null || npm install
|
||||
|
||||
# ---- 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
|
||||
ENV NODE_ENV=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/node_modules ./packages/api/node_modules 2>/dev/null || true
|
||||
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 ./
|
||||
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 ["node_modules/.bin/tsx", "watch", "packages/api/src/main.ts"]
|
||||
42
docker/Dockerfile.web
Normal file
42
docker/Dockerfile.web
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# ---- Base ----
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# ---- Dependencies ----
|
||||
FROM base AS dependencies
|
||||
COPY package.json package-lock.json* .npmrc* ./
|
||||
COPY packages/web/package.json ./packages/web/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
RUN npm install --workspace=packages/shared --workspace=packages/web 2>/dev/null || npm install
|
||||
|
||||
# ---- Build ----
|
||||
FROM dependencies AS build
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY packages/web/ ./packages/web/
|
||||
COPY tsconfig.base.json ./
|
||||
RUN npm run build --workspace=packages/shared
|
||||
RUN npm run build --workspace=packages/web
|
||||
|
||||
# ---- Production ----
|
||||
FROM base AS production
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/packages/web/.next ./packages/web/.next
|
||||
COPY --from=build /app/packages/web/public ./packages/web/public
|
||||
COPY --from=build /app/packages/web/package.json ./packages/web/
|
||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=build /app/packages/shared/package.json ./packages/shared/
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./
|
||||
EXPOSE 3000
|
||||
WORKDIR /app/packages/web
|
||||
CMD ["node", "/app/node_modules/.bin/next", "start", "-p", "3000"]
|
||||
|
||||
# ---- Development ----
|
||||
FROM dependencies AS development
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY packages/web/ ./packages/web/
|
||||
COPY tsconfig.base.json ./
|
||||
RUN npm run build --workspace=packages/shared
|
||||
EXPOSE 3000
|
||||
WORKDIR /app/packages/web
|
||||
CMD ["node", "/app/node_modules/.bin/next", "dev", "--port", "3000", "--turbopack"]
|
||||
139
docker/docker-compose.yml
Normal file
139
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
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
|
||||
- ./mongo/keyfile:/tmp/mongo-keyfile:ro
|
||||
- ./mongo/docker-entrypoint-init.sh:/usr/local/bin/docker-entrypoint-init.sh:ro
|
||||
entrypoint: ['/bin/bash', '/usr/local/bin/docker-entrypoint-init.sh']
|
||||
command: ['--replSet', 'rs0', '--bind_ip_all', '--keyFile', '/etc/mongodb/keyfile']
|
||||
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-file
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin}
|
||||
KC_HEALTH_ENABLED: 'true'
|
||||
KC_HOSTNAME_URL: http://localhost:8080
|
||||
command: start-dev --import-realm
|
||||
volumes:
|
||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'bash',
|
||||
'-c',
|
||||
"exec 3<>/dev/tcp/127.0.0.1/8080 && echo -e 'GET /health/ready HTTP/1.0\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'UP'",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 15
|
||||
start_period: 60s
|
||||
|
||||
mongo-init:
|
||||
image: mongo:7
|
||||
container_name: meshitrack-mongo-init
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
mongosh --host mongodb:27017
|
||||
--username meshitrack
|
||||
--password ${MONGO_PASSWORD:-devpassword}
|
||||
--authenticationDatabase admin
|
||||
--eval "
|
||||
try { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }
|
||||
catch(e) { if(e.codeName !== 'AlreadyInitialized') throw e }
|
||||
"
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.api
|
||||
target: development
|
||||
container_name: meshitrack-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3001:3001'
|
||||
depends_on:
|
||||
mongo-init:
|
||||
condition: service_completed_successfully
|
||||
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_ISSUER_URL: http://localhost:8080
|
||||
KEYCLOAK_REALM: meshitrack
|
||||
KEYCLOAK_CLIENT_ID: meshitrack-api
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
volumes:
|
||||
- ../packages/api/src:/app/packages/api/src
|
||||
- ../packages/shared/src:/app/packages/shared/src
|
||||
|
||||
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
|
||||
KEYCLOAK_URL: http://keycloak:8080
|
||||
KEYCLOAK_REALM: meshitrack
|
||||
KEYCLOAK_CLIENT_ID: meshitrack-web
|
||||
KEYCLOAK_CLIENT_SECRET: dev-web-client-secret
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-dev-secret-change-in-production}
|
||||
volumes:
|
||||
- ../packages/web/src:/app/packages/web/src
|
||||
|
||||
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:
|
||||
123
docker/keycloak/realm-export.json
Normal file
123
docker/keycloak/realm-export.json
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
"realm": "meshitrack",
|
||||
"enabled": true,
|
||||
"displayName": "MeshiTrack",
|
||||
"registrationAllowed": true,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"editUsernameAllowed": false,
|
||||
"sslRequired": "none",
|
||||
"accessTokenLifespan": 1800,
|
||||
"ssoSessionIdleTimeout": 86400,
|
||||
"ssoSessionMaxLifespan": 604800,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Administrator role - can manage household settings",
|
||||
"composite": false
|
||||
},
|
||||
{
|
||||
"name": "member",
|
||||
"description": "Standard member role",
|
||||
"composite": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"defaultRoles": ["member"],
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "meshitrack-web",
|
||||
"name": "MeshiTrack Web App",
|
||||
"enabled": true,
|
||||
"publicClient": false,
|
||||
"secret": "dev-web-client-secret",
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"redirectUris": [
|
||||
"http://localhost:3000/*",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3000/api/auth/callback/keycloak"
|
||||
],
|
||||
"webOrigins": ["http://localhost:3000"],
|
||||
"attributes": {},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "audience-mapper",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"config": {
|
||||
"included.client.audience": "meshitrack-api",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "meshitrack-api",
|
||||
"name": "MeshiTrack API",
|
||||
"enabled": true,
|
||||
"publicClient": false,
|
||||
"bearerOnly": true,
|
||||
"standardFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "testuser1",
|
||||
"enabled": true,
|
||||
"email": "testuser1@meshitrack.local",
|
||||
"firstName": "Test",
|
||||
"lastName": "User1",
|
||||
"emailVerified": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "test1234",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": ["member", "admin"],
|
||||
"attributes": {
|
||||
"householdIds": ["[\"000000000000000000000001\"]"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "testuser2",
|
||||
"enabled": true,
|
||||
"email": "testuser2@meshitrack.local",
|
||||
"firstName": "Test",
|
||||
"lastName": "User2",
|
||||
"emailVerified": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "test1234",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": ["member"],
|
||||
"attributes": {
|
||||
"householdIds": ["[\"000000000000000000000001\"]"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
7
docker/mongo/docker-entrypoint-init.sh
Normal file
7
docker/mongo/docker-entrypoint-init.sh
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
# Copy the mounted keyfile and set required permissions
|
||||
mkdir -p /etc/mongodb
|
||||
cp /tmp/mongo-keyfile /etc/mongodb/keyfile
|
||||
chmod 400 /etc/mongodb/keyfile
|
||||
chown mongodb:mongodb /etc/mongodb/keyfile
|
||||
exec docker-entrypoint.sh "$@"
|
||||
15
docker/mongo/init-replica.js
Normal file
15
docker/mongo/init-replica.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// MongoDB replica set initialization script
|
||||
// Runs on first startup via /docker-entrypoint-initdb.d/
|
||||
try {
|
||||
rs.initiate({
|
||||
_id: 'rs0',
|
||||
members: [{ _id: 0, host: 'mongodb:27017' }],
|
||||
});
|
||||
print('Replica set initiated successfully');
|
||||
} catch (e) {
|
||||
if (e.codeName === 'AlreadyInitialized') {
|
||||
print('Replica set already initialized');
|
||||
} else {
|
||||
print('Error initiating replica set: ' + e.message);
|
||||
}
|
||||
}
|
||||
127
docs/PLAN.md
Normal file
127
docs/PLAN.md
Normal 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
233
docs/architecture.md
Normal 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
225
docs/cross-cutting.md
Normal 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)
|
||||
259
docs/instructions/conventions.md
Normal file
259
docs/instructions/conventions.md
Normal 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
331
docs/instructions/docker.md
Normal 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
|
||||
324
docs/instructions/fastify.md
Normal file
324
docs/instructions/fastify.md
Normal 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' });
|
||||
});
|
||||
});
|
||||
```
|
||||
347
docs/instructions/keycloak.md
Normal file
347
docs/instructions/keycloak.md
Normal 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.
|
||||
392
docs/instructions/mongodb.md
Normal file
392
docs/instructions/mongodb.md
Normal 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
470
docs/instructions/nextjs.md
Normal 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)
|
||||
515
docs/instructions/testing.md
Normal file
515
docs/instructions/testing.md
Normal 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
|
||||
```
|
||||
291
docs/instructions/turborepo.md
Normal file
291
docs/instructions/turborepo.md
Normal 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`.
|
||||
360
docs/instructions/typescript-zod.md
Normal file
360
docs/instructions/typescript-zod.md
Normal 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';
|
||||
```
|
||||
222
docs/phases/phase-0-foundation.md
Normal file
222
docs/phases/phase-0-foundation.md
Normal 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.
|
||||
214
docs/phases/phase-1-medicine-library.md
Normal file
214
docs/phases/phase-1-medicine-library.md
Normal 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
315
docs/phases/phase-10-llm.md
Normal 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)
|
||||
252
docs/phases/phase-2-medicine-cabinet.md
Normal file
252
docs/phases/phase-2-medicine-cabinet.md
Normal 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).
|
||||
354
docs/phases/phase-3-regimens-pill-organizer.md
Normal file
354
docs/phases/phase-3-regimens-pill-organizer.md
Normal 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.
|
||||
360
docs/phases/phase-4-pharmacies-prices-refills.md
Normal file
360
docs/phases/phase-4-pharmacies-prices-refills.md
Normal 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.
|
||||
254
docs/phases/phase-5-product-library.md
Normal file
254
docs/phases/phase-5-product-library.md
Normal 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.
|
||||
237
docs/phases/phase-6-recipes.md
Normal file
237
docs/phases/phase-6-recipes.md
Normal 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.
|
||||
346
docs/phases/phase-7-pantry.md
Normal file
346
docs/phases/phase-7-pantry.md
Normal 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.
|
||||
269
docs/phases/phase-8-meal-planning.md
Normal file
269
docs/phases/phase-8-meal-planning.md
Normal 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 (Monday–Sunday)
|
||||
- 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 (Mon–Sun) × 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.
|
||||
357
docs/phases/phase-9-grocery.md
Normal file
357
docs/phases/phase-9-grocery.md
Normal 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.
|
||||
9115
package-lock.json
generated
Normal file
9115
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "meshitrack",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "npm@11.6.2",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"lint-fix": "turbo run lint-fix",
|
||||
"test": "turbo run test",
|
||||
"test:cov": "turbo run test:cov",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"clean": "turbo run clean",
|
||||
"seed": "npm run seed --workspace=packages/api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"prettier": "^3.8.1",
|
||||
"turbo": "^2.8.20",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.57.2"
|
||||
}
|
||||
}
|
||||
40
packages/api/eslint.config.js
Normal file
40
packages/api/eslint.config.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import prettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/**', 'coverage/**', 'eslint.config.js'] },
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname, // points to packages/api
|
||||
project: ['./tsconfig.json', './tsconfig.test.json'],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Relax some rules in test files
|
||||
files: ['**/*.test.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/explicit-member-accessibility': 'off',
|
||||
},
|
||||
},
|
||||
prettierRecommended,
|
||||
);
|
||||
47
packages/api/package.json
Normal file
47
packages/api/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@meshitrack/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src",
|
||||
"lint-fix": "eslint src --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
||||
"seed": "tsx --env-file ../../.env src/scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/awilix": "^8.2.0",
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@fastify/swagger-ui": "^5.2.5",
|
||||
"@meshitrack/shared": "*",
|
||||
"awilix": "^13.0.3",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fastify-type-provider-zod": "^6.1.0",
|
||||
"jose": "^6.2.2",
|
||||
"mongoose": "^9.3.3",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"rimraf": "^6.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
85
packages/api/src/common/errors.test.ts
Normal file
85
packages/api/src/common/errors.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
AppError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
ConflictError,
|
||||
BadRequestError,
|
||||
} from './errors.js';
|
||||
|
||||
describe(AppError.name, () => {
|
||||
it('sets statusCode, error, message, and details', () => {
|
||||
const details = { field: ['required'] };
|
||||
const err = new AppError(422, 'Unprocessable', 'Bad input', details);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
expect(err.statusCode).toBe(422);
|
||||
expect(err.error).toBe('Unprocessable');
|
||||
expect(err.message).toBe('Bad input');
|
||||
expect(err.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('works without details', () => {
|
||||
const err = new AppError(500, 'Internal', 'Oops');
|
||||
expect(err.details).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(NotFoundError.name, () => {
|
||||
it('defaults to 404 with standard message', () => {
|
||||
const err = new NotFoundError();
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.error).toBe('Not Found');
|
||||
expect(err.message).toBe('Resource not found');
|
||||
});
|
||||
|
||||
it('accepts custom message', () => {
|
||||
const err = new NotFoundError('User not found');
|
||||
expect(err.message).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe(UnauthorizedError.name, () => {
|
||||
it('defaults to 401', () => {
|
||||
const err = new UnauthorizedError();
|
||||
expect(err.statusCode).toBe(401);
|
||||
expect(err.error).toBe('Unauthorized');
|
||||
expect(err.message).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe(ForbiddenError.name, () => {
|
||||
it('defaults to 403', () => {
|
||||
const err = new ForbiddenError();
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err.error).toBe('Forbidden');
|
||||
expect(err.message).toBe('Forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe(ConflictError.name, () => {
|
||||
it('defaults to 409', () => {
|
||||
const err = new ConflictError();
|
||||
expect(err.statusCode).toBe(409);
|
||||
expect(err.error).toBe('Conflict');
|
||||
expect(err.message).toBe('Conflict');
|
||||
});
|
||||
});
|
||||
|
||||
describe(BadRequestError.name, () => {
|
||||
it('defaults to 400', () => {
|
||||
const err = new BadRequestError();
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err.error).toBe('Bad Request');
|
||||
expect(err.message).toBe('Bad Request');
|
||||
});
|
||||
|
||||
it('accepts message and details', () => {
|
||||
const details = { name: ['too short'] };
|
||||
const err = new BadRequestError('Invalid input', details);
|
||||
expect(err.message).toBe('Invalid input');
|
||||
expect(err.details).toEqual(details);
|
||||
});
|
||||
});
|
||||
49
packages/api/src/common/errors.ts
Normal file
49
packages/api/src/common/errors.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export class AppError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly error: string;
|
||||
public readonly details?: Record<string, string[]>;
|
||||
|
||||
public constructor(
|
||||
statusCode: number,
|
||||
error: string,
|
||||
message: string,
|
||||
details?: Record<string, string[]>,
|
||||
) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.error = error;
|
||||
this.details = details;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
public constructor(message = 'Resource not found') {
|
||||
super(404, 'Not Found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
public constructor(message = 'Unauthorized') {
|
||||
super(401, 'Unauthorized', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
public constructor(message = 'Forbidden') {
|
||||
super(403, 'Forbidden', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
public constructor(message = 'Conflict') {
|
||||
super(409, 'Conflict', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends AppError {
|
||||
public constructor(message = 'Bad Request', details?: Record<string, string[]>) {
|
||||
super(400, 'Bad Request', message, details);
|
||||
}
|
||||
}
|
||||
24
packages/api/src/common/types.ts
Normal file
24
packages/api/src/common/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type mongoose from 'mongoose';
|
||||
|
||||
export interface AuthUser {
|
||||
keycloakId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
householdIds: string[];
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user: AuthUser;
|
||||
householdId: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
mongoose: typeof mongoose;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface RequestCradle {}
|
||||
}
|
||||
14
packages/api/src/config/configuration.test.ts
Normal file
14
packages/api/src/config/configuration.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import config from './configuration.js';
|
||||
|
||||
describe('configuration', () => {
|
||||
it('exports default config values', () => {
|
||||
expect(config.port).toBe(3001);
|
||||
expect(config.mongodb.uri).toContain('mongodb://');
|
||||
expect(config.keycloak.url).toBe('http://localhost:8080');
|
||||
expect(config.keycloak.issuerUrl).toBe('http://localhost:8080');
|
||||
expect(config.keycloak.realm).toBe('meshitrack');
|
||||
expect(config.keycloak.clientId).toBe('meshitrack-api');
|
||||
expect(config.cors.origin).toBe('http://localhost:3000');
|
||||
});
|
||||
});
|
||||
21
packages/api/src/config/configuration.ts
Normal file
21
packages/api/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const config = {
|
||||
port: parseInt(process.env['PORT'] || '3001', 10),
|
||||
mongodb: {
|
||||
uri:
|
||||
process.env['MONGODB_URI'] ||
|
||||
'mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin&replicaSet=rs0',
|
||||
},
|
||||
keycloak: {
|
||||
url: process.env['KEYCLOAK_URL'] || 'http://localhost:8080',
|
||||
issuerUrl:
|
||||
process.env['KEYCLOAK_ISSUER_URL'] || process.env['KEYCLOAK_URL'] || 'http://localhost:8080',
|
||||
realm: process.env['KEYCLOAK_REALM'] || 'meshitrack',
|
||||
clientId: process.env['KEYCLOAK_CLIENT_ID'] || 'meshitrack-api',
|
||||
},
|
||||
cors: {
|
||||
origin: process.env['CORS_ORIGIN'] || 'http://localhost:3000',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Config = typeof config;
|
||||
export default config;
|
||||
188
packages/api/src/main.test.ts
Normal file
188
packages/api/src/main.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock mongoose to prevent real DB connections; provide class-based models
|
||||
vi.mock('mongoose', () => {
|
||||
class FakeSchema {
|
||||
paths: Record<string, unknown> = {};
|
||||
constructor(def: Record<string, unknown>, _opts?: unknown) {
|
||||
for (const key of Object.keys(def)) {
|
||||
this.paths[key] = { path: key };
|
||||
}
|
||||
this.paths['createdAt'] = { path: 'createdAt' };
|
||||
this.paths['updatedAt'] = { path: 'updatedAt' };
|
||||
}
|
||||
}
|
||||
|
||||
const models: Record<string, unknown> = {};
|
||||
|
||||
function createFakeModel(name: string) {
|
||||
const mockExec = vi.fn().mockResolvedValue(null);
|
||||
const mockLean = vi.fn(() => ({ exec: mockExec }));
|
||||
|
||||
class Model {
|
||||
_data: Record<string, unknown>;
|
||||
constructor(data: Record<string, unknown>) {
|
||||
this._data = data;
|
||||
Object.assign(this, data);
|
||||
}
|
||||
save() {
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
toObject() {
|
||||
return { _id: `${name}-id`, ...this._data };
|
||||
}
|
||||
static modelName = name;
|
||||
static schema = new FakeSchema({});
|
||||
static findOne = vi.fn(() => ({ lean: mockLean }));
|
||||
static findById = vi.fn(() => ({ lean: mockLean }));
|
||||
static findOneAndUpdate = vi.fn(() => ({ exec: mockExec }));
|
||||
static findByIdAndUpdate = vi.fn(() => ({ exec: mockExec }));
|
||||
}
|
||||
|
||||
return Model;
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
Schema: FakeSchema,
|
||||
model: vi.fn((name: string, _schema?: unknown) => {
|
||||
if (!models[name]) models[name] = createFakeModel(name);
|
||||
return models[name];
|
||||
}),
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildApp } from './main.js';
|
||||
import * as jose from 'jose';
|
||||
import { NotFoundError } from './common/errors.js';
|
||||
|
||||
describe('buildApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates a Fastify app that is ready', async () => {
|
||||
const app = await buildApp({ logger: false });
|
||||
await app.ready();
|
||||
expect(app).toBeDefined();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('has the health route available', async () => {
|
||||
const app = await buildApp({ logger: false });
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().status).toBe('ok');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handler', () => {
|
||||
async function getApp() {
|
||||
// Set up a valid JWT mock for authenticated routes
|
||||
vi.mocked(jose.jwtVerify).mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {} as never,
|
||||
} as never);
|
||||
|
||||
const app = await buildApp({ logger: false });
|
||||
|
||||
// Register test routes that throw various errors
|
||||
app.get('/test/app-error', { config: { public: true } as never }, async () => {
|
||||
throw new NotFoundError('Test not found');
|
||||
});
|
||||
|
||||
app.get('/test/generic-error', { config: { public: true } as never }, async () => {
|
||||
const err = new Error('Something broke');
|
||||
(err as unknown as Record<string, unknown>).statusCode = 422;
|
||||
throw err;
|
||||
});
|
||||
|
||||
app.get('/test/unknown-error', { config: { public: true } as never }, async () => {
|
||||
throw new Error('Unexpected');
|
||||
});
|
||||
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('handles AppError with correct status and body', async () => {
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/test/app-error' });
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = res.json();
|
||||
expect(body.error).toBe('Not Found');
|
||||
expect(body.message).toBe('Test not found');
|
||||
expect(body.timestamp).toBeDefined();
|
||||
expect(body.path).toBe('/test/app-error');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('handles generic errors with statusCode', async () => {
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/test/generic-error' });
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const body = res.json();
|
||||
expect(body.error).toBe('Error');
|
||||
expect(body.message).toBe('Something broke');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('handles unknown 500 errors without leaking messages', async () => {
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/test/unknown-error' });
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
const body = res.json();
|
||||
expect(body.error).toBe('Internal Server Error');
|
||||
expect(body.message).toBe('An unexpected error occurred');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 404 for unknown routes', async () => {
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/nonexistent',
|
||||
headers: {
|
||||
authorization: 'Bearer test-token',
|
||||
'x-household-id': 'hh1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
186
packages/api/src/main.ts
Normal file
186
packages/api/src/main.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import compress from '@fastify/compress';
|
||||
import swagger from '@fastify/swagger';
|
||||
import swaggerUi from '@fastify/swagger-ui';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import {
|
||||
serializerCompiler,
|
||||
validatorCompiler,
|
||||
jsonSchemaTransform,
|
||||
hasZodFastifySchemaValidationErrors,
|
||||
isResponseSerializationError,
|
||||
} from 'fastify-type-provider-zod';
|
||||
import type { ApiError } from '@meshitrack/shared';
|
||||
import config from './config/configuration.js';
|
||||
import { AppError } from './common/errors.js';
|
||||
|
||||
// Import types to enable declaration merging
|
||||
import './common/types.js';
|
||||
|
||||
// Import plugins
|
||||
import mongoosePlugin from './plugins/mongoose.plugin.js';
|
||||
import authPlugin from './plugins/auth.plugin.js';
|
||||
import householdPlugin from './plugins/household.plugin.js';
|
||||
|
||||
// Import route modules
|
||||
import healthRoutes from './modules/health/health.routes.js';
|
||||
import usersRoutes from './modules/users/users.routes.js';
|
||||
import householdsRoutes from './modules/households/households.routes.js';
|
||||
|
||||
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||
const app = Fastify({
|
||||
logger: opts.logger ?? {
|
||||
level: 'info',
|
||||
...(process.env['NODE_ENV'] !== 'production' ? { transport: { target: 'pino-pretty' } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Zod type provider
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
|
||||
// Security & compression
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: config.cors.origin, credentials: true });
|
||||
await app.register(compress);
|
||||
|
||||
// Swagger / OpenAPI
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'MeshiTrack API',
|
||||
description: 'Nutrition & Pantry Management Platform',
|
||||
version: '0.0.1',
|
||||
},
|
||||
servers: [{ url: `http://localhost:${config.port}` }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
transform: jsonSchemaTransform,
|
||||
});
|
||||
|
||||
await app.register(swaggerUi, { routePrefix: '/api/docs' });
|
||||
|
||||
// DI container (Awilix)
|
||||
await app.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
|
||||
// Database
|
||||
await app.register(mongoosePlugin);
|
||||
|
||||
// Auth & household guards
|
||||
await app.register(authPlugin);
|
||||
await app.register(householdPlugin);
|
||||
|
||||
// Route modules
|
||||
await app.register(healthRoutes);
|
||||
await app.register(usersRoutes);
|
||||
await app.register(householdsRoutes);
|
||||
|
||||
// Global error handler
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
/* v8 ignore start -- Zod validation errors (tested via integration/E2E) */
|
||||
if (hasZodFastifySchemaValidationErrors(error)) {
|
||||
const body: ApiError = {
|
||||
statusCode: 400,
|
||||
error: 'Validation Error',
|
||||
message: 'Request validation failed',
|
||||
details: formatZodIssues(error.validation),
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
return reply.status(400).send(body);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/* v8 ignore start -- Response serialization errors (tested via integration/E2E) */
|
||||
if (isResponseSerializationError(error)) {
|
||||
request.log.error(error, 'Response serialization error');
|
||||
const body: ApiError = {
|
||||
statusCode: 500,
|
||||
error: 'Internal Server Error',
|
||||
message: 'Response validation failed',
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
return reply.status(500).send(body);
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
// Application errors (our custom error classes)
|
||||
if (error instanceof AppError) {
|
||||
const body: ApiError = {
|
||||
statusCode: error.statusCode,
|
||||
error: error.error,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
return reply.status(error.statusCode).send(body);
|
||||
}
|
||||
|
||||
// Fastify-level errors (e.g., 404 from routing)
|
||||
const fastifyError = error as { statusCode?: number; name?: string; message?: string };
|
||||
const statusCode = fastifyError.statusCode ?? 500;
|
||||
if (statusCode >= 500) {
|
||||
request.log.error(error, 'Unhandled error');
|
||||
}
|
||||
|
||||
const body: ApiError = {
|
||||
statusCode,
|
||||
error: statusCode >= 500 ? 'Internal Server Error' : (fastifyError.name ?? 'Error'),
|
||||
message:
|
||||
statusCode >= 500
|
||||
? 'An unexpected error occurred'
|
||||
: (fastifyError.message ?? 'Unknown error'),
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
return reply.status(statusCode).send(body);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/* v8 ignore start -- called only from Zod validation handler above */
|
||||
function formatZodIssues(issues: unknown[]): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const issue of issues) {
|
||||
const zodIssue = issue as {
|
||||
params?: { issue?: { path?: (string | number)[]; message?: string } };
|
||||
};
|
||||
const path = zodIssue.params?.issue?.path?.join('.') || '_root';
|
||||
const message = zodIssue.params?.issue?.message || 'Validation failed';
|
||||
if (!result[path]) result[path] = [];
|
||||
result[path].push(message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/* v8 ignore start -- entry-point bootstrap, tested via integration/E2E */
|
||||
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'));
|
||||
if (isMain || process.argv[1]?.endsWith('main.js') || process.argv[1]?.endsWith('main.ts')) {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
await app.listen({ port: config.port, host: '0.0.0.0' });
|
||||
} catch (err) {
|
||||
app.log.fatal(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
40
packages/api/src/modules/health/health.routes.test.ts
Normal file
40
packages/api/src/modules/health/health.routes.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
import healthRoutes from './health.routes.js';
|
||||
|
||||
describe('Health Routes', () => {
|
||||
async function buildTestApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
await app.register(healthRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
it('GET /api/v1/health returns 200 with status ok', async () => {
|
||||
const app = await buildTestApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body).toMatchObject({
|
||||
status: 'ok',
|
||||
version: expect.any(String),
|
||||
uptime: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/v1/health returns increasing uptime', async () => {
|
||||
const app = await buildTestApp();
|
||||
|
||||
const first = await app.inject({ method: 'GET', url: '/api/v1/health' });
|
||||
const second = await app.inject({ method: 'GET', url: '/api/v1/health' });
|
||||
|
||||
expect(second.json().uptime).toBeGreaterThanOrEqual(first.json().uptime);
|
||||
});
|
||||
});
|
||||
34
packages/api/src/modules/health/health.routes.ts
Normal file
34
packages/api/src/modules/health/health.routes.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { z } from 'zod/v4';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
|
||||
const packageVersion = '0.0.1';
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/health',
|
||||
config: { public: true },
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.literal('ok'),
|
||||
version: z.string(),
|
||||
uptime: z.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: async (_request, reply) => {
|
||||
return reply.send({
|
||||
status: 'ok' as const,
|
||||
version: packageVersion,
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{ name: 'health-routes' },
|
||||
);
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
|
||||
const { _mockLean, mockExec, mockFindById, mockFindOne, mockFindByIdAndUpdate, mockSave } =
|
||||
vi.hoisted(() => {
|
||||
const mockExec = vi.fn();
|
||||
const mockLean = vi.fn(() => ({ exec: mockExec }));
|
||||
return {
|
||||
mockExec,
|
||||
mockLean,
|
||||
mockFindById: vi.fn(() => ({ lean: mockLean })),
|
||||
mockFindOne: vi.fn(() => ({ lean: mockLean })),
|
||||
mockFindByIdAndUpdate: vi.fn(() => ({ exec: mockExec })),
|
||||
mockSave: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../schemas/household.schema.js', () => {
|
||||
class MockHouseholdModel {
|
||||
_data: Record<string, unknown>;
|
||||
constructor(data: Record<string, unknown>) {
|
||||
this._data = data;
|
||||
Object.assign(this, data);
|
||||
}
|
||||
save() {
|
||||
mockSave();
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
toObject() {
|
||||
return { _id: 'hh-new', ...this._data };
|
||||
}
|
||||
static findById = mockFindById;
|
||||
static findOne = mockFindOne;
|
||||
static findByIdAndUpdate = mockFindByIdAndUpdate;
|
||||
}
|
||||
return { HouseholdModel: MockHouseholdModel };
|
||||
});
|
||||
|
||||
import { HouseholdsRepository } from './households.repository.js';
|
||||
|
||||
describe('HouseholdsRepository', () => {
|
||||
let repo: HouseholdsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new HouseholdsRepository();
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('calls findById with lean', async () => {
|
||||
const household = { _id: 'hh1', name: 'Test' };
|
||||
mockExec.mockResolvedValue(household);
|
||||
|
||||
const result = await repo.findById('hh1');
|
||||
|
||||
expect(mockFindById).toHaveBeenCalledWith('hh1');
|
||||
expect(result).toEqual(household);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByInviteCode', () => {
|
||||
it('calls findOne with inviteCode', async () => {
|
||||
const household = { _id: 'hh1', inviteCode: 'ABCD' };
|
||||
mockExec.mockResolvedValue(household);
|
||||
|
||||
const result = await repo.findByInviteCode('ABCD');
|
||||
|
||||
expect(mockFindOne).toHaveBeenCalledWith({ inviteCode: 'ABCD' });
|
||||
expect(result).toEqual(household);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a household with owner as first member', async () => {
|
||||
mockSave.mockResolvedValue({});
|
||||
|
||||
const result = await repo.create({ name: 'Test' }, 'owner-1', 'INVITE1');
|
||||
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ name: 'Test', ownerUserId: 'owner-1', inviteCode: 'INVITE1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('calls findByIdAndUpdate with $set', async () => {
|
||||
mockExec.mockResolvedValue({ _id: 'hh1', name: 'Updated' });
|
||||
|
||||
const result = await repo.update('hh1', { name: 'Updated' });
|
||||
|
||||
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
{ $set: { name: 'Updated' } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
expect(result).toEqual({ _id: 'hh1', name: 'Updated' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMember', () => {
|
||||
it('pushes a new member to the array', async () => {
|
||||
mockExec.mockResolvedValue({ _id: 'hh1', members: [] });
|
||||
|
||||
await repo.addMember('hh1', 'user-2', HouseholdRole.MEMBER);
|
||||
|
||||
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
{
|
||||
$push: {
|
||||
members: expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
role: HouseholdRole.MEMBER,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInviteCode', () => {
|
||||
it('sets the new invite code', async () => {
|
||||
mockExec.mockResolvedValue({ _id: 'hh1', inviteCode: 'NEWCODE' });
|
||||
|
||||
const result = await repo.updateInviteCode('hh1', 'NEWCODE');
|
||||
|
||||
expect(mockFindByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
{ $set: { inviteCode: 'NEWCODE' } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
expect(result).toEqual({ _id: 'hh1', inviteCode: 'NEWCODE' });
|
||||
});
|
||||
});
|
||||
});
|
||||
55
packages/api/src/modules/households/households.repository.ts
Normal file
55
packages/api/src/modules/households/households.repository.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type mongoose from 'mongoose';
|
||||
import { HouseholdModel } from '../../schemas/household.schema.js';
|
||||
import type { CreateHouseholdInput, UpdateHouseholdInput } from '@meshitrack/shared';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
|
||||
export class HouseholdsRepository {
|
||||
public async findById(id: string) {
|
||||
return HouseholdModel.findById(id).lean().exec();
|
||||
}
|
||||
|
||||
public async findByInviteCode(inviteCode: string) {
|
||||
return HouseholdModel.findOne({ inviteCode }).lean().exec();
|
||||
}
|
||||
|
||||
public async create(
|
||||
data: CreateHouseholdInput,
|
||||
ownerUserId: string,
|
||||
inviteCode: string,
|
||||
session?: mongoose.ClientSession,
|
||||
) {
|
||||
const household = new HouseholdModel({
|
||||
...data,
|
||||
ownerUserId,
|
||||
inviteCode,
|
||||
members: [{ userId: ownerUserId, role: HouseholdRole.OWNER, joinedAt: new Date() }],
|
||||
});
|
||||
const saved = await household.save({ session });
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(id: string, data: UpdateHouseholdInput) {
|
||||
return HouseholdModel.findByIdAndUpdate(id, { $set: data }, { new: true, lean: true }).exec();
|
||||
}
|
||||
|
||||
public async addMember(
|
||||
id: string,
|
||||
userId: string,
|
||||
role: HouseholdRole,
|
||||
session?: mongoose.ClientSession,
|
||||
) {
|
||||
return HouseholdModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $push: { members: { userId, role, joinedAt: new Date() } } },
|
||||
{ new: true, lean: true, session },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async updateInviteCode(id: string, inviteCode: string) {
|
||||
return HouseholdModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: { inviteCode } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
225
packages/api/src/modules/households/households.routes.test.ts
Normal file
225
packages/api/src/modules/households/households.routes.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
|
||||
// Mock jose for auth plugin
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Hoist mock fns
|
||||
const {
|
||||
mockCreate,
|
||||
mockFindById,
|
||||
mockUpdate,
|
||||
mockUpdateInviteCode,
|
||||
mockFindByInviteCode,
|
||||
mockAddMember,
|
||||
mockFindByKeycloakId,
|
||||
mockUserUpdate,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreate: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockUpdateInviteCode: vi.fn(),
|
||||
mockFindByInviteCode: vi.fn(),
|
||||
mockAddMember: vi.fn(),
|
||||
mockFindByKeycloakId: vi.fn(),
|
||||
mockUserUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./households.repository.js', () => ({
|
||||
HouseholdsRepository: class {
|
||||
create = mockCreate;
|
||||
findById = mockFindById;
|
||||
update = mockUpdate;
|
||||
updateInviteCode = mockUpdateInviteCode;
|
||||
findByInviteCode = mockFindByInviteCode;
|
||||
addMember = mockAddMember;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = mockFindByKeycloakId;
|
||||
update = mockUserUpdate;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => '12345678-1234-1234-1234-123456789abc'),
|
||||
}));
|
||||
|
||||
vi.mock('mongoose', () => ({
|
||||
default: {
|
||||
startSession: vi.fn().mockResolvedValue({
|
||||
startTransaction: vi.fn(),
|
||||
commitTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
abortTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
endSession: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../plugins/household.plugin.js';
|
||||
import usersRoutes from '../users/users.routes.js';
|
||||
import householdsRoutes from './households.routes.js';
|
||||
|
||||
function makeFakeHousehold(overrides = {}) {
|
||||
return {
|
||||
_id: 'hh1',
|
||||
name: 'Test Household',
|
||||
ownerUserId: 'kc-1',
|
||||
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER, joinedAt: new Date().toISOString() }],
|
||||
inviteCode: '12345678',
|
||||
settings: { timezone: 'UTC', currency: 'USD', language: 'en' },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('households.routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(householdsRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households', () => {
|
||||
it('creates a household', async () => {
|
||||
const household = makeFakeHousehold();
|
||||
mockCreate.mockResolvedValue(household);
|
||||
mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null });
|
||||
mockUserUpdate.mockResolvedValue({});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Test Household' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().name).toBe('Test Household');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:id', () => {
|
||||
it('returns a household', async () => {
|
||||
const household = makeFakeHousehold();
|
||||
mockFindById.mockResolvedValue(household);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Test Household');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:id', () => {
|
||||
it('updates a household', async () => {
|
||||
const household = makeFakeHousehold();
|
||||
mockFindById.mockResolvedValue(household);
|
||||
mockUpdate.mockResolvedValue({ ...household, name: 'Updated' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Updated' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:id/invite', () => {
|
||||
it('generates a new invite code', async () => {
|
||||
const household = makeFakeHousehold();
|
||||
mockFindById.mockResolvedValue(household);
|
||||
mockUpdateInviteCode.mockResolvedValue({ ...household, inviteCode: '12345678' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/invite',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().inviteCode).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/join', () => {
|
||||
it('joins a household via invite code', async () => {
|
||||
const household = makeFakeHousehold({
|
||||
members: [
|
||||
{ userId: 'kc-other', role: HouseholdRole.OWNER, joinedAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
mockFindByInviteCode.mockResolvedValue(household);
|
||||
mockAddMember.mockResolvedValue({
|
||||
...household,
|
||||
members: [
|
||||
...household.members,
|
||||
{ userId: 'kc-1', role: HouseholdRole.MEMBER, joinedAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null });
|
||||
mockUserUpdate.mockResolvedValue({});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/join',
|
||||
headers: authHeaders,
|
||||
payload: { inviteCode: '12345678' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
166
packages/api/src/modules/households/households.routes.ts
Normal file
166
packages/api/src/modules/households/households.routes.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateHouseholdSchema,
|
||||
UpdateHouseholdSchema,
|
||||
JoinHouseholdSchema,
|
||||
HouseholdResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type AnyHouseholdDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
members: ReadonlyArray<{
|
||||
userId: string;
|
||||
role: string;
|
||||
joinedAt: string | { toISOString: () => string };
|
||||
}>;
|
||||
inviteCode: string;
|
||||
settings?: { timezone?: string; currency?: string; language?: string } | null;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | { toISOString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
|
||||
function toHouseholdResponse(doc: AnyHouseholdDoc): z.infer<typeof HouseholdResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
name: doc.name,
|
||||
ownerUserId: doc.ownerUserId,
|
||||
members: doc.members.map((m) => ({
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
joinedAt: toIso(m.joinedAt),
|
||||
})),
|
||||
inviteCode: doc.inviteCode,
|
||||
settings: {
|
||||
timezone: doc.settings?.timezone ?? 'UTC',
|
||||
currency: doc.settings?.currency ?? 'USD',
|
||||
language: doc.settings?.language ?? 'en',
|
||||
},
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
import { HouseholdsRepository } from './households.repository.js';
|
||||
import { HouseholdsService } from './households.service.js';
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
householdsRepository: HouseholdsRepository;
|
||||
householdsService: HouseholdsService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
// Register DI
|
||||
fastify.diContainer.register({
|
||||
householdsRepository: asClass(HouseholdsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
householdsService: asClass(HouseholdsService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
// POST /api/v1/households — create a new household
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households',
|
||||
config: { skipHousehold: true },
|
||||
schema: {
|
||||
body: CreateHouseholdSchema,
|
||||
response: { 201: HouseholdResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.create(request.body, request.user.keycloakId);
|
||||
return reply.status(201).send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId — get household by id (members only)
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId',
|
||||
schema: {
|
||||
params: z.object({ householdId: z.string() }),
|
||||
response: { 200: HouseholdResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.getById(request.params.householdId);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
||||
// PATCH /api/v1/households/:householdId — update household settings
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId',
|
||||
schema: {
|
||||
params: z.object({ householdId: z.string() }),
|
||||
body: UpdateHouseholdSchema,
|
||||
response: { 200: HouseholdResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.update(
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/invite — generate new invite code
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/invite',
|
||||
schema: {
|
||||
params: z.object({ householdId: z.string() }),
|
||||
response: { 200: HouseholdResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.generateInviteCode(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/join — join via invite code
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/join',
|
||||
config: { skipHousehold: true },
|
||||
schema: {
|
||||
body: JoinHouseholdSchema,
|
||||
response: { 200: HouseholdResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.join(request.body.inviteCode, request.user.keycloakId);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'households-routes',
|
||||
// users-routes must load first: it registers UsersRepository into the DI container,
|
||||
// which HouseholdsService depends on.
|
||||
dependencies: ['auth-plugin', 'users-routes'],
|
||||
},
|
||||
);
|
||||
253
packages/api/src/modules/households/households.service.test.ts
Normal file
253
packages/api/src/modules/households/households.service.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HouseholdsService } from './households.service.js';
|
||||
import { NotFoundError, ForbiddenError, ConflictError } from '../../common/errors.js';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
|
||||
// Mock uuid to return deterministic values
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => '12345678-1234-1234-1234-123456789abc'),
|
||||
}));
|
||||
|
||||
const { mockSession } = vi.hoisted(() => ({
|
||||
mockSession: {
|
||||
startTransaction: vi.fn(),
|
||||
commitTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
abortTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
endSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('mongoose', () => ({
|
||||
default: { startSession: vi.fn().mockResolvedValue(mockSession) },
|
||||
}));
|
||||
|
||||
describe('HouseholdsService', () => {
|
||||
const mockHouseholdsRepo = {
|
||||
findById: vi.fn(),
|
||||
findByInviteCode: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
updateInviteCode: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUsersRepo = {
|
||||
findByKeycloakId: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsertFromToken: vi.fn(),
|
||||
};
|
||||
|
||||
let service: HouseholdsService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new HouseholdsService({
|
||||
householdsRepository: mockHouseholdsRepo as never,
|
||||
usersRepository: mockUsersRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a household and updates owner user', async () => {
|
||||
const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1', inviteCode: '12345678' };
|
||||
mockHouseholdsRepo.create.mockResolvedValue(household);
|
||||
mockUsersRepo.findByKeycloakId.mockResolvedValue({
|
||||
householdIds: [],
|
||||
defaultHouseholdId: null,
|
||||
});
|
||||
mockUsersRepo.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.create({ name: 'Test' }, 'kc-1');
|
||||
|
||||
expect(result).toEqual(household);
|
||||
expect(mockHouseholdsRepo.create).toHaveBeenCalledWith(
|
||||
{ name: 'Test' },
|
||||
'kc-1',
|
||||
'12345678',
|
||||
mockSession,
|
||||
);
|
||||
expect(mockUsersRepo.update).toHaveBeenCalledWith(
|
||||
'kc-1',
|
||||
{ householdIds: ['hh1'], defaultHouseholdId: 'hh1' },
|
||||
mockSession,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing defaultHouseholdId when user already has one', async () => {
|
||||
const household = { _id: 'hh2', name: 'Second', ownerUserId: 'kc-1' };
|
||||
mockHouseholdsRepo.create.mockResolvedValue(household);
|
||||
mockUsersRepo.findByKeycloakId.mockResolvedValue({
|
||||
householdIds: ['hh1'],
|
||||
defaultHouseholdId: 'hh1',
|
||||
});
|
||||
mockUsersRepo.update.mockResolvedValue({});
|
||||
|
||||
await service.create({ name: 'Second' }, 'kc-1');
|
||||
|
||||
expect(mockUsersRepo.update).toHaveBeenCalledWith(
|
||||
'kc-1',
|
||||
{ householdIds: ['hh1', 'hh2'], defaultHouseholdId: 'hh1' },
|
||||
mockSession,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles case when owner user not found in db', async () => {
|
||||
const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1' };
|
||||
mockHouseholdsRepo.create.mockResolvedValue(household);
|
||||
mockUsersRepo.findByKeycloakId.mockResolvedValue(null);
|
||||
|
||||
const result = await service.create({ name: 'Test' }, 'kc-1');
|
||||
|
||||
expect(result).toEqual(household);
|
||||
expect(mockUsersRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns household when found', async () => {
|
||||
const household = { _id: 'hh1', name: 'Test' };
|
||||
mockHouseholdsRepo.findById.mockResolvedValue(household);
|
||||
|
||||
const result = await service.getById('hh1');
|
||||
expect(result).toEqual(household);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockHouseholdsRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('allows owner to update', async () => {
|
||||
mockHouseholdsRepo.findById.mockResolvedValue({
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
|
||||
});
|
||||
mockHouseholdsRepo.update.mockResolvedValue({ _id: 'hh1', name: 'Updated' });
|
||||
|
||||
const result = await service.update('hh1', { name: 'Updated' }, 'kc-1');
|
||||
expect(result).toEqual({ _id: 'hh1', name: 'Updated' });
|
||||
});
|
||||
|
||||
it('allows admin to update', async () => {
|
||||
mockHouseholdsRepo.findById.mockResolvedValue({
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-2', role: HouseholdRole.ADMIN }],
|
||||
});
|
||||
mockHouseholdsRepo.update.mockResolvedValue({ _id: 'hh1', name: 'Updated' });
|
||||
|
||||
await service.update('hh1', { name: 'Updated' }, 'kc-2');
|
||||
expect(mockHouseholdsRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ForbiddenError for regular member', async () => {
|
||||
mockHouseholdsRepo.findById.mockResolvedValue({
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-3', role: HouseholdRole.MEMBER }],
|
||||
});
|
||||
|
||||
await expect(service.update('hh1', { name: 'X' }, 'kc-3')).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('throws ForbiddenError for non-member', async () => {
|
||||
mockHouseholdsRepo.findById.mockResolvedValue({
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
|
||||
});
|
||||
|
||||
await expect(service.update('hh1', { name: 'X' }, 'kc-other')).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateInviteCode', () => {
|
||||
it('generates new invite code for owner', async () => {
|
||||
mockHouseholdsRepo.findById.mockResolvedValue({
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
|
||||
});
|
||||
mockHouseholdsRepo.updateInviteCode.mockResolvedValue({ inviteCode: '12345678' });
|
||||
|
||||
const result = await service.generateInviteCode('hh1', 'kc-1');
|
||||
expect(mockHouseholdsRepo.updateInviteCode).toHaveBeenCalledWith('hh1', '12345678');
|
||||
expect(result).toEqual({ inviteCode: '12345678' });
|
||||
});
|
||||
|
||||
it('throws ForbiddenError for regular member', async () => {
|
||||
mockHouseholdsRepo.findById.mockResolvedValue({
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-3', role: HouseholdRole.MEMBER }],
|
||||
});
|
||||
|
||||
await expect(service.generateInviteCode('hh1', 'kc-3')).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('join', () => {
|
||||
it('joins a household via invite code', async () => {
|
||||
const household = {
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
|
||||
};
|
||||
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
|
||||
mockHouseholdsRepo.addMember.mockResolvedValue({
|
||||
...household,
|
||||
members: [...household.members, { userId: 'kc-2' }],
|
||||
});
|
||||
mockUsersRepo.findByKeycloakId.mockResolvedValue({
|
||||
householdIds: [],
|
||||
defaultHouseholdId: null,
|
||||
});
|
||||
mockUsersRepo.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.join('ABCD1234', 'kc-2');
|
||||
|
||||
expect(mockHouseholdsRepo.addMember).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'kc-2',
|
||||
HouseholdRole.MEMBER,
|
||||
mockSession,
|
||||
);
|
||||
expect(mockUsersRepo.update).toHaveBeenCalledWith(
|
||||
'kc-2',
|
||||
{ householdIds: ['hh1'], defaultHouseholdId: 'hh1' },
|
||||
mockSession,
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws NotFoundError for invalid invite code', async () => {
|
||||
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(null);
|
||||
|
||||
await expect(service.join('INVALID', 'kc-2')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws ConflictError when already a member', async () => {
|
||||
mockHouseholdsRepo.findByInviteCode.mockResolvedValue({
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
|
||||
});
|
||||
|
||||
await expect(service.join('CODE', 'kc-1')).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('handles case when joining user not found in db', async () => {
|
||||
const household = {
|
||||
_id: 'hh1',
|
||||
members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }],
|
||||
};
|
||||
mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household);
|
||||
mockHouseholdsRepo.addMember.mockResolvedValue({});
|
||||
mockUsersRepo.findByKeycloakId.mockResolvedValue(null);
|
||||
|
||||
await service.join('CODE', 'kc-new');
|
||||
|
||||
expect(mockUsersRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/api/src/modules/households/households.service.ts
Normal file
135
packages/api/src/modules/households/households.service.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { HouseholdsRepository } from './households.repository.js';
|
||||
import type { UsersRepository } from '../users/users.repository.js';
|
||||
import type { CreateHouseholdInput, UpdateHouseholdInput } from '@meshitrack/shared';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
import { NotFoundError, ForbiddenError, ConflictError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
householdsRepository: HouseholdsRepository;
|
||||
usersRepository: UsersRepository;
|
||||
}
|
||||
|
||||
export class HouseholdsService {
|
||||
private readonly householdsRepository: HouseholdsRepository;
|
||||
private readonly usersRepository: UsersRepository;
|
||||
|
||||
public constructor({ householdsRepository, usersRepository }: Deps) {
|
||||
this.householdsRepository = householdsRepository;
|
||||
this.usersRepository = usersRepository;
|
||||
}
|
||||
|
||||
public async create(data: CreateHouseholdInput, ownerKeycloakId: string) {
|
||||
const inviteCode = uuidv4().slice(0, 8).toUpperCase();
|
||||
const session = await mongoose.startSession();
|
||||
try {
|
||||
session.startTransaction();
|
||||
|
||||
const household = await this.householdsRepository.create(
|
||||
data,
|
||||
ownerKeycloakId,
|
||||
inviteCode,
|
||||
session,
|
||||
);
|
||||
|
||||
const user = await this.usersRepository.findByKeycloakId(ownerKeycloakId, session);
|
||||
if (user) {
|
||||
const householdId = household._id.toString();
|
||||
await this.usersRepository.update(
|
||||
ownerKeycloakId,
|
||||
{
|
||||
householdIds: [...user.householdIds, householdId],
|
||||
defaultHouseholdId: user.defaultHouseholdId ?? householdId,
|
||||
},
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
await session.commitTransaction();
|
||||
return household;
|
||||
} catch (err) {
|
||||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
}
|
||||
|
||||
public async getById(id: string) {
|
||||
const household = await this.householdsRepository.findById(id);
|
||||
if (!household) {
|
||||
throw new NotFoundError('Household not found');
|
||||
}
|
||||
return household;
|
||||
}
|
||||
|
||||
public async update(id: string, data: UpdateHouseholdInput, requestingUserId: string) {
|
||||
const household = await this.getById(id);
|
||||
const member = household.members.find((m) => m.userId === requestingUserId);
|
||||
if (!member || (member.role !== HouseholdRole.OWNER && member.role !== HouseholdRole.ADMIN)) {
|
||||
throw new ForbiddenError('Only owners and admins can update household settings');
|
||||
}
|
||||
const updated = await this.householdsRepository.update(id, data);
|
||||
if (!updated) throw new NotFoundError('Household not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async generateInviteCode(id: string, requestingUserId: string) {
|
||||
const household = await this.getById(id);
|
||||
const member = household.members.find((m) => m.userId === requestingUserId);
|
||||
if (!member || (member.role !== HouseholdRole.OWNER && member.role !== HouseholdRole.ADMIN)) {
|
||||
throw new ForbiddenError('Only owners and admins can generate invite codes');
|
||||
}
|
||||
const newCode = uuidv4().slice(0, 8).toUpperCase();
|
||||
const updated = await this.householdsRepository.updateInviteCode(id, newCode);
|
||||
if (!updated) throw new NotFoundError('Household not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async join(inviteCode: string, userId: string) {
|
||||
const household = await this.householdsRepository.findByInviteCode(inviteCode);
|
||||
if (!household) {
|
||||
throw new NotFoundError('Invalid invite code');
|
||||
}
|
||||
|
||||
const alreadyMember = household.members.some((m) => m.userId === userId);
|
||||
if (alreadyMember) {
|
||||
throw new ConflictError('Already a member of this household');
|
||||
}
|
||||
|
||||
const session = await mongoose.startSession();
|
||||
try {
|
||||
session.startTransaction();
|
||||
|
||||
const updated = await this.householdsRepository.addMember(
|
||||
household._id.toString(),
|
||||
userId,
|
||||
HouseholdRole.MEMBER,
|
||||
session,
|
||||
);
|
||||
if (!updated) throw new NotFoundError('Household not found');
|
||||
|
||||
const user = await this.usersRepository.findByKeycloakId(userId, session);
|
||||
if (user) {
|
||||
const householdId = household._id.toString();
|
||||
await this.usersRepository.update(
|
||||
userId,
|
||||
{
|
||||
householdIds: [...user.householdIds, householdId],
|
||||
defaultHouseholdId: user.defaultHouseholdId ?? householdId,
|
||||
},
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
await session.commitTransaction();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
128
packages/api/src/modules/users/users.repository.test.ts
Normal file
128
packages/api/src/modules/users/users.repository.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Use vi.hoisted so mocks are available in vi.mock factory (which is hoisted)
|
||||
const { mockLean, mockExec, mockFindOne, mockFindById, mockFindOneAndUpdate, mockSave } =
|
||||
vi.hoisted(() => {
|
||||
const mockExec = vi.fn();
|
||||
const mockLean = vi.fn(() => ({ exec: mockExec }));
|
||||
return {
|
||||
mockExec,
|
||||
mockLean,
|
||||
mockFindOne: vi.fn(() => ({ lean: mockLean })),
|
||||
mockFindById: vi.fn(() => ({ lean: mockLean })),
|
||||
mockFindOneAndUpdate: vi.fn(() => ({ exec: mockExec })),
|
||||
mockSave: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../schemas/user.schema.js', () => {
|
||||
class MockUserModel {
|
||||
_data: Record<string, unknown>;
|
||||
constructor(data: Record<string, unknown>) {
|
||||
this._data = data;
|
||||
Object.assign(this, data);
|
||||
}
|
||||
save() {
|
||||
mockSave();
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
toObject() {
|
||||
return { _id: 'new-id', ...this._data };
|
||||
}
|
||||
static findOne = mockFindOne;
|
||||
static findById = mockFindById;
|
||||
static findOneAndUpdate = mockFindOneAndUpdate;
|
||||
}
|
||||
return { UserModel: MockUserModel };
|
||||
});
|
||||
|
||||
import { UsersRepository } from './users.repository.js';
|
||||
|
||||
describe('UsersRepository', () => {
|
||||
let repo: UsersRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new UsersRepository();
|
||||
});
|
||||
|
||||
describe('findByKeycloakId', () => {
|
||||
it('calls findOne with keycloakId and returns lean result', async () => {
|
||||
const user = { _id: 'u1', keycloakId: 'kc-1' };
|
||||
mockExec.mockResolvedValue(user);
|
||||
|
||||
const result = await repo.findByKeycloakId('kc-1');
|
||||
|
||||
expect(mockFindOne).toHaveBeenCalledWith({ keycloakId: 'kc-1' }, null, {
|
||||
session: undefined,
|
||||
});
|
||||
expect(mockLean).toHaveBeenCalled();
|
||||
expect(mockExec).toHaveBeenCalled();
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('calls findById and returns lean result', async () => {
|
||||
const user = { _id: 'u1' };
|
||||
mockExec.mockResolvedValue(user);
|
||||
|
||||
const result = await repo.findById('u1');
|
||||
|
||||
expect(mockFindById).toHaveBeenCalledWith('u1');
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a new user and returns plain object', async () => {
|
||||
mockSave.mockResolvedValue({});
|
||||
|
||||
const data = {
|
||||
keycloakId: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test',
|
||||
householdIds: [],
|
||||
};
|
||||
const result = await repo.create(data as never);
|
||||
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ keycloakId: 'kc-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('calls findOneAndUpdate with $set', async () => {
|
||||
const updated = { _id: 'u1', displayName: 'Updated' };
|
||||
mockExec.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.update('kc-1', { displayName: 'Updated' } as never);
|
||||
|
||||
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ keycloakId: 'kc-1' },
|
||||
{ $set: { displayName: 'Updated' } },
|
||||
{ new: true, lean: true, session: undefined },
|
||||
);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertFromToken', () => {
|
||||
it('upserts user with $set and $setOnInsert', async () => {
|
||||
const upserted = { _id: 'u1', keycloakId: 'kc-1' };
|
||||
mockExec.mockResolvedValue(upserted);
|
||||
|
||||
const result = await repo.upsertFromToken('kc-1', 'a@b.com', 'Name');
|
||||
|
||||
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ keycloakId: 'kc-1' },
|
||||
{
|
||||
$set: { email: 'a@b.com', displayName: 'Name' },
|
||||
$setOnInsert: { keycloakId: 'kc-1', householdIds: [], defaultHouseholdId: null },
|
||||
},
|
||||
{ upsert: true, new: true, lean: true },
|
||||
);
|
||||
expect(result).toEqual(upserted);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
packages/api/src/modules/users/users.repository.ts
Normal file
38
packages/api/src/modules/users/users.repository.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type mongoose from 'mongoose';
|
||||
import { UserModel } from '../../schemas/user.schema.js';
|
||||
import type { CreateUserInput, UpdateUserInput } from '@meshitrack/shared';
|
||||
|
||||
export class UsersRepository {
|
||||
public async findByKeycloakId(keycloakId: string, session?: mongoose.ClientSession) {
|
||||
return UserModel.findOne({ keycloakId }, null, { session }).lean().exec();
|
||||
}
|
||||
|
||||
public async findById(id: string) {
|
||||
return UserModel.findById(id).lean().exec();
|
||||
}
|
||||
|
||||
public async create(data: CreateUserInput) {
|
||||
const user = new UserModel(data);
|
||||
const saved = await user.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(keycloakId: string, data: UpdateUserInput, session?: mongoose.ClientSession) {
|
||||
return UserModel.findOneAndUpdate(
|
||||
{ keycloakId },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true, session },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async upsertFromToken(keycloakId: string, email: string, displayName: string) {
|
||||
return UserModel.findOneAndUpdate(
|
||||
{ keycloakId },
|
||||
{
|
||||
$set: { email, displayName },
|
||||
$setOnInsert: { keycloakId, householdIds: [], defaultHouseholdId: null },
|
||||
},
|
||||
{ upsert: true, new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
82
packages/api/src/modules/users/users.routes.test.ts
Normal file
82
packages/api/src/modules/users/users.routes.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
// Mock jose for auth plugin
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the users repository module with a real class
|
||||
const mockUpsertFromToken = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./users.repository.js', () => ({
|
||||
UsersRepository: class MockUsersRepository {
|
||||
upsertFromToken = mockUpsertFromToken;
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import usersRoutes from './users.routes.js';
|
||||
|
||||
describe('users.routes', () => {
|
||||
async function buildTestApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
await app.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await app.register(authPlugin);
|
||||
await app.register(usersRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('GET /api/v1/users/me syncs user from token and returns profile', async () => {
|
||||
const mockUser = {
|
||||
_id: 'u1',
|
||||
keycloakId: 'kc-1',
|
||||
displayName: 'testuser',
|
||||
email: 'test@example.com',
|
||||
householdIds: ['hh1'],
|
||||
defaultHouseholdId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
mockUpsertFromToken.mockResolvedValue(mockUser);
|
||||
|
||||
const app = await buildTestApp();
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/users/me',
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.keycloakId).toBe('kc-1');
|
||||
expect(body.email).toBe('test@example.com');
|
||||
expect(mockUpsertFromToken).toHaveBeenCalledWith('kc-1', 'test@example.com', 'testuser');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
81
packages/api/src/modules/users/users.routes.ts
Normal file
81
packages/api/src/modules/users/users.routes.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import { UsersRepository } from './users.repository.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
type AnyUserDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
keycloakId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
householdIds: string[];
|
||||
defaultHouseholdId?: string | null;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toUserResponse(doc: AnyUserDoc) {
|
||||
const id = typeof doc._id === 'string' ? doc._id : doc._id.toString();
|
||||
const createdAt = typeof doc.createdAt === 'string' ? doc.createdAt : doc.createdAt.toISOString();
|
||||
const updatedAt = typeof doc.updatedAt === 'string' ? doc.updatedAt : doc.updatedAt.toISOString();
|
||||
return {
|
||||
_id: id,
|
||||
keycloakId: doc.keycloakId,
|
||||
displayName: doc.displayName,
|
||||
email: doc.email,
|
||||
householdIds: doc.householdIds,
|
||||
defaultHouseholdId: doc.defaultHouseholdId ?? null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
usersRepository: UsersRepository;
|
||||
usersService: UsersService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
// Register DI
|
||||
fastify.diContainer.register({
|
||||
usersRepository: asClass(UsersRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
usersService: asClass(UsersService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
// GET /api/v1/users/me — get current user profile (syncs from token on first call)
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/users/me',
|
||||
config: { skipHousehold: true },
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
_id: z.string(),
|
||||
keycloakId: z.string(),
|
||||
displayName: z.string(),
|
||||
email: z.string(),
|
||||
householdIds: z.array(z.string()),
|
||||
defaultHouseholdId: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('usersService');
|
||||
const user = await service.syncFromToken(request.user);
|
||||
if (!user) throw new NotFoundError('User sync failed');
|
||||
return reply.send(toUserResponse(user));
|
||||
},
|
||||
});
|
||||
},
|
||||
{ name: 'users-routes', dependencies: ['auth-plugin'] },
|
||||
);
|
||||
61
packages/api/src/modules/users/users.service.test.ts
Normal file
61
packages/api/src/modules/users/users.service.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
describe('UsersService', () => {
|
||||
const mockRepo = {
|
||||
findByKeycloakId: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsertFromToken: vi.fn(),
|
||||
};
|
||||
|
||||
let service: UsersService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new UsersService({ usersRepository: mockRepo as never });
|
||||
});
|
||||
|
||||
describe('syncFromToken', () => {
|
||||
it('upserts user from auth token data', async () => {
|
||||
const authUser = {
|
||||
keycloakId: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['member'],
|
||||
householdIds: [],
|
||||
};
|
||||
const upserted = { _id: 'u1', ...authUser };
|
||||
mockRepo.upsertFromToken.mockResolvedValue(upserted);
|
||||
|
||||
const result = await service.syncFromToken(authUser);
|
||||
|
||||
expect(mockRepo.upsertFromToken).toHaveBeenCalledWith(
|
||||
'kc-1',
|
||||
'test@example.com',
|
||||
'Test User',
|
||||
);
|
||||
expect(result).toEqual(upserted);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProfile', () => {
|
||||
it('returns user when found', async () => {
|
||||
const user = { _id: 'u1', keycloakId: 'kc-1', displayName: 'Test' };
|
||||
mockRepo.findByKeycloakId.mockResolvedValue(user);
|
||||
|
||||
const result = await service.getProfile('kc-1');
|
||||
|
||||
expect(result).toEqual(user);
|
||||
expect(mockRepo.findByKeycloakId).toHaveBeenCalledWith('kc-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when user not found', async () => {
|
||||
mockRepo.findByKeycloakId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getProfile('kc-missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
27
packages/api/src/modules/users/users.service.ts
Normal file
27
packages/api/src/modules/users/users.service.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { UsersRepository } from './users.repository.js';
|
||||
import type { AuthUser } from '../../common/types.js';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
usersRepository: UsersRepository;
|
||||
}
|
||||
|
||||
export class UsersService {
|
||||
private readonly usersRepository: UsersRepository;
|
||||
|
||||
public constructor({ usersRepository }: Deps) {
|
||||
this.usersRepository = usersRepository;
|
||||
}
|
||||
|
||||
public async syncFromToken(user: AuthUser) {
|
||||
return this.usersRepository.upsertFromToken(user.keycloakId, user.email, user.displayName);
|
||||
}
|
||||
|
||||
public async getProfile(keycloakId: string) {
|
||||
const user = await this.usersRepository.findByKeycloakId(keycloakId);
|
||||
if (!user) {
|
||||
throw new NotFoundError('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
161
packages/api/src/plugins/auth.plugin.test.ts
Normal file
161
packages/api/src/plugins/auth.plugin.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
|
||||
const { MockJOSEError } = vi.hoisted(() => ({
|
||||
MockJOSEError: class JOSEError extends Error {},
|
||||
}));
|
||||
|
||||
// Mock jose before importing the plugin
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn(),
|
||||
errors: { JOSEError: MockJOSEError },
|
||||
}));
|
||||
|
||||
import authPlugin from './auth.plugin.js';
|
||||
import * as jose from 'jose';
|
||||
|
||||
describe('auth.plugin', () => {
|
||||
function buildApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('skips auth for routes marked as public', async () => {
|
||||
const app = buildApp();
|
||||
await app.register(authPlugin);
|
||||
|
||||
app.get('/public', { config: { public: true } as never }, async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/public' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('throws 401 when no Authorization header', async () => {
|
||||
const app = buildApp();
|
||||
await app.register(authPlugin);
|
||||
|
||||
app.get('/protected', async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/protected' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().message).toContain('Missing or invalid Authorization');
|
||||
});
|
||||
|
||||
it('throws 401 when Authorization header is not Bearer', async () => {
|
||||
const app = buildApp();
|
||||
await app.register(authPlugin);
|
||||
|
||||
app.get('/protected', async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/protected',
|
||||
headers: { authorization: 'Basic abc123' },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('throws 401 when token is invalid', async () => {
|
||||
vi.mocked(jose.jwtVerify).mockRejectedValue(new MockJOSEError('Invalid token'));
|
||||
|
||||
const app = buildApp();
|
||||
await app.register(authPlugin);
|
||||
|
||||
app.get('/protected', async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/protected',
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().message).toContain('Invalid or expired token');
|
||||
});
|
||||
|
||||
it('sets request.user from valid JWT payload', async () => {
|
||||
vi.mocked(jose.jwtVerify).mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
iss: 'http://localhost:8080/realms/meshitrack',
|
||||
aud: 'meshitrack-api',
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {} as never,
|
||||
} as never);
|
||||
|
||||
const app = buildApp();
|
||||
await app.register(authPlugin);
|
||||
|
||||
let capturedUser: unknown;
|
||||
app.get('/protected', async (request) => {
|
||||
capturedUser = request.user;
|
||||
return { ok: true };
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/protected',
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(capturedUser).toEqual({
|
||||
keycloakId: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'testuser',
|
||||
roles: ['member'],
|
||||
householdIds: ['hh1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing optional fields in JWT payload', async () => {
|
||||
vi.mocked(jose.jwtVerify).mockResolvedValue({
|
||||
payload: {
|
||||
// sub, email, preferred_username, realm_access, householdIds all missing
|
||||
iss: 'http://localhost:8080/realms/meshitrack',
|
||||
aud: 'meshitrack-api',
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {} as never,
|
||||
} as never);
|
||||
|
||||
const app = buildApp();
|
||||
await app.register(authPlugin);
|
||||
|
||||
let capturedUser: unknown;
|
||||
app.get('/protected', async (request) => {
|
||||
capturedUser = request.user;
|
||||
return { ok: true };
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/protected',
|
||||
headers: { authorization: 'Bearer token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(capturedUser).toEqual({
|
||||
keycloakId: '',
|
||||
email: '',
|
||||
displayName: '',
|
||||
roles: [],
|
||||
householdIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/api/src/plugins/auth.plugin.ts
Normal file
72
packages/api/src/plugins/auth.plugin.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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) {
|
||||
// Use internal URL (config.keycloak.url) to reach JWKS endpoint within Docker network
|
||||
jwks = jose.createRemoteJWKSet(
|
||||
new URL(
|
||||
`${config.keycloak.url}/realms/${config.keycloak.realm}/protocol/openid-connect/certs`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
// Decorate request with user and householdId (null defaults)
|
||||
fastify.decorateRequest('user', null as unknown as AuthUser);
|
||||
fastify.decorateRequest('householdId', '');
|
||||
|
||||
fastify.addHook('onRequest', async (request, _reply) => {
|
||||
// Skip auth for routes marked as public via route config
|
||||
const routeConfig = request.routeOptions.config as unknown 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);
|
||||
// Use public issuer URL (config.keycloak.issuerUrl) to validate the iss claim in the token.
|
||||
// Tokens have iss set to the public URL (KC_HOSTNAME_URL), not the internal Docker hostname.
|
||||
const expectedIssuer = `${config.keycloak.issuerUrl}/realms/${config.keycloak.realm}`;
|
||||
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, getJwks(), {
|
||||
issuer: expectedIssuer,
|
||||
audience: config.keycloak.clientId,
|
||||
});
|
||||
|
||||
const user: AuthUser = {
|
||||
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[]) ?? [],
|
||||
};
|
||||
|
||||
request.user = user;
|
||||
} catch (err) {
|
||||
// Only map jose-specific errors (bad signature, expired, wrong issuer, etc.) to 401.
|
||||
// Other errors (e.g. JWKS network failure) propagate as 500 so callers aren't misled.
|
||||
if (err instanceof jose.errors.JOSEError) {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ name: 'auth-plugin' },
|
||||
);
|
||||
101
packages/api/src/plugins/household.plugin.test.ts
Normal file
101
packages/api/src/plugins/household.plugin.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
|
||||
// Mock jose for the auth plugin dependency
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import authPlugin from './auth.plugin.js';
|
||||
import householdPlugin from './household.plugin.js';
|
||||
|
||||
describe('household.plugin', () => {
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(authPlugin);
|
||||
await app.register(householdPlugin);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('skips household check for public routes', async () => {
|
||||
const app = await buildApp();
|
||||
app.get('/public', { config: { public: true } as never }, async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/public' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('skips household check for routes with skipHousehold', async () => {
|
||||
const app = await buildApp();
|
||||
app.get('/skip', { config: { skipHousehold: true } as never }, async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/skip',
|
||||
headers: { authorization: 'Bearer valid' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('throws 403 when user does not belong to household', async () => {
|
||||
const app = await buildApp();
|
||||
app.get('/households/:householdId/data', async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/households/hh-unknown/data',
|
||||
headers: { authorization: 'Bearer valid' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().message).toContain('do not belong');
|
||||
});
|
||||
|
||||
it('sets request.householdId when user belongs to household', async () => {
|
||||
const app = await buildApp();
|
||||
let capturedId: string | undefined;
|
||||
app.get('/households/:householdId/data', async (request) => {
|
||||
capturedId = request.householdId;
|
||||
return { ok: true };
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/households/hh1/data',
|
||||
headers: { authorization: 'Bearer valid' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(capturedId).toBe('hh1');
|
||||
});
|
||||
|
||||
it('skips household check when route has no householdId param', async () => {
|
||||
const app = await buildApp();
|
||||
app.get('/no-household', async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/no-household',
|
||||
headers: { authorization: 'Bearer valid' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
31
packages/api/src/plugins/household.plugin.ts
Normal file
31
packages/api/src/plugins/household.plugin.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { ForbiddenError } from '../common/errors.js';
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.addHook('preHandler', async (request) => {
|
||||
// Skip household check for public routes or routes that explicitly opt out
|
||||
const routeConfig = request.routeOptions.config as unknown as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (routeConfig?.['public'] === true || routeConfig?.['skipHousehold'] === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// householdId must be present in the route URL params (e.g. /households/:householdId/...)
|
||||
const params = request.params as Record<string, string> | undefined;
|
||||
const householdId = params?.['householdId'];
|
||||
if (!householdId) {
|
||||
return; // route has no household context — no scoping needed
|
||||
}
|
||||
|
||||
// Validate user belongs to this household
|
||||
if (!request.user?.householdIds?.includes(householdId)) {
|
||||
throw new ForbiddenError('You do not belong to this household');
|
||||
}
|
||||
|
||||
request.householdId = householdId;
|
||||
});
|
||||
},
|
||||
{ name: 'household-plugin', dependencies: ['auth-plugin'] },
|
||||
);
|
||||
46
packages/api/src/plugins/mongoose.plugin.test.ts
Normal file
46
packages/api/src/plugins/mongoose.plugin.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
|
||||
// Mock mongoose and awilix before importing the plugin
|
||||
vi.mock('mongoose', () => ({
|
||||
default: {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@fastify/awilix', () => ({
|
||||
diContainer: {
|
||||
register: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import mongoosePlugin from './mongoose.plugin.js';
|
||||
import mongoose from 'mongoose';
|
||||
import { diContainer } from '@fastify/awilix';
|
||||
|
||||
describe('mongoose.plugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('connects to MongoDB on registration', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(mongoosePlugin);
|
||||
await app.ready();
|
||||
|
||||
expect(mongoose.connect).toHaveBeenCalled();
|
||||
expect(diContainer.register).toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('disconnects from MongoDB on close', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(mongoosePlugin);
|
||||
await app.ready();
|
||||
await app.close();
|
||||
|
||||
expect(mongoose.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
22
packages/api/src/plugins/mongoose.plugin.ts
Normal file
22
packages/api/src/plugins/mongoose.plugin.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import mongoose from 'mongoose';
|
||||
import { diContainer } from '@fastify/awilix';
|
||||
import { asValue } from 'awilix';
|
||||
import config from '../config/configuration.js';
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.log.info('Connecting to MongoDB...');
|
||||
await mongoose.connect(config.mongodb.uri);
|
||||
fastify.log.info('MongoDB connected');
|
||||
|
||||
// Register mongoose in DI container for other services to use
|
||||
diContainer.register({ mongoose: asValue(mongoose) });
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
fastify.log.info('Closing MongoDB connection...');
|
||||
await mongoose.disconnect();
|
||||
});
|
||||
},
|
||||
{ name: 'mongoose-plugin' },
|
||||
);
|
||||
19
packages/api/src/schemas/household.schema.test.ts
Normal file
19
packages/api/src/schemas/household.schema.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { HouseholdModel } from './household.schema.js';
|
||||
|
||||
describe('HouseholdModel', () => {
|
||||
it('is a valid mongoose model', () => {
|
||||
expect(HouseholdModel.modelName).toBe('Household');
|
||||
});
|
||||
|
||||
it('has expected schema paths', () => {
|
||||
const paths = Object.keys(HouseholdModel.schema.paths);
|
||||
expect(paths).toContain('name');
|
||||
expect(paths).toContain('ownerUserId');
|
||||
expect(paths).toContain('members');
|
||||
expect(paths).toContain('inviteCode');
|
||||
expect(paths).toContain('settings');
|
||||
expect(paths).toContain('createdAt');
|
||||
expect(paths).toContain('updatedAt');
|
||||
});
|
||||
});
|
||||
45
packages/api/src/schemas/household.schema.ts
Normal file
45
packages/api/src/schemas/household.schema.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
|
||||
const householdMemberSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: { type: String, required: true },
|
||||
role: { type: String, enum: Object.values(HouseholdRole), required: true },
|
||||
joinedAt: { type: Date, default: Date.now },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const householdSettingsSchema = new mongoose.Schema(
|
||||
{
|
||||
timezone: { type: String, default: 'UTC' },
|
||||
currency: { type: String, default: 'USD', maxlength: 3 },
|
||||
language: { type: String, default: 'en', maxlength: 5 },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const householdSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, required: true },
|
||||
ownerUserId: { type: String, required: true, index: true },
|
||||
members: { type: [householdMemberSchema], default: [] },
|
||||
inviteCode: { type: String, required: true, index: true },
|
||||
/* v8 ignore start -- Mongoose default factory, only invoked at document creation */
|
||||
settings: {
|
||||
type: householdSettingsSchema,
|
||||
default: () => ({ timezone: 'UTC', currency: 'USD', language: 'en' }),
|
||||
},
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
},
|
||||
);
|
||||
|
||||
export const HouseholdModel = mongoose.model('Household', householdSchema);
|
||||
export type HouseholdDocument = mongoose.InferSchemaType<typeof householdSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
19
packages/api/src/schemas/user.schema.test.ts
Normal file
19
packages/api/src/schemas/user.schema.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { UserModel } from './user.schema.js';
|
||||
|
||||
describe('UserModel', () => {
|
||||
it('is a valid mongoose model', () => {
|
||||
expect(UserModel.modelName).toBe('User');
|
||||
});
|
||||
|
||||
it('has expected schema paths', () => {
|
||||
const paths = Object.keys(UserModel.schema.paths);
|
||||
expect(paths).toContain('keycloakId');
|
||||
expect(paths).toContain('displayName');
|
||||
expect(paths).toContain('email');
|
||||
expect(paths).toContain('householdIds');
|
||||
expect(paths).toContain('defaultHouseholdId');
|
||||
expect(paths).toContain('createdAt');
|
||||
expect(paths).toContain('updatedAt');
|
||||
});
|
||||
});
|
||||
21
packages/api/src/schemas/user.schema.ts
Normal file
21
packages/api/src/schemas/user.schema.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import mongoose from 'mongoose';
|
||||
|
||||
const userSchema = new mongoose.Schema(
|
||||
{
|
||||
keycloakId: { type: String, required: true, unique: true, index: true },
|
||||
displayName: { type: String, required: true },
|
||||
email: { type: String, required: true },
|
||||
householdIds: { type: [String], default: [] },
|
||||
defaultHouseholdId: { type: String, default: null },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
},
|
||||
);
|
||||
|
||||
export const UserModel = mongoose.model('User', userSchema);
|
||||
export type UserDocument = mongoose.InferSchemaType<typeof userSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
81
packages/api/src/scripts/seed.ts
Normal file
81
packages/api/src/scripts/seed.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import mongoose from 'mongoose';
|
||||
|
||||
// Fixed ID shared with the Keycloak test users' householdIds attribute.
|
||||
// Both must be kept in sync with docker/keycloak/realm-export.json.
|
||||
const TEST_HOUSEHOLD_ID = new mongoose.Types.ObjectId('000000000000000000000001');
|
||||
|
||||
// SEED_MONGODB_URI is preferred so host-machine seeding always uses localhost,
|
||||
// even when MONGODB_URI is set to the Docker-internal hostname in the environment.
|
||||
const MONGODB_URI =
|
||||
process.env.SEED_MONGODB_URI ||
|
||||
process.env.MONGODB_URI ||
|
||||
'mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin&replicaSet=rs0';
|
||||
|
||||
async function seed() {
|
||||
console.log('Starting seed...');
|
||||
console.log(`Connecting to: ${MONGODB_URI.replace(/:[^:@]+@/, ':****@')}`);
|
||||
|
||||
const conn = await mongoose.connect(MONGODB_URI);
|
||||
const db = conn.connection.db;
|
||||
|
||||
if (!db) {
|
||||
throw new Error('Failed to get database reference');
|
||||
}
|
||||
|
||||
// Clean existing dev data
|
||||
const collections = await db.listCollections().toArray();
|
||||
for (const col of collections) {
|
||||
await db.dropCollection(col.name);
|
||||
}
|
||||
console.log('Cleared existing collections');
|
||||
|
||||
// Create household with the fixed ID that matches Keycloak user attributes.
|
||||
// User documents are not seeded here — they are created automatically via
|
||||
// upsertFromToken when each test user logs in for the first time.
|
||||
const householdsCollection = db.collection('households');
|
||||
await householdsCollection.insertOne({
|
||||
_id: TEST_HOUSEHOLD_ID,
|
||||
name: 'Test Household',
|
||||
ownerUserId: 'testuser1-keycloak-id',
|
||||
inviteCode: 'TESTCODE',
|
||||
members: [
|
||||
{
|
||||
userId: 'testuser1-keycloak-id',
|
||||
role: 'owner',
|
||||
joinedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: 'testuser2-keycloak-id',
|
||||
role: 'member',
|
||||
joinedAt: new Date(),
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
timezone: 'UTC',
|
||||
currency: 'USD',
|
||||
language: 'en',
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const householdId = TEST_HOUSEHOLD_ID.toString();
|
||||
|
||||
console.log('Created test household');
|
||||
console.log('');
|
||||
console.log('=== Seed Complete ===');
|
||||
console.log('');
|
||||
console.log('Test Users (log in via Keycloak to create user documents):');
|
||||
console.log(' testuser1 / test1234 (owner, admin)');
|
||||
console.log(' testuser2 / test1234 (member)');
|
||||
console.log(` Household ID: ${householdId}`);
|
||||
console.log(` Invite Code: TESTCODE`);
|
||||
console.log('');
|
||||
|
||||
await conn.disconnect();
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/api/tsconfig.json
Normal file
13
packages/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": true,
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
8
packages/api/tsconfig.test.json
Normal file
8
packages/api/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": []
|
||||
}
|
||||
29
packages/api/vitest.config.ts
Normal file
29
packages/api/vitest.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
enabled: false, // enable via --coverage flag or test:cov script
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/scripts/**',
|
||||
'src/common/types.ts', // declaration merging only — no runtime logic
|
||||
],
|
||||
reporter: ['text', 'lcov', 'json-summary', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
thresholds: {
|
||||
lines: 100,
|
||||
functions: 100,
|
||||
branches: 90,
|
||||
statements: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 10_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
38
packages/shared/eslint.config.js
Normal file
38
packages/shared/eslint.config.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import prettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/**', 'coverage/**'] },
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname, // points to packages/shared
|
||||
project: ['./tsconfig.json', './tsconfig.test.json'],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
prettierRecommended,
|
||||
);
|
||||
37
packages/shared/package.json
Normal file
37
packages/shared/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "@meshitrack/shared",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./validation": {
|
||||
"types": "./dist/validation/index.d.ts",
|
||||
"import": "./dist/validation/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"lint-fix": "eslint src --fix",
|
||||
"test": "vitest run",
|
||||
"test:cov": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"rimraf": "^6.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
1
packages/shared/src/enums/index.ts
Normal file
1
packages/shared/src/enums/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './roles.enums.js';
|
||||
14
packages/shared/src/enums/roles.enums.test.ts
Normal file
14
packages/shared/src/enums/roles.enums.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { HouseholdRole } from './roles.enums.js';
|
||||
|
||||
describe('HouseholdRole', () => {
|
||||
it('has OWNER, ADMIN, MEMBER values', () => {
|
||||
expect(HouseholdRole.OWNER).toBe('owner');
|
||||
expect(HouseholdRole.ADMIN).toBe('admin');
|
||||
expect(HouseholdRole.MEMBER).toBe('member');
|
||||
});
|
||||
|
||||
it('has exactly 3 roles', () => {
|
||||
expect(Object.values(HouseholdRole)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
5
packages/shared/src/enums/roles.enums.ts
Normal file
5
packages/shared/src/enums/roles.enums.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum HouseholdRole {
|
||||
OWNER = 'owner',
|
||||
ADMIN = 'admin',
|
||||
MEMBER = 'member',
|
||||
}
|
||||
8
packages/shared/src/index.ts
Normal file
8
packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Types
|
||||
export * from './types/index.js';
|
||||
|
||||
// Enums
|
||||
export * from './enums/index.js';
|
||||
|
||||
// Validation schemas
|
||||
export * from './validation/index.js';
|
||||
28
packages/shared/src/types/common.ts
Normal file
28
packages/shared/src/types/common.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: 'ok';
|
||||
version: string;
|
||||
uptime: number;
|
||||
}
|
||||
24
packages/shared/src/types/household.ts
Normal file
24
packages/shared/src/types/household.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { HouseholdRole } from '../enums/roles.enums.js';
|
||||
|
||||
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;
|
||||
}
|
||||
3
packages/shared/src/types/index.ts
Normal file
3
packages/shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './user.js';
|
||||
export * from './household.js';
|
||||
export * from './common.js';
|
||||
10
packages/shared/src/types/user.ts
Normal file
10
packages/shared/src/types/user.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export interface User {
|
||||
id: string;
|
||||
keycloakId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
householdIds: string[];
|
||||
defaultHouseholdId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
136
packages/shared/src/validation/household.schemas.test.ts
Normal file
136
packages/shared/src/validation/household.schemas.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
HouseholdSettingsSchema,
|
||||
CreateHouseholdSchema,
|
||||
UpdateHouseholdSchema,
|
||||
JoinHouseholdSchema,
|
||||
HouseholdMemberSchema,
|
||||
} from './household.schemas.js';
|
||||
import { HouseholdRole } from '../enums/roles.enums.js';
|
||||
|
||||
describe('HouseholdSettingsSchema', () => {
|
||||
it('accepts empty object with defaults', () => {
|
||||
const result = HouseholdSettingsSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.timezone).toBe('UTC');
|
||||
expect(result.data.currency).toBe('USD');
|
||||
expect(result.data.language).toBe('en');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts custom settings', () => {
|
||||
const result = HouseholdSettingsSchema.safeParse({
|
||||
timezone: 'Asia/Tokyo',
|
||||
currency: 'JPY',
|
||||
language: 'ja',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects currency exceeding 3 chars', () => {
|
||||
const result = HouseholdSettingsSchema.safeParse({ currency: 'LONG' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects language exceeding 5 chars', () => {
|
||||
const result = HouseholdSettingsSchema.safeParse({ language: 'toolong' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateHouseholdSchema', () => {
|
||||
it('accepts valid input', () => {
|
||||
const result = CreateHouseholdSchema.safeParse({ name: 'My Household' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const result = CreateHouseholdSchema.safeParse({ name: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects name exceeding 100 chars', () => {
|
||||
const result = CreateHouseholdSchema.safeParse({ name: 'x'.repeat(101) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims name', () => {
|
||||
const result = CreateHouseholdSchema.safeParse({ name: ' Test ' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Test');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts optional settings', () => {
|
||||
const result = CreateHouseholdSchema.safeParse({
|
||||
name: 'Test',
|
||||
settings: { timezone: 'US/Eastern' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateHouseholdSchema', () => {
|
||||
it('accepts partial updates', () => {
|
||||
const result = UpdateHouseholdSchema.safeParse({ name: 'Updated' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty object', () => {
|
||||
const result = UpdateHouseholdSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts partial settings', () => {
|
||||
const result = UpdateHouseholdSchema.safeParse({ settings: { currency: 'EUR' } });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JoinHouseholdSchema', () => {
|
||||
it('accepts valid invite code', () => {
|
||||
const result = JoinHouseholdSchema.safeParse({ inviteCode: 'ABC123' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty invite code', () => {
|
||||
const result = JoinHouseholdSchema.safeParse({ inviteCode: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing invite code', () => {
|
||||
const result = JoinHouseholdSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HouseholdMemberSchema', () => {
|
||||
it('accepts valid member', () => {
|
||||
const result = HouseholdMemberSchema.safeParse({
|
||||
userId: 'user-1',
|
||||
role: HouseholdRole.OWNER,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid role', () => {
|
||||
const result = HouseholdMemberSchema.safeParse({
|
||||
userId: 'user-1',
|
||||
role: 'superadmin',
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty userId', () => {
|
||||
const result = HouseholdMemberSchema.safeParse({
|
||||
userId: '',
|
||||
role: HouseholdRole.MEMBER,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
54
packages/shared/src/validation/household.schemas.ts
Normal file
54
packages/shared/src/validation/household.schemas.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from 'zod/v4';
|
||||
import { HouseholdRole } from '../enums/roles.enums.js';
|
||||
|
||||
export const HouseholdSettingsSchema = z.object({
|
||||
timezone: z.string().default('UTC'),
|
||||
currency: z.string().max(3).default('USD'),
|
||||
language: z.string().max(5).default('en'),
|
||||
});
|
||||
|
||||
export const CreateHouseholdSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim(),
|
||||
settings: HouseholdSettingsSchema.optional(),
|
||||
});
|
||||
|
||||
export const UpdateHouseholdSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim().optional(),
|
||||
settings: HouseholdSettingsSchema.partial().optional(),
|
||||
});
|
||||
|
||||
export const JoinHouseholdSchema = z.object({
|
||||
inviteCode: z.string().min(1),
|
||||
});
|
||||
|
||||
export const HouseholdMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
role: z.nativeEnum(HouseholdRole),
|
||||
joinedAt: z.date(),
|
||||
});
|
||||
|
||||
// Response schema for API output — dates are ISO strings after JSON serialization.
|
||||
export const HouseholdResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
name: z.string(),
|
||||
ownerUserId: z.string(),
|
||||
members: z.array(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
role: z.string(),
|
||||
joinedAt: z.string(),
|
||||
}),
|
||||
),
|
||||
inviteCode: z.string(),
|
||||
settings: z.object({
|
||||
timezone: z.string(),
|
||||
currency: z.string(),
|
||||
language: z.string(),
|
||||
}),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type CreateHouseholdInput = z.infer<typeof CreateHouseholdSchema>;
|
||||
export type UpdateHouseholdInput = z.infer<typeof UpdateHouseholdSchema>;
|
||||
export type JoinHouseholdInput = z.infer<typeof JoinHouseholdSchema>;
|
||||
2
packages/shared/src/validation/index.ts
Normal file
2
packages/shared/src/validation/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './user.schemas.js';
|
||||
export * from './household.schemas.js';
|
||||
84
packages/shared/src/validation/user.schemas.test.ts
Normal file
84
packages/shared/src/validation/user.schemas.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateUserSchema, UpdateUserSchema } from './user.schemas.js';
|
||||
|
||||
describe('CreateUserSchema', () => {
|
||||
it('accepts valid input', () => {
|
||||
const result = CreateUserSchema.safeParse({
|
||||
keycloakId: 'kc-1',
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.householdIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing keycloakId', () => {
|
||||
const result = CreateUserSchema.safeParse({
|
||||
displayName: 'Test',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty keycloakId', () => {
|
||||
const result = CreateUserSchema.safeParse({
|
||||
keycloakId: '',
|
||||
displayName: 'Test',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
const result = CreateUserSchema.safeParse({
|
||||
keycloakId: 'kc-1',
|
||||
displayName: 'Test',
|
||||
email: 'not-email',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims displayName', () => {
|
||||
const result = CreateUserSchema.safeParse({
|
||||
keycloakId: 'kc-1',
|
||||
displayName: ' Test ',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.displayName).toBe('Test');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects displayName exceeding 100 chars', () => {
|
||||
const result = CreateUserSchema.safeParse({
|
||||
keycloakId: 'kc-1',
|
||||
displayName: 'x'.repeat(101),
|
||||
email: 'test@example.com',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateUserSchema', () => {
|
||||
it('accepts partial updates', () => {
|
||||
const result = UpdateUserSchema.safeParse({ displayName: 'New Name' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty object (all fields optional)', () => {
|
||||
const result = UpdateUserSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('does not allow keycloakId', () => {
|
||||
const result = UpdateUserSchema.safeParse({ keycloakId: 'kc-1' });
|
||||
// keycloakId is omitted, so it should be stripped or rejected
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect('keycloakId' in result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
14
packages/shared/src/validation/user.schemas.ts
Normal file
14
packages/shared/src/validation/user.schemas.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { z } from 'zod/v4';
|
||||
|
||||
export const CreateUserSchema = z.object({
|
||||
keycloakId: z.string().min(1),
|
||||
displayName: z.string().min(1).max(100).trim(),
|
||||
email: z.email(),
|
||||
householdIds: z.array(z.string()).default([]),
|
||||
defaultHouseholdId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateUserSchema = CreateUserSchema.partial().omit({ keycloakId: true });
|
||||
|
||||
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||
12
packages/shared/tsconfig.json
Normal file
12
packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
8
packages/shared/tsconfig.test.json
Normal file
8
packages/shared/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": []
|
||||
}
|
||||
30
packages/shared/vitest.config.ts
Normal file
30
packages/shared/vitest.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
enabled: false,
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/index.ts',
|
||||
'src/types/**', // pure type declarations
|
||||
'src/enums/index.ts',
|
||||
'src/validation/index.ts',
|
||||
],
|
||||
reporter: ['text', 'lcov', 'json-summary', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
thresholds: {
|
||||
lines: 100,
|
||||
functions: 100,
|
||||
branches: 90,
|
||||
statements: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
47
packages/web/eslint.config.js
Normal file
47
packages/web/eslint.config.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import nextPlugin from '@next/eslint-plugin-next';
|
||||
import prettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['.next/**', 'coverage/**'] },
|
||||
...tseslint.configs.recommended,
|
||||
// React flat config — uses JSX runtime transform (no need to import React)
|
||||
reactPlugin.configs.flat['jsx-runtime'],
|
||||
// React Hooks
|
||||
{
|
||||
plugins: { 'react-hooks': reactHooksPlugin },
|
||||
rules: reactHooksPlugin.configs.recommended.rules,
|
||||
},
|
||||
// Next.js
|
||||
{
|
||||
plugins: { '@next/next': nextPlugin },
|
||||
rules: {
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs['core-web-vitals'].rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
},
|
||||
},
|
||||
prettierRecommended,
|
||||
);
|
||||
6
packages/web/next-env.d.ts
vendored
Normal file
6
packages/web/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
packages/web/next.config.ts
Normal file
7
packages/web/next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@meshitrack/shared'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue