1731 lines
52 KiB
Markdown
1731 lines
52 KiB
Markdown
# Phase 01 Foundation & Deployment Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Create the buildable and deployable foundation for the Travel Planner: Angular PWA, NestJS API and worker, PostgreSQL/Redis infrastructure, health checks, a single-port production edge, and TeamCity-ready build/deploy scripts.
|
|
|
|
**Architecture:** Use a pnpm workspace with `frontend` and `backend`. The backend is one NestJS codebase with separate API and worker entry points plus shared libraries. Production compiles Angular into the `edge` image; only `edge` publishes a host TCP port and reverse-proxies `/api/*` to `api:3000`. PostgreSQL and Redis are internal Docker services. TeamCity invokes repository-owned scripts to validate, build immutable images, migrate, deploy, health-check, and roll back.
|
|
|
|
**Tech Stack:** Node.js 24 LTS, pnpm, TypeScript, Angular 22 + Vitest + Angular PWA service worker, NestJS 11 + Jest + Terminus, PostgreSQL 18.4, Redis 8.8.1, Docker Compose, Nginx edge, TeamCity.
|
|
|
|
## Global Constraints
|
|
|
|
- PostgreSQL is the source of truth.
|
|
- Do not add Mistral, agent logic, OIDC, trips, or business entities in Phase 01.
|
|
- Exactly one TCP port from production Compose may be published to the host.
|
|
- Only `edge` may contain a Compose `ports:` mapping.
|
|
- `api`, `worker`, `postgres`, and `redis` must have no host-published ports.
|
|
- Do not use `network_mode: host`.
|
|
- The edge serves Angular static files and proxies `/api/*` to `api:3000`.
|
|
- Production does not require host port 80.
|
|
- Node baseline is the 24 LTS line; Angular is 22.x; NestJS is 11.x.
|
|
- Pin PostgreSQL production image to `postgres:18.4-alpine` and Redis production image to `redis:8.8.1-alpine` in the initial deployment configuration.
|
|
- JavaScript dependency versions are frozen by `pnpm-lock.yaml`.
|
|
- Secrets are not committed or baked into images.
|
|
- TeamCity deployment uses immutable `IMAGE_TAG` values and does not run `docker compose down` as a routine deployment step.
|
|
- Live/paid providers do not exist in this phase.
|
|
|
|
---
|
|
|
|
## File Structure Locked by This Phase
|
|
|
|
```text
|
|
travel-planner/
|
|
├── package.json # workspace commands and Node/pnpm contract
|
|
├── pnpm-workspace.yaml # frontend/backend workspace membership
|
|
├── pnpm-lock.yaml
|
|
├── .editorconfig
|
|
├── .gitignore
|
|
├── .env.example # non-secret configuration names/defaults
|
|
├── README.md # developer startup and production topology
|
|
├── compose.yml # production stack; one published edge port
|
|
├── compose.dev.yml # PostgreSQL/Redis developer dependencies only
|
|
├── frontend/
|
|
│ ├── angular.json
|
|
│ ├── package.json
|
|
│ ├── ngsw-config.json
|
|
│ └── src/
|
|
│ ├── app/
|
|
│ │ ├── app.component.ts
|
|
│ │ ├── app.component.html
|
|
│ │ └── app.component.spec.ts
|
|
│ ├── index.html
|
|
│ └── manifest.webmanifest
|
|
├── backend/
|
|
│ ├── package.json
|
|
│ ├── eslint.config.mjs
|
|
│ ├── nest-cli.json
|
|
│ ├── tsconfig.json
|
|
│ ├── apps/
|
|
│ │ ├── api/src/
|
|
│ │ │ ├── main.ts
|
|
│ │ │ ├── migration.ts
|
|
│ │ │ ├── api.module.ts
|
|
│ │ │ └── health/
|
|
│ │ │ ├── health.controller.ts
|
|
│ │ │ ├── health.controller.spec.ts
|
|
│ │ │ ├── health.module.ts
|
|
│ │ │ └── readiness.service.ts
|
|
│ │ └── worker/src/
|
|
│ │ ├── main.ts
|
|
│ │ ├── worker.module.ts
|
|
│ │ └── worker.bootstrap.spec.ts
|
|
│ └── libs/
|
|
│ ├── configuration/src/
|
|
│ │ ├── environment.ts
|
|
│ │ ├── environment.spec.ts
|
|
│ │ └── index.ts
|
|
│ └── infrastructure/src/
|
|
│ ├── postgres/postgres.module.ts
|
|
│ ├── redis/redis.module.ts
|
|
│ └── index.ts
|
|
├── docker/
|
|
│ ├── api.Dockerfile
|
|
│ ├── worker.Dockerfile
|
|
│ ├── edge.Dockerfile
|
|
│ └── edge/
|
|
│ ├── nginx.conf
|
|
│ └── default.conf.template
|
|
├── scripts/
|
|
│ ├── verify-compose-invariants.mjs
|
|
│ └── teamcity/
|
|
│ ├── validate.sh
|
|
│ ├── build-images.sh
|
|
│ ├── deploy.sh
|
|
│ ├── rollback.sh
|
|
│ └── smoke.sh
|
|
├── tests/
|
|
│ └── compose-invariants.test.mjs
|
|
└── docs/
|
|
├── architecture/
|
|
│ └── deployment.md
|
|
└── superpowers/
|
|
└── plans/
|
|
├── 2026-08-17-travel-planner-master-roadmap.md
|
|
└── 2026-08-17-phase-01-foundation-deployment.md
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Establish the pnpm Workspace and Version Contract
|
|
|
|
**Files:**
|
|
- Create: `package.json`
|
|
- Create: `pnpm-workspace.yaml`
|
|
- Create: `.editorconfig`
|
|
- Create: `.gitignore`
|
|
- Create: `.env.example`
|
|
- Create: `README.md`
|
|
- Test: workspace commands executed from repository root
|
|
|
|
**Interfaces:**
|
|
- Consumes: none.
|
|
- Produces: root commands `pnpm lint`, `pnpm test`, `pnpm build`, `pnpm dev:infra`, `pnpm dev:infra:down`; workspace locations `frontend` and `backend`.
|
|
|
|
- [ ] **Step 1: Create the root workspace manifest**
|
|
|
|
Create `package.json`:
|
|
|
|
```json
|
|
{
|
|
"name": "travel-planner",
|
|
"private": true,
|
|
"packageManager": "pnpm@10.15.0",
|
|
"engines": {
|
|
"node": ">=24.15.0 <25"
|
|
},
|
|
"scripts": {
|
|
"lint": "pnpm -r --if-present run lint",
|
|
"test": "pnpm -r --if-present run test",
|
|
"build": "pnpm -r --if-present run build",
|
|
"dev:infra": "docker compose -f compose.dev.yml up -d --wait",
|
|
"dev:infra:down": "docker compose -f compose.dev.yml down",
|
|
"test:compose": "node --test tests/compose-invariants.test.mjs"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Declare the workspaces and repository defaults**
|
|
|
|
Create `pnpm-workspace.yaml`:
|
|
|
|
```yaml
|
|
packages:
|
|
- frontend
|
|
- backend
|
|
```
|
|
|
|
Create `.editorconfig`:
|
|
|
|
```ini
|
|
root = true
|
|
|
|
[*]
|
|
charset = utf-8
|
|
end_of_line = lf
|
|
insert_final_newline = true
|
|
indent_style = space
|
|
indent_size = 2
|
|
trim_trailing_whitespace = true
|
|
|
|
[*.md]
|
|
trim_trailing_whitespace = false
|
|
```
|
|
|
|
Create `.gitignore` with at least:
|
|
|
|
```text
|
|
node_modules/
|
|
dist/
|
|
.angular/
|
|
coverage/
|
|
.env
|
|
.env.*
|
|
!.env.example
|
|
/data/
|
|
*.log
|
|
.DS_Store
|
|
```
|
|
|
|
- [ ] **Step 3: Define non-secret environment names**
|
|
|
|
Create `.env.example`:
|
|
|
|
```dotenv
|
|
APP_HTTPS_PORT=443
|
|
IMAGE_TAG=local
|
|
REGISTRY=local
|
|
POSTGRES_IMAGE_TAG=18.4-alpine
|
|
REDIS_IMAGE_TAG=8.8.1-alpine
|
|
POSTGRES_DB=travel_planner
|
|
POSTGRES_USER=travel_planner
|
|
POSTGRES_PASSWORD=change-me-outside-source-control
|
|
DATABASE_URL=postgresql://travel_planner:change-me-outside-source-control@postgres:5432/travel_planner
|
|
REDIS_URL=redis://redis:6379
|
|
APP_VERSION=dev
|
|
TEAMCITY_BUILD_NUMBER=local
|
|
SOURCE_REVISION=local
|
|
TLS_CERT_FILE=/etc/travel-planner/tls/tls.crt
|
|
TLS_KEY_FILE=/etc/travel-planner/tls/tls.key
|
|
```
|
|
|
|
Add to `README.md` an explicit warning that `.env.example` contains examples only and production secrets live on the deployment host.
|
|
|
|
- [ ] **Step 4: Verify the root package contract**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
node --version
|
|
corepack enable
|
|
pnpm --version
|
|
pnpm install
|
|
```
|
|
|
|
Expected:
|
|
- Node satisfies `>=24.15.0 <25`.
|
|
- `pnpm install` succeeds and creates `pnpm-lock.yaml`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add package.json pnpm-workspace.yaml pnpm-lock.yaml .editorconfig .gitignore .env.example README.md
|
|
git commit -m "chore: establish travel planner workspace"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Scaffold the NestJS API and Worker Entry Points
|
|
|
|
**Files:**
|
|
- Create: `backend/package.json`
|
|
- Create: `backend/nest-cli.json`
|
|
- Create: `backend/tsconfig.json`
|
|
- Create: `backend/apps/api/tsconfig.app.json`
|
|
- Create: `backend/apps/api/src/main.ts`
|
|
- Create: `backend/apps/api/src/api.module.ts`
|
|
- Create: `backend/apps/worker/tsconfig.app.json`
|
|
- Create: `backend/apps/worker/src/main.ts`
|
|
- Create: `backend/apps/worker/src/worker.module.ts`
|
|
- Test: `backend/apps/worker/src/worker.bootstrap.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Node/pnpm root contract from Task 1.
|
|
- Produces: `pnpm --filter backend build:api`, `build:worker`, `start:api`, `start:worker`; API listens on internal port `3000`; worker creates a Nest application context and exposes no HTTP listener.
|
|
|
|
- [ ] **Step 1: Write the worker bootstrap test first**
|
|
|
|
Create `backend/apps/worker/src/worker.bootstrap.spec.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it, jest } from '@jest/globals';
|
|
import { bootstrapWorker } from './main';
|
|
|
|
describe('bootstrapWorker', () => {
|
|
it('creates an application context without starting an HTTP listener', async () => {
|
|
const close = jest.fn().mockResolvedValue(undefined);
|
|
const enableShutdownHooks = jest.fn();
|
|
const appContext = { close, enableShutdownHooks };
|
|
const createContext = jest.fn().mockResolvedValue(appContext);
|
|
|
|
const app = await bootstrapWorker(createContext as never);
|
|
|
|
expect(createContext).toHaveBeenCalledTimes(1);
|
|
expect(enableShutdownHooks).toHaveBeenCalledTimes(1);
|
|
expect(app).toBe(appContext);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the focused test and verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter backend test -- apps/worker/src/worker.bootstrap.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because `bootstrapWorker` and backend test configuration do not yet exist.
|
|
|
|
- [ ] **Step 3: Create the backend package and Nest configuration**
|
|
|
|
Create `backend/package.json` with NestJS 11 packages, Jest + SWC, TypeScript, and scripts:
|
|
|
|
```json
|
|
{
|
|
"name": "backend",
|
|
"private": true,
|
|
"scripts": {
|
|
"build": "pnpm run build:api && pnpm run build:worker",
|
|
"build:api": "nest build api",
|
|
"build:worker": "nest build worker",
|
|
"start:api": "node dist/apps/api/main.js",
|
|
"start:worker": "node dist/apps/worker/main.js",
|
|
"lint": "eslint \"{apps,libs}/**/*.ts\"",
|
|
"test": "jest --runInBand"
|
|
},
|
|
"dependencies": {
|
|
"@nestjs/common": "^11.0.0",
|
|
"@nestjs/core": "^11.0.0",
|
|
"@nestjs/platform-express": "^11.0.0",
|
|
"reflect-metadata": "^0.2.2",
|
|
"rxjs": "^7.8.0"
|
|
},
|
|
"devDependencies": {
|
|
"@jest/globals": "^30.0.0",
|
|
"@nestjs/cli": "^11.0.0",
|
|
"@nestjs/testing": "^11.0.0",
|
|
"@types/jest": "^30.0.0",
|
|
"@types/node": "^24.0.0",
|
|
"@eslint/js": "^9.0.0",
|
|
"@swc/core": "^1.0.0",
|
|
"@swc/jest": "^0.2.39",
|
|
"eslint": "^9.0.0",
|
|
"jest": "^30.0.0",
|
|
"typescript": "^6.0.0",
|
|
"typescript-eslint": "^8.0.0"
|
|
},
|
|
"jest": {
|
|
"moduleFileExtensions": ["js", "json", "ts"],
|
|
"rootDir": ".",
|
|
"testRegex": ".*\\.spec\\.ts$",
|
|
"transform": {
|
|
"^.+\\.(t|j)s$": [
|
|
"@swc/jest",
|
|
{
|
|
"jsc": {
|
|
"parser": {"syntax": "typescript", "decorators": true},
|
|
"transform": {"legacyDecorator": true, "decoratorMetadata": true}
|
|
},
|
|
"module": {"type": "commonjs"}
|
|
}
|
|
]
|
|
},
|
|
"collectCoverageFrom": ["apps/**/*.ts", "libs/**/*.ts"],
|
|
"coverageDirectory": "coverage",
|
|
"testEnvironment": "node"
|
|
}
|
|
}
|
|
```
|
|
|
|
Create `backend/nest-cli.json`:
|
|
|
|
```json
|
|
{
|
|
"$schema": "https://json.schemastore.org/nest-cli",
|
|
"collection": "@nestjs/schematics",
|
|
"monorepo": true,
|
|
"root": "apps/api",
|
|
"sourceRoot": "apps/api/src",
|
|
"compilerOptions": {"deleteOutDir": true},
|
|
"projects": {
|
|
"api": {
|
|
"type": "application",
|
|
"root": "apps/api",
|
|
"entryFile": "main",
|
|
"sourceRoot": "apps/api/src",
|
|
"compilerOptions": {"tsConfigPath": "apps/api/tsconfig.app.json"}
|
|
},
|
|
"worker": {
|
|
"type": "application",
|
|
"root": "apps/worker",
|
|
"entryFile": "main",
|
|
"sourceRoot": "apps/worker/src",
|
|
"compilerOptions": {"tsConfigPath": "apps/worker/tsconfig.app.json"}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Create `backend/tsconfig.json`:
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"module": "commonjs",
|
|
"declaration": true,
|
|
"removeComments": true,
|
|
"emitDecoratorMetadata": true,
|
|
"experimentalDecorators": true,
|
|
"allowSyntheticDefaultImports": true,
|
|
"target": "ES2023",
|
|
"sourceMap": true,
|
|
"outDir": "./dist",
|
|
"baseUrl": "./",
|
|
"incremental": true,
|
|
"skipLibCheck": true,
|
|
"strict": true,
|
|
"paths": {
|
|
"@travel/configuration": ["libs/configuration/src"],
|
|
"@travel/infrastructure": ["libs/infrastructure/src"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Create both `backend/apps/api/tsconfig.app.json` and `backend/apps/worker/tsconfig.app.json` with the appropriate output exclusions:
|
|
|
|
```json
|
|
{
|
|
"extends": "../../tsconfig.json",
|
|
"compilerOptions": {"declaration": false},
|
|
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
|
}
|
|
```
|
|
|
|
Create `backend/eslint.config.mjs`:
|
|
|
|
```js
|
|
import eslint from '@eslint/js';
|
|
import tseslint from 'typescript-eslint';
|
|
|
|
export default tseslint.config(
|
|
{ ignores: ['dist/**', 'coverage/**'] },
|
|
eslint.configs.recommended,
|
|
...tseslint.configs.recommended,
|
|
);
|
|
```
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm install
|
|
```
|
|
|
|
- [ ] **Step 4: Implement the API and worker bootstraps**
|
|
|
|
Create `backend/apps/api/src/api.module.ts`:
|
|
|
|
```ts
|
|
import { Module } from '@nestjs/common';
|
|
|
|
@Module({})
|
|
export class ApiModule {}
|
|
```
|
|
|
|
Create `backend/apps/api/src/main.ts`:
|
|
|
|
```ts
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { ApiModule } from './api.module';
|
|
|
|
export async function bootstrapApi(): Promise<void> {
|
|
const app = await NestFactory.create(ApiModule);
|
|
app.enableShutdownHooks();
|
|
app.setGlobalPrefix('api/v1');
|
|
await app.listen(3000, '0.0.0.0');
|
|
}
|
|
|
|
if (require.main === module) {
|
|
void bootstrapApi();
|
|
}
|
|
```
|
|
|
|
Create `backend/apps/worker/src/worker.module.ts`:
|
|
|
|
```ts
|
|
import { Module } from '@nestjs/common';
|
|
|
|
@Module({})
|
|
export class WorkerModule {}
|
|
```
|
|
|
|
Create `backend/apps/worker/src/main.ts`:
|
|
|
|
```ts
|
|
import { INestApplicationContext, Type } from '@nestjs/common';
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { WorkerModule } from './worker.module';
|
|
|
|
type ContextFactory = (module: Type<unknown>) => Promise<INestApplicationContext>;
|
|
|
|
export async function bootstrapWorker(
|
|
createContext: ContextFactory = (module) => NestFactory.createApplicationContext(module),
|
|
): Promise<INestApplicationContext> {
|
|
const app = await createContext(WorkerModule);
|
|
app.enableShutdownHooks();
|
|
return app;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
void bootstrapWorker();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests and builds**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter backend test -- apps/worker/src/worker.bootstrap.spec.ts
|
|
pnpm --filter backend build
|
|
```
|
|
|
|
Expected: PASS; both API and worker build outputs exist.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend pnpm-lock.yaml
|
|
git commit -m "feat: add nestjs api and worker skeletons"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add Configuration Parsing with Fail-Fast Validation
|
|
|
|
**Files:**
|
|
- Create: `backend/libs/configuration/src/environment.ts`
|
|
- Create: `backend/libs/configuration/src/environment.spec.ts`
|
|
- Create: `backend/libs/configuration/src/index.ts`
|
|
- Modify: `backend/apps/api/src/api.module.ts`
|
|
- Modify: `backend/apps/worker/src/worker.module.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: process environment variables.
|
|
- Produces: `loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment` with `databaseUrl`, `redisUrl`, and safe build metadata.
|
|
|
|
- [ ] **Step 1: Write failing environment tests**
|
|
|
|
Create `backend/libs/configuration/src/environment.spec.ts`:
|
|
|
|
```ts
|
|
import { loadEnvironment } from './environment';
|
|
|
|
describe('loadEnvironment', () => {
|
|
it('requires database and redis URLs', () => {
|
|
expect(() => loadEnvironment({})).toThrow('DATABASE_URL');
|
|
});
|
|
|
|
it('returns build metadata without secrets', () => {
|
|
expect(loadEnvironment({
|
|
DATABASE_URL: 'postgresql://u:p@postgres:5432/db',
|
|
REDIS_URL: 'redis://redis:6379',
|
|
APP_VERSION: '1.2.3',
|
|
TEAMCITY_BUILD_NUMBER: '42',
|
|
SOURCE_REVISION: 'abc123',
|
|
})).toEqual({
|
|
databaseUrl: 'postgresql://u:p@postgres:5432/db',
|
|
redisUrl: 'redis://redis:6379',
|
|
appVersion: '1.2.3',
|
|
teamCityBuildNumber: '42',
|
|
sourceRevision: 'abc123',
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test and verify failure**
|
|
|
|
```bash
|
|
pnpm --filter backend test -- libs/configuration/src/environment.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because `loadEnvironment` does not exist.
|
|
|
|
- [ ] **Step 3: Implement minimal validated configuration**
|
|
|
|
Create `environment.ts`:
|
|
|
|
```ts
|
|
export interface AppEnvironment {
|
|
databaseUrl: string;
|
|
redisUrl: string;
|
|
appVersion: string;
|
|
teamCityBuildNumber: string;
|
|
sourceRevision: string;
|
|
}
|
|
|
|
function required(env: NodeJS.ProcessEnv, name: string): string {
|
|
const value = env[name]?.trim();
|
|
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
|
return value;
|
|
}
|
|
|
|
export function loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment {
|
|
return {
|
|
databaseUrl: required(env, 'DATABASE_URL'),
|
|
redisUrl: required(env, 'REDIS_URL'),
|
|
appVersion: env.APP_VERSION?.trim() || 'dev',
|
|
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local',
|
|
sourceRevision: env.SOURCE_REVISION?.trim() || 'local',
|
|
};
|
|
}
|
|
```
|
|
|
|
Export it from `index.ts` together with a typed provider:
|
|
|
|
```ts
|
|
import { loadEnvironment } from './environment';
|
|
|
|
export * from './environment';
|
|
export const APP_ENVIRONMENT = Symbol('APP_ENVIRONMENT');
|
|
export const appEnvironmentProvider = {
|
|
provide: APP_ENVIRONMENT,
|
|
useFactory: () => loadEnvironment(process.env),
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Wire validation into both application roots**
|
|
|
|
Add `appEnvironmentProvider` to the providers of both `ApiModule` and `WorkerModule` and export it from a small shared configuration module if Nest module reuse is needed. Do not expose the raw `process.env` object through dependency injection.
|
|
|
|
- [ ] **Step 5: Run tests and build**
|
|
|
|
```bash
|
|
pnpm --filter backend test -- libs/configuration/src/environment.spec.ts
|
|
DATABASE_URL=postgresql://u:p@localhost:5432/db REDIS_URL=redis://localhost:6379 pnpm --filter backend build
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/libs/configuration backend/apps/api/src/api.module.ts backend/apps/worker/src/worker.module.ts
|
|
git commit -m "feat: validate backend runtime configuration"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Scaffold the Angular 22 PWA with a Minimal Shell
|
|
|
|
**Files:**
|
|
- Create: `frontend/**` via Angular CLI 22
|
|
- Modify: `frontend/src/app/app.component.ts`
|
|
- Modify: `frontend/src/app/app.component.html`
|
|
- Modify: `frontend/src/app/app.component.spec.ts`
|
|
- Create/Modify: `frontend/ngsw-config.json`
|
|
- Create/Modify: `frontend/src/manifest.webmanifest`
|
|
|
|
**Interfaces:**
|
|
- Consumes: root pnpm workspace.
|
|
- Produces: static production bundle in `frontend/dist/frontend/browser`; installable PWA shell; root app title `Travel Planner`.
|
|
|
|
- [ ] **Step 1: Generate the Angular application**
|
|
|
|
Run from repository root:
|
|
|
|
```bash
|
|
pnpm dlx @angular/cli@22 new frontend --routing --style=scss --standalone --strict --skip-git --package-manager=pnpm --test-runner=vitest
|
|
cd frontend
|
|
pnpm exec ng add @angular/pwa --project frontend --skip-confirmation
|
|
cd ..
|
|
pnpm install
|
|
```
|
|
|
|
Expected: Angular workspace exists with Vitest tests and PWA service-worker configuration.
|
|
|
|
- [ ] **Step 2: Replace the generated component test with product-shell expectations**
|
|
|
|
Update `frontend/src/app/app.component.spec.ts` to assert:
|
|
|
|
```ts
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { AppComponent } from './app.component';
|
|
|
|
describe('AppComponent', () => {
|
|
it('renders the Travel Planner shell', async () => {
|
|
await TestBed.configureTestingModule({ imports: [AppComponent] }).compileComponents();
|
|
const fixture = TestBed.createComponent(AppComponent);
|
|
await fixture.whenStable();
|
|
expect(fixture.nativeElement.textContent).toContain('Travel Planner');
|
|
expect(fixture.nativeElement.textContent).toContain('Reisen planen, gemeinsam entscheiden.');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run the test and verify it fails against the generated template**
|
|
|
|
```bash
|
|
pnpm --filter frontend test -- --watch=false
|
|
```
|
|
|
|
Expected: FAIL on the product copy assertion.
|
|
|
|
- [ ] **Step 4: Implement the minimal app shell**
|
|
|
|
Set `AppComponent` to a standalone component with no business state. Use this template:
|
|
|
|
```html
|
|
<main class="app-shell">
|
|
<h1>Travel Planner</h1>
|
|
<p>Reisen planen, gemeinsam entscheiden.</p>
|
|
<router-outlet />
|
|
</main>
|
|
```
|
|
|
|
Keep styling minimal; Phase 11 owns final UI polish.
|
|
|
|
- [ ] **Step 5: Verify test, production build, and PWA artifacts**
|
|
|
|
```bash
|
|
pnpm --filter frontend test -- --watch=false
|
|
pnpm --filter frontend build
|
|
find frontend/dist -name ngsw.json -o -name manifest.webmanifest
|
|
```
|
|
|
|
Expected: tests PASS; production build succeeds; service-worker/PWA artifacts exist.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add frontend pnpm-lock.yaml
|
|
git commit -m "feat: add angular pwa shell"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add PostgreSQL and Redis Development Infrastructure
|
|
|
|
**Files:**
|
|
- Create: `compose.dev.yml`
|
|
- Modify: `README.md`
|
|
- Test: manual Compose health verification in this task; invariant automation is added in Task 8.
|
|
|
|
**Interfaces:**
|
|
- Consumes: `.env.example` names.
|
|
- Produces: development services `postgres` on container port 5432 and `redis` on container port 6379, both healthy before dependent integration work.
|
|
|
|
- [ ] **Step 1: Create the development Compose file**
|
|
|
|
Create `compose.dev.yml`:
|
|
|
|
```yaml
|
|
services:
|
|
postgres:
|
|
image: postgres:18.4-alpine
|
|
environment:
|
|
POSTGRES_DB: travel_planner
|
|
POSTGRES_USER: travel_planner
|
|
POSTGRES_PASSWORD: travel_planner_dev
|
|
ports:
|
|
- "127.0.0.1:5432:5432"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U travel_planner -d travel_planner"]
|
|
interval: 2s
|
|
timeout: 3s
|
|
retries: 20
|
|
volumes:
|
|
- travel_postgres_dev:/var/lib/postgresql
|
|
|
|
redis:
|
|
image: redis:8.8.1-alpine
|
|
ports:
|
|
- "127.0.0.1:6379:6379"
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 2s
|
|
timeout: 3s
|
|
retries: 20
|
|
|
|
volumes:
|
|
travel_postgres_dev:
|
|
```
|
|
|
|
This file is development-only. The one-public-port invariant applies to production `compose.yml`, not this local dependency convenience file.
|
|
|
|
- [ ] **Step 2: Start development infrastructure**
|
|
|
|
```bash
|
|
pnpm dev:infra
|
|
docker compose -f compose.dev.yml ps
|
|
```
|
|
|
|
Expected: both services show `healthy`.
|
|
|
|
- [ ] **Step 3: Verify connectivity**
|
|
|
|
```bash
|
|
docker compose -f compose.dev.yml exec -T postgres pg_isready -U travel_planner -d travel_planner
|
|
docker compose -f compose.dev.yml exec -T redis redis-cli ping
|
|
```
|
|
|
|
Expected: PostgreSQL accepts connections; Redis returns `PONG`.
|
|
|
|
- [ ] **Step 4: Document developer startup**
|
|
|
|
Add to `README.md` the exact commands:
|
|
|
|
```bash
|
|
pnpm install
|
|
pnpm dev:infra
|
|
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 pnpm --filter backend start:api
|
|
pnpm --filter frontend start
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add compose.dev.yml README.md
|
|
git commit -m "chore: add postgres and redis development services"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Implement Liveness and Dependency-Aware Readiness
|
|
|
|
**Files:**
|
|
- Create: `backend/libs/infrastructure/src/postgres/postgres.module.ts`
|
|
- Create: `backend/libs/infrastructure/src/redis/redis.module.ts`
|
|
- Create: `backend/libs/infrastructure/src/index.ts`
|
|
- Create: `backend/apps/api/src/health/health.module.ts`
|
|
- Create: `backend/apps/api/src/health/readiness.service.ts`
|
|
- Create: `backend/apps/api/src/health/health.controller.ts`
|
|
- Create: `backend/apps/api/src/health/health.controller.spec.ts`
|
|
- Modify: `backend/apps/api/src/api.module.ts`
|
|
- Modify: `backend/apps/worker/src/worker.module.ts`
|
|
- Modify: `backend/apps/api/src/main.ts`
|
|
- Modify: `backend/package.json`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `DATABASE_URL`, `REDIS_URL` from configuration.
|
|
- Produces: `GET /health/live` returns 200 without checking external providers; `GET /health/ready` returns 200 only when PostgreSQL and Redis are reachable; these routes remain outside `/api/v1` so infrastructure probes are stable.
|
|
|
|
- [ ] **Step 1: Write failing controller tests**
|
|
|
|
Create `health.controller.spec.ts` with a mocked readiness service:
|
|
|
|
```ts
|
|
import { Test } from '@nestjs/testing';
|
|
import { HealthController } from './health.controller';
|
|
import { ReadinessService } from './readiness.service';
|
|
|
|
describe('HealthController', () => {
|
|
it('returns liveness without dependency checks', async () => {
|
|
const readiness = { check: jest.fn() };
|
|
const moduleRef = await Test.createTestingModule({
|
|
controllers: [HealthController],
|
|
providers: [{ provide: ReadinessService, useValue: readiness }],
|
|
}).compile();
|
|
|
|
expect(moduleRef.get(HealthController).live()).toEqual({ status: 'ok' });
|
|
expect(readiness.check).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('delegates readiness to dependency checks', async () => {
|
|
const readiness = { check: jest.fn().mockResolvedValue({ status: 'ok' }) };
|
|
const moduleRef = await Test.createTestingModule({
|
|
controllers: [HealthController],
|
|
providers: [{ provide: ReadinessService, useValue: readiness }],
|
|
}).compile();
|
|
|
|
await expect(moduleRef.get(HealthController).ready()).resolves.toEqual({ status: 'ok' });
|
|
expect(readiness.check).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run and verify failure**
|
|
|
|
```bash
|
|
pnpm --filter backend test -- apps/api/src/health/health.controller.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because health classes do not exist.
|
|
|
|
- [ ] **Step 3: Add infrastructure dependencies**
|
|
|
|
Install:
|
|
|
|
```bash
|
|
pnpm --filter backend add @nestjs/terminus pg ioredis
|
|
pnpm --filter backend add -D @types/pg
|
|
```
|
|
|
|
Implement `POSTGRES_POOL` and `REDIS_CLIENT` providers. The essential provider factories are:
|
|
|
|
```ts
|
|
export const POSTGRES_POOL = Symbol('POSTGRES_POOL');
|
|
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
|
|
|
|
export const postgresPoolProvider = {
|
|
provide: POSTGRES_POOL,
|
|
inject: [APP_ENVIRONMENT],
|
|
useFactory: (env: AppEnvironment) => new Pool({ connectionString: env.databaseUrl }),
|
|
};
|
|
|
|
export const redisClientProvider = {
|
|
provide: REDIS_CLIENT,
|
|
inject: [APP_ENVIRONMENT],
|
|
useFactory: (env: AppEnvironment) => new Redis(env.redisUrl, { lazyConnect: false }),
|
|
};
|
|
```
|
|
|
|
Wrap each provider in a Nest module and add lifecycle providers implementing `OnModuleDestroy` so the PostgreSQL pool calls `end()` and Redis calls `quit()`. Import both infrastructure modules in `ApiModule` and `WorkerModule`. Export typed injection tokens from `libs/infrastructure/src/index.ts`.
|
|
|
|
- [ ] **Step 4: Implement readiness checks**
|
|
|
|
`ReadinessService.check()` must execute:
|
|
|
|
```ts
|
|
await postgresPool.query('SELECT 1');
|
|
const pong = await redis.ping();
|
|
if (pong !== 'PONG') throw new Error('Redis ping failed');
|
|
return { status: 'ok' as const };
|
|
```
|
|
|
|
`HealthController` must define exactly:
|
|
|
|
```ts
|
|
@Get('/health/live')
|
|
live(): { status: 'ok' }
|
|
|
|
@Get('/health/ready')
|
|
ready(): Promise<{ status: 'ok' }>
|
|
```
|
|
|
|
Configure these infrastructure paths outside the global `/api/v1` prefix in `apps/api/src/main.ts`:
|
|
|
|
```ts
|
|
import { RequestMethod } from '@nestjs/common';
|
|
|
|
app.setGlobalPrefix('api/v1', {
|
|
exclude: [
|
|
{ path: 'health/live', method: RequestMethod.GET },
|
|
{ path: 'health/ready', method: RequestMethod.GET },
|
|
],
|
|
});
|
|
```
|
|
|
|
Use `@Controller()` on `HealthController` with `@Get('health/live')` and `@Get('health/ready')`.
|
|
|
|
- [ ] **Step 5: Run unit tests**
|
|
|
|
```bash
|
|
pnpm --filter backend test -- apps/api/src/health/health.controller.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Run API against development infrastructure**
|
|
|
|
```bash
|
|
pnpm dev:infra
|
|
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 pnpm --filter backend build:api
|
|
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 node backend/dist/apps/api/main.js &
|
|
API_PID=$!
|
|
sleep 2
|
|
curl --fail http://127.0.0.1:3000/health/live
|
|
curl --fail http://127.0.0.1:3000/health/ready
|
|
kill "$API_PID"
|
|
```
|
|
|
|
Expected: both endpoints return HTTP 200 with `{"status":"ok"}`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add backend pnpm-lock.yaml
|
|
git commit -m "feat: add api liveness and readiness checks"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Build Production API, Worker, and Edge Images
|
|
|
|
**Files:**
|
|
- Create: `docker/api.Dockerfile`
|
|
- Create: `docker/worker.Dockerfile`
|
|
- Create: `docker/edge.Dockerfile`
|
|
- Create: `docker/edge/nginx.conf`
|
|
- Create: `docker/edge/default.conf.template`
|
|
- Create: `compose.yml`
|
|
- Create: `docs/architecture/deployment.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: built backend and frontend workspaces; environment names from Task 1.
|
|
- Produces: images `travel-api:${IMAGE_TAG}`, `travel-worker:${IMAGE_TAG}`, `travel-edge:${IMAGE_TAG}`; production service DNS names `api`, `worker`, `postgres`, `redis`; edge HTTPS endpoint; internal API at `api:3000`.
|
|
|
|
- [ ] **Step 1: Create multi-stage API and worker Dockerfiles**
|
|
|
|
Create `docker/api.Dockerfile`:
|
|
|
|
```dockerfile
|
|
FROM node:24.18.0-bookworm-slim AS build
|
|
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
|
WORKDIR /app
|
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
|
COPY backend/package.json backend/package.json
|
|
COPY frontend/package.json frontend/package.json
|
|
RUN pnpm install --frozen-lockfile
|
|
COPY backend backend
|
|
RUN pnpm --filter backend build:api
|
|
|
|
FROM node:24.18.0-bookworm-slim AS runtime
|
|
ENV NODE_ENV=production
|
|
WORKDIR /app
|
|
COPY --from=build /app/node_modules ./node_modules
|
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
|
COPY --from=build /app/backend/dist ./backend/dist
|
|
USER node
|
|
EXPOSE 3000
|
|
CMD ["node", "backend/dist/apps/api/main.js"]
|
|
```
|
|
|
|
Create `docker/worker.Dockerfile`:
|
|
|
|
```dockerfile
|
|
FROM node:24.18.0-bookworm-slim AS build
|
|
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
|
WORKDIR /app
|
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
|
COPY backend/package.json backend/package.json
|
|
COPY frontend/package.json frontend/package.json
|
|
RUN pnpm install --frozen-lockfile
|
|
COPY backend backend
|
|
RUN pnpm --filter backend build:worker
|
|
|
|
FROM node:24.18.0-bookworm-slim AS runtime
|
|
ENV NODE_ENV=production
|
|
WORKDIR /app
|
|
COPY --from=build /app/node_modules ./node_modules
|
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
|
COPY --from=build /app/backend/dist ./backend/dist
|
|
USER node
|
|
CMD ["node", "backend/dist/apps/worker/main.js"]
|
|
```
|
|
|
|
Do not add `EXPOSE` to the worker image. Neither image contains secrets.
|
|
|
|
- [ ] **Step 2: Create the edge image**
|
|
|
|
Create `docker/edge.Dockerfile`:
|
|
|
|
```dockerfile
|
|
FROM node:24.18.0-bookworm-slim AS frontend-build
|
|
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
|
WORKDIR /app
|
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
|
COPY frontend/package.json frontend/package.json
|
|
COPY backend/package.json backend/package.json
|
|
RUN pnpm install --frozen-lockfile
|
|
COPY frontend frontend
|
|
RUN pnpm --filter frontend build
|
|
|
|
FROM nginx:1.29.8-alpine
|
|
COPY docker/edge/nginx.conf /etc/nginx/nginx.conf
|
|
COPY docker/edge/default.conf.template /etc/nginx/templates/default.conf.template
|
|
COPY --from=frontend-build /app/frontend/dist/frontend/browser /usr/share/nginx/html
|
|
EXPOSE 443
|
|
```
|
|
|
|
Create `docker/edge/nginx.conf`:
|
|
|
|
```nginx
|
|
user nginx;
|
|
worker_processes auto;
|
|
error_log /var/log/nginx/error.log notice;
|
|
pid /var/run/nginx.pid;
|
|
|
|
events {
|
|
worker_connections 1024;
|
|
}
|
|
|
|
http {
|
|
include /etc/nginx/mime.types;
|
|
default_type application/octet-stream;
|
|
sendfile on;
|
|
keepalive_timeout 65;
|
|
include /etc/nginx/conf.d/*.conf;
|
|
}
|
|
```
|
|
|
|
The edge image listens on container port 443 only and expects TLS certificate/key mounts at `/run/tls/tls.crt` and `/run/tls/tls.key`.
|
|
|
|
- [ ] **Step 3: Configure Nginx routing**
|
|
|
|
`default.conf.template` must define:
|
|
|
|
```nginx
|
|
server {
|
|
listen 443 ssl;
|
|
server_name _;
|
|
|
|
ssl_certificate /run/tls/tls.crt;
|
|
ssl_certificate_key /run/tls/tls.key;
|
|
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
location /api/ {
|
|
proxy_pass http://api:3000;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-Proto https;
|
|
proxy_buffering off;
|
|
}
|
|
|
|
location /health/ {
|
|
proxy_pass http://api:3000;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-Proto https;
|
|
}
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|
|
```
|
|
|
|
Do not add a port-80 server block.
|
|
|
|
- [ ] **Step 4: Create the production Compose topology**
|
|
|
|
Create `compose.yml`:
|
|
|
|
```yaml
|
|
services:
|
|
edge:
|
|
image: "${REGISTRY}/travel-edge:${IMAGE_TAG}"
|
|
build:
|
|
context: .
|
|
dockerfile: docker/edge.Dockerfile
|
|
ports:
|
|
- "${APP_HTTPS_PORT:-443}:443"
|
|
volumes:
|
|
- "${TLS_CERT_FILE}:/run/tls/tls.crt:ro"
|
|
- "${TLS_KEY_FILE}:/run/tls/tls.key:ro"
|
|
depends_on:
|
|
api:
|
|
condition: service_healthy
|
|
networks: [travel]
|
|
restart: unless-stopped
|
|
|
|
api:
|
|
image: "${REGISTRY}/travel-api:${IMAGE_TAG}"
|
|
build:
|
|
context: .
|
|
dockerfile: docker/api.Dockerfile
|
|
expose:
|
|
- "3000"
|
|
environment:
|
|
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
|
|
REDIS_URL: "redis://redis:6379"
|
|
APP_VERSION: "${APP_VERSION:-dev}"
|
|
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
|
|
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 20
|
|
start_period: 10s
|
|
networks: [travel]
|
|
restart: unless-stopped
|
|
|
|
worker:
|
|
image: "${REGISTRY}/travel-worker:${IMAGE_TAG}"
|
|
build:
|
|
context: .
|
|
dockerfile: docker/worker.Dockerfile
|
|
environment:
|
|
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
|
|
REDIS_URL: "redis://redis:6379"
|
|
APP_VERSION: "${APP_VERSION:-dev}"
|
|
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
|
|
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
networks: [travel]
|
|
restart: unless-stopped
|
|
|
|
postgres:
|
|
image: "postgres:${POSTGRES_IMAGE_TAG:-18.4-alpine}"
|
|
environment:
|
|
POSTGRES_DB: "${POSTGRES_DB}"
|
|
POSTGRES_USER: "${POSTGRES_USER}"
|
|
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
|
expose:
|
|
- "5432"
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 20
|
|
networks: [travel]
|
|
restart: unless-stopped
|
|
|
|
redis:
|
|
image: "redis:${REDIS_IMAGE_TAG:-8.8.1-alpine}"
|
|
expose:
|
|
- "6379"
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 20
|
|
networks: [travel]
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
postgres_data:
|
|
|
|
networks:
|
|
travel:
|
|
driver: bridge
|
|
```
|
|
|
|
`worker` has neither `ports` nor `expose` because it accepts no inbound traffic. The PostgreSQL 18 volume is mounted at `/var/lib/postgresql`, matching the official PostgreSQL 18 image layout. The edge is the only service with `ports:`. Do not add port 80 or `network_mode: host`.
|
|
|
|
- [ ] **Step 5: Build all images locally**
|
|
|
|
```bash
|
|
IMAGE_TAG=phase01 \
|
|
REGISTRY=local \
|
|
POSTGRES_DB=travel_planner \
|
|
POSTGRES_USER=travel_planner \
|
|
POSTGRES_PASSWORD=build-only \
|
|
TLS_CERT_FILE=/tmp/not-used-during-build.crt \
|
|
TLS_KEY_FILE=/tmp/not-used-during-build.key \
|
|
docker compose -f compose.yml build edge api worker
|
|
```
|
|
|
|
Expected: all three application images build successfully from one revision.
|
|
|
|
- [ ] **Step 6: Document production topology and TLS contract**
|
|
|
|
In `docs/architecture/deployment.md`, document:
|
|
- only edge publishes a host port;
|
|
- production requires a certificate and key mounted at the configured paths;
|
|
- no port 80 requirement;
|
|
- PostgreSQL/Redis/API are inaccessible from the host through Compose-published ports;
|
|
- outbound API/worker egress remains allowed;
|
|
- TeamCity supplies immutable image tags.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add docker compose.yml docs/architecture/deployment.md
|
|
git commit -m "feat: add single-port production docker topology"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Enforce the Single-Published-Port Invariant Automatically
|
|
|
|
**Files:**
|
|
- Create: `scripts/verify-compose-invariants.mjs`
|
|
- Create: `tests/compose-invariants.test.mjs`
|
|
- Modify: `package.json`
|
|
- Modify: root dev dependencies/lockfile to include `yaml`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `compose.yml`.
|
|
- Produces: `verifyComposeInvariants(compose)` function and `pnpm test:compose`; TeamCity can reject topology regressions before image build/deploy.
|
|
|
|
- [ ] **Step 1: Add YAML parser and write failing invariant test**
|
|
|
|
Install:
|
|
|
|
```bash
|
|
pnpm add -Dw yaml
|
|
```
|
|
|
|
Create `tests/compose-invariants.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { parse } from 'yaml';
|
|
import { verifyComposeInvariants } from '../scripts/verify-compose-invariants.mjs';
|
|
|
|
test('production compose publishes exactly one edge port', () => {
|
|
const compose = parse(fs.readFileSync(new URL('../compose.yml', import.meta.url), 'utf8'));
|
|
assert.doesNotThrow(() => verifyComposeInvariants(compose));
|
|
});
|
|
|
|
test('invariant rejects a host port on postgres', () => {
|
|
const invalid = {
|
|
services: {
|
|
edge: { ports: ['443:443'] },
|
|
postgres: { ports: ['5432:5432'] },
|
|
},
|
|
};
|
|
assert.throws(() => verifyComposeInvariants(invalid), /postgres.*ports/i);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test and verify failure**
|
|
|
|
```bash
|
|
pnpm test:compose
|
|
```
|
|
|
|
Expected: FAIL because `verifyComposeInvariants` does not exist.
|
|
|
|
- [ ] **Step 3: Implement the invariant validator**
|
|
|
|
Create `scripts/verify-compose-invariants.mjs`:
|
|
|
|
```js
|
|
export function verifyComposeInvariants(compose) {
|
|
const services = compose?.services ?? {};
|
|
const serviceNames = Object.keys(services);
|
|
const published = serviceNames.filter((name) => Array.isArray(services[name]?.ports) && services[name].ports.length > 0);
|
|
|
|
if (published.length !== 1 || published[0] !== 'edge') {
|
|
throw new Error(`Only edge may define ports; found: ${published.join(', ') || 'none'}`);
|
|
}
|
|
|
|
if (services.edge.ports.length !== 1) {
|
|
throw new Error(`edge must publish exactly one port; found ${services.edge.ports.length}`);
|
|
}
|
|
|
|
for (const [name, service] of Object.entries(services)) {
|
|
if (name !== 'edge' && Array.isArray(service.ports) && service.ports.length > 0) {
|
|
throw new Error(`${name} must not define ports`);
|
|
}
|
|
if (service.network_mode === 'host') {
|
|
throw new Error(`${name} must not use network_mode: host`);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the invariant test**
|
|
|
|
```bash
|
|
pnpm test:compose
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add package.json pnpm-lock.yaml scripts/verify-compose-invariants.mjs tests/compose-invariants.test.mjs
|
|
git commit -m "test: enforce production docker network invariants"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Add TeamCity-Owned Repository Scripts for Validate, Build, Deploy, Smoke, and Rollback
|
|
|
|
**Files:**
|
|
- Create: `scripts/teamcity/validate.sh`
|
|
- Create: `scripts/teamcity/build-images.sh`
|
|
- Create: `scripts/teamcity/smoke.sh`
|
|
- Create: `scripts/teamcity/deploy.sh`
|
|
- Create: `scripts/teamcity/rollback.sh`
|
|
- Modify: `README.md`
|
|
- Modify: `docs/architecture/deployment.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: TeamCity environment variables `BUILD_NUMBER`, `BUILD_VCS_NUMBER`, registry credentials supplied externally, deployment-host `.env`, and immutable `IMAGE_TAG`.
|
|
- Produces: stable shell entry points that an existing TeamCity pipeline can call without duplicating deployment logic.
|
|
|
|
- [ ] **Step 1: Create validation script**
|
|
|
|
Create `scripts/teamcity/validate.sh`:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
corepack enable
|
|
pnpm install --frozen-lockfile
|
|
pnpm lint
|
|
pnpm test
|
|
pnpm test:compose
|
|
pnpm build
|
|
```
|
|
|
|
Make executable:
|
|
|
|
```bash
|
|
chmod +x scripts/teamcity/validate.sh
|
|
```
|
|
|
|
- [ ] **Step 2: Create immutable image build script**
|
|
|
|
Create `scripts/teamcity/build-images.sh`:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
: "${REGISTRY:?REGISTRY is required}"
|
|
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
|
|
: "${BUILD_VCS_NUMBER:?BUILD_VCS_NUMBER is required}"
|
|
|
|
IMAGE_TAG="${BUILD_NUMBER}-${BUILD_VCS_NUMBER}"
|
|
export IMAGE_TAG
|
|
|
|
if [[ "$IMAGE_TAG" == "latest" ]]; then
|
|
echo "Refusing floating deployment tag" >&2
|
|
exit 1
|
|
fi
|
|
|
|
docker build --pull -f docker/edge.Dockerfile -t "${REGISTRY}/travel-edge:${IMAGE_TAG}" .
|
|
docker build --pull -f docker/api.Dockerfile -t "${REGISTRY}/travel-api:${IMAGE_TAG}" .
|
|
docker build --pull -f docker/worker.Dockerfile -t "${REGISTRY}/travel-worker:${IMAGE_TAG}" .
|
|
|
|
docker push "${REGISTRY}/travel-edge:${IMAGE_TAG}"
|
|
docker push "${REGISTRY}/travel-api:${IMAGE_TAG}"
|
|
docker push "${REGISTRY}/travel-worker:${IMAGE_TAG}"
|
|
|
|
printf '%s\n' "$IMAGE_TAG"
|
|
```
|
|
|
|
Make it executable. Registry authentication is supplied by the TeamCity Docker/registry connection before this script runs; credentials are not arguments or source-controlled values.
|
|
|
|
- [ ] **Step 3: Create smoke script against the single edge port**
|
|
|
|
Create `scripts/teamcity/smoke.sh`:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
: "${APP_BASE_URL:?APP_BASE_URL is required}"
|
|
curl --fail --silent --show-error "${APP_BASE_URL}/health/live"
|
|
curl --fail --silent --show-error "${APP_BASE_URL}/health/ready"
|
|
curl --fail --silent --show-error "${APP_BASE_URL}/" >/dev/null
|
|
```
|
|
|
|
Make it executable.
|
|
|
|
- [ ] **Step 4: Create deployment script**
|
|
|
|
Create `scripts/teamcity/deploy.sh`:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
: "${IMAGE_TAG:?IMAGE_TAG is required}"
|
|
: "${REGISTRY:?REGISTRY is required}"
|
|
: "${APP_BASE_URL:?APP_BASE_URL is required}"
|
|
|
|
exec 9>/var/lock/travel-planner-deploy.lock
|
|
flock -n 9 || { echo "Another deployment is already running" >&2; exit 1; }
|
|
|
|
if [[ -f .deployed-image-tag ]]; then
|
|
cp .deployed-image-tag .previous-image-tag
|
|
fi
|
|
|
|
export IMAGE_TAG REGISTRY
|
|
|
|
docker compose pull edge api worker postgres redis
|
|
docker compose up -d postgres redis --wait --wait-timeout 120
|
|
docker compose run --rm --no-deps api node backend/dist/apps/api/migration.js
|
|
docker compose up -d --remove-orphans --wait --wait-timeout 120
|
|
scripts/teamcity/smoke.sh
|
|
printf '%s\n' "$IMAGE_TAG" > .deployed-image-tag
|
|
```
|
|
|
|
Make it executable. The target working directory contains `compose.yml` and the protected production `.env` used by Docker Compose. Do not add `docker compose down`.
|
|
|
|
- [ ] **Step 5: Create rollback script**
|
|
|
|
Create `scripts/teamcity/rollback.sh`:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
: "${REGISTRY:?REGISTRY is required}"
|
|
: "${APP_BASE_URL:?APP_BASE_URL is required}"
|
|
|
|
if [[ ! -s .previous-image-tag ]]; then
|
|
echo "No previous image tag is available for rollback" >&2
|
|
exit 1
|
|
fi
|
|
|
|
IMAGE_TAG="$(cat .previous-image-tag)"
|
|
export IMAGE_TAG REGISTRY
|
|
|
|
docker compose pull edge api worker
|
|
docker compose up -d --remove-orphans --wait --wait-timeout 120
|
|
scripts/teamcity/smoke.sh
|
|
printf '%s\n' "$IMAGE_TAG" > .deployed-image-tag
|
|
```
|
|
|
|
Make it executable. Rollback never performs an automatic database downgrade.
|
|
|
|
- [ ] **Step 6: Add the Phase-01 compiled migration entry point**
|
|
|
|
Create `backend/apps/api/src/migration.ts`:
|
|
|
|
```ts
|
|
export async function runMigrations(): Promise<void> {
|
|
console.log('No migrations configured in Phase 01');
|
|
}
|
|
|
|
if (require.main === module) {
|
|
void runMigrations();
|
|
}
|
|
```
|
|
|
|
Verify that `pnpm --filter backend build:api` produces `backend/dist/apps/api/migration.js`. Phase 02 replaces the body with the real versioned migration runner while preserving this container command contract.
|
|
|
|
- [ ] **Step 7: Verify scripts syntactically and run validation**
|
|
|
|
```bash
|
|
chmod +x scripts/teamcity/*.sh
|
|
bash -n scripts/teamcity/validate.sh
|
|
bash -n scripts/teamcity/build-images.sh
|
|
bash -n scripts/teamcity/deploy.sh
|
|
bash -n scripts/teamcity/rollback.sh
|
|
bash -n scripts/teamcity/smoke.sh
|
|
scripts/teamcity/validate.sh
|
|
```
|
|
|
|
Expected: shell syntax passes; validation passes.
|
|
|
|
- [ ] **Step 8: Document TeamCity wiring**
|
|
|
|
Add a table to `docs/architecture/deployment.md`:
|
|
|
|
```text
|
|
TeamCity stage Repository entry point
|
|
Validate scripts/teamcity/validate.sh
|
|
Build + Push scripts/teamcity/build-images.sh
|
|
Deploy over SSH scripts/teamcity/deploy.sh
|
|
Post-deploy smoke scripts/teamcity/smoke.sh
|
|
Rollback scripts/teamcity/rollback.sh
|
|
```
|
|
|
|
Document that the existing TeamCity project may configure these as command-line/SSH steps; deployment logic must remain in version control.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add scripts/teamcity backend/package.json README.md docs/architecture/deployment.md
|
|
git commit -m "ci: add teamcity build and deployment entry points"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Add Build Metadata Endpoint and Complete Phase-01 Verification
|
|
|
|
**Files:**
|
|
- Create: `backend/apps/api/src/version/version.controller.ts`
|
|
- Create: `backend/apps/api/src/version/version.controller.spec.ts`
|
|
- Create: `backend/apps/api/src/version/version.module.ts`
|
|
- Modify: `backend/apps/api/src/api.module.ts`
|
|
- Modify: `README.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: validated build metadata from `AppEnvironment`.
|
|
- Produces: safe `GET /api/v1/version` response with `appVersion`, `teamCityBuildNumber`, and `sourceRevision`; no secrets.
|
|
|
|
- [ ] **Step 1: Write the failing version-controller test**
|
|
|
|
Create a test that injects:
|
|
|
|
```ts
|
|
{
|
|
appVersion: '1.0.0',
|
|
teamCityBuildNumber: '123',
|
|
sourceRevision: 'abc123'
|
|
}
|
|
```
|
|
|
|
and expects exactly:
|
|
|
|
```json
|
|
{
|
|
"appVersion": "1.0.0",
|
|
"teamCityBuildNumber": "123",
|
|
"sourceRevision": "abc123"
|
|
}
|
|
```
|
|
|
|
Assert that `databaseUrl` and `redisUrl` are absent.
|
|
|
|
- [ ] **Step 2: Run and verify failure**
|
|
|
|
```bash
|
|
pnpm --filter backend test -- apps/api/src/version/version.controller.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because version module/controller do not exist.
|
|
|
|
- [ ] **Step 3: Implement the safe version endpoint**
|
|
|
|
Create `VersionController` at `/version` under the existing `/api/v1` prefix. Return only the three safe metadata fields from the validated environment provider.
|
|
|
|
- [ ] **Step 4: Run complete repository verification**
|
|
|
|
```bash
|
|
pnpm install --frozen-lockfile
|
|
pnpm lint
|
|
pnpm test
|
|
pnpm test:compose
|
|
pnpm build
|
|
```
|
|
|
|
Expected: all PASS.
|
|
|
|
- [ ] **Step 5: Verify production Compose rendering**
|
|
|
|
```bash
|
|
APP_HTTPS_PORT=443 \
|
|
IMAGE_TAG=phase01 \
|
|
REGISTRY=example.invalid \
|
|
POSTGRES_IMAGE_TAG=18.4-alpine \
|
|
REDIS_IMAGE_TAG=8.8.1-alpine \
|
|
POSTGRES_DB=travel_planner \
|
|
POSTGRES_USER=travel_planner \
|
|
POSTGRES_PASSWORD=render-only \
|
|
TLS_CERT_FILE=/tmp/travel-tls/tls.crt \
|
|
TLS_KEY_FILE=/tmp/travel-tls/tls.key \
|
|
docker compose -f compose.yml config > /tmp/travel-compose-rendered.yml
|
|
|
|
grep -n "ports:" /tmp/travel-compose-rendered.yml
|
|
```
|
|
|
|
Expected: the rendered production configuration has only one `ports:` section and it belongs to `edge`.
|
|
|
|
- [ ] **Step 6: Run an end-to-end local container smoke test with a test certificate**
|
|
|
|
Generate a disposable certificate outside source control:
|
|
|
|
```bash
|
|
mkdir -p /tmp/travel-tls
|
|
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
|
-keyout /tmp/travel-tls/tls.key \
|
|
-out /tmp/travel-tls/tls.crt \
|
|
-subj "/CN=localhost"
|
|
```
|
|
|
|
Start the stack with `TLS_CERT_FILE=/tmp/travel-tls/tls.crt`, `TLS_KEY_FILE=/tmp/travel-tls/tls.key`, and `APP_HTTPS_PORT=8443`, then verify:
|
|
|
|
```bash
|
|
curl --insecure --fail https://127.0.0.1:8443/health/live
|
|
curl --insecure --fail https://127.0.0.1:8443/health/ready
|
|
curl --insecure --fail https://127.0.0.1:8443/
|
|
```
|
|
|
|
Expected: all three requests succeed through `edge`; no direct host URL exists for API, PostgreSQL, Redis, or worker in the production Compose file.
|
|
|
|
- [ ] **Step 7: Update README with Phase-01 completion commands**
|
|
|
|
Document:
|
|
- developer startup;
|
|
- production environment names;
|
|
- single published-port rule;
|
|
- TeamCity script entry points;
|
|
- health/version URLs;
|
|
- compiled no-op migration entry point is temporary until Phase 02 introduces real migrations.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add backend/apps/api/src/version backend/apps/api/src/api.module.ts README.md
|
|
git commit -m "feat: expose safe build metadata and verify foundation"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 01 Acceptance Checklist
|
|
|
|
Run this checklist before starting Phase 02:
|
|
|
|
```bash
|
|
pnpm install --frozen-lockfile
|
|
pnpm lint
|
|
pnpm test
|
|
pnpm test:compose
|
|
pnpm build
|
|
bash -n scripts/teamcity/*.sh
|
|
```
|
|
|
|
All commands must pass.
|
|
|
|
Then verify:
|
|
|
|
- [ ] Angular 22 PWA builds and contains service-worker artifacts.
|
|
- [ ] NestJS API builds and listens internally on port 3000.
|
|
- [ ] NestJS worker boots as an application context and has no HTTP listener.
|
|
- [ ] `/health/live` does not depend on PostgreSQL/Redis/Mistral/external APIs.
|
|
- [ ] `/health/ready` validates PostgreSQL and Redis only.
|
|
- [ ] `/api/v1/version` exposes only safe build metadata.
|
|
- [ ] `compose.yml` contains services `edge`, `api`, `worker`, `postgres`, `redis`.
|
|
- [ ] Only `edge` defines `ports:` and defines exactly one published TCP port.
|
|
- [ ] Production Compose does not publish port 80.
|
|
- [ ] `api`, `postgres`, and `redis` use only internal ports/expose semantics.
|
|
- [ ] `worker` has no inbound port.
|
|
- [ ] No service uses `network_mode: host`.
|
|
- [ ] PostgreSQL image is initially pinned to `18.4-alpine` and Redis to `8.8.1-alpine`.
|
|
- [ ] TeamCity scripts build immutable images from one revision.
|
|
- [ ] Deploy script pulls, runs migration hook, runs `compose up -d --remove-orphans`, and smoke-tests through the edge URL.
|
|
- [ ] Deploy script never calls `docker compose down`.
|
|
- [ ] Rollback reuses the prior immutable image tag and does not attempt automatic DB downgrade.
|
|
- [ ] No secret values are committed or built into frontend/images.
|
|
|
|
## Phase 01 Review Boundary
|
|
|
|
Do not start OIDC, users, trips, Mistral, agent tools, web research, or business migrations during this phase. Phase 02 begins only after the Phase 01 acceptance checklist is reviewed and green.
|
|
|
|
---
|
|
|
|
## Addendum: Adjustments Against the Pre-Existing Scaffold (2026-08-17)
|
|
|
|
The repository already contains a generated Angular project (`frontend/`, Angular 21.2.0, npm) and a generated NestJS project (`backend/`, NestJS 11, single-app layout, ts-jest). The master instructions explicitly forbid re-scaffolding either project. This addendum documents the small, non-architectural adjustments applied to this plan as a result — per "kleine technische Details darfst du selbst sinnvoll lösen", no user decision was required:
|
|
|
|
1. **Task 1** — pnpm workspace wraps the *existing* `frontend/` and `backend/` directories; nothing there is regenerated.
|
|
2. **Task 2** — `backend/src/{app.module.ts,app.controller.ts,app.controller.spec.ts,app.service.ts,main.ts}` are **migrated** (moved + adapted) into `backend/apps/api/src/`, not regenerated from a schematic. `nest-cli.json` is converted to `monorepo: true` with `api` + `worker` projects, matching the target file structure. The existing `AppController`/`AppService` are kept as the initial `ApiModule` content instead of an empty module, since deleting working, tested code has no benefit.
|
|
3. **Test runner** — the existing backend Jest config uses `ts-jest`, not `@swc/jest`, and passes today. Per "bestehende funktionierende Konfiguration bevorzugt weiterverwenden", `ts-jest` is kept for Phase 01 instead of migrating to SWC; this is a test-runner detail, not an architectural one, and can be revisited later if build performance requires it.
|
|
4. **Task 4** — no `ng new`/`ng add @angular/pwa` full re-scaffold. The existing Angular 21.2 project is kept and extended in place: `pnpm add -D @angular/pwa` equivalent (`ng add @angular/pwa`) is run *against the existing project* to layer in the service worker/manifest without touching unrelated generated files. The project already uses the newer Angular file-naming convention (`app.ts`/`app.html`/`app.scss`/`app.spec.ts` instead of `app.component.ts` etc.); all plan steps referencing `app.component.*` apply to these equivalently-named files instead.
|
|
5. **Angular version** — kept at 21.2.x (already installed) rather than upgraded to 22.x. Upgrading a working, freshly generated Angular major version is out of scope for "establish the foundation" and is not required by any Phase 01 acceptance criterion; the roadmap's "Angular 22.x" baseline is a target for new projects, not a mandate to upgrade an existing working one mid-foundation. Revisit as an explicit, isolated upgrade task if a later phase needs an Angular-22-only feature.
|
|
6. **package manager pin** — both existing projects pin `packageManager` fields (`npm@11.12.1`). These are superseded by the root `packageManager: pnpm@...` per the master instructions (Abschnitt 52); the per-package fields are removed during the pnpm migration to avoid Corepack conflicts.
|
|
|
|
No product/business behavior changes result from these adjustments; they only change *how* the target file structure is reached.
|