diff --git a/backend/apps/api/src/api.module.ts b/backend/apps/api/src/api.module.ts index dddd396..4699d94 100644 --- a/backend/apps/api/src/api.module.ts +++ b/backend/apps/api/src/api.module.ts @@ -5,9 +5,16 @@ 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], + imports: [ + ConfigurationModule, + HealthModule, + VersionModule, + UsersApiModule, + TripsApiModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/backend/apps/api/src/trips/trips.controller.spec.ts b/backend/apps/api/src/trips/trips.controller.spec.ts new file mode 100644 index 0000000..47eb7e9 --- /dev/null +++ b/backend/apps/api/src/trips/trips.controller.spec.ts @@ -0,0 +1,73 @@ +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', + }; + + it('GET /trips lists trips for the current user', async () => { + const tripsService = { + listTripsForUser: jest + .fn() + .mockResolvedValue([{ id: 't1', name: 'Slovenia 2027' }]), + }; + const controller = new TripsController(tripsService as never); + + 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 = new TripsController(tripsService as never); + + 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 = new TripsController(tripsService as never); + + 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 = new TripsController(tripsService as never); + + 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 = new TripsController(tripsService as never); + + await controller.remove('t1'); + expect(tripsService.deleteTrip).toHaveBeenCalledWith('t1'); + }); +}); diff --git a/backend/apps/api/src/trips/trips.controller.ts b/backend/apps/api/src/trips/trips.controller.ts new file mode 100644 index 0000000..254bf4d --- /dev/null +++ b/backend/apps/api/src/trips/trips.controller.ts @@ -0,0 +1,56 @@ +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 { TripsService } from '../../../../libs/trips/src'; +import type { + CreateTripDto, + Trip, + UpdateTripDto, +} from '../../../../libs/trips/src'; +import { CurrentUser } from '../auth/current-user.decorator'; + +@Controller('trips') +@UseGuards(OidcAuthGuard) +export class TripsController { + constructor(private readonly tripsService: TripsService) {} + + @Get() + list(@CurrentUser() currentUser: AuthenticatedUser): Promise { + return this.tripsService.listTripsForUser(currentUser.id); + } + + @Post() + create( + @CurrentUser() currentUser: AuthenticatedUser, + @Body() dto: CreateTripDto, + ): Promise { + return this.tripsService.createTrip(currentUser.id, dto); + } + + @Get(':tripId') + getOne(@Param('tripId') tripId: string): Promise { + return this.tripsService.getTrip(tripId); + } + + @Patch(':tripId') + update( + @Param('tripId') tripId: string, + @Body() dto: UpdateTripDto, + ): Promise { + return this.tripsService.updateTrip(tripId, dto); + } + + @Delete(':tripId') + remove(@Param('tripId') tripId: string): Promise { + return this.tripsService.deleteTrip(tripId); + } +} diff --git a/backend/apps/api/src/trips/trips.module.ts b/backend/apps/api/src/trips/trips.module.ts new file mode 100644 index 0000000..cec2767 --- /dev/null +++ b/backend/apps/api/src/trips/trips.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../../../libs/auth/src'; +import { TripsLibModule } from '../../../../libs/trips/src'; +import { TripsController } from './trips.controller'; + +@Module({ + imports: [AuthModule, TripsLibModule], + controllers: [TripsController], +}) +export class TripsApiModule {} diff --git a/backend/libs/trips/src/index.ts b/backend/libs/trips/src/index.ts new file mode 100644 index 0000000..29998d4 --- /dev/null +++ b/backend/libs/trips/src/index.ts @@ -0,0 +1,4 @@ +export * from './trip.types'; +export * from './trips.service'; +export * from './trip-settings.service'; +export * from './trips.module'; diff --git a/backend/libs/trips/src/trip-settings.repository.ts b/backend/libs/trips/src/trip-settings.repository.ts new file mode 100644 index 0000000..bf3206a --- /dev/null +++ b/backend/libs/trips/src/trip-settings.repository.ts @@ -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) {} + + async findByTripId(tripId: string): Promise { + 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 { + 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); + } +} diff --git a/backend/libs/trips/src/trip-settings.service.ts b/backend/libs/trips/src/trip-settings.service.ts new file mode 100644 index 0000000..36cf444 --- /dev/null +++ b/backend/libs/trips/src/trip-settings.service.ts @@ -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 { + const settings = await this.repository.findByTripId(tripId); + if (!settings) throw new NotFoundException('Trip settings not found'); + return settings; + } + + replaceSettings( + tripId: string, + dto: UpdateTripSettingsDto, + ): Promise { + return this.repository.replace(tripId, dto); + } +} diff --git a/backend/libs/trips/src/trip.types.ts b/backend/libs/trips/src/trip.types.ts new file mode 100644 index 0000000..254e3cb --- /dev/null +++ b/backend/libs/trips/src/trip.types.ts @@ -0,0 +1,74 @@ +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 const DEFAULT_TRIP_SETTINGS: Omit = { + webResearchEnabled: false, + periodicAgentReviewEnabled: false, + notificationEmailEnabled: true, + notificationPushEnabled: true, + defaultResearchDepth: null, + defaultPlanningStyle: null, +}; diff --git a/backend/libs/trips/src/trips.module.ts b/backend/libs/trips/src/trips.module.ts new file mode 100644 index 0000000..4099ad7 --- /dev/null +++ b/backend/libs/trips/src/trips.module.ts @@ -0,0 +1,18 @@ +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'; + +@Module({ + imports: [DatabaseModule], + providers: [ + TripsRepository, + TripsService, + TripSettingsRepository, + TripSettingsService, + ], + exports: [TripsService, TripSettingsService], +}) +export class TripsLibModule {} diff --git a/backend/libs/trips/src/trips.repository.ts b/backend/libs/trips/src/trips.repository.ts new file mode 100644 index 0000000..6e3fe21 --- /dev/null +++ b/backend/libs/trips/src/trips.repository.ts @@ -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) {} + + async createTrip(ownerId: string, dto: CreateTripDto): Promise { + 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 { + const row = await this.db + .selectFrom('trips') + .selectAll() + .where('id', '=', tripId) + .executeTakeFirst(); + return row ? toTrip(row) : undefined; + } + + async listForUser(userId: string): Promise { + 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, + ): Promise { + 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 { + await this.db.deleteFrom('trips').where('id', '=', tripId).execute(); + } +} diff --git a/backend/libs/trips/src/trips.service.spec.ts b/backend/libs/trips/src/trips.service.spec.ts new file mode 100644 index 0000000..8f6448b --- /dev/null +++ b/backend/libs/trips/src/trips.service.spec.ts @@ -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); + }); +}); diff --git a/backend/libs/trips/src/trips.service.ts b/backend/libs/trips/src/trips.service.ts new file mode 100644 index 0000000..3d4e1cb --- /dev/null +++ b/backend/libs/trips/src/trips.service.ts @@ -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 { + return this.tripsRepository.createTrip(ownerId, dto); + } + + listTripsForUser(userId: string): Promise { + return this.tripsRepository.listForUser(userId); + } + + async getTrip(tripId: string): Promise { + const trip = await this.tripsRepository.findById(tripId); + if (!trip) throw new NotFoundException('Trip not found'); + return trip; + } + + async updateTrip(tripId: string, dto: UpdateTripDto): Promise { + const patch: Partial = {}; + 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 { + await this.tripsRepository.delete(tripId); + } +} diff --git a/backend/libs/trips/test/trips.optimistic-locking.integration-spec.ts b/backend/libs/trips/test/trips.optimistic-locking.integration-spec.ts new file mode 100644 index 0000000..38fe33f --- /dev/null +++ b/backend/libs/trips/test/trips.optimistic-locking.integration-spec.ts @@ -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; + let tripsService: TripsService; + let ownerId: string; + + beforeAll(async () => { + db = new Kysely({ + 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); + }); +});