feat: add trip and trip settings with optimistic locking

This commit is contained in:
Bastian Wagner
2026-08-17 15:06:00 +02:00
parent 2eb1692216
commit ddf1d03447
13 changed files with 615 additions and 1 deletions

View File

@@ -0,0 +1,4 @@
export * from './trip.types';
export * from './trips.service';
export * from './trip-settings.service';
export * from './trips.module';

View 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);
}
}

View 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);
}
}

View File

@@ -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<TripSettings, 'tripId'> = {
webResearchEnabled: false,
periodicAgentReviewEnabled: false,
notificationEmailEnabled: true,
notificationPushEnabled: true,
defaultResearchDepth: null,
defaultPlanningStyle: null,
};

View File

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

View 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();
}
}

View 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);
});
});

View 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);
}
}

View File

@@ -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);
});
});