feat: add trip and trip settings with optimistic locking
This commit is contained in:
4
backend/libs/trips/src/index.ts
Normal file
4
backend/libs/trips/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './trip.types';
|
||||
export * from './trips.service';
|
||||
export * from './trip-settings.service';
|
||||
export * from './trips.module';
|
||||
67
backend/libs/trips/src/trip-settings.repository.ts
Normal file
67
backend/libs/trips/src/trip-settings.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
backend/libs/trips/src/trip-settings.service.ts
Normal file
21
backend/libs/trips/src/trip-settings.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
74
backend/libs/trips/src/trip.types.ts
Normal file
74
backend/libs/trips/src/trip.types.ts
Normal 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,
|
||||
};
|
||||
18
backend/libs/trips/src/trips.module.ts
Normal file
18
backend/libs/trips/src/trips.module.ts
Normal 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 {}
|
||||
141
backend/libs/trips/src/trips.repository.ts
Normal file
141
backend/libs/trips/src/trips.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
33
backend/libs/trips/src/trips.service.spec.ts
Normal file
33
backend/libs/trips/src/trips.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
55
backend/libs/trips/src/trips.service.ts
Normal file
55
backend/libs/trips/src/trips.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user