Compare commits

32 Commits

Author SHA1 Message Date
Bastian Wagner
1d2467049d feat: fully backend-driven OIDC session flow (session cookie, not bearer token)
Replace the hybrid flow (frontend PKCE + POST /auth/session token
exchange, access token in sessionStorage) with a classic backend-driven
BFF: the browser only ever navigates to GET /api/v1/auth/login and is
redirected straight to the IdP; PKCE verifier/state live server-side in
Redis (SessionStoreService); GET /api/v1/auth/callback (now the
registered IdP redirect URI, replacing the frontend's /auth/callback
route, which is deleted) verifies the id_token, JIT-provisions the
user, creates a Redis-backed session, and sets one httpOnly SameSite=Lax
cookie before redirecting into the app. No token material of any kind
ever reaches the browser.

OidcAuthGuard (per-request bearer JWT verification) is replaced by
SessionAuthGuard (cookie -> Redis session lookup) across every
controller that used it. cookie-parser is now wired into main.ts.

Frontend AuthService shrinks to login()/logout()/ensureSessionChecked();
pkce.ts, auth.interceptor.ts, and the callback component/route are all
removed as dead code under this model.

New required env var: APP_BASE_URL (source of truth for the OIDC
redirect_uri and the post-login redirect target).

Verified end-to-end against the real API, Redis, and a mocked IdP:
login redirect shape, callback cookie + redirect, state-replay
rejection, /users/me 401<->200 around the cookie, and logout.
2026-08-17 17:33:55 +02:00
Bastian Wagner
4aca6b64a0 style: prettier formatting 2026-08-17 16:43:15 +02:00
Bastian Wagner
698a2180aa chore: auto-load .env for local backend start/migrate scripts 2026-08-17 16:42:57 +02:00
Bastian Wagner
981cecbcbd feat: switch oidc client to confidential (backend token exchange)
The provisioned IdP client (https://auth.forgecore.work) is confidential
rather than public/PKCE-only, so a client secret must never reach the
browser. The frontend now only performs the Authorization Code + PKCE
redirect itself (hand-rolled PKCE, oidc-client-ts dependency removed)
and hands the resulting code + verifier to a new, intentionally
unauthenticated POST /api/v1/auth/session endpoint, which performs the
code-for-tokens exchange server-side using OIDC_CLIENT_SECRET and
returns only {accessToken, expiresIn} — refresh_token/id_token are
never forwarded to the client.

New required backend env vars: OIDC_CLIENT_ID, OIDC_CLIENT_SECRET.
Added frontend/proxy.conf.json so the Angular dev server forwards
/api and /health to the local API without needing CORS.
2026-08-17 16:36:49 +02:00
Bastian Wagner
8eb5f0a3ed docs: record phase 02 completion 2026-08-17 16:10:36 +02:00
Bastian Wagner
854df5bb2d fix: export guard dependencies so cross-module @UseGuards resolves
NestJS resolves a guard referenced via @UseGuards(SomeGuard) using the
consuming module's injector, not the guard's own declaring module's
injector. OidcAuthGuard and TripMembershipGuard are shared across
several feature modules, so every constructor dependency they need
(UsersRepository/UsersService, TripMembersRepository, etc.) must be
re-exported by AuthModule/TripsLibModule/UsersLibModule, not just the
guard classes themselves. Found via the Phase 02 end-to-end smoke test
against a mocked IdP, which failed to boot the API before this fix.
2026-08-17 16:10:27 +02:00
Bastian Wagner
60e09d5050 chore: wire oidc environment configuration 2026-08-17 15:54:30 +02:00
Bastian Wagner
6e991bfd00 feat: add minimal trips list, create trip, and members ui 2026-08-17 15:49:32 +02:00
Bastian Wagner
bd91a3da4a feat: add oidc authorization code with pkce login flow 2026-08-17 15:43:17 +02:00
Bastian Wagner
dedb3fff40 feat: add trip preference overrides with precedence resolution 2026-08-17 15:38:03 +02:00
Bastian Wagner
ee7ec94ea0 feat: add traveler entity distinct from trip membership 2026-08-17 15:28:35 +02:00
Bastian Wagner
6954024622 feat: add trip invitation creation and acceptance flow 2026-08-17 15:21:08 +02:00
Bastian Wagner
baae15dcbc feat: enforce trip membership and role authorization 2026-08-17 15:13:23 +02:00
Bastian Wagner
ddf1d03447 feat: add trip and trip settings with optimistic locking 2026-08-17 15:06:00 +02:00
Bastian Wagner
2eb1692216 feat: expose current user and preference endpoints 2026-08-17 14:59:01 +02:00
Bastian Wagner
5abc7cbe29 feat: verify oidc bearer tokens and jit-provision users 2026-08-17 14:54:42 +02:00
Bastian Wagner
7ca4bd9cf7 feat: add user jit provisioning and preferences service 2026-08-17 14:43:13 +02:00
Bastian Wagner
ec95a8e527 feat: add versioned migrations and kysely query layer 2026-08-17 14:33:28 +02:00
Bastian Wagner
acfbb3ab22 docs: add phase 02 implementation plan 2026-08-17 14:23:22 +02:00
Bastian Wagner
a7ce7c3730 fix: wire version module into api module 2026-08-17 14:09:13 +02:00
Bastian Wagner
74ae283fea feat: expose safe build metadata and verify foundation 2026-08-17 14:08:56 +02:00
Bastian Wagner
1957e3b337 chore: mark teamcity scripts executable 2026-08-17 13:53:24 +02:00
Bastian Wagner
2994e46274 ci: add teamcity build and deployment entry points 2026-08-17 13:53:01 +02:00
Bastian Wagner
ccea4e48ec test: enforce production docker network invariants 2026-08-17 13:45:14 +02:00
Bastian Wagner
5edb16de73 feat: add single-port production docker topology 2026-08-17 13:43:52 +02:00
Bastian Wagner
0815f702d2 feat: add api liveness and readiness checks 2026-08-17 13:39:15 +02:00
Bastian Wagner
db6d62c303 chore: add postgres and redis development services 2026-08-17 13:33:00 +02:00
Bastian Wagner
755cc88dc2 feat: add angular pwa shell 2026-08-17 13:31:38 +02:00
Bastian Wagner
ceaef3028f feat: validate backend runtime configuration 2026-08-17 13:26:38 +02:00
Bastian Wagner
a08eecd4e3 feat: add nestjs api and worker skeletons 2026-08-17 13:18:24 +02:00
Bastian Wagner
c23bda56a3 chore: establish travel planner pnpm workspace 2026-08-17 13:09:32 +02:00
Bastian Wagner
2c91596c48 docs: adjust phase 01 plan for pre-existing angular/nestjs scaffold 2026-08-17 12:59:32 +02:00
174 changed files with 17596 additions and 19312 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
**/node_modules
**/dist
**/.angular
**/coverage
.git
.env
.env.*
!.env.example
*.log

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
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

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
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
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
OIDC_AUDIENCE=travel-planner-api
OIDC_CLIENT_ID=travel-planner-web
# OIDC_CLIENT_SECRET: this client is confidential (holds a secret). Never commit
# a real value here; supply it only via the deployment host's secret store /
# the developer's own shell environment.
OIDC_CLIENT_SECRET=change-me-outside-source-control
# APP_BASE_URL: the public origin end users load the app from (used to build the
# OIDC redirect_uri and the post-login redirect target). Must match a redirect
# URI registered with the IdP client, e.g. https://travel-planner.example.com
APP_BASE_URL=http://localhost:4200

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
.angular/
coverage/
.env
.env.*
!.env.example
/data/
*.log
.DS_Store

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
# 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>
APP_BASE_URL=http://localhost:4200
```
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/api/v1/auth/callback` — note this is a **backend** URL (proxied through the same origin), not the frontend's `/auth/callback`.
This IdP client is **confidential** (it has a client secret), so the entire Authorization Code + PKCE flow — including the callback and token exchange — runs server-side (see "OIDC client type" below). The browser only ever sees an httpOnly session cookie, never an access token.
## 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 & session model:** this deployment's IdP client is confidential (has a client secret), not a public/PKCE-only SPA client. A client secret must never be embedded in a browser bundle, so the **entire** Authorization Code + PKCE flow runs server-side, not just the token exchange:
- `GET /api/v1/auth/login` (`AuthLoginController`) generates the PKCE verifier/challenge and `state`, stores the verifier in Redis keyed by `state` (`SessionStoreService`, short TTL), and 302-redirects the browser straight to the IdP's `authorization_endpoint`. The frontend only ever navigates to this URL (`AuthService.login()`); it holds no PKCE state at all.
- The IdP redirects back to `GET /api/v1/auth/callback` (a **backend** URL, registered as the client's redirect URI) with `code`+`state`. The backend consumes the matching verifier from Redis (single-use — replaying a `state` returns 400), exchanges the code using `OIDC_CLIENT_SECRET` (`TokenExchangeService`), verifies the returned `id_token`'s signature via the IdP's JWKS, JIT-provisions the local `User` from its claims, and stores a server-side session in Redis (`SessionStoreService`, TTL = access-token lifetime).
- The callback sets **one** cookie — `travel_planner_session` (httpOnly, `SameSite=Lax`, `Secure` when `APP_BASE_URL` is https) — containing only an opaque session id, then redirects the browser into the app (`${APP_BASE_URL}/trips`). The browser never receives an access token, ID token, or refresh token; `refresh_token` is never even stored.
- Every subsequent request to a protected route is authenticated by `SessionAuthGuard`, which reads the cookie and looks up the session in Redis — no per-request JWT verification, no `Authorization` header, and (since the frontend and API share an origin via the edge/dev-proxy) no CORS configuration needed.
- `POST /api/v1/auth/logout` deletes the Redis session and clears the cookie. The frontend's `AuthService.ensureSessionChecked()` simply calls `GET /api/v1/users/me` on demand to ask "is there a valid session?" — it holds no token/session state of its own beyond a boolean signal.
- New required backend env vars: `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `APP_BASE_URL` (validated fail-fast like `DATABASE_URL`/`REDIS_URL`; `OIDC_CLIENT_SECRET` is a real secret, never committed). `APP_BASE_URL` is the public origin used to build the OIDC `redirect_uri` and the post-login redirect target — it must match a redirect URI registered with the IdP client. 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 — used only to know where to send the user, since the actual flow is backend-driven).
- 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 /api/v1/auth/login`, `GET /api/v1/auth/callback`, `POST /api/v1/auth/logout`, `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 `SessionAuthGuard` (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 \
OIDC_CLIENT_ID=test-client \
OIDC_CLIENT_SECRET=test-secret \
APP_BASE_URL=http://localhost:4200 \
pnpm --filter backend test:integration
```
- Verified end-to-end against the real running API, Redis, and a mocked IdP (local JWKS + discovery document): `/auth/login` redirects with a well-formed PKCE authorization URL, `/auth/callback` verifies the ID token, JIT-provisions the user, sets the httpOnly session cookie, and redirects into the app; replaying a consumed `state` is rejected (400); `/users/me` is 401 without the cookie and 200 with it; `/auth/logout` clears the session so `/users/me` returns 401 again. Trip flows verified: 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.

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../../../libs/configuration/src';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HealthModule } from './health/health.module';
import { VersionModule } from './version/version.module';
import { UsersApiModule } from './users/users.module';
import { TripsApiModule } from './trips/trips.module';
import { AuthApiModule } from './auth/auth.module';
@Module({
imports: [
ConfigurationModule,
HealthModule,
VersionModule,
AuthApiModule,
UsersApiModule,
TripsApiModule,
],
controllers: [AppController],
providers: [AppService],
})
export class ApiModule {}

View File

@@ -0,0 +1,90 @@
import { AuthLoginController } from './auth-login.controller';
function fakeResponse() {
return {
redirect: jest.fn(),
cookie: jest.fn(),
clearCookie: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
}
describe('AuthLoginController', () => {
const environment = { appBaseUrl: 'http://localhost:4200' };
it('GET /auth/login redirects to the authorization URL built by AuthFlowService', async () => {
const authFlow = {
buildAuthorizationRedirect: jest
.fn()
.mockResolvedValue({ url: 'https://idp.example.test/oidc/auth?...' }),
};
const sessionStore = { deleteSession: jest.fn() };
const controller = new AuthLoginController(
environment as never,
authFlow as never,
sessionStore as never,
);
const res = fakeResponse();
await controller.login(res as never);
expect(res.redirect).toHaveBeenCalledWith(
'https://idp.example.test/oidc/auth?...',
);
});
it('GET /auth/callback sets an httpOnly session cookie and redirects into the app', async () => {
const authFlow = {
handleCallback: jest
.fn()
.mockResolvedValue({ sessionId: 'session-1', expiresIn: 3600 }),
};
const sessionStore = { deleteSession: jest.fn() };
const controller = new AuthLoginController(
environment as never,
authFlow as never,
sessionStore as never,
);
const res = fakeResponse();
await controller.callback('code-1', 'state-1', res as never);
expect(authFlow.handleCallback).toHaveBeenCalledWith('code-1', 'state-1');
expect(res.cookie).toHaveBeenCalledWith(
'travel_planner_session',
'session-1',
expect.objectContaining({
httpOnly: true,
sameSite: 'lax',
maxAge: 3600 * 1000,
}),
);
expect(res.redirect).toHaveBeenCalledWith('http://localhost:4200/trips');
});
it('POST /auth/logout deletes the session and clears the cookie', async () => {
const authFlow = {};
const sessionStore = {
deleteSession: jest.fn().mockResolvedValue(undefined),
};
const controller = new AuthLoginController(
environment as never,
authFlow as never,
sessionStore as never,
);
const res = fakeResponse();
await controller.logout(
{ cookies: { travel_planner_session: 'session-1' } } as never,
res as never,
);
expect(sessionStore.deleteSession).toHaveBeenCalledWith('session-1');
expect(res.clearCookie).toHaveBeenCalledWith(
'travel_planner_session',
expect.objectContaining({ path: '/' }),
);
expect(res.status).toHaveBeenCalledWith(204);
});
});

View File

@@ -0,0 +1,64 @@
import {
Controller,
Get,
HttpCode,
Inject,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { APP_ENVIRONMENT } from '../../../../libs/configuration/src';
import type { AppEnvironment } from '../../../../libs/configuration/src';
import {
AuthFlowService,
SessionStoreService,
SESSION_COOKIE_NAME,
} from '../../../../libs/auth/src';
@Controller('auth')
export class AuthLoginController {
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
private readonly authFlow: AuthFlowService,
private readonly sessionStore: SessionStoreService,
) {}
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { url } = await this.authFlow.buildAuthorizationRedirect();
res.redirect(url);
}
@Get('callback')
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Res() res: Response,
): Promise<void> {
const { sessionId, expiresIn } = await this.authFlow.handleCallback(
code,
state,
);
res.cookie(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: this.environment.appBaseUrl.startsWith('https://'),
maxAge: expiresIn * 1000,
path: '/',
});
res.redirect(`${this.environment.appBaseUrl}/trips`);
}
@Post('logout')
@HttpCode(204)
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const sessionId = (req.cookies as Record<string, string> | undefined)?.[
SESSION_COOKIE_NAME
];
if (sessionId) await this.sessionStore.deleteSession(sessionId);
res.clearCookie(SESSION_COOKIE_NAME, { path: '/' });
res.status(204).send();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthLoginController } from './auth-login.controller';
@Module({
controllers: [AuthLoginController],
})
export class AuthApiModule {}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { SessionUser } from '../../../../libs/auth/src';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): SessionUser => {
const request = ctx.switchToHttp().getRequest<{ user: SessionUser }>();
return request.user;
},
);

View File

@@ -0,0 +1,29 @@
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);
});
});

View File

@@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ReadinessService } from './readiness.service';
@Controller()
export class HealthController {
constructor(private readonly readiness: ReadinessService) {}
@Get('health/live')
live(): { status: 'ok' } {
return { status: 'ok' };
}
@Get('health/ready')
ready(): Promise<{ status: 'ok' }> {
return this.readiness.check();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import {
PostgresModule,
RedisModule,
} from '../../../../libs/infrastructure/src';
import { HealthController } from './health.controller';
import { ReadinessService } from './readiness.service';
@Module({
imports: [PostgresModule, RedisModule],
controllers: [HealthController],
providers: [ReadinessService],
})
export class HealthModule {}

View File

@@ -0,0 +1,22 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Pool } from 'pg';
import type Redis from 'ioredis';
import {
POSTGRES_POOL,
REDIS_CLIENT,
} from '../../../../libs/infrastructure/src';
@Injectable()
export class ReadinessService {
constructor(
@Inject(POSTGRES_POOL) private readonly postgresPool: Pool,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
async check(): Promise<{ status: 'ok' }> {
await this.postgresPool.query('SELECT 1');
const pong = await this.redis.ping();
if (pong !== 'PONG') throw new Error('Redis ping failed');
return { status: 'ok' as const };
}
}

View File

@@ -0,0 +1,21 @@
import { RequestMethod } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { ApiModule } from './api.module';
export async function bootstrapApi(): Promise<void> {
const app = await NestFactory.create(ApiModule);
app.enableShutdownHooks();
app.use(cookieParser());
app.setGlobalPrefix('api/v1', {
exclude: [
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'health/ready', method: RequestMethod.GET },
],
});
await app.listen(3000, '0.0.0.0');
}
if (require.main === module) {
void bootstrapApi();
}

View File

@@ -0,0 +1,41 @@
import { join, sep } from 'node:path';
import { runner } from 'node-pg-migrate';
import { loadEnvironment } from '../../../libs/configuration/src';
/**
* Resolves the `backend/migrations` directory relative to this file's own
* location rather than `process.cwd()`, so migrations run identically from
* the compiled `dist/apps/api/src/migration.js` (production/deploy) and from
* the TypeScript source at `apps/api/src/migration.ts` (ts-jest integration
* tests), even though those two locations sit at different directory depths
* relative to the `backend/` package root.
*/
function resolveMigrationsDir(): string {
const segments = __dirname.split(sep);
const distIndex = segments.lastIndexOf('dist');
const backendRoot =
distIndex !== -1
? segments.slice(0, distIndex).join(sep)
: segments.slice(0, -3).join(sep);
return join(backendRoot, 'migrations');
}
export async function runMigrations(): Promise<void> {
const env = loadEnvironment(process.env);
await runner({
databaseUrl: env.databaseUrl,
dir: resolveMigrationsDir(),
direction: 'up',
count: Infinity,
migrationsTable: 'pgmigrations',
checkOrder: true,
log: (message: string) => console.log(message),
});
}
if (require.main === module) {
void runMigrations().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { TripMemberRole } from '../../../../libs/trips/src';
export const CurrentTripRole = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): TripMemberRole => {
const request = ctx
.switchToHttp()
.getRequest<{ tripMembership: { role: TripMemberRole } }>();
return request.tripMembership.role;
},
);

View File

@@ -0,0 +1,72 @@
import { TravelersController } from './travelers.controller';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TravelersController', () => {
const currentUser: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
};
it('GET /trips/:tripId/travelers lists travelers', async () => {
const travelersService = { listTravelers: jest.fn().mockResolvedValue([]) };
const controller = new TravelersController(travelersService as never);
await expect(controller.list('t1')).resolves.toEqual([]);
expect(travelersService.listTravelers).toHaveBeenCalledWith('t1');
});
it('POST /trips/:tripId/travelers creates a traveler recorded by the current member', async () => {
const created = {
id: 'trav-1',
tripId: 't1',
displayName: 'Mila',
travelerType: 'CHILD',
};
const travelersService = {
createTraveler: jest.fn().mockResolvedValue(created),
};
const controller = new TravelersController(travelersService as never);
const dto = { displayName: 'Mila', travelerType: 'CHILD' as const };
await expect(controller.create('t1', currentUser, dto)).resolves.toEqual(
created,
);
expect(travelersService.createTraveler).toHaveBeenCalledWith(
't1',
dto,
'u1',
);
});
it('PATCH /trips/:tripId/travelers/:travelerId updates a traveler', async () => {
const updated = { id: 'trav-1', tripId: 't1', displayName: 'Mila Renamed' };
const travelersService = {
updateTraveler: jest.fn().mockResolvedValue(updated),
};
const controller = new TravelersController(travelersService as never);
await expect(
controller.update('t1', 'trav-1', { displayName: 'Mila Renamed' }),
).resolves.toEqual(updated);
expect(travelersService.updateTraveler).toHaveBeenCalledWith(
't1',
'trav-1',
{ displayName: 'Mila Renamed' },
);
});
it('DELETE /trips/:tripId/travelers/:travelerId removes a traveler', async () => {
const travelersService = {
removeTraveler: jest.fn().mockResolvedValue(undefined),
};
const controller = new TravelersController(travelersService as never);
await controller.remove('t1', 'trav-1');
expect(travelersService.removeTraveler).toHaveBeenCalledWith(
't1',
'trav-1',
);
});
});

View File

@@ -0,0 +1,59 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TravelersService,
TripMembershipGuard,
} from '../../../../libs/trips/src';
import type {
CreateTravelerDto,
Traveler,
UpdateTravelerDto,
} from '../../../../libs/trips/src';
import { CurrentUser } from '../auth/current-user.decorator';
@Controller('trips/:tripId/travelers')
@UseGuards(SessionAuthGuard, TripMembershipGuard)
export class TravelersController {
constructor(private readonly travelersService: TravelersService) {}
@Get()
list(@Param('tripId') tripId: string): Promise<Traveler[]> {
return this.travelersService.listTravelers(tripId);
}
@Post()
create(
@Param('tripId') tripId: string,
@CurrentUser() currentUser: SessionUser,
@Body() dto: CreateTravelerDto,
): Promise<Traveler> {
return this.travelersService.createTraveler(tripId, dto, currentUser.id);
}
@Patch(':travelerId')
update(
@Param('tripId') tripId: string,
@Param('travelerId') travelerId: string,
@Body() dto: UpdateTravelerDto,
): Promise<Traveler> {
return this.travelersService.updateTraveler(tripId, travelerId, dto);
}
@Delete(':travelerId')
remove(
@Param('tripId') tripId: string,
@Param('travelerId') travelerId: string,
): Promise<void> {
return this.travelersService.removeTraveler(tripId, travelerId);
}
}

View File

@@ -0,0 +1,78 @@
import { TripInvitationsController } from './trip-invitations.controller';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TripInvitationsController', () => {
const currentUser: SessionUser = {
id: 'owner-1',
externalSubjectId: 'sub-1',
displayName: 'Owner',
email: 'owner@example.com',
};
it('POST /trips/:tripId/invitations creates an invitation and returns the raw token', async () => {
const result = {
invitation: { id: 'inv-1', tripId: 't1', email: 'friend@example.com' },
rawToken: 'raw-token',
};
const invitationsService = {
createInvitation: jest.fn().mockResolvedValue(result),
};
const controller = new TripInvitationsController(
invitationsService as never,
);
await expect(
controller.create('t1', currentUser, { email: 'friend@example.com' }),
).resolves.toEqual(result);
expect(invitationsService.createInvitation).toHaveBeenCalledWith(
't1',
'owner-1',
'friend@example.com',
);
});
it('GET /trips/:tripId/invitations lists invitations', async () => {
const invitationsService = {
listInvitations: jest.fn().mockResolvedValue([]),
};
const controller = new TripInvitationsController(
invitationsService as never,
);
await expect(controller.list('t1')).resolves.toEqual([]);
expect(invitationsService.listInvitations).toHaveBeenCalledWith('t1');
});
it('DELETE /trips/:tripId/invitations/:invitationId removes an invitation', async () => {
const invitationsService = {
removeInvitation: jest.fn().mockResolvedValue(undefined),
};
const controller = new TripInvitationsController(
invitationsService as never,
);
await controller.remove('t1', 'inv-1');
expect(invitationsService.removeInvitation).toHaveBeenCalledWith(
't1',
'inv-1',
);
});
it('POST /invitations/:token/accept accepts the invitation for the current user', async () => {
const member = { id: 'm1', tripId: 't1', userId: 'owner-1' };
const invitationsService = {
acceptInvitation: jest.fn().mockResolvedValue(member),
};
const controller = new TripInvitationsController(
invitationsService as never,
);
await expect(controller.accept('raw-token', currentUser)).resolves.toEqual(
member,
);
expect(invitationsService.acceptInvitation).toHaveBeenCalledWith(
'raw-token',
'owner-1',
);
});
});

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TripInvitationsService,
TripMembershipGuard,
TripRoles,
} from '../../../../libs/trips/src';
import type { TripInvitation, TripMember } from '../../../../libs/trips/src';
import { CurrentUser } from '../auth/current-user.decorator';
interface CreateTripInvitationDto {
email: string;
}
@Controller()
export class TripInvitationsController {
constructor(private readonly invitationsService: TripInvitationsService) {}
@Post('trips/:tripId/invitations')
@UseGuards(SessionAuthGuard, TripMembershipGuard)
@TripRoles('OWNER')
create(
@Param('tripId') tripId: string,
@CurrentUser() currentUser: SessionUser,
@Body() dto: CreateTripInvitationDto,
): Promise<{ invitation: TripInvitation; rawToken: string }> {
return this.invitationsService.createInvitation(
tripId,
currentUser.id,
dto.email,
);
}
@Get('trips/:tripId/invitations')
@UseGuards(SessionAuthGuard, TripMembershipGuard)
@TripRoles('OWNER')
list(@Param('tripId') tripId: string): Promise<TripInvitation[]> {
return this.invitationsService.listInvitations(tripId);
}
@Delete('trips/:tripId/invitations/:invitationId')
@UseGuards(SessionAuthGuard, TripMembershipGuard)
@TripRoles('OWNER')
remove(
@Param('tripId') tripId: string,
@Param('invitationId') invitationId: string,
): Promise<void> {
return this.invitationsService.removeInvitation(tripId, invitationId);
}
@Post('invitations/:token/accept')
@UseGuards(SessionAuthGuard)
accept(
@Param('token') token: string,
@CurrentUser() currentUser: SessionUser,
): Promise<TripMember> {
return this.invitationsService.acceptInvitation(token, currentUser.id);
}
}

View File

@@ -0,0 +1,47 @@
import { TripMembersController } from './trip-members.controller';
describe('TripMembersController', () => {
it('GET /trips/:tripId/members lists members', async () => {
const members = [
{ id: 'm1', tripId: 't1', userId: 'u1', role: 'OWNER', status: 'ACTIVE' },
];
const tripMembersService = {
listMembers: jest.fn().mockResolvedValue(members),
};
const controller = new TripMembersController(tripMembersService as never);
await expect(controller.list('t1')).resolves.toEqual(members);
expect(tripMembersService.listMembers).toHaveBeenCalledWith('t1');
});
it('PATCH /trips/:tripId/members/:memberId updates the member', async () => {
const updated = {
id: 'm1',
tripId: 't1',
userId: 'u2',
role: 'MEMBER',
status: 'ACTIVE',
};
const tripMembersService = {
updateMember: jest.fn().mockResolvedValue(updated),
};
const controller = new TripMembersController(tripMembersService as never);
await expect(
controller.update('t1', 'm1', { role: 'MEMBER' }),
).resolves.toEqual(updated);
expect(tripMembersService.updateMember).toHaveBeenCalledWith('t1', 'm1', {
role: 'MEMBER',
});
});
it('DELETE /trips/:tripId/members/:memberId removes the member', async () => {
const tripMembersService = {
removeMember: jest.fn().mockResolvedValue(undefined),
};
const controller = new TripMembersController(tripMembersService as never);
await controller.remove('t1', 'm1');
expect(tripMembersService.removeMember).toHaveBeenCalledWith('t1', 'm1');
});
});

View File

@@ -0,0 +1,55 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
UseGuards,
} from '@nestjs/common';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import {
TripMembersService,
TripMembershipGuard,
TripRoles,
} from '../../../../libs/trips/src';
import type {
TripMember,
TripMemberRole,
TripMemberStatus,
} from '../../../../libs/trips/src';
interface UpdateTripMemberDto {
role?: TripMemberRole;
status?: TripMemberStatus;
}
@Controller('trips/:tripId/members')
@UseGuards(SessionAuthGuard, TripMembershipGuard)
export class TripMembersController {
constructor(private readonly tripMembersService: TripMembersService) {}
@Get()
list(@Param('tripId') tripId: string): Promise<TripMember[]> {
return this.tripMembersService.listMembers(tripId);
}
@Patch(':memberId')
@TripRoles('OWNER')
update(
@Param('tripId') tripId: string,
@Param('memberId') memberId: string,
@Body() dto: UpdateTripMemberDto,
): Promise<TripMember> {
return this.tripMembersService.updateMember(tripId, memberId, dto);
}
@Delete(':memberId')
@TripRoles('OWNER')
remove(
@Param('tripId') tripId: string,
@Param('memberId') memberId: string,
): Promise<void> {
return this.tripMembersService.removeMember(tripId, memberId);
}
}

View File

@@ -0,0 +1,88 @@
import { ForbiddenException } from '@nestjs/common';
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TripPreferenceOverridesController', () => {
const member: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
};
it('GET /trips/:tripId/preference-overrides lists overrides', async () => {
const service = { listOverrides: jest.fn().mockResolvedValue([]) };
const controller = new TripPreferenceOverridesController(service as never);
await expect(controller.list('t1')).resolves.toEqual([]);
expect(service.listOverrides).toHaveBeenCalledWith('t1');
});
it('allows a member to upsert their own USER override', async () => {
const dto = {
subject: { type: 'USER' as const, userId: 'u1' },
overrides: { preferredPace: 'fast' },
};
const updated = { id: 'o1', tripId: 't1', ...dto };
const service = { upsertOverride: jest.fn().mockResolvedValue(updated) };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'MEMBER', dto),
).resolves.toEqual(updated);
expect(service.upsertOverride).toHaveBeenCalledWith(
't1',
dto.subject,
dto.overrides,
);
});
it('denies a non-owner member from setting another user override', async () => {
const dto = {
subject: { type: 'USER' as const, userId: 'someone-else' },
overrides: {},
};
const service = { upsertOverride: jest.fn() };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'MEMBER', dto),
).rejects.toThrow(ForbiddenException);
expect(service.upsertOverride).not.toHaveBeenCalled();
});
it('denies a non-owner member from setting a traveler override', async () => {
const dto = {
subject: { type: 'TRAVELER' as const, travelerId: 'trav-1' },
overrides: {},
};
const service = { upsertOverride: jest.fn() };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'MEMBER', dto),
).rejects.toThrow(ForbiddenException);
});
it('allows an OWNER to set overrides for any subject', async () => {
const dto = {
subject: { type: 'TRAVELER' as const, travelerId: 'trav-1' },
overrides: {},
};
const updated = { id: 'o1', tripId: 't1', ...dto };
const service = { upsertOverride: jest.fn().mockResolvedValue(updated) };
const controller = new TripPreferenceOverridesController(service as never);
await expect(
controller.upsert('t1', member, 'OWNER', dto),
).resolves.toEqual(updated);
});
it('DELETE /trips/:tripId/preference-overrides/:overrideId removes an override', async () => {
const service = { removeOverride: jest.fn().mockResolvedValue(undefined) };
const controller = new TripPreferenceOverridesController(service as never);
await controller.remove('t1', 'o1');
expect(service.removeOverride).toHaveBeenCalledWith('t1', 'o1');
});
});

View File

@@ -0,0 +1,59 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Put,
UseGuards,
} from '@nestjs/common';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TripMembershipGuard,
TripPreferenceOverridesService,
} from '../../../../libs/trips/src';
import type {
TripMemberRole,
TripPreferenceOverride,
UpsertPreferenceOverrideDto,
} from '../../../../libs/trips/src';
import { CurrentUser } from '../auth/current-user.decorator';
import { CurrentTripRole } from './current-trip-role.decorator';
@Controller('trips/:tripId/preference-overrides')
@UseGuards(SessionAuthGuard, TripMembershipGuard)
export class TripPreferenceOverridesController {
constructor(private readonly service: TripPreferenceOverridesService) {}
@Get()
list(@Param('tripId') tripId: string): Promise<TripPreferenceOverride[]> {
return this.service.listOverrides(tripId);
}
@Put()
async upsert(
@Param('tripId') tripId: string,
@CurrentUser() currentUser: SessionUser,
@CurrentTripRole() role: TripMemberRole,
@Body() dto: UpsertPreferenceOverrideDto,
): Promise<TripPreferenceOverride> {
const isOwnUserOverride =
dto.subject.type === 'USER' && dto.subject.userId === currentUser.id;
if (role !== 'OWNER' && !isOwnUserOverride) {
throw new ForbiddenException(
'Only the trip owner may set preference overrides for other members or travelers',
);
}
return this.service.upsertOverride(tripId, dto.subject, dto.overrides);
}
@Delete(':overrideId')
remove(
@Param('tripId') tripId: string,
@Param('overrideId') overrideId: string,
): Promise<void> {
return this.service.removeOverride(tripId, overrideId);
}
}

View File

@@ -0,0 +1,115 @@
import { TripsController } from './trips.controller';
import type { SessionUser } from '../../../../libs/auth/src';
describe('TripsController', () => {
const currentUser: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
};
function controllerWith(
tripsService: object,
tripSettingsService: object = {},
): TripsController {
return new TripsController(
tripsService as never,
tripSettingsService as never,
);
}
it('GET /trips lists trips for the current user', async () => {
const tripsService = {
listTripsForUser: jest
.fn()
.mockResolvedValue([{ id: 't1', name: 'Slovenia 2027' }]),
};
const controller = controllerWith(tripsService);
await expect(controller.list(currentUser)).resolves.toEqual([
{ id: 't1', name: 'Slovenia 2027' },
]);
expect(tripsService.listTripsForUser).toHaveBeenCalledWith('u1');
});
it('POST /trips creates a trip owned by the current user', async () => {
const created = {
id: 't1',
name: 'Slovenia 2027',
ownerId: 'u1',
version: 1,
};
const tripsService = { createTrip: jest.fn().mockResolvedValue(created) };
const controller = controllerWith(tripsService);
await expect(
controller.create(currentUser, { name: 'Slovenia 2027' }),
).resolves.toEqual(created);
expect(tripsService.createTrip).toHaveBeenCalledWith('u1', {
name: 'Slovenia 2027',
});
});
it('GET /trips/:tripId returns a single trip', async () => {
const trip = { id: 't1', name: 'Slovenia 2027' };
const tripsService = { getTrip: jest.fn().mockResolvedValue(trip) };
const controller = controllerWith(tripsService);
await expect(controller.getOne('t1')).resolves.toEqual(trip);
});
it('PATCH /trips/:tripId forwards the update dto including version', async () => {
const updated = { id: 't1', name: 'New name', version: 2 };
const tripsService = { updateTrip: jest.fn().mockResolvedValue(updated) };
const controller = controllerWith(tripsService);
await expect(
controller.update('t1', { name: 'New name', version: 1 }),
).resolves.toEqual(updated);
expect(tripsService.updateTrip).toHaveBeenCalledWith('t1', {
name: 'New name',
version: 1,
});
});
it('DELETE /trips/:tripId deletes the trip', async () => {
const tripsService = { deleteTrip: jest.fn().mockResolvedValue(undefined) };
const controller = controllerWith(tripsService);
await controller.remove('t1');
expect(tripsService.deleteTrip).toHaveBeenCalledWith('t1');
});
it('GET /trips/:tripId/settings returns the trip settings', async () => {
const settings = { tripId: 't1', webResearchEnabled: false };
const tripSettingsService = {
getSettings: jest.fn().mockResolvedValue(settings),
};
const controller = controllerWith({}, tripSettingsService);
await expect(controller.getSettings('t1')).resolves.toEqual(settings);
expect(tripSettingsService.getSettings).toHaveBeenCalledWith('t1');
});
it('PUT /trips/:tripId/settings replaces the trip settings', async () => {
const dto = {
webResearchEnabled: true,
periodicAgentReviewEnabled: false,
notificationEmailEnabled: true,
notificationPushEnabled: true,
defaultResearchDepth: null,
defaultPlanningStyle: null,
};
const updated = { tripId: 't1', ...dto };
const tripSettingsService = {
replaceSettings: jest.fn().mockResolvedValue(updated),
};
const controller = controllerWith({}, tripSettingsService);
await expect(controller.replaceSettings('t1', dto)).resolves.toEqual(
updated,
);
expect(tripSettingsService.replaceSettings).toHaveBeenCalledWith('t1', dto);
});
});

View File

@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
TripMembershipGuard,
TripRoles,
TripSettingsService,
TripsService,
} from '../../../../libs/trips/src';
import type {
CreateTripDto,
Trip,
TripSettings,
UpdateTripDto,
UpdateTripSettingsDto,
} from '../../../../libs/trips/src';
import { CurrentUser } from '../auth/current-user.decorator';
@Controller('trips')
@UseGuards(SessionAuthGuard)
export class TripsController {
constructor(
private readonly tripsService: TripsService,
private readonly tripSettingsService: TripSettingsService,
) {}
@Get()
list(@CurrentUser() currentUser: SessionUser): Promise<Trip[]> {
return this.tripsService.listTripsForUser(currentUser.id);
}
@Post()
create(
@CurrentUser() currentUser: SessionUser,
@Body() dto: CreateTripDto,
): Promise<Trip> {
return this.tripsService.createTrip(currentUser.id, dto);
}
@Get(':tripId')
@UseGuards(TripMembershipGuard)
getOne(@Param('tripId') tripId: string): Promise<Trip> {
return this.tripsService.getTrip(tripId);
}
@Patch(':tripId')
@UseGuards(TripMembershipGuard)
@TripRoles('OWNER')
update(
@Param('tripId') tripId: string,
@Body() dto: UpdateTripDto,
): Promise<Trip> {
return this.tripsService.updateTrip(tripId, dto);
}
@Delete(':tripId')
@UseGuards(TripMembershipGuard)
@TripRoles('OWNER')
remove(@Param('tripId') tripId: string): Promise<void> {
return this.tripsService.deleteTrip(tripId);
}
@Get(':tripId/settings')
@UseGuards(TripMembershipGuard)
getSettings(@Param('tripId') tripId: string): Promise<TripSettings> {
return this.tripSettingsService.getSettings(tripId);
}
@Put(':tripId/settings')
@UseGuards(TripMembershipGuard)
@TripRoles('OWNER')
replaceSettings(
@Param('tripId') tripId: string,
@Body() dto: UpdateTripSettingsDto,
): Promise<TripSettings> {
return this.tripSettingsService.replaceSettings(tripId, dto);
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../../../libs/auth/src';
import { TripsLibModule } from '../../../../libs/trips/src';
import { TripsController } from './trips.controller';
import { TripMembersController } from './trip-members.controller';
import { TripInvitationsController } from './trip-invitations.controller';
import { TravelersController } from './travelers.controller';
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
@Module({
imports: [AuthModule, TripsLibModule],
controllers: [
TripsController,
TripMembersController,
TripInvitationsController,
TravelersController,
TripPreferenceOverridesController,
],
})
export class TripsApiModule {}

View File

@@ -0,0 +1,95 @@
import { UsersController } from './users.controller';
import type { SessionUser } from '../../../../libs/auth/src';
describe('UsersController', () => {
const currentUser: SessionUser = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
};
it('GET /users/me returns the current user profile', async () => {
const usersService = {
findById: jest.fn().mockResolvedValue({
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),
}),
};
const preferencesService = { getOrDefault: jest.fn(), upsert: jest.fn() };
const controller = new UsersController(
usersService as never,
preferencesService as never,
);
const result = await controller.me(currentUser);
expect(usersService.findById).toHaveBeenCalledWith('u1');
expect(result).toEqual({
id: 'u1',
displayName: 'Alex',
email: 'a@example.com',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),
});
});
it('GET /users/me/preferences returns the effective preference', async () => {
const preference = {
userId: 'u1',
preferredPace: null,
preferredBudgetLevel: null,
maxWalkingDistanceKm: null,
preferredStartTime: null,
childFriendlyPreferred: false,
interests: [],
notes: null,
};
const usersService = { findById: jest.fn() };
const preferencesService = {
getOrDefault: jest.fn().mockResolvedValue(preference),
upsert: jest.fn(),
};
const controller = new UsersController(
usersService as never,
preferencesService as never,
);
await expect(controller.getPreferences(currentUser)).resolves.toEqual(
preference,
);
expect(preferencesService.getOrDefault).toHaveBeenCalledWith('u1');
});
it('PUT /users/me/preferences round-trips the dto through the service', async () => {
const dto = { childFriendlyPreferred: true };
const updated = {
userId: 'u1',
preferredPace: null,
preferredBudgetLevel: null,
maxWalkingDistanceKm: null,
preferredStartTime: null,
childFriendlyPreferred: true,
interests: [],
notes: null,
};
const usersService = { findById: jest.fn() };
const preferencesService = {
getOrDefault: jest.fn(),
upsert: jest.fn().mockResolvedValue(updated),
};
const controller = new UsersController(
usersService as never,
preferencesService as never,
);
await expect(
controller.updatePreferences(currentUser, dto),
).resolves.toEqual(updated);
expect(preferencesService.upsert).toHaveBeenCalledWith('u1', dto);
});
});

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Get,
NotFoundException,
Put,
UseGuards,
} from '@nestjs/common';
import { SessionAuthGuard } from '../../../../libs/auth/src';
import type { SessionUser } from '../../../../libs/auth/src';
import {
UsersService,
UserPreferencesService,
} from '../../../../libs/users/src';
import type {
UpdateUserPreferenceDto,
UserPreference,
} from '../../../../libs/users/src';
import { CurrentUser } from '../auth/current-user.decorator';
interface UserProfileResponse {
id: string;
displayName: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
@Controller('users/me')
@UseGuards(SessionAuthGuard)
export class UsersController {
constructor(
private readonly usersService: UsersService,
private readonly preferencesService: UserPreferencesService,
) {}
@Get()
async me(
@CurrentUser() currentUser: SessionUser,
): Promise<UserProfileResponse> {
const user = await this.usersService.findById(currentUser.id);
if (!user) throw new NotFoundException('User not found');
return {
id: user.id,
displayName: user.displayName,
email: user.email,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
@Get('preferences')
getPreferences(
@CurrentUser() currentUser: SessionUser,
): Promise<UserPreference> {
return this.preferencesService.getOrDefault(currentUser.id);
}
@Put('preferences')
updatePreferences(
@CurrentUser() currentUser: SessionUser,
@Body() dto: UpdateUserPreferenceDto,
): Promise<UserPreference> {
return this.preferencesService.upsert(currentUser.id, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../../../libs/auth/src';
import { UsersLibModule } from '../../../../libs/users/src';
import { UsersController } from './users.controller';
@Module({
imports: [AuthModule, UsersLibModule],
controllers: [UsersController],
})
export class UsersApiModule {}

View File

@@ -0,0 +1,33 @@
import { Test } from '@nestjs/testing';
import {
APP_ENVIRONMENT,
AppEnvironment,
} from '../../../../libs/configuration/src';
import { VersionController } from './version.controller';
describe('VersionController', () => {
it('returns only safe build metadata', async () => {
const environment: AppEnvironment = {
databaseUrl: 'postgresql://u:p@postgres:5432/db',
redisUrl: 'redis://redis:6379',
appVersion: '1.0.0',
teamCityBuildNumber: '123',
sourceRevision: 'abc123',
};
const moduleRef = await Test.createTestingModule({
controllers: [VersionController],
providers: [{ provide: APP_ENVIRONMENT, useValue: environment }],
}).compile();
const response = moduleRef.get(VersionController).version();
expect(response).toEqual({
appVersion: '1.0.0',
teamCityBuildNumber: '123',
sourceRevision: 'abc123',
});
expect(response).not.toHaveProperty('databaseUrl');
expect(response).not.toHaveProperty('redisUrl');
});
});

View File

@@ -0,0 +1,25 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { APP_ENVIRONMENT } from '../../../../libs/configuration/src';
import type { AppEnvironment } from '../../../../libs/configuration/src';
interface SafeVersionInfo {
appVersion: string;
teamCityBuildNumber: string;
sourceRevision: string;
}
@Controller('version')
export class VersionController {
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
) {}
@Get()
version(): SafeVersionInfo {
return {
appVersion: this.environment.appVersion,
teamCityBuildNumber: this.environment.teamCityBuildNumber,
sourceRevision: this.environment.sourceRevision,
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { VersionController } from './version.controller';
@Module({
controllers: [VersionController],
})
export class VersionModule {}

View File

@@ -2,14 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { ApiModule } from './../src/api.module';
describe('AppController (e2e)', () => {
describe('ApiModule (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
imports: [ApiModule],
}).compile();
app = moduleFixture.createNestApplication();

View File

@@ -0,0 +1,10 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "../../..",
"testEnvironment": "node",
"testRegex": "apps/api/test/.*\\.e2e-spec\\.ts$",
"setupFiles": ["<rootDir>/test/setup-env.ts"],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false
},
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

View File

@@ -0,0 +1,20 @@
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();
}

View File

@@ -0,0 +1,16 @@
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);
});
});

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../../../libs/configuration/src';
import { PostgresModule, RedisModule } from '../../../libs/infrastructure/src';
@Module({
imports: [ConfigurationModule, PostgresModule, RedisModule],
})
export class WorkerModule {}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false
},
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

View File

@@ -2,7 +2,8 @@
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"testRegex": ".*\\.integration-spec\\.ts$",
"testPathIgnorePatterns": ["<rootDir>/node_modules/", "<rootDir>/dist/"],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}

View File

@@ -0,0 +1,148 @@
import { BadRequestException } from '@nestjs/common';
import { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } from 'jose';
import { AuthFlowService } from './auth-flow.service';
import type { AppEnvironment } from '../../configuration/src';
describe('AuthFlowService', () => {
const environment: AppEnvironment = {
databaseUrl: 'postgresql://u:p@postgres:5432/db',
redisUrl: 'redis://redis:6379',
oidcIssuer: 'https://idp.example.test',
oidcAudience: 'client-1',
oidcClientId: 'client-1',
oidcClientSecret: 'secret-1',
appBaseUrl: 'http://localhost:4200',
appVersion: 'dev',
teamCityBuildNumber: 'local',
sourceRevision: 'local',
};
function fakeDiscovery() {
return {
getAuthorizationEndpoint: jest
.fn()
.mockReturnValue('https://idp.example.test/oidc/auth'),
};
}
describe('buildAuthorizationRedirect', () => {
it('persists a login attempt and returns a well-formed authorization URL', async () => {
const sessionStore = { createLoginAttempt: jest.fn() };
const service = new AuthFlowService(
environment,
fakeDiscovery() as never,
{} as never,
sessionStore as never,
{} as never,
);
const { url, state } = await service.buildAuthorizationRedirect();
expect(sessionStore.createLoginAttempt).toHaveBeenCalledWith(
state,
expect.any(String),
);
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
'https://idp.example.test/oidc/auth',
);
expect(parsed.searchParams.get('response_type')).toBe('code');
expect(parsed.searchParams.get('client_id')).toBe('client-1');
expect(parsed.searchParams.get('redirect_uri')).toBe(
'http://localhost:4200/api/v1/auth/callback',
);
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('state')).toBe(state);
});
});
describe('handleCallback', () => {
it('rejects when the state does not match a stored login attempt', async () => {
const sessionStore = {
consumeLoginAttempt: jest.fn().mockResolvedValue(undefined),
};
const service = new AuthFlowService(
environment,
fakeDiscovery() as never,
{} as never,
sessionStore as never,
{} as never,
);
await expect(
service.handleCallback('code-1', 'unknown-state'),
).rejects.toThrow(BadRequestException);
});
it('verifies the id_token, jit-provisions the user, and creates a session', async () => {
const issuer = environment.oidcIssuer;
const { publicKey, privateKey } = await generateKeyPair('RS256');
const jwk = (await exportJWK(publicKey)) as Record<string, string>;
jwk.kid = 'flow-key';
const jwks = createLocalJWKSet({ keys: [jwk as never] });
const idToken = await new SignJWT({
sub: 'idp-sub-1',
email: 'a@example.com',
name: 'A',
})
.setProtectedHeader({ alg: 'RS256', kid: 'flow-key' })
.setIssuer(issuer)
.setAudience(environment.oidcClientId)
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);
const sessionStore = {
consumeLoginAttempt: jest.fn().mockResolvedValue('verifier-1'),
createSession: jest.fn().mockResolvedValue('session-1'),
};
const tokenExchange = {
exchangeAuthorizationCode: jest
.fn()
.mockResolvedValue({ accessToken: 'at-1', expiresIn: 3600, idToken }),
};
const usersService = {
findOrCreateByExternalSubjectId: jest.fn().mockResolvedValue({
id: 'local-1',
externalSubjectId: 'idp-sub-1',
displayName: 'A',
email: 'a@example.com',
}),
};
const discovery = {
getAuthorizationEndpoint: jest.fn(),
getVerificationKeySet: jest.fn().mockReturnValue(jwks),
getIssuer: jest.fn().mockReturnValue(issuer),
};
const service = new AuthFlowService(
environment,
discovery as never,
tokenExchange as never,
sessionStore as never,
usersService as never,
);
const result = await service.handleCallback('code-1', 'state-1');
expect(usersService.findOrCreateByExternalSubjectId).toHaveBeenCalledWith(
'idp-sub-1',
{
email: 'a@example.com',
displayName: 'A',
},
);
expect(sessionStore.createSession).toHaveBeenCalledWith(
{
id: 'local-1',
externalSubjectId: 'idp-sub-1',
displayName: 'A',
email: 'a@example.com',
},
3600,
);
expect(result).toEqual({ sessionId: 'session-1', expiresIn: 3600 });
});
});
});

View File

@@ -0,0 +1,106 @@
import {
BadRequestException,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { jwtVerify } from 'jose';
import { APP_ENVIRONMENT } from '../../configuration/src';
import type { AppEnvironment } from '../../configuration/src';
import { UsersService } from '../../users/src';
import { OidcDiscoveryService } from './oidc-discovery.service';
import { TokenExchangeService } from './token-exchange.service';
import { SessionStoreService } from './session-store.service';
import type { SessionUser } from './session-store.service';
import { generateCodeChallenge, generateRandomString } from './pkce';
export interface AuthorizationRedirect {
url: string;
state: string;
}
export interface CallbackResult {
sessionId: string;
expiresIn: number;
}
const SCOPE = 'openid profile email';
@Injectable()
export class AuthFlowService {
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
private readonly discovery: OidcDiscoveryService,
private readonly tokenExchange: TokenExchangeService,
private readonly sessionStore: SessionStoreService,
private readonly usersService: UsersService,
) {}
private getRedirectUri(): string {
return `${this.environment.appBaseUrl}/api/v1/auth/callback`;
}
async buildAuthorizationRedirect(): Promise<AuthorizationRedirect> {
const codeVerifier = generateRandomString();
const state = generateRandomString();
const codeChallenge = generateCodeChallenge(codeVerifier);
await this.sessionStore.createLoginAttempt(state, codeVerifier);
const url = new URL(this.discovery.getAuthorizationEndpoint());
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.environment.oidcClientId);
url.searchParams.set('redirect_uri', this.getRedirectUri());
url.searchParams.set('scope', SCOPE);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
return { url: url.toString(), state };
}
async handleCallback(code: string, state: string): Promise<CallbackResult> {
const codeVerifier = await this.sessionStore.consumeLoginAttempt(state);
if (!codeVerifier) {
throw new BadRequestException('Invalid or expired login attempt');
}
const tokenResult = await this.tokenExchange.exchangeAuthorizationCode({
code,
codeVerifier,
redirectUri: this.getRedirectUri(),
});
const { payload } = await jwtVerify(
tokenResult.idToken,
this.discovery.getVerificationKeySet(),
{
issuer: this.discovery.getIssuer(),
audience: this.environment.oidcClientId,
},
).catch(() => {
throw new UnauthorizedException('Invalid ID token');
});
const sub = payload.sub;
if (!sub) throw new UnauthorizedException('ID token has no subject claim');
const user = await this.usersService.findOrCreateByExternalSubjectId(sub, {
email: (payload.email as string) ?? '',
displayName: (payload.name as string) ?? (payload.email as string) ?? sub,
});
const sessionUser: SessionUser = {
id: user.id,
externalSubjectId: user.externalSubjectId,
displayName: user.displayName,
email: user.email,
};
const sessionId = await this.sessionStore.createSession(
sessionUser,
tokenResult.expiresIn,
);
return { sessionId, expiresIn: tokenResult.expiresIn };
}
}

View File

@@ -0,0 +1,29 @@
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '../../infrastructure/src';
import { UsersLibModule } from '../../users/src';
import { OidcDiscoveryService } from './oidc-discovery.service';
import { TokenExchangeService } from './token-exchange.service';
import { SessionStoreService } from './session-store.service';
import { SessionAuthGuard } from './session-auth.guard';
import { AuthFlowService } from './auth-flow.service';
@Global()
@Module({
imports: [RedisModule, UsersLibModule],
providers: [
OidcDiscoveryService,
TokenExchangeService,
SessionStoreService,
SessionAuthGuard,
AuthFlowService,
],
exports: [
OidcDiscoveryService,
TokenExchangeService,
SessionStoreService,
SessionAuthGuard,
AuthFlowService,
UsersLibModule,
],
})
export class AuthModule {}

View File

@@ -0,0 +1,6 @@
export * from './oidc-discovery.service';
export * from './token-exchange.service';
export * from './session-store.service';
export * from './session-auth.guard';
export * from './auth-flow.service';
export * from './auth.module';

View File

@@ -0,0 +1,55 @@
import { OidcDiscoveryService } from './oidc-discovery.service';
import type { AppEnvironment } from '../../configuration/src';
describe('OidcDiscoveryService', () => {
const environment: AppEnvironment = {
databaseUrl: 'postgresql://u:p@postgres:5432/db',
redisUrl: 'redis://redis:6379',
oidcIssuer: 'https://idp.example.test',
oidcAudience: 'client-1',
oidcClientId: 'client-1',
oidcClientSecret: 'secret-1',
appBaseUrl: 'http://localhost:4200',
appVersion: 'dev',
teamCityBuildNumber: 'local',
sourceRevision: 'local',
};
it('fetches the discovery document once and exposes its endpoints', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
jwks_uri: 'https://idp.example.test/oidc/jwks',
token_endpoint: 'https://idp.example.test/oidc/token',
authorization_endpoint: 'https://idp.example.test/oidc/auth',
}),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new OidcDiscoveryService(environment);
await service.onModuleInit();
expect(fetchMock).toHaveBeenCalledWith(
'https://idp.example.test/.well-known/openid-configuration',
);
expect(service.getTokenEndpoint()).toBe(
'https://idp.example.test/oidc/token',
);
expect(service.getAuthorizationEndpoint()).toBe(
'https://idp.example.test/oidc/auth',
);
expect(service.getIssuer()).toBe('https://idp.example.test');
expect(service.getAudience()).toBe('client-1');
});
it('throws when asked for an endpoint before discovery has completed', () => {
const service = new OidcDiscoveryService(environment);
expect(() => service.getTokenEndpoint()).toThrow(
'OIDC discovery has not completed yet',
);
expect(() => service.getAuthorizationEndpoint()).toThrow(
'OIDC discovery has not completed yet',
);
});
});

View File

@@ -0,0 +1,65 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { createRemoteJWKSet } from 'jose';
import type { JWTVerifyGetKey } from 'jose';
import { APP_ENVIRONMENT } from '../../configuration/src';
import type { AppEnvironment } from '../../configuration/src';
interface OidcDiscoveryDocument {
jwks_uri: string;
token_endpoint: string;
authorization_endpoint: string;
}
@Injectable()
export class OidcDiscoveryService implements OnModuleInit {
private verificationKeySet: JWTVerifyGetKey | undefined;
private tokenEndpoint: string | undefined;
private authorizationEndpoint: string | undefined;
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
) {}
async onModuleInit(): Promise<void> {
const issuer = this.environment.oidcIssuer.replace(/\/$/, '');
const response = await fetch(`${issuer}/.well-known/openid-configuration`);
if (!response.ok) {
throw new Error(
`Failed to fetch OIDC discovery document: HTTP ${response.status}`,
);
}
const document = (await response.json()) as OidcDiscoveryDocument;
this.verificationKeySet = createRemoteJWKSet(new URL(document.jwks_uri));
this.tokenEndpoint = document.token_endpoint;
this.authorizationEndpoint = document.authorization_endpoint;
}
getIssuer(): string {
return this.environment.oidcIssuer;
}
getAudience(): string {
return this.environment.oidcAudience;
}
getVerificationKeySet(): JWTVerifyGetKey {
if (!this.verificationKeySet) {
throw new Error('OIDC discovery has not completed yet');
}
return this.verificationKeySet;
}
getTokenEndpoint(): string {
if (!this.tokenEndpoint) {
throw new Error('OIDC discovery has not completed yet');
}
return this.tokenEndpoint;
}
getAuthorizationEndpoint(): string {
if (!this.authorizationEndpoint) {
throw new Error('OIDC discovery has not completed yet');
}
return this.authorizationEndpoint;
}
}

View File

@@ -0,0 +1,20 @@
import { generateCodeChallenge, generateRandomString } from './pkce';
describe('pkce (backend)', () => {
it('computes the RFC 7636 Appendix B S256 test vector', () => {
const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
expect(generateCodeChallenge(codeVerifier)).toBe(
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
);
});
it('generates a URL-safe random string', () => {
const value = generateRandomString();
expect(value).toMatch(/^[A-Za-z0-9_-]+$/);
expect(value.length).toBeGreaterThanOrEqual(43);
});
it('generates different values on each call', () => {
expect(generateRandomString()).not.toBe(generateRandomString());
});
});

View File

@@ -0,0 +1,9 @@
import { createHash, randomBytes } from 'node:crypto';
export function generateRandomString(byteLength = 32): string {
return randomBytes(byteLength).toString('base64url');
}
export function generateCodeChallenge(codeVerifier: string): string {
return createHash('sha256').update(codeVerifier).digest('base64url');
}

View File

@@ -0,0 +1,56 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { SessionAuthGuard } from './session-auth.guard';
describe('SessionAuthGuard', () => {
function contextWithCookies(
cookies?: Record<string, string>,
): ExecutionContext {
const req: Record<string, unknown> = { cookies };
return {
switchToHttp: () => ({ getRequest: () => req }),
} as unknown as ExecutionContext;
}
it('rejects a request with no session cookie', async () => {
const sessionStore = { getSession: jest.fn() };
const guard = new SessionAuthGuard(sessionStore as never);
await expect(guard.canActivate(contextWithCookies())).rejects.toThrow(
UnauthorizedException,
);
expect(sessionStore.getSession).not.toHaveBeenCalled();
});
it('rejects a session cookie that does not match a stored session', async () => {
const sessionStore = { getSession: jest.fn().mockResolvedValue(undefined) };
const guard = new SessionAuthGuard(sessionStore as never);
await expect(
guard.canActivate(
contextWithCookies({ travel_planner_session: 'unknown-session' }),
),
).rejects.toThrow(UnauthorizedException);
});
it('attaches the stored session user to the request on a valid session', async () => {
const user = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
};
const sessionStore = { getSession: jest.fn().mockResolvedValue(user) };
const guard = new SessionAuthGuard(sessionStore as never);
const req: Record<string, unknown> = {
cookies: { travel_planner_session: 'session-1' },
};
const context = {
switchToHttp: () => ({ getRequest: () => req }),
} as unknown as ExecutionContext;
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(sessionStore.getSession).toHaveBeenCalledWith('session-1');
expect(req.user).toEqual(user);
});
});

View File

@@ -0,0 +1,33 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
SessionStoreService,
SESSION_COOKIE_NAME,
} from './session-store.service';
import type { SessionUser } from './session-store.service';
interface RequestWithSession {
cookies?: Record<string, string>;
user?: SessionUser;
}
@Injectable()
export class SessionAuthGuard implements CanActivate {
constructor(private readonly sessionStore: SessionStoreService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<RequestWithSession>();
const sessionId = request.cookies?.[SESSION_COOKIE_NAME];
if (!sessionId) throw new UnauthorizedException('Missing session cookie');
const user = await this.sessionStore.getSession(sessionId);
if (!user) throw new UnauthorizedException('Session expired or invalid');
request.user = user;
return true;
}
}

View File

@@ -0,0 +1,76 @@
import { SessionStoreService } from './session-store.service';
function fakeRedis() {
const store = new Map<string, string>();
return {
store,
set: jest.fn((key: string, value: string) => {
store.set(key, value);
return Promise.resolve('OK');
}),
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
del: jest.fn((key: string) => {
const existed = store.delete(key);
return Promise.resolve(existed ? 1 : 0);
}),
};
}
describe('SessionStoreService', () => {
describe('login attempts', () => {
it('stores and consumes a code verifier for a given state exactly once', async () => {
const redis = fakeRedis();
const service = new SessionStoreService(redis as never);
await service.createLoginAttempt('state-1', 'verifier-1');
await expect(service.consumeLoginAttempt('state-1')).resolves.toBe(
'verifier-1',
);
await expect(
service.consumeLoginAttempt('state-1'),
).resolves.toBeUndefined();
});
it('returns undefined for an unknown state', async () => {
const redis = fakeRedis();
const service = new SessionStoreService(redis as never);
await expect(
service.consumeLoginAttempt('never-seen'),
).resolves.toBeUndefined();
});
});
describe('sessions', () => {
const user = {
id: 'u1',
externalSubjectId: 'sub-1',
displayName: 'Alex',
email: 'a@example.com',
};
it('creates a session and retrieves the stored user by session id', async () => {
const redis = fakeRedis();
const service = new SessionStoreService(redis as never);
const sessionId = await service.createSession(user, 3600);
expect(sessionId).toEqual(expect.any(String));
await expect(service.getSession(sessionId)).resolves.toEqual(user);
});
it('returns undefined for an unknown or deleted session', async () => {
const redis = fakeRedis();
const service = new SessionStoreService(redis as never);
const sessionId = await service.createSession(user, 3600);
await service.deleteSession(sessionId);
await expect(service.getSession(sessionId)).resolves.toBeUndefined();
await expect(
service.getSession('does-not-exist'),
).resolves.toBeUndefined();
});
});
});

View File

@@ -0,0 +1,64 @@
import { randomBytes } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type Redis from 'ioredis';
import { REDIS_CLIENT } from '../../infrastructure/src';
export interface SessionUser {
id: string;
externalSubjectId: string;
displayName: string;
email: string;
}
export const SESSION_COOKIE_NAME = 'travel_planner_session';
const LOGIN_ATTEMPT_TTL_SECONDS = 10 * 60;
function loginAttemptKey(state: string): string {
return `oidc:login-attempt:${state}`;
}
function sessionKey(sessionId: string): string {
return `session:${sessionId}`;
}
@Injectable()
export class SessionStoreService {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
async createLoginAttempt(state: string, codeVerifier: string): Promise<void> {
await this.redis.set(
loginAttemptKey(state),
codeVerifier,
'EX',
LOGIN_ATTEMPT_TTL_SECONDS,
);
}
async consumeLoginAttempt(state: string): Promise<string | undefined> {
const key = loginAttemptKey(state);
const codeVerifier = await this.redis.get(key);
if (codeVerifier) await this.redis.del(key);
return codeVerifier ?? undefined;
}
async createSession(user: SessionUser, ttlSeconds: number): Promise<string> {
const sessionId = randomBytes(32).toString('base64url');
await this.redis.set(
sessionKey(sessionId),
JSON.stringify(user),
'EX',
ttlSeconds,
);
return sessionId;
}
async getSession(sessionId: string): Promise<SessionUser | undefined> {
const raw = await this.redis.get(sessionKey(sessionId));
return raw ? (JSON.parse(raw) as SessionUser) : undefined;
}
async deleteSession(sessionId: string): Promise<void> {
await this.redis.del(sessionKey(sessionId));
}
}

View File

@@ -0,0 +1,114 @@
import { BadRequestException } from '@nestjs/common';
import { TokenExchangeService } from './token-exchange.service';
import type { AppEnvironment } from '../../configuration/src';
describe('TokenExchangeService.exchangeAuthorizationCode', () => {
const environment: AppEnvironment = {
databaseUrl: 'postgresql://u:p@postgres:5432/db',
redisUrl: 'redis://redis:6379',
oidcIssuer: 'https://idp.example.test',
oidcAudience: 'client-1',
oidcClientId: 'client-1',
oidcClientSecret: 'super-secret',
appBaseUrl: 'http://localhost:4200',
appVersion: 'dev',
teamCityBuildNumber: 'local',
sourceRevision: 'local',
};
function fakeDiscovery(
tokenEndpoint = 'https://idp.example.test/oidc/token',
) {
return { getTokenEndpoint: jest.fn().mockReturnValue(tokenEndpoint) };
}
it('posts a client-secret-authenticated request and returns the access + id token', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
access_token: 'at-1',
expires_in: 3600,
refresh_token: 'rt-1',
id_token: 'idt-1',
}),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new TokenExchangeService(
environment,
fakeDiscovery() as never,
);
const result = await service.exchangeAuthorizationCode({
code: 'auth-code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
});
expect(result).toEqual({
accessToken: 'at-1',
expiresIn: 3600,
idToken: 'idt-1',
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://idp.example.test/oidc/token');
const body = new URLSearchParams(init.body as string);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('client_id')).toBe('client-1');
expect(body.get('client_secret')).toBe('super-secret');
expect(body.get('code')).toBe('auth-code-1');
expect(body.get('code_verifier')).toBe('verifier-1');
expect(body.get('redirect_uri')).toBe(
'http://localhost:4200/api/v1/auth/callback',
);
});
it('never exposes the refresh_token, which is not needed by this MVP (no silent refresh)', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
access_token: 'at-1',
expires_in: 3600,
refresh_token: 'rt-1',
id_token: 'idt-1',
}),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new TokenExchangeService(
environment,
fakeDiscovery() as never,
);
const result = await service.exchangeAuthorizationCode({
code: 'auth-code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
});
expect(result).not.toHaveProperty('refreshToken');
});
it('rejects with BadRequestException when the identity provider rejects the code', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ error: 'invalid_grant' }),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new TokenExchangeService(
environment,
fakeDiscovery() as never,
);
await expect(
service.exchangeAuthorizationCode({
code: 'bad-code',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
}),
).rejects.toThrow(BadRequestException);
});
});

View File

@@ -0,0 +1,70 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { APP_ENVIRONMENT } from '../../configuration/src';
import type { AppEnvironment } from '../../configuration/src';
import { OidcDiscoveryService } from './oidc-discovery.service';
export interface AuthorizationCodeExchangeRequest {
code: string;
codeVerifier: string;
redirectUri: string;
}
export interface AuthorizationCodeExchangeResult {
accessToken: string;
expiresIn: number;
idToken: string;
}
interface TokenEndpointResponse {
access_token: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
}
/**
* Exchanges an OIDC authorization code for tokens on behalf of the (confidential,
* client-secret-bearing) SPA client. The secret never reaches the browser: the
* frontend performs the Authorization Code + PKCE redirect itself, then hands the
* resulting code + PKCE verifier to this service, which is the only place the
* client secret is used.
*/
@Injectable()
export class TokenExchangeService {
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
private readonly discovery: OidcDiscoveryService,
) {}
async exchangeAuthorizationCode(
request: AuthorizationCodeExchangeRequest,
): Promise<AuthorizationCodeExchangeResult> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.environment.oidcClientId,
client_secret: this.environment.oidcClientSecret,
code: request.code,
code_verifier: request.codeVerifier,
redirect_uri: request.redirectUri,
});
const response = await fetch(this.discovery.getTokenEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
throw new BadRequestException(
'The identity provider rejected the authorization code',
);
}
const tokenResponse = (await response.json()) as TokenEndpointResponse;
return {
accessToken: tokenResponse.access_token,
expiresIn: tokenResponse.expires_in,
idToken: tokenResponse.id_token ?? '',
};
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { appEnvironmentProvider } from './environment';
@Global()
@Module({
providers: [appEnvironmentProvider],
exports: [appEnvironmentProvider],
})
export class ConfigurationModule {}

View File

@@ -0,0 +1,35 @@
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',
OIDC_ISSUER: 'https://idp.example.test/',
OIDC_AUDIENCE: 'travel-planner-api',
OIDC_CLIENT_ID: 'test-client',
OIDC_CLIENT_SECRET: 'test-secret',
APP_BASE_URL: 'http://localhost:4200/',
APP_VERSION: '1.2.3',
TEAMCITY_BUILD_NUMBER: '42',
SOURCE_REVISION: 'abc123',
}),
).toEqual({
databaseUrl: 'postgresql://u:p@postgres:5432/db',
redisUrl: 'redis://redis:6379',
oidcIssuer: 'https://idp.example.test/',
oidcAudience: 'travel-planner-api',
oidcClientId: 'test-client',
oidcClientSecret: 'test-secret',
appBaseUrl: 'http://localhost:4200',
appVersion: '1.2.3',
teamCityBuildNumber: '42',
sourceRevision: 'abc123',
});
});
});

View File

@@ -0,0 +1,40 @@
export interface AppEnvironment {
databaseUrl: string;
redisUrl: string;
oidcIssuer: string;
oidcAudience: string;
oidcClientId: string;
oidcClientSecret: string;
appBaseUrl: 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'),
oidcIssuer: required(env, 'OIDC_ISSUER'),
oidcAudience: required(env, 'OIDC_AUDIENCE'),
oidcClientId: required(env, 'OIDC_CLIENT_ID'),
oidcClientSecret: required(env, 'OIDC_CLIENT_SECRET'),
appBaseUrl: required(env, 'APP_BASE_URL').replace(/\/$/, ''),
appVersion: env.APP_VERSION?.trim() || 'dev',
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local',
sourceRevision: env.SOURCE_REVISION?.trim() || 'local',
};
}
export const APP_ENVIRONMENT = Symbol('APP_ENVIRONMENT');
export const appEnvironmentProvider = {
provide: APP_ENVIRONMENT,
useFactory: () => loadEnvironment(process.env),
};

View File

@@ -0,0 +1,2 @@
export * from './environment';
export * from './configuration.module';

View File

@@ -0,0 +1,2 @@
export * from './schema';
export * from './kysely.module';

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { Kysely, PostgresDialect } from 'kysely';
import type { Pool } from 'pg';
import { PostgresModule } from '../../infrastructure/src';
import { POSTGRES_POOL } from '../../infrastructure/src';
import type { Database } from './schema';
export const KYSELY_DB = Symbol('KYSELY_DB');
export const kyselyDbProvider = {
provide: KYSELY_DB,
inject: [POSTGRES_POOL],
useFactory: (pool: Pool) =>
new Kysely<Database>({ dialect: new PostgresDialect({ pool }) }),
};
@Module({
imports: [PostgresModule],
providers: [kyselyDbProvider],
exports: [kyselyDbProvider],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,105 @@
import type { ColumnType, Generated } from 'kysely';
export interface UsersTable {
id: Generated<string>;
external_subject_id: string;
display_name: string;
email: string;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface UserPreferencesTable {
user_id: string;
preferred_pace: string | null;
preferred_budget_level: string | null;
max_walking_distance_km: number | null;
preferred_start_time: string | null;
child_friendly_preferred: boolean;
interests: string[];
notes: string | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripsTable {
id: Generated<string>;
name: string;
description: string | null;
owner_id: string;
start_date: string | null;
end_date: string | null;
status: string;
planning_stage: string | null;
currency: string;
version: Generated<number>;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripSettingsTable {
trip_id: string;
web_research_enabled: boolean;
periodic_agent_review_enabled: boolean;
notification_email_enabled: boolean;
notification_push_enabled: boolean;
default_research_depth: string | null;
default_planning_style: string | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripMembersTable {
id: Generated<string>;
trip_id: string;
user_id: string;
role: string;
status: string;
joined_at: ColumnType<Date, string | undefined, string> | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripInvitationsTable {
id: Generated<string>;
trip_id: string;
email: string;
invited_by_user_id: string;
token_hash: string;
expires_at: ColumnType<Date, string, string>;
accepted_at: ColumnType<Date, string | undefined, string> | null;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TravelersTable {
id: Generated<string>;
trip_id: string;
linked_user_id: string | null;
display_name: string;
traveler_type: string;
created_by_user_id: string;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface TripPreferenceOverridesTable {
id: Generated<string>;
trip_id: string;
user_id: string | null;
traveler_id: string | null;
overrides: unknown;
created_at: ColumnType<Date, string | undefined, never>;
updated_at: ColumnType<Date, string | undefined, string>;
}
export interface Database {
users: UsersTable;
user_preferences: UserPreferencesTable;
trips: TripsTable;
trip_settings: TripSettingsTable;
trip_members: TripMembersTable;
trip_invitations: TripInvitationsTable;
travelers: TravelersTable;
trip_preference_overrides: TripPreferenceOverridesTable;
}

View File

@@ -0,0 +1,31 @@
import { Pool } from 'pg';
import { runMigrations } from '../../../apps/api/src/migration';
describe('runMigrations (integration)', () => {
it('applies all pending migrations idempotently against a real database', async () => {
await runMigrations();
await runMigrations(); // must be safe to run twice
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
const tables = await pool.query<{ table_name: string }>(
`select table_name from information_schema.tables where table_schema = 'public'`,
);
const names = tables.rows.map((r) => r.table_name);
for (const expected of [
'users',
'user_preferences',
'trips',
'trip_settings',
'trip_members',
'trip_invitations',
'travelers',
'trip_preference_overrides',
]) {
expect(names).toContain(expected);
}
} finally {
await pool.end();
}
});
});

View File

@@ -0,0 +1,2 @@
export * from './postgres/postgres.module';
export * from './redis/redis.module';

View File

@@ -0,0 +1,27 @@
import { Inject, Injectable, Module, OnModuleDestroy } from '@nestjs/common';
import { Pool } from 'pg';
import { APP_ENVIRONMENT, AppEnvironment } from '../../../configuration/src';
export const POSTGRES_POOL = Symbol('POSTGRES_POOL');
export const postgresPoolProvider = {
provide: POSTGRES_POOL,
inject: [APP_ENVIRONMENT],
useFactory: (env: AppEnvironment) =>
new Pool({ connectionString: env.databaseUrl }),
};
@Injectable()
class PostgresLifecycle implements OnModuleDestroy {
constructor(@Inject(POSTGRES_POOL) private readonly pool: Pool) {}
async onModuleDestroy(): Promise<void> {
await this.pool.end();
}
}
@Module({
providers: [postgresPoolProvider, PostgresLifecycle],
exports: [postgresPoolProvider],
})
export class PostgresModule {}

View File

@@ -0,0 +1,27 @@
import { Inject, Injectable, Module, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { APP_ENVIRONMENT, AppEnvironment } from '../../../configuration/src';
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
export const redisClientProvider = {
provide: REDIS_CLIENT,
inject: [APP_ENVIRONMENT],
useFactory: (env: AppEnvironment) =>
new Redis(env.redisUrl, { lazyConnect: false }),
};
@Injectable()
class RedisLifecycle implements OnModuleDestroy {
constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}
async onModuleDestroy(): Promise<void> {
await this.client.quit();
}
}
@Module({
providers: [redisClientProvider, RedisLifecycle],
exports: [redisClientProvider],
})
export class RedisModule {}

View File

@@ -0,0 +1,11 @@
export * from './trip.types';
export * from './trips.service';
export * from './trip-settings.service';
export * from './trip-members.service';
export * from './trip-membership.guard';
export * from './trip-invitations.service';
export * from './travelers.service';
export * from './trip-preference-overrides.service';
export * from './preference-precedence';
export * from './trip-roles.decorator';
export * from './trips.module';

View File

@@ -0,0 +1,47 @@
import { resolveEffectivePreference } from './preference-precedence';
import type { UserPreferenceFields } from '../../users/src';
describe('resolveEffectivePreference', () => {
const appDefault: UserPreferenceFields = {
preferredPace: 'moderate',
preferredBudgetLevel: 'medium',
maxWalkingDistanceKm: 5,
preferredStartTime: null,
childFriendlyPreferred: false,
interests: [],
notes: null,
};
it('falls back to the application default when nothing is set', () => {
expect(
resolveEffectivePreference(undefined, undefined, appDefault),
).toEqual(appDefault);
});
it('prefers the persistent user preference over the application default', () => {
const userPref = { ...appDefault, preferredPace: 'relaxed' };
expect(
resolveEffectivePreference(undefined, userPref, appDefault).preferredPace,
).toBe('relaxed');
});
it('prefers a trip-specific override over the persistent user preference', () => {
const userPref = { ...appDefault, preferredPace: 'relaxed' };
const override = { preferredPace: 'fast' };
expect(
resolveEffectivePreference(override, userPref, appDefault).preferredPace,
).toBe('fast');
});
it('merges field-by-field rather than replacing the whole object', () => {
const userPref = {
...appDefault,
preferredPace: 'relaxed',
childFriendlyPreferred: true,
};
const override = { preferredPace: 'fast' };
const result = resolveEffectivePreference(override, userPref, appDefault);
expect(result.preferredPace).toBe('fast');
expect(result.childFriendlyPreferred).toBe(true);
});
});

View File

@@ -0,0 +1,14 @@
import type { UserPreferenceFields } from '../../users/src';
/**
* Precedence: trip override > persistent user preference > application default.
* Merges field-by-field so an override touching only one field never masks the
* caller's other persisted preferences.
*/
export function resolveEffectivePreference(
override: Partial<UserPreferenceFields> | undefined,
userPreference: UserPreferenceFields | undefined,
appDefault: UserPreferenceFields,
): UserPreferenceFields {
return { ...appDefault, ...userPreference, ...override };
}

View File

@@ -0,0 +1,97 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import type {
CreateTravelerDto,
Traveler,
TravelerType,
UpdateTravelerDto,
} from './trip.types';
function toTraveler(row: {
id: string;
trip_id: string;
linked_user_id: string | null;
display_name: string;
traveler_type: string;
created_by_user_id: string;
created_at: Date;
updated_at: Date;
}): Traveler {
return {
id: row.id,
tripId: row.trip_id,
linkedUserId: row.linked_user_id,
displayName: row.display_name,
travelerType: row.traveler_type as TravelerType,
createdByUserId: row.created_by_user_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
@Injectable()
export class TravelersRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async create(
tripId: string,
dto: CreateTravelerDto,
createdByUserId: string,
): Promise<Traveler> {
const row = await this.db
.insertInto('travelers')
.values({
trip_id: tripId,
linked_user_id: dto.linkedUserId ?? null,
display_name: dto.displayName,
traveler_type: dto.travelerType,
created_by_user_id: createdByUserId,
})
.returningAll()
.executeTakeFirstOrThrow();
return toTraveler(row);
}
async listByTrip(tripId: string): Promise<Traveler[]> {
const rows = await this.db
.selectFrom('travelers')
.selectAll()
.where('trip_id', '=', tripId)
.execute();
return rows.map(toTraveler);
}
async update(
tripId: string,
travelerId: string,
dto: UpdateTravelerDto,
): Promise<Traveler | undefined> {
const patch: {
display_name?: string;
traveler_type?: TravelerType;
linked_user_id?: string | null;
} = {};
if (dto.displayName !== undefined) patch.display_name = dto.displayName;
if (dto.travelerType !== undefined) patch.traveler_type = dto.travelerType;
if (dto.linkedUserId !== undefined) patch.linked_user_id = dto.linkedUserId;
const row = await this.db
.updateTable('travelers')
.set({ ...patch, updated_at: new Date().toISOString() })
.where('trip_id', '=', tripId)
.where('id', '=', travelerId)
.returningAll()
.executeTakeFirst();
return row ? toTraveler(row) : undefined;
}
async remove(tripId: string, travelerId: string): Promise<void> {
await this.db
.deleteFrom('travelers')
.where('trip_id', '=', tripId)
.where('id', '=', travelerId)
.execute();
}
}

View File

@@ -0,0 +1,56 @@
import { TravelersService } from './travelers.service';
describe('TripMember vs Traveler', () => {
it('creating a traveler does not create or require a trip_members row', async () => {
const travelersRepo = {
create: jest.fn().mockResolvedValue({
id: 'trav-1',
tripId: 't1',
linkedUserId: null,
displayName: 'Mila',
travelerType: 'CHILD',
createdByUserId: 'u1',
}),
};
const membersRepo = { create: jest.fn(), findByTripAndUser: jest.fn() };
const service = new TravelersService(travelersRepo as never);
const traveler = await service.createTraveler(
't1',
{ displayName: 'Mila', travelerType: 'CHILD' },
'u1',
);
expect(traveler.linkedUserId).toBeNull();
expect(membersRepo.create).not.toHaveBeenCalled();
expect(membersRepo.findByTripAndUser).not.toHaveBeenCalled();
});
it('a Traveler can be linked to a user who is independently a TripMember, without either row implying the other', async () => {
const travelersRepo = {
create: jest.fn().mockResolvedValue({
id: 'trav-2',
tripId: 't1',
linkedUserId: 'u2',
displayName: 'Alex',
travelerType: 'ADULT',
createdByUserId: 'u1',
}),
};
const service = new TravelersService(travelersRepo as never);
const traveler = await service.createTraveler(
't1',
{ displayName: 'Alex', travelerType: 'ADULT', linkedUserId: 'u2' },
'u1',
);
// linkedUserId is informational only; TripMembershipGuard never consults the travelers table.
expect(traveler.linkedUserId).toBe('u2');
expect(travelersRepo.create).toHaveBeenCalledWith(
't1',
expect.objectContaining({ linkedUserId: 'u2' }),
'u1',
);
});
});

View File

@@ -0,0 +1,38 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TravelersRepository } from './travelers.repository';
import type {
CreateTravelerDto,
Traveler,
UpdateTravelerDto,
} from './trip.types';
@Injectable()
export class TravelersService {
constructor(private readonly repository: TravelersRepository) {}
createTraveler(
tripId: string,
dto: CreateTravelerDto,
createdByUserId: string,
): Promise<Traveler> {
return this.repository.create(tripId, dto, createdByUserId);
}
listTravelers(tripId: string): Promise<Traveler[]> {
return this.repository.listByTrip(tripId);
}
async updateTraveler(
tripId: string,
travelerId: string,
dto: UpdateTravelerDto,
): Promise<Traveler> {
const updated = await this.repository.update(tripId, travelerId, dto);
if (!updated) throw new NotFoundException('Traveler not found');
return updated;
}
removeTraveler(tripId: string, travelerId: string): Promise<void> {
return this.repository.remove(tripId, travelerId);
}
}

View File

@@ -0,0 +1,91 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import type { CreateTripInvitationFields, TripInvitation } from './trip.types';
function toTripInvitation(row: {
id: string;
trip_id: string;
email: string;
invited_by_user_id: string;
token_hash: string;
expires_at: Date;
accepted_at: Date | null;
created_at: Date;
updated_at: Date;
}): TripInvitation {
return {
id: row.id,
tripId: row.trip_id,
email: row.email,
invitedByUserId: row.invited_by_user_id,
tokenHash: row.token_hash,
expiresAt: row.expires_at,
acceptedAt: row.accepted_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
@Injectable()
export class TripInvitationsRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async create(
tripId: string,
fields: CreateTripInvitationFields,
): Promise<TripInvitation> {
const row = await this.db
.insertInto('trip_invitations')
.values({
trip_id: tripId,
email: fields.email,
invited_by_user_id: fields.invitedByUserId,
token_hash: fields.tokenHash,
expires_at: fields.expiresAt.toISOString(),
})
.returningAll()
.executeTakeFirstOrThrow();
return toTripInvitation(row);
}
async findByTokenHash(
tokenHash: string,
): Promise<TripInvitation | undefined> {
const row = await this.db
.selectFrom('trip_invitations')
.selectAll()
.where('token_hash', '=', tokenHash)
.executeTakeFirst();
return row ? toTripInvitation(row) : undefined;
}
async listByTrip(tripId: string): Promise<TripInvitation[]> {
const rows = await this.db
.selectFrom('trip_invitations')
.selectAll()
.where('trip_id', '=', tripId)
.execute();
return rows.map(toTripInvitation);
}
async markAccepted(invitationId: string): Promise<void> {
await this.db
.updateTable('trip_invitations')
.set({
accepted_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.where('id', '=', invitationId)
.execute();
}
async remove(tripId: string, invitationId: string): Promise<void> {
await this.db
.deleteFrom('trip_invitations')
.where('trip_id', '=', tripId)
.where('id', '=', invitationId)
.execute();
}
}

View File

@@ -0,0 +1,119 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { TripInvitationsService } from './trip-invitations.service';
describe('TripInvitationsService.acceptInvitation', () => {
const future = new Date(Date.now() + 60_000);
const past = new Date(Date.now() - 60_000);
it('rejects accepting the same invitation twice', async () => {
const repo = {
findByTokenHash: jest.fn().mockResolvedValue({
id: 'inv-1',
tripId: 't1',
expiresAt: future,
acceptedAt: new Date(),
}),
};
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
await expect(
service.acceptInvitation('raw-token', 'user-3'),
).rejects.toThrow(ConflictException);
expect(members.upsertActiveMember).not.toHaveBeenCalled();
});
it('rejects an expired invitation', async () => {
const repo = {
findByTokenHash: jest.fn().mockResolvedValue({
id: 'inv-1',
tripId: 't1',
expiresAt: past,
acceptedAt: null,
}),
};
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
await expect(
service.acceptInvitation('raw-token', 'user-2'),
).rejects.toThrow(BadRequestException);
expect(members.upsertActiveMember).not.toHaveBeenCalled();
});
it('rejects an unknown token without leaking whether the trip exists', async () => {
const repo = { findByTokenHash: jest.fn().mockResolvedValue(undefined) };
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
await expect(
service.acceptInvitation('does-not-exist', 'user-2'),
).rejects.toThrow(NotFoundException);
});
it('accepts a fresh, unexpired invitation and activates trip membership', async () => {
const repo = {
findByTokenHash: jest.fn().mockResolvedValue({
id: 'inv-1',
tripId: 't1',
expiresAt: future,
acceptedAt: null,
}),
markAccepted: jest.fn().mockResolvedValue(undefined),
};
const members = {
upsertActiveMember: jest
.fn()
.mockResolvedValue({ id: 'm1', tripId: 't1', userId: 'user-2' }),
};
const service = new TripInvitationsService(repo as never, members as never);
const member = await service.acceptInvitation('raw-token', 'user-2');
expect(repo.markAccepted).toHaveBeenCalledWith('inv-1');
expect(members.upsertActiveMember).toHaveBeenCalledWith(
't1',
'user-2',
'MEMBER',
);
expect(member).toEqual({ id: 'm1', tripId: 't1', userId: 'user-2' });
});
});
describe('TripInvitationsService.createInvitation', () => {
it('stores only a hash of the generated token and returns the raw token once', async () => {
const repo = {
create: jest
.fn()
.mockImplementation((tripId: string, fields: Record<string, unknown>) =>
Promise.resolve({ id: 'inv-1', tripId, ...fields }),
),
};
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
const result = await service.createInvitation(
't1',
'owner-1',
'friend@example.com',
);
expect(result.rawToken).toEqual(expect.any(String));
expect(repo.create).toHaveBeenCalledWith(
't1',
expect.objectContaining({
email: 'friend@example.com',
invitedByUserId: 'owner-1',
}),
);
const [, fields] = repo.create.mock.calls[0] as [
string,
{ tokenHash: string },
];
expect(fields.tokenHash).not.toBe(result.rawToken);
});
});

View File

@@ -0,0 +1,69 @@
import { randomBytes, createHash } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TripInvitationsRepository } from './trip-invitations.repository';
import { TripMembersRepository } from './trip-members.repository';
import type { TripInvitation, TripMember } from './trip.types';
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function hashToken(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
@Injectable()
export class TripInvitationsService {
constructor(
private readonly invitationsRepository: TripInvitationsRepository,
private readonly membersRepository: TripMembersRepository,
) {}
async createInvitation(
tripId: string,
invitedByUserId: string,
email: string,
ttlMs = DEFAULT_TTL_MS,
): Promise<{ invitation: TripInvitation; rawToken: string }> {
const rawToken = randomBytes(32).toString('base64url');
const invitation = await this.invitationsRepository.create(tripId, {
email,
invitedByUserId,
tokenHash: hashToken(rawToken),
expiresAt: new Date(Date.now() + ttlMs),
});
return { invitation, rawToken };
}
listInvitations(tripId: string): Promise<TripInvitation[]> {
return this.invitationsRepository.listByTrip(tripId);
}
removeInvitation(tripId: string, invitationId: string): Promise<void> {
return this.invitationsRepository.remove(tripId, invitationId);
}
async acceptInvitation(
rawToken: string,
acceptingUserId: string,
): Promise<TripMember> {
const invitation = await this.invitationsRepository.findByTokenHash(
hashToken(rawToken),
);
if (!invitation) throw new NotFoundException('Invitation not found');
if (invitation.acceptedAt)
throw new ConflictException('Invitation has already been accepted');
if (invitation.expiresAt.getTime() < Date.now())
throw new BadRequestException('Invitation has expired');
await this.invitationsRepository.markAccepted(invitation.id);
return this.membersRepository.upsertActiveMember(
invitation.tripId,
acceptingUserId,
'MEMBER',
);
}
}

View File

@@ -0,0 +1,108 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import type {
TripMember,
TripMemberRole,
TripMemberStatus,
} from './trip.types';
function toTripMember(row: {
id: string;
trip_id: string;
user_id: string;
role: string;
status: string;
joined_at: Date | null;
created_at: Date;
updated_at: Date;
}): TripMember {
return {
id: row.id,
tripId: row.trip_id,
userId: row.user_id,
role: row.role as TripMemberRole,
status: row.status as TripMemberStatus,
joinedAt: row.joined_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
@Injectable()
export class TripMembersRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async findByTripAndUser(
tripId: string,
userId: string,
): Promise<TripMember | undefined> {
const row = await this.db
.selectFrom('trip_members')
.selectAll()
.where('trip_id', '=', tripId)
.where('user_id', '=', userId)
.executeTakeFirst();
return row ? toTripMember(row) : undefined;
}
async listByTrip(tripId: string): Promise<TripMember[]> {
const rows = await this.db
.selectFrom('trip_members')
.selectAll()
.where('trip_id', '=', tripId)
.execute();
return rows.map(toTripMember);
}
async upsertActiveMember(
tripId: string,
userId: string,
role: TripMemberRole = 'MEMBER',
): Promise<TripMember> {
const row = await this.db
.insertInto('trip_members')
.values({
trip_id: tripId,
user_id: userId,
role,
status: 'ACTIVE',
joined_at: new Date().toISOString(),
})
.onConflict((oc) =>
oc.columns(['trip_id', 'user_id']).doUpdateSet({
status: 'ACTIVE',
joined_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
return toTripMember(row);
}
async updateRoleOrStatus(
tripId: string,
memberId: string,
patch: { role?: TripMemberRole; status?: TripMemberStatus },
): Promise<TripMember | undefined> {
const row = await this.db
.updateTable('trip_members')
.set({ ...patch, updated_at: new Date().toISOString() })
.where('trip_id', '=', tripId)
.where('id', '=', memberId)
.returningAll()
.executeTakeFirst();
return row ? toTripMember(row) : undefined;
}
async remove(tripId: string, memberId: string): Promise<void> {
await this.db
.deleteFrom('trip_members')
.where('trip_id', '=', tripId)
.where('id', '=', memberId)
.execute();
}
}

View File

@@ -0,0 +1,34 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TripMembersRepository } from './trip-members.repository';
import type {
TripMember,
TripMemberRole,
TripMemberStatus,
} from './trip.types';
@Injectable()
export class TripMembersService {
constructor(private readonly repository: TripMembersRepository) {}
listMembers(tripId: string): Promise<TripMember[]> {
return this.repository.listByTrip(tripId);
}
async updateMember(
tripId: string,
memberId: string,
patch: { role?: TripMemberRole; status?: TripMemberStatus },
): Promise<TripMember> {
const updated = await this.repository.updateRoleOrStatus(
tripId,
memberId,
patch,
);
if (!updated) throw new NotFoundException('Trip member not found');
return updated;
}
removeMember(tripId: string, memberId: string): Promise<void> {
return this.repository.remove(tripId, memberId);
}
}

View File

@@ -0,0 +1,94 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { TripMembershipGuard } from './trip-membership.guard';
describe('TripMembershipGuard', () => {
function context(
params: Record<string, string>,
user: { id: string },
): ExecutionContext {
const req = { params, user };
return {
switchToHttp: () => ({ getRequest: () => req }),
getHandler: () => ({}),
getClass: () => ({}),
} as unknown as ExecutionContext;
}
it('denies a user who has no trip_members row for the trip', async () => {
const members = {
findByTripAndUser: jest.fn().mockResolvedValue(undefined),
};
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(undefined),
};
const guard = new TripMembershipGuard(members as never, reflector as never);
await expect(
guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })),
).rejects.toThrow(ForbiddenException);
});
it('denies an ACTIVE MEMBER when the route requires OWNER', async () => {
const members = {
findByTripAndUser: jest
.fn()
.mockResolvedValue({ role: 'MEMBER', status: 'ACTIVE' }),
};
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(['OWNER']),
};
const guard = new TripMembershipGuard(members as never, reflector as never);
await expect(
guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })),
).rejects.toThrow(ForbiddenException);
});
it('denies an INVITED (not yet ACTIVE) member', async () => {
const members = {
findByTripAndUser: jest
.fn()
.mockResolvedValue({ role: 'MEMBER', status: 'INVITED' }),
};
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(undefined),
};
const guard = new TripMembershipGuard(members as never, reflector as never);
await expect(
guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })),
).rejects.toThrow(ForbiddenException);
});
it('allows an ACTIVE OWNER through an OWNER-only route', async () => {
const members = {
findByTripAndUser: jest
.fn()
.mockResolvedValue({ role: 'OWNER', status: 'ACTIVE' }),
};
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(['OWNER']),
};
const guard = new TripMembershipGuard(members as never, reflector as never);
await expect(
guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })),
).resolves.toBe(true);
});
it('allows an ACTIVE MEMBER through a route with no role restriction', async () => {
const members = {
findByTripAndUser: jest
.fn()
.mockResolvedValue({ role: 'MEMBER', status: 'ACTIVE' }),
};
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(undefined),
};
const guard = new TripMembershipGuard(members as never, reflector as never);
await expect(
guard.canActivate(context({ tripId: 't1' }, { id: 'u1' })),
).resolves.toBe(true);
});
});

View File

@@ -0,0 +1,59 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TripMembersRepository } from './trip-members.repository';
import { TRIP_ROLES_KEY } from './trip-roles.decorator';
import type { TripRole } from './trip-roles.decorator';
interface RequestWithTripMembership {
params: Record<string, string>;
user: { id: string };
tripMembership?: { role: string; status: string };
}
@Injectable()
export class TripMembershipGuard implements CanActivate {
constructor(
private readonly tripMembers: TripMembersRepository,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<
TripRole[] | undefined
>(TRIP_ROLES_KEY, [context.getHandler(), context.getClass()]);
const request = context
.switchToHttp()
.getRequest<RequestWithTripMembership>();
const tripId = request.params.tripId;
const membership = await this.tripMembers.findByTripAndUser(
tripId,
request.user.id,
);
if (!membership || membership.status !== 'ACTIVE') {
throw new ForbiddenException('You are not an active member of this trip');
}
if (
requiredRoles &&
requiredRoles.length > 0 &&
!requiredRoles.includes(membership.role)
) {
throw new ForbiddenException(
'You do not have the required role for this action',
);
}
request.tripMembership = {
role: membership.role,
status: membership.status,
};
return true;
}
}

View File

@@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import type { PreferenceSubject, TripPreferenceOverride } from './trip.types';
function toOverride(row: {
id: string;
trip_id: string;
user_id: string | null;
traveler_id: string | null;
overrides: unknown;
created_at: Date;
updated_at: Date;
}): TripPreferenceOverride {
return {
id: row.id,
tripId: row.trip_id,
userId: row.user_id,
travelerId: row.traveler_id,
overrides: (row.overrides ?? {}) as Record<string, unknown>,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
@Injectable()
export class TripPreferenceOverridesRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async listByTrip(tripId: string): Promise<TripPreferenceOverride[]> {
const rows = await this.db
.selectFrom('trip_preference_overrides')
.selectAll()
.where('trip_id', '=', tripId)
.execute();
return rows.map(toOverride);
}
async upsert(
tripId: string,
subject: PreferenceSubject,
overrides: Record<string, unknown>,
): Promise<TripPreferenceOverride> {
const userId = subject.type === 'USER' ? subject.userId : null;
const travelerId = subject.type === 'TRAVELER' ? subject.travelerId : null;
const row = await this.db
.insertInto('trip_preference_overrides')
.values({
trip_id: tripId,
user_id: userId,
traveler_id: travelerId,
overrides: overrides,
})
.onConflict((oc) =>
(subject.type === 'USER'
? oc.columns(['trip_id', 'user_id']).where('user_id', 'is not', null)
: oc
.columns(['trip_id', 'traveler_id'])
.where('traveler_id', 'is not', null)
).doUpdateSet({
overrides: overrides,
updated_at: new Date().toISOString(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
return toOverride(row);
}
async remove(tripId: string, overrideId: string): Promise<void> {
await this.db
.deleteFrom('trip_preference_overrides')
.where('trip_id', '=', tripId)
.where('id', '=', overrideId)
.execute();
}
}

View File

@@ -0,0 +1,23 @@
import { TripPreferenceOverridesService } from './trip-preference-overrides.service';
describe('TripPreferenceOverridesService', () => {
it('forwards the subject and overrides to the repository', async () => {
const created = {
id: 'o1',
tripId: 't1',
userId: 'u1',
travelerId: null,
overrides: { preferredPace: 'fast' },
};
const repository = { upsert: jest.fn().mockResolvedValue(created) };
const service = new TripPreferenceOverridesService(repository as never);
const subject = { type: 'USER' as const, userId: 'u1' };
await expect(
service.upsertOverride('t1', subject, { preferredPace: 'fast' }),
).resolves.toEqual(created);
expect(repository.upsert).toHaveBeenCalledWith('t1', subject, {
preferredPace: 'fast',
});
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { TripPreferenceOverridesRepository } from './trip-preference-overrides.repository';
import type { PreferenceSubject, TripPreferenceOverride } from './trip.types';
@Injectable()
export class TripPreferenceOverridesService {
constructor(private readonly repository: TripPreferenceOverridesRepository) {}
listOverrides(tripId: string): Promise<TripPreferenceOverride[]> {
return this.repository.listByTrip(tripId);
}
upsertOverride(
tripId: string,
subject: PreferenceSubject,
overrides: Record<string, unknown>,
): Promise<TripPreferenceOverride> {
return this.repository.upsert(tripId, subject, overrides);
}
removeOverride(tripId: string, overrideId: string): Promise<void> {
return this.repository.remove(tripId, overrideId);
}
}

View File

@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export type TripRole = 'OWNER' | 'MEMBER';
export const TRIP_ROLES_KEY = 'tripRoles';
export const TripRoles = (...roles: TripRole[]) =>
SetMetadata(TRIP_ROLES_KEY, roles);

View File

@@ -0,0 +1,67 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import type {
ResearchDepth,
TripPlanningStyle,
TripSettings,
UpdateTripSettingsDto,
} from './trip.types';
function toTripSettings(row: {
trip_id: string;
web_research_enabled: boolean;
periodic_agent_review_enabled: boolean;
notification_email_enabled: boolean;
notification_push_enabled: boolean;
default_research_depth: string | null;
default_planning_style: string | null;
}): TripSettings {
return {
tripId: row.trip_id,
webResearchEnabled: row.web_research_enabled,
periodicAgentReviewEnabled: row.periodic_agent_review_enabled,
notificationEmailEnabled: row.notification_email_enabled,
notificationPushEnabled: row.notification_push_enabled,
defaultResearchDepth: row.default_research_depth as ResearchDepth | null,
defaultPlanningStyle:
row.default_planning_style as TripPlanningStyle | null,
};
}
@Injectable()
export class TripSettingsRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async findByTripId(tripId: string): Promise<TripSettings | undefined> {
const row = await this.db
.selectFrom('trip_settings')
.selectAll()
.where('trip_id', '=', tripId)
.executeTakeFirst();
return row ? toTripSettings(row) : undefined;
}
async replace(
tripId: string,
dto: UpdateTripSettingsDto,
): Promise<TripSettings> {
const row = await this.db
.updateTable('trip_settings')
.set({
web_research_enabled: dto.webResearchEnabled,
periodic_agent_review_enabled: dto.periodicAgentReviewEnabled,
notification_email_enabled: dto.notificationEmailEnabled,
notification_push_enabled: dto.notificationPushEnabled,
default_research_depth: dto.defaultResearchDepth,
default_planning_style: dto.defaultPlanningStyle,
updated_at: new Date().toISOString(),
})
.where('trip_id', '=', tripId)
.returningAll()
.executeTakeFirstOrThrow();
return toTripSettings(row);
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TripSettingsRepository } from './trip-settings.repository';
import type { TripSettings, UpdateTripSettingsDto } from './trip.types';
@Injectable()
export class TripSettingsService {
constructor(private readonly repository: TripSettingsRepository) {}
async getSettings(tripId: string): Promise<TripSettings> {
const settings = await this.repository.findByTripId(tripId);
if (!settings) throw new NotFoundException('Trip settings not found');
return settings;
}
replaceSettings(
tripId: string,
dto: UpdateTripSettingsDto,
): Promise<TripSettings> {
return this.repository.replace(tripId, dto);
}
}

View File

@@ -0,0 +1,150 @@
export type TripStatus =
| 'DRAFT'
| 'PLANNING'
| 'BOOKING'
| 'UPCOMING'
| 'ACTIVE'
| 'COMPLETED'
| 'ARCHIVED';
export interface Trip {
id: string;
name: string;
description: string | null;
ownerId: string;
startDate: string | null;
endDate: string | null;
status: TripStatus;
planningStage: string | null;
currency: string;
version: number;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTripDto {
name: string;
description?: string;
startDate?: string;
endDate?: string;
currency?: string;
}
export interface UpdateTripDto {
name?: string;
description?: string | null;
startDate?: string | null;
endDate?: string | null;
status?: TripStatus;
planningStage?: string | null;
currency?: string;
/** The caller's last-known version; required so stale concurrent writes are rejected. */
version: number;
}
export type TripPlanningStyle = 'RELAXED' | 'BALANCED' | 'PACKED';
export type ResearchDepth = 'MINIMAL' | 'STANDARD' | 'THOROUGH';
export interface TripSettings {
tripId: string;
webResearchEnabled: boolean;
periodicAgentReviewEnabled: boolean;
notificationEmailEnabled: boolean;
notificationPushEnabled: boolean;
defaultResearchDepth: ResearchDepth | null;
defaultPlanningStyle: TripPlanningStyle | null;
}
export interface UpdateTripSettingsDto {
webResearchEnabled: boolean;
periodicAgentReviewEnabled: boolean;
notificationEmailEnabled: boolean;
notificationPushEnabled: boolean;
defaultResearchDepth: ResearchDepth | null;
defaultPlanningStyle: TripPlanningStyle | null;
}
export type TripMemberRole = 'OWNER' | 'MEMBER';
export type TripMemberStatus = 'INVITED' | 'ACTIVE' | 'DECLINED';
export interface TripMember {
id: string;
tripId: string;
userId: string;
role: TripMemberRole;
status: TripMemberStatus;
joinedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface TripInvitation {
id: string;
tripId: string;
email: string;
invitedByUserId: string;
tokenHash: string;
expiresAt: Date;
acceptedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTripInvitationFields {
email: string;
invitedByUserId: string;
tokenHash: string;
expiresAt: Date;
}
export type TravelerType = 'ADULT' | 'CHILD' | 'INFANT';
export interface Traveler {
id: string;
tripId: string;
linkedUserId: string | null;
displayName: string;
travelerType: TravelerType;
createdByUserId: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTravelerDto {
displayName: string;
travelerType: TravelerType;
linkedUserId?: string;
}
export interface UpdateTravelerDto {
displayName?: string;
travelerType?: TravelerType;
linkedUserId?: string | null;
}
export type PreferenceSubject =
{ type: 'USER'; userId: string } | { type: 'TRAVELER'; travelerId: string };
export interface TripPreferenceOverride {
id: string;
tripId: string;
userId: string | null;
travelerId: string | null;
overrides: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface UpsertPreferenceOverrideDto {
subject: PreferenceSubject;
overrides: Record<string, unknown>;
}
export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
webResearchEnabled: false,
periodicAgentReviewEnabled: false,
notificationEmailEnabled: true,
notificationPushEnabled: true,
defaultResearchDepth: null,
defaultPlanningStyle: null,
};

View File

@@ -0,0 +1,51 @@
import { Global, Module } from '@nestjs/common';
import { DatabaseModule } from '../../database/src';
import { TripsRepository } from './trips.repository';
import { TripsService } from './trips.service';
import { TripSettingsRepository } from './trip-settings.repository';
import { TripSettingsService } from './trip-settings.service';
import { TripMembersRepository } from './trip-members.repository';
import { TripMembersService } from './trip-members.service';
import { TripMembershipGuard } from './trip-membership.guard';
import { TripInvitationsRepository } from './trip-invitations.repository';
import { TripInvitationsService } from './trip-invitations.service';
import { TravelersRepository } from './travelers.repository';
import { TravelersService } from './travelers.service';
import { TripPreferenceOverridesRepository } from './trip-preference-overrides.repository';
import { TripPreferenceOverridesService } from './trip-preference-overrides.service';
@Global()
@Module({
imports: [DatabaseModule],
providers: [
TripsRepository,
TripsService,
TripSettingsRepository,
TripSettingsService,
TripMembersRepository,
TripMembersService,
TripMembershipGuard,
TripInvitationsRepository,
TripInvitationsService,
TravelersRepository,
TravelersService,
TripPreferenceOverridesRepository,
TripPreferenceOverridesService,
],
exports: [
TripsRepository,
TripsService,
TripSettingsRepository,
TripSettingsService,
TripMembersRepository,
TripMembersService,
TripMembershipGuard,
TripInvitationsRepository,
TripInvitationsService,
TravelersRepository,
TravelersService,
TripPreferenceOverridesRepository,
TripPreferenceOverridesService,
],
})
export class TripsLibModule {}

View File

@@ -0,0 +1,141 @@
import { Inject, Injectable } from '@nestjs/common';
import { sql } from 'kysely';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import { DEFAULT_TRIP_SETTINGS } from './trip.types';
import type { CreateTripDto, Trip, TripStatus } from './trip.types';
function toTrip(row: {
id: string;
name: string;
description: string | null;
owner_id: string;
start_date: string | null;
end_date: string | null;
status: string;
planning_stage: string | null;
currency: string;
version: number;
created_at: Date;
updated_at: Date;
}): Trip {
return {
id: row.id,
name: row.name,
description: row.description,
ownerId: row.owner_id,
startDate: row.start_date,
endDate: row.end_date,
status: row.status as TripStatus,
planningStage: row.planning_stage,
currency: row.currency,
version: row.version,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export interface TripUpdateFields {
name: string;
description: string | null;
start_date: string | null;
end_date: string | null;
status: string;
planning_stage: string | null;
currency: string;
}
@Injectable()
export class TripsRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async createTrip(ownerId: string, dto: CreateTripDto): Promise<Trip> {
return this.db.transaction().execute(async (trx) => {
const trip = await trx
.insertInto('trips')
.values({
name: dto.name,
description: dto.description ?? null,
owner_id: ownerId,
start_date: dto.startDate ?? null,
end_date: dto.endDate ?? null,
status: 'DRAFT',
currency: dto.currency ?? 'EUR',
})
.returningAll()
.executeTakeFirstOrThrow();
await trx
.insertInto('trip_settings')
.values({
trip_id: trip.id,
web_research_enabled: DEFAULT_TRIP_SETTINGS.webResearchEnabled,
periodic_agent_review_enabled:
DEFAULT_TRIP_SETTINGS.periodicAgentReviewEnabled,
notification_email_enabled:
DEFAULT_TRIP_SETTINGS.notificationEmailEnabled,
notification_push_enabled:
DEFAULT_TRIP_SETTINGS.notificationPushEnabled,
})
.execute();
await trx
.insertInto('trip_members')
.values({
trip_id: trip.id,
user_id: ownerId,
role: 'OWNER',
status: 'ACTIVE',
joined_at: new Date().toISOString(),
})
.execute();
return toTrip(trip);
});
}
async findById(tripId: string): Promise<Trip | undefined> {
const row = await this.db
.selectFrom('trips')
.selectAll()
.where('id', '=', tripId)
.executeTakeFirst();
return row ? toTrip(row) : undefined;
}
async listForUser(userId: string): Promise<Trip[]> {
const rows = await this.db
.selectFrom('trips')
.innerJoin('trip_members', 'trip_members.trip_id', 'trips.id')
.selectAll('trips')
.where('trip_members.user_id', '=', userId)
.where('trip_members.status', '=', 'ACTIVE')
.execute();
return rows.map(toTrip);
}
async updateWithVersionCheck(
tripId: string,
expectedVersion: number,
patch: Partial<TripUpdateFields>,
): Promise<Trip | undefined> {
const row = await this.db
.updateTable('trips')
.set({
...patch,
version: sql`version + 1`,
updated_at: new Date().toISOString(),
})
.where('id', '=', tripId)
.where('version', '=', expectedVersion)
.returningAll()
.executeTakeFirst();
return row ? toTrip(row) : undefined;
}
async delete(tripId: string): Promise<void> {
await this.db.deleteFrom('trips').where('id', '=', tripId).execute();
}
}

View File

@@ -0,0 +1,33 @@
import { ConflictException } from '@nestjs/common';
import { TripsService } from './trips.service';
describe('TripsService.updateTrip', () => {
it('throws ConflictException when the repository update matches no row', async () => {
const repo = {
updateWithVersionCheck: jest.fn().mockResolvedValue(undefined),
};
const service = new TripsService(repo as never);
await expect(
service.updateTrip('trip-1', { name: 'New name', version: 1 }),
).rejects.toThrow(ConflictException);
expect(repo.updateWithVersionCheck).toHaveBeenCalledWith(
'trip-1',
1,
expect.objectContaining({ name: 'New name' }),
);
});
it('returns the updated trip when the version matches', async () => {
const updated = { id: 'trip-1', name: 'New name', version: 2 };
const repo = {
updateWithVersionCheck: jest.fn().mockResolvedValue(updated),
};
const service = new TripsService(repo as never);
await expect(
service.updateTrip('trip-1', { name: 'New name', version: 1 }),
).resolves.toEqual(updated);
});
});

View File

@@ -0,0 +1,55 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TripsRepository } from './trips.repository';
import type { TripUpdateFields } from './trips.repository';
import type { CreateTripDto, Trip, UpdateTripDto } from './trip.types';
@Injectable()
export class TripsService {
constructor(private readonly tripsRepository: TripsRepository) {}
createTrip(ownerId: string, dto: CreateTripDto): Promise<Trip> {
return this.tripsRepository.createTrip(ownerId, dto);
}
listTripsForUser(userId: string): Promise<Trip[]> {
return this.tripsRepository.listForUser(userId);
}
async getTrip(tripId: string): Promise<Trip> {
const trip = await this.tripsRepository.findById(tripId);
if (!trip) throw new NotFoundException('Trip not found');
return trip;
}
async updateTrip(tripId: string, dto: UpdateTripDto): Promise<Trip> {
const patch: Partial<TripUpdateFields> = {};
if (dto.name !== undefined) patch.name = dto.name;
if (dto.description !== undefined) patch.description = dto.description;
if (dto.startDate !== undefined) patch.start_date = dto.startDate;
if (dto.endDate !== undefined) patch.end_date = dto.endDate;
if (dto.status !== undefined) patch.status = dto.status;
if (dto.planningStage !== undefined)
patch.planning_stage = dto.planningStage;
if (dto.currency !== undefined) patch.currency = dto.currency;
const updated = await this.tripsRepository.updateWithVersionCheck(
tripId,
dto.version,
patch,
);
if (!updated) {
throw new ConflictException(
'Trip was modified by someone else. Reload and retry.',
);
}
return updated;
}
async deleteTrip(tripId: string): Promise<void> {
await this.tripsRepository.delete(tripId);
}
}

View File

@@ -0,0 +1,55 @@
import { ConflictException } from '@nestjs/common';
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
import type { Database } from '../../database/src';
import { TripsRepository } from '../src/trips.repository';
import { TripsService } from '../src/trips.service';
import { UsersRepository } from '../../users/src/users.repository';
describe('Trip optimistic locking (integration)', () => {
let db: Kysely<Database>;
let tripsService: TripsService;
let ownerId: string;
beforeAll(async () => {
db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL }),
}),
});
const usersRepository = new UsersRepository(db);
const owner = await usersRepository.upsertByExternalSubjectId(
'optimistic-locking-test-sub',
{
email: 'owner@example.test',
displayName: 'Owner',
},
);
ownerId = owner.id;
tripsService = new TripsService(new TripsRepository(db));
});
afterAll(async () => {
await db.destroy();
});
it('rejects a second update that used a stale version', async () => {
const trip = await tripsService.createTrip(ownerId, {
name: 'Slovenia 2027',
});
expect(trip.version).toBe(1);
const firstUpdate = await tripsService.updateTrip(trip.id, {
name: 'Slovenia 2027 (v2)',
version: 1,
});
expect(firstUpdate.version).toBe(2);
await expect(
tripsService.updateTrip(trip.id, {
name: 'Conflicting concurrent edit',
version: 1,
}),
).rejects.toThrow(ConflictException);
});
});

View File

@@ -0,0 +1,4 @@
export * from './user.types';
export * from './users.service';
export * from './user-preferences.service';
export * from './users.module';

View File

@@ -0,0 +1,72 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Kysely } from 'kysely';
import { KYSELY_DB } from '../../database/src';
import type { Database } from '../../database/src';
import type { UserPreference, UserPreferenceFields } from './user.types';
function toUserPreference(row: {
user_id: string;
preferred_pace: string | null;
preferred_budget_level: string | null;
max_walking_distance_km: string | number | null;
preferred_start_time: string | null;
child_friendly_preferred: boolean;
interests: string[];
notes: string | null;
}): UserPreference {
return {
userId: row.user_id,
preferredPace: row.preferred_pace,
preferredBudgetLevel: row.preferred_budget_level,
maxWalkingDistanceKm:
row.max_walking_distance_km === null
? null
: Number(row.max_walking_distance_km),
preferredStartTime: row.preferred_start_time,
childFriendlyPreferred: row.child_friendly_preferred,
interests: row.interests,
notes: row.notes,
};
}
@Injectable()
export class UserPreferencesRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async findByUserId(userId: string): Promise<UserPreference | undefined> {
const row = await this.db
.selectFrom('user_preferences')
.selectAll()
.where('user_id', '=', userId)
.executeTakeFirst();
return row ? toUserPreference(row) : undefined;
}
async upsert(
userId: string,
fields: UserPreferenceFields,
): Promise<UserPreference> {
const values = {
preferred_pace: fields.preferredPace,
preferred_budget_level: fields.preferredBudgetLevel,
max_walking_distance_km: fields.maxWalkingDistanceKm,
preferred_start_time: fields.preferredStartTime,
child_friendly_preferred: fields.childFriendlyPreferred,
interests: fields.interests,
notes: fields.notes,
};
const row = await this.db
.insertInto('user_preferences')
.values({ user_id: userId, ...values })
.onConflict((oc) =>
oc
.column('user_id')
.doUpdateSet({ ...values, updated_at: new Date().toISOString() }),
)
.returningAll()
.executeTakeFirstOrThrow();
return toUserPreference(row);
}
}

View File

@@ -0,0 +1,60 @@
import { UserPreferencesService } from './user-preferences.service';
import { DEFAULT_USER_PREFERENCE } from './user.types';
import type { UpdateUserPreferenceDto } from './user.types';
describe('UserPreferencesService', () => {
describe('getOrDefault', () => {
it('returns the stored preference when one exists', async () => {
const stored = {
userId: 'u1',
...DEFAULT_USER_PREFERENCE,
preferredPace: 'relaxed',
};
const repo = { findByUserId: jest.fn().mockResolvedValue(stored) };
const service = new UserPreferencesService(repo as never);
await expect(service.getOrDefault('u1')).resolves.toEqual(stored);
});
it('returns an in-memory default without persisting a row when none exists', async () => {
const repo = {
findByUserId: jest.fn().mockResolvedValue(undefined),
upsert: jest.fn(),
};
const service = new UserPreferencesService(repo as never);
const result = await service.getOrDefault('u1');
expect(result).toEqual({ userId: 'u1', ...DEFAULT_USER_PREFERENCE });
expect(repo.upsert).not.toHaveBeenCalled();
});
});
describe('upsert', () => {
it('merges the partial dto onto the current effective preference before persisting', async () => {
const repo = {
findByUserId: jest.fn().mockResolvedValue({
userId: 'u1',
...DEFAULT_USER_PREFERENCE,
preferredPace: 'relaxed',
}),
upsert: jest
.fn()
.mockImplementation((userId: string, dto: UpdateUserPreferenceDto) =>
Promise.resolve({ userId, ...DEFAULT_USER_PREFERENCE, ...dto }),
),
};
const service = new UserPreferencesService(repo as never);
await service.upsert('u1', { childFriendlyPreferred: true });
expect(repo.upsert).toHaveBeenCalledWith(
'u1',
expect.objectContaining({
preferredPace: 'relaxed',
childFriendlyPreferred: true,
}),
);
});
});
});

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { UserPreferencesRepository } from './user-preferences.repository';
import { DEFAULT_USER_PREFERENCE } from './user.types';
import type { UpdateUserPreferenceDto, UserPreference } from './user.types';
@Injectable()
export class UserPreferencesService {
constructor(private readonly repository: UserPreferencesRepository) {}
async getOrDefault(userId: string): Promise<UserPreference> {
const stored = await this.repository.findByUserId(userId);
return stored ?? { userId, ...DEFAULT_USER_PREFERENCE };
}
async upsert(
userId: string,
dto: UpdateUserPreferenceDto,
): Promise<UserPreference> {
const current = await this.getOrDefault(userId);
return this.repository.upsert(userId, {
preferredPace:
dto.preferredPace !== undefined
? dto.preferredPace
: current.preferredPace,
preferredBudgetLevel:
dto.preferredBudgetLevel !== undefined
? dto.preferredBudgetLevel
: current.preferredBudgetLevel,
maxWalkingDistanceKm:
dto.maxWalkingDistanceKm !== undefined
? dto.maxWalkingDistanceKm
: current.maxWalkingDistanceKm,
preferredStartTime:
dto.preferredStartTime !== undefined
? dto.preferredStartTime
: current.preferredStartTime,
childFriendlyPreferred:
dto.childFriendlyPreferred !== undefined
? dto.childFriendlyPreferred
: current.childFriendlyPreferred,
interests:
dto.interests !== undefined ? dto.interests : current.interests,
notes: dto.notes !== undefined ? dto.notes : current.notes,
});
}
}

View File

@@ -0,0 +1,47 @@
export interface User {
id: string;
externalSubjectId: string;
displayName: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
export interface UserClaims {
email: string;
displayName: string;
}
export interface UserPreference {
userId: string;
preferredPace: string | null;
preferredBudgetLevel: string | null;
maxWalkingDistanceKm: number | null;
preferredStartTime: string | null;
childFriendlyPreferred: boolean;
interests: string[];
notes: string | null;
}
export interface UpdateUserPreferenceDto {
preferredPace?: string | null;
preferredBudgetLevel?: string | null;
maxWalkingDistanceKm?: number | null;
preferredStartTime?: string | null;
childFriendlyPreferred?: boolean;
interests?: string[];
notes?: string | null;
}
/** Fully-resolved preference fields (no optional/undefined members) ready to persist. */
export type UserPreferenceFields = Omit<UserPreference, 'userId'>;
export const DEFAULT_USER_PREFERENCE: Omit<UserPreference, 'userId'> = {
preferredPace: null,
preferredBudgetLevel: null,
maxWalkingDistanceKm: null,
preferredStartTime: null,
childFriendlyPreferred: false,
interests: [],
notes: null,
};

Some files were not shown because too many files have changed in this diff Show More