Compare commits
22 Commits
master
...
ee7ec94ea0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7ec94ea0 | ||
|
|
6954024622 | ||
|
|
baae15dcbc | ||
|
|
ddf1d03447 | ||
|
|
2eb1692216 | ||
|
|
5abc7cbe29 | ||
|
|
7ca4bd9cf7 | ||
|
|
ec95a8e527 | ||
|
|
acfbb3ab22 | ||
|
|
a7ce7c3730 | ||
|
|
74ae283fea | ||
|
|
1957e3b337 | ||
|
|
2994e46274 | ||
|
|
ccea4e48ec | ||
|
|
5edb16de73 | ||
|
|
0815f702d2 | ||
|
|
db6d62c303 | ||
|
|
755cc88dc2 | ||
|
|
ceaef3028f | ||
|
|
a08eecd4e3 | ||
|
|
c23bda56a3 | ||
|
|
2c91596c48 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.angular
|
||||||
|
**/coverage
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
|
||||||
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
/data/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 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
|
||||||
|
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 pnpm --filter backend start:api
|
||||||
|
pnpm --filter frontend start
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm dev:infra` starts local PostgreSQL/Redis (see `compose.dev.yml`); `pnpm dev:infra:down` stops them.
|
||||||
|
|
||||||
|
## 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.
|
||||||
21
backend/apps/api/src/api.module.ts
Normal file
21
backend/apps/api/src/api.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
HealthModule,
|
||||||
|
VersionModule,
|
||||||
|
UsersApiModule,
|
||||||
|
TripsApiModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
})
|
||||||
|
export class ApiModule {}
|
||||||
11
backend/apps/api/src/auth/current-user.decorator.ts
Normal file
11
backend/apps/api/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||||
|
const request = ctx
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ user: AuthenticatedUser }>();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
29
backend/apps/api/src/health/health.controller.spec.ts
Normal file
29
backend/apps/api/src/health/health.controller.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
backend/apps/api/src/health/health.controller.ts
Normal file
17
backend/apps/api/src/health/health.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/apps/api/src/health/health.module.ts
Normal file
14
backend/apps/api/src/health/health.module.ts
Normal 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 {}
|
||||||
22
backend/apps/api/src/health/readiness.service.ts
Normal file
22
backend/apps/api/src/health/readiness.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/apps/api/src/main.ts
Normal file
19
backend/apps/api/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { RequestMethod } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ApiModule } from './api.module';
|
||||||
|
|
||||||
|
export async function bootstrapApi(): Promise<void> {
|
||||||
|
const app = await NestFactory.create(ApiModule);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
app.setGlobalPrefix('api/v1', {
|
||||||
|
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();
|
||||||
|
}
|
||||||
41
backend/apps/api/src/migration.ts
Normal file
41
backend/apps/api/src/migration.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
72
backend/apps/api/src/trips/travelers.controller.spec.ts
Normal file
72
backend/apps/api/src/trips/travelers.controller.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { TravelersController } from './travelers.controller';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
|
||||||
|
describe('TravelersController', () => {
|
||||||
|
const currentUser: AuthenticatedUser = {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
backend/apps/api/src/trips/travelers.controller.ts
Normal file
59
backend/apps/api/src/trips/travelers.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||||
|
import type { AuthenticatedUser } 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(OidcAuthGuard, 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: AuthenticatedUser,
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { TripInvitationsController } from './trip-invitations.controller';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
|
||||||
|
describe('TripInvitationsController', () => {
|
||||||
|
const currentUser: AuthenticatedUser = {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
backend/apps/api/src/trips/trip-invitations.controller.ts
Normal file
68
backend/apps/api/src/trips/trip-invitations.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||||
|
import type { AuthenticatedUser } 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(OidcAuthGuard, TripMembershipGuard)
|
||||||
|
@TripRoles('OWNER')
|
||||||
|
create(
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@CurrentUser() currentUser: AuthenticatedUser,
|
||||||
|
@Body() dto: CreateTripInvitationDto,
|
||||||
|
): Promise<{ invitation: TripInvitation; rawToken: string }> {
|
||||||
|
return this.invitationsService.createInvitation(
|
||||||
|
tripId,
|
||||||
|
currentUser.id,
|
||||||
|
dto.email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('trips/:tripId/invitations')
|
||||||
|
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||||
|
@TripRoles('OWNER')
|
||||||
|
list(@Param('tripId') tripId: string): Promise<TripInvitation[]> {
|
||||||
|
return this.invitationsService.listInvitations(tripId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('trips/:tripId/invitations/:invitationId')
|
||||||
|
@UseGuards(OidcAuthGuard, 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(OidcAuthGuard)
|
||||||
|
accept(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@CurrentUser() currentUser: AuthenticatedUser,
|
||||||
|
): Promise<TripMember> {
|
||||||
|
return this.invitationsService.acceptInvitation(token, currentUser.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/apps/api/src/trips/trip-members.controller.spec.ts
Normal file
47
backend/apps/api/src/trips/trip-members.controller.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
backend/apps/api/src/trips/trip-members.controller.ts
Normal file
55
backend/apps/api/src/trips/trip-members.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } 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(OidcAuthGuard, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
backend/apps/api/src/trips/trips.controller.spec.ts
Normal file
115
backend/apps/api/src/trips/trips.controller.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { TripsController } from './trips.controller';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
|
||||||
|
describe('TripsController', () => {
|
||||||
|
const currentUser: AuthenticatedUser = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
backend/apps/api/src/trips/trips.controller.ts
Normal file
88
backend/apps/api/src/trips/trips.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||||
|
import type { AuthenticatedUser } 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(OidcAuthGuard)
|
||||||
|
export class TripsController {
|
||||||
|
constructor(
|
||||||
|
private readonly tripsService: TripsService,
|
||||||
|
private readonly tripSettingsService: TripSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() currentUser: AuthenticatedUser): Promise<Trip[]> {
|
||||||
|
return this.tripsService.listTripsForUser(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentUser() currentUser: AuthenticatedUser,
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/apps/api/src/trips/trips.module.ts
Normal file
18
backend/apps/api/src/trips/trips.module.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, TripsLibModule],
|
||||||
|
controllers: [
|
||||||
|
TripsController,
|
||||||
|
TripMembersController,
|
||||||
|
TripInvitationsController,
|
||||||
|
TravelersController,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class TripsApiModule {}
|
||||||
95
backend/apps/api/src/users/users.controller.spec.ts
Normal file
95
backend/apps/api/src/users/users.controller.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
|
||||||
|
describe('UsersController', () => {
|
||||||
|
const currentUser: AuthenticatedUser = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
backend/apps/api/src/users/users.controller.ts
Normal file
66
backend/apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||||
|
import type { AuthenticatedUser } 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(OidcAuthGuard)
|
||||||
|
export class UsersController {
|
||||||
|
constructor(
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly preferencesService: UserPreferencesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async me(
|
||||||
|
@CurrentUser() currentUser: AuthenticatedUser,
|
||||||
|
): 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: AuthenticatedUser,
|
||||||
|
): Promise<UserPreference> {
|
||||||
|
return this.preferencesService.getOrDefault(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('preferences')
|
||||||
|
updatePreferences(
|
||||||
|
@CurrentUser() currentUser: AuthenticatedUser,
|
||||||
|
@Body() dto: UpdateUserPreferenceDto,
|
||||||
|
): Promise<UserPreference> {
|
||||||
|
return this.preferencesService.upsert(currentUser.id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/apps/api/src/users/users.module.ts
Normal file
10
backend/apps/api/src/users/users.module.ts
Normal 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 {}
|
||||||
33
backend/apps/api/src/version/version.controller.spec.ts
Normal file
33
backend/apps/api/src/version/version.controller.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
backend/apps/api/src/version/version.controller.ts
Normal file
25
backend/apps/api/src/version/version.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/apps/api/src/version/version.module.ts
Normal file
7
backend/apps/api/src/version/version.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { VersionController } from './version.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [VersionController],
|
||||||
|
})
|
||||||
|
export class VersionModule {}
|
||||||
@@ -2,14 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
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>;
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [ApiModule],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
10
backend/apps/api/test/jest-e2e.json
Normal file
10
backend/apps/api/test/jest-e2e.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/apps/api/tsconfig.app.json
Normal file
7
backend/apps/api/tsconfig.app.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
20
backend/apps/worker/src/main.ts
Normal file
20
backend/apps/worker/src/main.ts
Normal 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();
|
||||||
|
}
|
||||||
16
backend/apps/worker/src/worker.bootstrap.spec.ts
Normal file
16
backend/apps/worker/src/worker.bootstrap.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
backend/apps/worker/src/worker.module.ts
Normal file
8
backend/apps/worker/src/worker.module.ts
Normal 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 {}
|
||||||
7
backend/apps/worker/tsconfig.app.json
Normal file
7
backend/apps/worker/tsconfig.app.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".*\\.integration-spec\\.ts$",
|
||||||
|
"testPathIgnorePatterns": ["<rootDir>/node_modules/", "<rootDir>/dist/"],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
}
|
}
|
||||||
11
backend/libs/auth/src/auth.module.ts
Normal file
11
backend/libs/auth/src/auth.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersLibModule } from '../../users/src';
|
||||||
|
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||||
|
import { OidcAuthGuard } from './oidc-auth.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UsersLibModule],
|
||||||
|
providers: [OidcDiscoveryService, OidcAuthGuard],
|
||||||
|
exports: [OidcDiscoveryService, OidcAuthGuard],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
3
backend/libs/auth/src/index.ts
Normal file
3
backend/libs/auth/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './oidc-discovery.service';
|
||||||
|
export * from './oidc-auth.guard';
|
||||||
|
export * from './auth.module';
|
||||||
123
backend/libs/auth/src/oidc-auth.guard.spec.ts
Normal file
123
backend/libs/auth/src/oidc-auth.guard.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { generateKeyPair, exportJWK, SignJWT, createLocalJWKSet } from 'jose';
|
||||||
|
import type { KeyLike } from 'jose';
|
||||||
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } from './oidc-auth.guard';
|
||||||
|
|
||||||
|
const issuer = 'https://idp.example.test/';
|
||||||
|
const audience = 'travel-planner-api';
|
||||||
|
|
||||||
|
function contextWithHeader(authorization?: string): ExecutionContext {
|
||||||
|
const req: Record<string, unknown> = authorization
|
||||||
|
? { headers: { authorization } }
|
||||||
|
: { headers: {} };
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({ getRequest: () => req }),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OidcAuthGuard', () => {
|
||||||
|
let privateKey: KeyLike;
|
||||||
|
let discovery: {
|
||||||
|
getVerificationKeySet: jest.Mock;
|
||||||
|
getIssuer: jest.Mock;
|
||||||
|
getAudience: jest.Mock;
|
||||||
|
};
|
||||||
|
let usersService: { findOrCreateByExternalSubjectId: jest.Mock };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
|
||||||
|
privateKey = pk;
|
||||||
|
const jwk = await exportJWK(publicKey);
|
||||||
|
(jwk as Record<string, string>).kid = 'test-key';
|
||||||
|
const jwks = createLocalJWKSet({ keys: [jwk as never] });
|
||||||
|
discovery = {
|
||||||
|
getVerificationKeySet: jest.fn().mockReturnValue(jwks),
|
||||||
|
getIssuer: jest.fn().mockReturnValue(issuer),
|
||||||
|
getAudience: jest.fn().mockReturnValue(audience),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usersService = {
|
||||||
|
findOrCreateByExternalSubjectId: jest.fn().mockResolvedValue({
|
||||||
|
id: 'local-1',
|
||||||
|
externalSubjectId: 'idp-sub-1',
|
||||||
|
displayName: 'A',
|
||||||
|
email: 'a@example.com',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sign(claims: Record<string, unknown>, expires = '5m') {
|
||||||
|
return new SignJWT(claims)
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||||
|
.setIssuer(issuer)
|
||||||
|
.setAudience(audience)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(expires)
|
||||||
|
.sign(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects a request with no Authorization header', async () => {
|
||||||
|
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||||
|
await expect(guard.canActivate(contextWithHeader())).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an expired token', async () => {
|
||||||
|
const token = await sign(
|
||||||
|
{ sub: 'idp-sub-1', email: 'a@example.com', name: 'A' },
|
||||||
|
'-10s',
|
||||||
|
);
|
||||||
|
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(contextWithHeader(`Bearer ${token}`)),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token issued for a different audience', async () => {
|
||||||
|
const token = await new SignJWT({ sub: 'idp-sub-1' })
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||||
|
.setIssuer(issuer)
|
||||||
|
.setAudience('some-other-api')
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('5m')
|
||||||
|
.sign(privateKey);
|
||||||
|
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(contextWithHeader(`Bearer ${token}`)),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provisions the local user and attaches req.user on a valid token', async () => {
|
||||||
|
const token = await sign({
|
||||||
|
sub: 'idp-sub-1',
|
||||||
|
email: 'a@example.com',
|
||||||
|
name: 'A',
|
||||||
|
});
|
||||||
|
const req: Record<string, unknown> = {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({ getRequest: () => req }),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const guard = new OidcAuthGuard(discovery as never, usersService as never);
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(usersService.findOrCreateByExternalSubjectId).toHaveBeenCalledWith(
|
||||||
|
'idp-sub-1',
|
||||||
|
{
|
||||||
|
email: 'a@example.com',
|
||||||
|
displayName: 'A',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(req.user).toEqual({
|
||||||
|
id: 'local-1',
|
||||||
|
externalSubjectId: 'idp-sub-1',
|
||||||
|
displayName: 'A',
|
||||||
|
email: 'a@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
66
backend/libs/auth/src/oidc-auth.guard.ts
Normal file
66
backend/libs/auth/src/oidc-auth.guard.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
import type { JWTPayload } from 'jose';
|
||||||
|
import { UsersService } from '../../users/src';
|
||||||
|
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: string;
|
||||||
|
externalSubjectId: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OidcAuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly discovery: OidcDiscoveryService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
headers: Record<string, string | undefined>;
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}>();
|
||||||
|
const header = request.headers?.authorization;
|
||||||
|
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
|
||||||
|
if (!token) throw new UnauthorizedException('Missing bearer token');
|
||||||
|
|
||||||
|
let payload: JWTPayload;
|
||||||
|
try {
|
||||||
|
const result = await jwtVerify(
|
||||||
|
token,
|
||||||
|
this.discovery.getVerificationKeySet(),
|
||||||
|
{
|
||||||
|
issuer: this.discovery.getIssuer(),
|
||||||
|
audience: this.discovery.getAudience(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
payload = result.payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid or expired token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = payload.sub;
|
||||||
|
if (!sub) throw new UnauthorizedException('Token has no subject claim');
|
||||||
|
|
||||||
|
const user = await this.users.findOrCreateByExternalSubjectId(sub, {
|
||||||
|
email: (payload.email as string) ?? '',
|
||||||
|
displayName: (payload.name as string) ?? (payload.email as string) ?? sub,
|
||||||
|
});
|
||||||
|
|
||||||
|
request.user = {
|
||||||
|
id: user.id,
|
||||||
|
externalSubjectId: user.externalSubjectId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/libs/auth/src/oidc-discovery.service.ts
Normal file
45
backend/libs/auth/src/oidc-discovery.service.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OidcDiscoveryService implements OnModuleInit {
|
||||||
|
private verificationKeySet: JWTVerifyGetKey | 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/libs/configuration/src/configuration.module.ts
Normal file
9
backend/libs/configuration/src/configuration.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { appEnvironmentProvider } from './environment';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [appEnvironmentProvider],
|
||||||
|
exports: [appEnvironmentProvider],
|
||||||
|
})
|
||||||
|
export class ConfigurationModule {}
|
||||||
29
backend/libs/configuration/src/environment.spec.ts
Normal file
29
backend/libs/configuration/src/environment.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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',
|
||||||
|
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',
|
||||||
|
appVersion: '1.2.3',
|
||||||
|
teamCityBuildNumber: '42',
|
||||||
|
sourceRevision: 'abc123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
backend/libs/configuration/src/environment.ts
Normal file
34
backend/libs/configuration/src/environment.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export interface AppEnvironment {
|
||||||
|
databaseUrl: string;
|
||||||
|
redisUrl: string;
|
||||||
|
oidcIssuer: string;
|
||||||
|
oidcAudience: 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'),
|
||||||
|
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),
|
||||||
|
};
|
||||||
2
backend/libs/configuration/src/index.ts
Normal file
2
backend/libs/configuration/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './environment';
|
||||||
|
export * from './configuration.module';
|
||||||
2
backend/libs/database/src/index.ts
Normal file
2
backend/libs/database/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './schema';
|
||||||
|
export * from './kysely.module';
|
||||||
22
backend/libs/database/src/kysely.module.ts
Normal file
22
backend/libs/database/src/kysely.module.ts
Normal 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 {}
|
||||||
105
backend/libs/database/src/schema.ts
Normal file
105
backend/libs/database/src/schema.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
2
backend/libs/infrastructure/src/index.ts
Normal file
2
backend/libs/infrastructure/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './postgres/postgres.module';
|
||||||
|
export * from './redis/redis.module';
|
||||||
27
backend/libs/infrastructure/src/postgres/postgres.module.ts
Normal file
27
backend/libs/infrastructure/src/postgres/postgres.module.ts
Normal 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 {}
|
||||||
27
backend/libs/infrastructure/src/redis/redis.module.ts
Normal file
27
backend/libs/infrastructure/src/redis/redis.module.ts
Normal 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 {}
|
||||||
9
backend/libs/trips/src/index.ts
Normal file
9
backend/libs/trips/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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-roles.decorator';
|
||||||
|
export * from './trips.module';
|
||||||
97
backend/libs/trips/src/travelers.repository.ts
Normal file
97
backend/libs/trips/src/travelers.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/libs/trips/src/travelers.service.spec.ts
Normal file
56
backend/libs/trips/src/travelers.service.spec.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
backend/libs/trips/src/travelers.service.ts
Normal file
38
backend/libs/trips/src/travelers.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/libs/trips/src/trip-invitations.repository.ts
Normal file
91
backend/libs/trips/src/trip-invitations.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
backend/libs/trips/src/trip-invitations.service.spec.ts
Normal file
119
backend/libs/trips/src/trip-invitations.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
backend/libs/trips/src/trip-invitations.service.ts
Normal file
69
backend/libs/trips/src/trip-invitations.service.ts
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
backend/libs/trips/src/trip-members.repository.ts
Normal file
108
backend/libs/trips/src/trip-members.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/libs/trips/src/trip-members.service.ts
Normal file
34
backend/libs/trips/src/trip-members.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend/libs/trips/src/trip-membership.guard.spec.ts
Normal file
94
backend/libs/trips/src/trip-membership.guard.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
backend/libs/trips/src/trip-membership.guard.ts
Normal file
59
backend/libs/trips/src/trip-membership.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/libs/trips/src/trip-roles.decorator.ts
Normal file
8
backend/libs/trips/src/trip-roles.decorator.ts
Normal 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);
|
||||||
67
backend/libs/trips/src/trip-settings.repository.ts
Normal file
67
backend/libs/trips/src/trip-settings.repository.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/libs/trips/src/trip-settings.service.ts
Normal file
21
backend/libs/trips/src/trip-settings.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
backend/libs/trips/src/trip.types.ts
Normal file
132
backend/libs/trips/src/trip.types.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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 const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
|
||||||
|
webResearchEnabled: false,
|
||||||
|
periodicAgentReviewEnabled: false,
|
||||||
|
notificationEmailEnabled: true,
|
||||||
|
notificationPushEnabled: true,
|
||||||
|
defaultResearchDepth: null,
|
||||||
|
defaultPlanningStyle: null,
|
||||||
|
};
|
||||||
39
backend/libs/trips/src/trips.module.ts
Normal file
39
backend/libs/trips/src/trips.module.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { 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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
providers: [
|
||||||
|
TripsRepository,
|
||||||
|
TripsService,
|
||||||
|
TripSettingsRepository,
|
||||||
|
TripSettingsService,
|
||||||
|
TripMembersRepository,
|
||||||
|
TripMembersService,
|
||||||
|
TripMembershipGuard,
|
||||||
|
TripInvitationsRepository,
|
||||||
|
TripInvitationsService,
|
||||||
|
TravelersRepository,
|
||||||
|
TravelersService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
TripsService,
|
||||||
|
TripSettingsService,
|
||||||
|
TripMembersService,
|
||||||
|
TripMembershipGuard,
|
||||||
|
TripInvitationsService,
|
||||||
|
TravelersService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class TripsLibModule {}
|
||||||
141
backend/libs/trips/src/trips.repository.ts
Normal file
141
backend/libs/trips/src/trips.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/libs/trips/src/trips.service.spec.ts
Normal file
33
backend/libs/trips/src/trips.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
backend/libs/trips/src/trips.service.ts
Normal file
55
backend/libs/trips/src/trips.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
backend/libs/users/src/index.ts
Normal file
4
backend/libs/users/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './user.types';
|
||||||
|
export * from './users.service';
|
||||||
|
export * from './user-preferences.service';
|
||||||
|
export * from './users.module';
|
||||||
72
backend/libs/users/src/user-preferences.repository.ts
Normal file
72
backend/libs/users/src/user-preferences.repository.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/libs/users/src/user-preferences.service.spec.ts
Normal file
60
backend/libs/users/src/user-preferences.service.spec.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
backend/libs/users/src/user-preferences.service.ts
Normal file
46
backend/libs/users/src/user-preferences.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/libs/users/src/user.types.ts
Normal file
47
backend/libs/users/src/user.types.ts
Normal 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,
|
||||||
|
};
|
||||||
18
backend/libs/users/src/users.module.ts
Normal file
18
backend/libs/users/src/users.module.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseModule } from '../../database/src';
|
||||||
|
import { UsersRepository } from './users.repository';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { UserPreferencesRepository } from './user-preferences.repository';
|
||||||
|
import { UserPreferencesService } from './user-preferences.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
providers: [
|
||||||
|
UsersRepository,
|
||||||
|
UsersService,
|
||||||
|
UserPreferencesRepository,
|
||||||
|
UserPreferencesService,
|
||||||
|
],
|
||||||
|
exports: [UsersService, UserPreferencesService],
|
||||||
|
})
|
||||||
|
export class UsersLibModule {}
|
||||||
61
backend/libs/users/src/users.repository.ts
Normal file
61
backend/libs/users/src/users.repository.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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 { User, UserClaims } from './user.types';
|
||||||
|
|
||||||
|
function toUser(row: {
|
||||||
|
id: string;
|
||||||
|
external_subject_id: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}): User {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
externalSubjectId: row.external_subject_id,
|
||||||
|
displayName: row.display_name,
|
||||||
|
email: row.email,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersRepository {
|
||||||
|
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
|
||||||
|
|
||||||
|
async upsertByExternalSubjectId(
|
||||||
|
externalSubjectId: string,
|
||||||
|
claims: UserClaims,
|
||||||
|
): Promise<User> {
|
||||||
|
const row = await this.db
|
||||||
|
.insertInto('users')
|
||||||
|
.values({
|
||||||
|
external_subject_id: externalSubjectId,
|
||||||
|
display_name: claims.displayName,
|
||||||
|
email: claims.email,
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.column('external_subject_id').doUpdateSet({
|
||||||
|
display_name: claims.displayName,
|
||||||
|
email: claims.email,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return toUser(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<User | undefined> {
|
||||||
|
const row = await this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return row ? toUser(row) : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/libs/users/src/users.service.spec.ts
Normal file
50
backend/libs/users/src/users.service.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
describe('UsersService.findOrCreateByExternalSubjectId', () => {
|
||||||
|
it('creates a new local user on first login', async () => {
|
||||||
|
const repo = {
|
||||||
|
upsertByExternalSubjectId: jest.fn().mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
externalSubjectId: 'sub-1',
|
||||||
|
displayName: 'Alex',
|
||||||
|
email: 'a@example.com',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(repo as never);
|
||||||
|
|
||||||
|
const user = await service.findOrCreateByExternalSubjectId('sub-1', {
|
||||||
|
email: 'a@example.com',
|
||||||
|
displayName: 'Alex',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repo.upsertByExternalSubjectId).toHaveBeenCalledWith('sub-1', {
|
||||||
|
email: 'a@example.com',
|
||||||
|
displayName: 'Alex',
|
||||||
|
});
|
||||||
|
expect(user.id).toBe('u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes displayName/email on subsequent logins without changing the id', async () => {
|
||||||
|
const repo = {
|
||||||
|
upsertByExternalSubjectId: jest.fn().mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
externalSubjectId: 'sub-1',
|
||||||
|
displayName: 'Alex Renamed',
|
||||||
|
email: 'a@example.com',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(repo as never);
|
||||||
|
|
||||||
|
const user = await service.findOrCreateByExternalSubjectId('sub-1', {
|
||||||
|
email: 'a@example.com',
|
||||||
|
displayName: 'Alex Renamed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user.id).toBe('u1');
|
||||||
|
expect(user.displayName).toBe('Alex Renamed');
|
||||||
|
});
|
||||||
|
});
|
||||||
22
backend/libs/users/src/users.service.ts
Normal file
22
backend/libs/users/src/users.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UsersRepository } from './users.repository';
|
||||||
|
import type { User, UserClaims } from './user.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly usersRepository: UsersRepository) {}
|
||||||
|
|
||||||
|
findOrCreateByExternalSubjectId(
|
||||||
|
externalSubjectId: string,
|
||||||
|
claims: UserClaims,
|
||||||
|
): Promise<User> {
|
||||||
|
return this.usersRepository.upsertByExternalSubjectId(
|
||||||
|
externalSubjectId,
|
||||||
|
claims,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): Promise<User | undefined> {
|
||||||
|
return this.usersRepository.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/migrations/1700000001000_create-users.cjs
Normal file
16
backend/migrations/1700000001000_create-users.cjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createExtension('pgcrypto', { ifNotExists: true });
|
||||||
|
pgm.createTable('users', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
external_subject_id: { type: 'text', notNull: true },
|
||||||
|
display_name: { type: 'text', notNull: true },
|
||||||
|
email: { type: 'text', notNull: true },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint('users', 'users_external_subject_id_key', 'UNIQUE(external_subject_id)');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('users');
|
||||||
|
};
|
||||||
24
backend/migrations/1700000002000_create-user-preferences.cjs
Normal file
24
backend/migrations/1700000002000_create-user-preferences.cjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createTable('user_preferences', {
|
||||||
|
user_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
notNull: true,
|
||||||
|
references: 'users(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
preferred_pace: { type: 'text' },
|
||||||
|
preferred_budget_level: { type: 'text' },
|
||||||
|
max_walking_distance_km: { type: 'numeric' },
|
||||||
|
preferred_start_time: { type: 'text' },
|
||||||
|
child_friendly_preferred: { type: 'boolean', notNull: true, default: false },
|
||||||
|
interests: { type: 'text[]', notNull: true, default: '{}' },
|
||||||
|
notes: { type: 'text' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('user_preferences');
|
||||||
|
};
|
||||||
26
backend/migrations/1700000003000_create-trips.cjs
Normal file
26
backend/migrations/1700000003000_create-trips.cjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createTable('trips', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
name: { type: 'text', notNull: true },
|
||||||
|
description: { type: 'text' },
|
||||||
|
owner_id: { type: 'uuid', notNull: true, references: 'users(id)' },
|
||||||
|
start_date: { type: 'date' },
|
||||||
|
end_date: { type: 'date' },
|
||||||
|
status: { type: 'text', notNull: true, default: 'DRAFT' },
|
||||||
|
planning_stage: { type: 'text' },
|
||||||
|
currency: { type: 'text', notNull: true, default: 'EUR' },
|
||||||
|
version: { type: 'integer', notNull: true, default: 1 },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint(
|
||||||
|
'trips',
|
||||||
|
'trips_status_check',
|
||||||
|
"CHECK (status IN ('DRAFT','PLANNING','BOOKING','UPCOMING','ACTIVE','COMPLETED','ARCHIVED'))",
|
||||||
|
);
|
||||||
|
pgm.createIndex('trips', 'owner_id');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('trips');
|
||||||
|
};
|
||||||
23
backend/migrations/1700000004000_create-trip-settings.cjs
Normal file
23
backend/migrations/1700000004000_create-trip-settings.cjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createTable('trip_settings', {
|
||||||
|
trip_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
notNull: true,
|
||||||
|
references: 'trips(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
web_research_enabled: { type: 'boolean', notNull: true, default: false },
|
||||||
|
periodic_agent_review_enabled: { type: 'boolean', notNull: true, default: false },
|
||||||
|
notification_email_enabled: { type: 'boolean', notNull: true, default: true },
|
||||||
|
notification_push_enabled: { type: 'boolean', notNull: true, default: true },
|
||||||
|
default_research_depth: { type: 'text' },
|
||||||
|
default_planning_style: { type: 'text' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('trip_settings');
|
||||||
|
};
|
||||||
25
backend/migrations/1700000005000_create-trip-members.cjs
Normal file
25
backend/migrations/1700000005000_create-trip-members.cjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createTable('trip_members', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
|
||||||
|
user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
|
||||||
|
role: { type: 'text', notNull: true },
|
||||||
|
status: { type: 'text', notNull: true },
|
||||||
|
joined_at: { type: 'timestamptz' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint('trip_members', 'trip_members_trip_user_key', 'UNIQUE(trip_id, user_id)');
|
||||||
|
pgm.addConstraint('trip_members', 'trip_members_role_check', "CHECK (role IN ('OWNER','MEMBER'))");
|
||||||
|
pgm.addConstraint(
|
||||||
|
'trip_members',
|
||||||
|
'trip_members_status_check',
|
||||||
|
"CHECK (status IN ('INVITED','ACTIVE','DECLINED'))",
|
||||||
|
);
|
||||||
|
pgm.createIndex('trip_members', 'trip_id');
|
||||||
|
pgm.createIndex('trip_members', 'user_id');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('trip_members');
|
||||||
|
};
|
||||||
20
backend/migrations/1700000006000_create-trip-invitations.cjs
Normal file
20
backend/migrations/1700000006000_create-trip-invitations.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createTable('trip_invitations', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
|
||||||
|
email: { type: 'text', notNull: true },
|
||||||
|
invited_by_user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
|
||||||
|
token_hash: { type: 'text', notNull: true },
|
||||||
|
expires_at: { type: 'timestamptz', notNull: true },
|
||||||
|
accepted_at: { type: 'timestamptz' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint('trip_invitations', 'trip_invitations_token_hash_key', 'UNIQUE(token_hash)');
|
||||||
|
pgm.createIndex('trip_invitations', 'trip_id');
|
||||||
|
pgm.sql('CREATE INDEX trip_invitations_lower_email_idx ON trip_invitations (lower(email))');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('trip_invitations');
|
||||||
|
};
|
||||||
22
backend/migrations/1700000007000_create-travelers.cjs
Normal file
22
backend/migrations/1700000007000_create-travelers.cjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createTable('travelers', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
|
||||||
|
linked_user_id: { type: 'uuid', references: 'users(id)' },
|
||||||
|
display_name: { type: 'text', notNull: true },
|
||||||
|
traveler_type: { type: 'text', notNull: true },
|
||||||
|
created_by_user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint(
|
||||||
|
'travelers',
|
||||||
|
'travelers_traveler_type_check',
|
||||||
|
"CHECK (traveler_type IN ('ADULT','CHILD','INFANT'))",
|
||||||
|
);
|
||||||
|
pgm.createIndex('travelers', 'trip_id');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('travelers');
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createTable('trip_preference_overrides', {
|
||||||
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
|
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
|
||||||
|
user_id: { type: 'uuid', references: 'users(id)' },
|
||||||
|
traveler_id: { type: 'uuid', references: 'travelers(id)', onDelete: 'CASCADE' },
|
||||||
|
overrides: { type: 'jsonb', notNull: true, default: '{}' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint(
|
||||||
|
'trip_preference_overrides',
|
||||||
|
'trip_preference_overrides_subject_check',
|
||||||
|
'CHECK (num_nonnulls(user_id, traveler_id) = 1)',
|
||||||
|
);
|
||||||
|
pgm.createIndex('trip_preference_overrides', ['trip_id', 'user_id'], {
|
||||||
|
unique: true,
|
||||||
|
where: 'user_id IS NOT NULL',
|
||||||
|
name: 'trip_pref_overrides_trip_user_uq',
|
||||||
|
});
|
||||||
|
pgm.createIndex('trip_preference_overrides', ['trip_id', 'traveler_id'], {
|
||||||
|
unique: true,
|
||||||
|
where: 'traveler_id IS NOT NULL',
|
||||||
|
name: 'trip_pref_overrides_trip_traveler_uq',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('trip_preference_overrides');
|
||||||
|
};
|
||||||
@@ -1,8 +1,30 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"monorepo": true,
|
||||||
|
"root": "apps/api",
|
||||||
|
"sourceRoot": "apps/api/src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": false
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"api": {
|
||||||
|
"type": "application",
|
||||||
|
"root": "apps/api",
|
||||||
|
"entryFile": "main",
|
||||||
|
"sourceRoot": "apps/api/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "apps/api/tsconfig.app.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"type": "application",
|
||||||
|
"root": "apps/worker",
|
||||||
|
"entryFile": "main",
|
||||||
|
"sourceRoot": "apps/worker/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "apps/worker/tsconfig.app.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10211
backend/package-lock.json
generated
10211
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,23 +6,32 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "pnpm run build:api && pnpm run build:worker",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"build:api": "nest build api",
|
||||||
"start": "nest start",
|
"build:worker": "nest build worker",
|
||||||
"start:dev": "nest start --watch",
|
"format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start": "nest start api",
|
||||||
"start:prod": "node dist/main",
|
"start:dev": "nest start api --watch",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"start:debug": "nest start api --debug --watch",
|
||||||
"test": "jest",
|
"start:api": "node dist/apps/api/src/main.js",
|
||||||
|
"start:worker": "node dist/apps/worker/src/main.js",
|
||||||
|
"lint": "eslint \"{apps,libs}/**/*.ts\" --fix",
|
||||||
|
"test": "jest --runInBand",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./apps/api/test/jest-e2e.json",
|
||||||
|
"test:integration": "jest --config jest-integration.json --runInBand"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
|
"jose": "^5.10.0",
|
||||||
|
"kysely": "0.28.17",
|
||||||
|
"node-pg-migrate": "^7.9.1",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -35,6 +44,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/pg": "^8.21.0",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -57,15 +67,23 @@
|
|||||||
"json",
|
"json",
|
||||||
"ts"
|
"ts"
|
||||||
],
|
],
|
||||||
"rootDir": "src",
|
"rootDir": ".",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"testPathIgnorePatterns": [
|
||||||
|
"<rootDir>/node_modules/",
|
||||||
|
"<rootDir>/dist/"
|
||||||
|
],
|
||||||
|
"setupFiles": [
|
||||||
|
"<rootDir>/test/setup-env.ts"
|
||||||
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"apps/**/*.(t|j)s",
|
||||||
|
"libs/**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AppController } from './app.controller';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [],
|
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
import { AppModule } from './app.module';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const app = await NestFactory.create(AppModule);
|
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
|
||||||
}
|
|
||||||
bootstrap();
|
|
||||||
4
backend/test/setup-env.ts
Normal file
4
backend/test/setup-env.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test';
|
||||||
|
process.env.REDIS_URL ??= 'redis://localhost:6379';
|
||||||
|
process.env.OIDC_ISSUER ??= 'https://idp.example.test/';
|
||||||
|
process.env.OIDC_AUDIENCE ??= 'travel-planner-api';
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": ".",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user