From ee7ec94ea01ba11635b61e2571da6c51b6b84cd1 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 17 Aug 2026 15:28:35 +0200 Subject: [PATCH] feat: add traveler entity distinct from trip membership --- .../src/trips/travelers.controller.spec.ts | 72 ++++++++++++++ .../api/src/trips/travelers.controller.ts | 59 +++++++++++ backend/apps/api/src/trips/trips.module.ts | 2 + backend/libs/trips/src/index.ts | 1 + .../libs/trips/src/travelers.repository.ts | 97 +++++++++++++++++++ .../libs/trips/src/travelers.service.spec.ts | 56 +++++++++++ backend/libs/trips/src/travelers.service.ts | 38 ++++++++ backend/libs/trips/src/trip.types.ts | 25 +++++ backend/libs/trips/src/trips.module.ts | 5 + 9 files changed, 355 insertions(+) create mode 100644 backend/apps/api/src/trips/travelers.controller.spec.ts create mode 100644 backend/apps/api/src/trips/travelers.controller.ts create mode 100644 backend/libs/trips/src/travelers.repository.ts create mode 100644 backend/libs/trips/src/travelers.service.spec.ts create mode 100644 backend/libs/trips/src/travelers.service.ts diff --git a/backend/apps/api/src/trips/travelers.controller.spec.ts b/backend/apps/api/src/trips/travelers.controller.spec.ts new file mode 100644 index 0000000..ba89ebf --- /dev/null +++ b/backend/apps/api/src/trips/travelers.controller.spec.ts @@ -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', + ); + }); +}); diff --git a/backend/apps/api/src/trips/travelers.controller.ts b/backend/apps/api/src/trips/travelers.controller.ts new file mode 100644 index 0000000..62e2b48 --- /dev/null +++ b/backend/apps/api/src/trips/travelers.controller.ts @@ -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 { + return this.travelersService.listTravelers(tripId); + } + + @Post() + create( + @Param('tripId') tripId: string, + @CurrentUser() currentUser: AuthenticatedUser, + @Body() dto: CreateTravelerDto, + ): Promise { + return this.travelersService.createTraveler(tripId, dto, currentUser.id); + } + + @Patch(':travelerId') + update( + @Param('tripId') tripId: string, + @Param('travelerId') travelerId: string, + @Body() dto: UpdateTravelerDto, + ): Promise { + return this.travelersService.updateTraveler(tripId, travelerId, dto); + } + + @Delete(':travelerId') + remove( + @Param('tripId') tripId: string, + @Param('travelerId') travelerId: string, + ): Promise { + return this.travelersService.removeTraveler(tripId, travelerId); + } +} diff --git a/backend/apps/api/src/trips/trips.module.ts b/backend/apps/api/src/trips/trips.module.ts index 0d43e53..7e4da2c 100644 --- a/backend/apps/api/src/trips/trips.module.ts +++ b/backend/apps/api/src/trips/trips.module.ts @@ -4,6 +4,7 @@ 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], @@ -11,6 +12,7 @@ import { TripInvitationsController } from './trip-invitations.controller'; TripsController, TripMembersController, TripInvitationsController, + TravelersController, ], }) export class TripsApiModule {} diff --git a/backend/libs/trips/src/index.ts b/backend/libs/trips/src/index.ts index 9b607d7..f721243 100644 --- a/backend/libs/trips/src/index.ts +++ b/backend/libs/trips/src/index.ts @@ -4,5 +4,6 @@ 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'; diff --git a/backend/libs/trips/src/travelers.repository.ts b/backend/libs/trips/src/travelers.repository.ts new file mode 100644 index 0000000..4035a33 --- /dev/null +++ b/backend/libs/trips/src/travelers.repository.ts @@ -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) {} + + async create( + tripId: string, + dto: CreateTravelerDto, + createdByUserId: string, + ): Promise { + 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 { + 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 { + 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 { + await this.db + .deleteFrom('travelers') + .where('trip_id', '=', tripId) + .where('id', '=', travelerId) + .execute(); + } +} diff --git a/backend/libs/trips/src/travelers.service.spec.ts b/backend/libs/trips/src/travelers.service.spec.ts new file mode 100644 index 0000000..99b3949 --- /dev/null +++ b/backend/libs/trips/src/travelers.service.spec.ts @@ -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', + ); + }); +}); diff --git a/backend/libs/trips/src/travelers.service.ts b/backend/libs/trips/src/travelers.service.ts new file mode 100644 index 0000000..3cee2b8 --- /dev/null +++ b/backend/libs/trips/src/travelers.service.ts @@ -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 { + return this.repository.create(tripId, dto, createdByUserId); + } + + listTravelers(tripId: string): Promise { + return this.repository.listByTrip(tripId); + } + + async updateTraveler( + tripId: string, + travelerId: string, + dto: UpdateTravelerDto, + ): Promise { + 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 { + return this.repository.remove(tripId, travelerId); + } +} diff --git a/backend/libs/trips/src/trip.types.ts b/backend/libs/trips/src/trip.types.ts index 06d73f4..bf9259a 100644 --- a/backend/libs/trips/src/trip.types.ts +++ b/backend/libs/trips/src/trip.types.ts @@ -97,6 +97,31 @@ export interface CreateTripInvitationFields { 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 = { webResearchEnabled: false, periodicAgentReviewEnabled: false, diff --git a/backend/libs/trips/src/trips.module.ts b/backend/libs/trips/src/trips.module.ts index ea6f6da..35544f3 100644 --- a/backend/libs/trips/src/trips.module.ts +++ b/backend/libs/trips/src/trips.module.ts @@ -9,6 +9,8 @@ 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], @@ -22,6 +24,8 @@ import { TripInvitationsService } from './trip-invitations.service'; TripMembershipGuard, TripInvitationsRepository, TripInvitationsService, + TravelersRepository, + TravelersService, ], exports: [ TripsService, @@ -29,6 +33,7 @@ import { TripInvitationsService } from './trip-invitations.service'; TripMembersService, TripMembershipGuard, TripInvitationsService, + TravelersService, ], }) export class TripsLibModule {}