# Travel Planner

Self-hosted, AI-assisted travel planner. Angular PWA frontend, NestJS API + background worker, PostgreSQL, Redis/BullMQ, and a Mistral-backed travel agent behind a strict tool boundary.

> `.env.example` lists configuration **names and non-secret defaults only**. Real production secrets (database password, SMTP credentials, LLM API keys, etc.) are never committed and live only on the deployment host / secret store, supplied to containers as environment variables.

## Repository layout

```text
frontend/   Angular PWA (pnpm workspace member)
backend/    NestJS workspace: apps/api, apps/worker, libs/*
docker/     Production Dockerfiles and edge (Nginx) config
scripts/    Compose-invariant checks and TeamCity entry points
docs/       Specs, plans, and architecture documentation
```

## Developer setup

```bash
pnpm install
pnpm dev:infra
```

`pnpm dev:infra` starts local PostgreSQL/Redis (see `compose.dev.yml`); `pnpm dev:infra:down` stops them.

Create a **`.env` file at the repository root** (already gitignored, never committed) with the required values. `backend`'s `start:api`/`start:worker`/`migrate` scripts load it automatically via Node's `--env-file-if-exists` — no manual `export` needed. **`OIDC_CLIENT_SECRET` is a real secret — put it only in this local, gitignored file, never in `.env.example`, never pasted into a shared/logged terminal.** `OIDC_AUDIENCE` should be set to the same value as `OIDC_CLIENT_ID` unless your IdP issues a distinct API audience.

```dotenv
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner
REDIS_URL=redis://localhost:6379
OIDC_ISSUER=https://auth.forgecore.work
OIDC_CLIENT_ID=client_a297fd8d9c1f47a79d3600ea0c96984
OIDC_AUDIENCE=client_a297fd8d9c1f47a79d3600ea0c96984
OIDC_CLIENT_SECRET=<your-client-secret>
```

Run pending migrations against the dev database (first time only), then start the API and frontend:

```bash
pnpm --filter backend build:api
pnpm --filter backend migrate
pnpm --filter backend start:api
pnpm --filter frontend start
```

Open `http://localhost:4200`. The Angular dev server proxies `/api/*` and `/health/*` to the API on `localhost:3000` (see `frontend/proxy.conf.json`), so no CORS configuration is needed locally. Make sure the IdP client `client_a297fd8d9c1f47a79d3600ea0c96984` allows the redirect URI `http://localhost:4200/auth/callback`.

This IdP client is **confidential** (it has a client secret), so the Authorization Code + PKCE token exchange happens server-side via `POST /api/v1/auth/session` (see "OIDC client type" below) — the secret never reaches the browser.

## Quality gates

```bash
pnpm lint
pnpm test
pnpm test:compose
pnpm build
```

## TeamCity deployment scripts

TeamCity invokes repository-owned scripts rather than duplicating deployment logic in step configuration:

```text
scripts/teamcity/validate.sh       # install, lint, test, compose-invariant check, build
scripts/teamcity/build-images.sh   # build + push immutable IMAGE_TAG images
scripts/teamcity/deploy.sh         # pull, migrate, compose up -d, smoke test
scripts/teamcity/smoke.sh          # health/root checks against APP_BASE_URL
scripts/teamcity/rollback.sh       # redeploy the previous IMAGE_TAG
```

`deploy.sh` never runs `docker compose down` as part of a routine deployment; `rollback.sh` never attempts an automatic database downgrade.

## Production topology

Production Docker Compose (`compose.yml`) publishes **exactly one** host port, on the `edge` (Nginx) service, which serves the built Angular app and reverse-proxies `/api/*` and `/health/*` to the internal `api` service. `api`, `worker`, `postgres`, and `redis` are reachable only over the internal Docker network. See `docs/architecture/deployment.md` for the full contract and `scripts/teamcity/` for the TeamCity-invoked build/deploy/rollback scripts.

## Phase 01 status: foundation complete

- `GET /health/live` — process liveness only, no dependency checks.
- `GET /health/ready` — validates PostgreSQL and Redis connectivity.
- `GET /api/v1/version` — safe build metadata only (`appVersion`, `teamCityBuildNumber`, `sourceRevision`); never database/Redis URLs.
- The compiled no-op migration entry point (`backend/dist/apps/api/src/migration.js`) gives `deploy.sh` a stable container command contract; Phase 02 replaces its body with the real versioned migration runner.
- Verified end-to-end: `docker compose -f compose.yml up` with a real TLS certificate serves `/health/live`, `/health/ready`, `/`, and `/api/v1/version` through the single published edge port, with `postgres`/`redis`/`api`/`worker` unreachable from the host.

## Phase 02 status: OIDC auth, users, and trip core complete

- Authentication: OIDC Authorization Code + PKCE against an external IdP. No local password storage; users are keyed by the OIDC `sub` claim and just-in-time provisioned on first login.
- **OIDC client type:** this deployment's IdP client is confidential (has a client secret), not a plain public/PKCE-only SPA client. A client secret must never be embedded in a browser bundle, so the frontend performs only the browser-side Authorization Code + PKCE redirect (hand-rolled PKCE in `frontend/src/app/auth/pkce.ts`, no `oidc-client-ts` dependency); the resulting `code` + PKCE `code_verifier` are then POSTed to the backend's `POST /api/v1/auth/session` (unauthenticated by design — there is no token yet), which performs the actual code-for-tokens exchange using `OIDC_CLIENT_SECRET` server-side (`TokenExchangeService`) and returns only `{ accessToken, expiresIn }` to the frontend — `refresh_token`/`id_token` are never forwarded. If a future deployment instead uses a public PKCE-only client, this proxy step could be skipped in favor of a direct frontend-to-IdP exchange, but the current IdP requires it.
- New required backend env vars: `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (validated fail-fast like `DATABASE_URL`/`REDIS_URL`; `OIDC_CLIENT_SECRET` is a real secret, never committed). New frontend build-time values: `OIDC_ISSUER`, `OIDC_CLIENT_ID` (non-secret; baked into the production bundle by `docker/edge.Dockerfile`, never read from the container at runtime).
- Real, versioned database migrations (`node-pg-migrate`, files under `backend/migrations/`) replace the Phase 01 no-op `migration.ts` body; the container command contract (`node backend/dist/apps/api/src/migration.js`) is unchanged. Database access goes through `kysely` (a type-safe query builder, not an ORM) over the existing `pg.Pool`; there is no schema auto-sync anywhere.
- New routes: `GET/PUT /api/v1/users/me`(`/preferences`), `GET/POST /api/v1/trips`, `GET/PATCH/DELETE /api/v1/trips/:tripId`, `GET/PUT /api/v1/trips/:tripId/settings`, `GET/PATCH/DELETE /api/v1/trips/:tripId/members(/:memberId)`, `POST/GET/DELETE /api/v1/trips/:tripId/invitations(/:invitationId)`, `POST /api/v1/invitations/:token/accept`, `GET/POST/PATCH/DELETE /api/v1/trips/:tripId/travelers(/:travelerId)`, `GET/PUT/DELETE /api/v1/trips/:tripId/preference-overrides(/:overrideId)`.
- Authorization is enforced backend-side by `OidcAuthGuard` (who) and `TripMembershipGuard` (trip access + `@TripRoles('OWNER')`), never only in the frontend. `TripMember` and `Traveler` are independent tables — a `Traveler` never implies or requires trip membership.
- `Trip.version` optimistic locking: a stale `PATCH` (mismatched `version`) returns HTTP 409 and never silently overwrites; proven by both a mocked unit test and a real-database integration test.
- Run backend integration tests (migration idempotency + optimistic-locking conflict) against `compose.dev.yml`:

  ```bash
  pnpm dev:infra
  DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \
  REDIS_URL=redis://localhost:6379 \
  OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner \
  OIDC_AUDIENCE=travel-planner-api \
    pnpm --filter backend test:integration
  ```

- Verified end-to-end against the real running API and a mocked IdP (local JWKS + discovery document): missing token → 401, valid token → `/users/me` succeeds and JIT-provisions the user, trip create/read, first `PATCH` with the correct version succeeds, a second `PATCH` reusing the stale version → 409, and a user with no `trip_members` row for the trip → 403.
