feat: add traveler entity distinct from trip membership
This commit is contained in:
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { TripsLibModule } from '../../../../libs/trips/src';
|
|||||||
import { TripsController } from './trips.controller';
|
import { TripsController } from './trips.controller';
|
||||||
import { TripMembersController } from './trip-members.controller';
|
import { TripMembersController } from './trip-members.controller';
|
||||||
import { TripInvitationsController } from './trip-invitations.controller';
|
import { TripInvitationsController } from './trip-invitations.controller';
|
||||||
|
import { TravelersController } from './travelers.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TripsLibModule],
|
imports: [AuthModule, TripsLibModule],
|
||||||
@@ -11,6 +12,7 @@ import { TripInvitationsController } from './trip-invitations.controller';
|
|||||||
TripsController,
|
TripsController,
|
||||||
TripMembersController,
|
TripMembersController,
|
||||||
TripInvitationsController,
|
TripInvitationsController,
|
||||||
|
TravelersController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TripsApiModule {}
|
export class TripsApiModule {}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export * from './trip-settings.service';
|
|||||||
export * from './trip-members.service';
|
export * from './trip-members.service';
|
||||||
export * from './trip-membership.guard';
|
export * from './trip-membership.guard';
|
||||||
export * from './trip-invitations.service';
|
export * from './trip-invitations.service';
|
||||||
|
export * from './travelers.service';
|
||||||
export * from './trip-roles.decorator';
|
export * from './trip-roles.decorator';
|
||||||
export * from './trips.module';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,31 @@ export interface CreateTripInvitationFields {
|
|||||||
expiresAt: Date;
|
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'> = {
|
export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
|
||||||
webResearchEnabled: false,
|
webResearchEnabled: false,
|
||||||
periodicAgentReviewEnabled: false,
|
periodicAgentReviewEnabled: false,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { TripMembersService } from './trip-members.service';
|
|||||||
import { TripMembershipGuard } from './trip-membership.guard';
|
import { TripMembershipGuard } from './trip-membership.guard';
|
||||||
import { TripInvitationsRepository } from './trip-invitations.repository';
|
import { TripInvitationsRepository } from './trip-invitations.repository';
|
||||||
import { TripInvitationsService } from './trip-invitations.service';
|
import { TripInvitationsService } from './trip-invitations.service';
|
||||||
|
import { TravelersRepository } from './travelers.repository';
|
||||||
|
import { TravelersService } from './travelers.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule],
|
imports: [DatabaseModule],
|
||||||
@@ -22,6 +24,8 @@ import { TripInvitationsService } from './trip-invitations.service';
|
|||||||
TripMembershipGuard,
|
TripMembershipGuard,
|
||||||
TripInvitationsRepository,
|
TripInvitationsRepository,
|
||||||
TripInvitationsService,
|
TripInvitationsService,
|
||||||
|
TravelersRepository,
|
||||||
|
TravelersService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TripsService,
|
TripsService,
|
||||||
@@ -29,6 +33,7 @@ import { TripInvitationsService } from './trip-invitations.service';
|
|||||||
TripMembersService,
|
TripMembersService,
|
||||||
TripMembershipGuard,
|
TripMembershipGuard,
|
||||||
TripInvitationsService,
|
TripInvitationsService,
|
||||||
|
TravelersService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TripsLibModule {}
|
export class TripsLibModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user