Setup initial project
This commit is contained in:
commit
db79af06f7
119 changed files with 20761 additions and 0 deletions
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';
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue