Setup initial project

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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