feat: add traveler entity distinct from trip membership

This commit is contained in:
Bastian Wagner
2026-08-17 15:28:35 +02:00
parent 6954024622
commit ee7ec94ea0
9 changed files with 355 additions and 0 deletions

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -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<TripSettings, 'tripId'> = {
webResearchEnabled: false,
periodicAgentReviewEnabled: false,

View File

@@ -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 {}