feat: add trip preference overrides with precedence resolution

This commit is contained in:
Bastian Wagner
2026-08-17 15:38:03 +02:00
parent ee7ec94ea0
commit dedb3fff40
12 changed files with 373 additions and 0 deletions

View File

@@ -5,5 +5,7 @@ export * from './trip-members.service';
export * from './trip-membership.guard';
export * from './trip-invitations.service';
export * from './travelers.service';
export * from './trip-preference-overrides.service';
export * from './preference-precedence';
export * from './trip-roles.decorator';
export * from './trips.module';

View File

@@ -0,0 +1,47 @@
import { resolveEffectivePreference } from './preference-precedence';
import type { UserPreferenceFields } from '../../users/src';
describe('resolveEffectivePreference', () => {
const appDefault: UserPreferenceFields = {
preferredPace: 'moderate',
preferredBudgetLevel: 'medium',
maxWalkingDistanceKm: 5,
preferredStartTime: null,
childFriendlyPreferred: false,
interests: [],
notes: null,
};
it('falls back to the application default when nothing is set', () => {
expect(
resolveEffectivePreference(undefined, undefined, appDefault),
).toEqual(appDefault);
});
it('prefers the persistent user preference over the application default', () => {
const userPref = { ...appDefault, preferredPace: 'relaxed' };
expect(
resolveEffectivePreference(undefined, userPref, appDefault).preferredPace,
).toBe('relaxed');
});
it('prefers a trip-specific override over the persistent user preference', () => {
const userPref = { ...appDefault, preferredPace: 'relaxed' };
const override = { preferredPace: 'fast' };
expect(
resolveEffectivePreference(override, userPref, appDefault).preferredPace,
).toBe('fast');
});
it('merges field-by-field rather than replacing the whole object', () => {
const userPref = {
...appDefault,
preferredPace: 'relaxed',
childFriendlyPreferred: true,
};
const override = { preferredPace: 'fast' };
const result = resolveEffectivePreference(override, userPref, appDefault);
expect(result.preferredPace).toBe('fast');
expect(result.childFriendlyPreferred).toBe(true);
});
});

View File

@@ -0,0 +1,14 @@
import type { UserPreferenceFields } from '../../users/src';
/**
* Precedence: trip override > persistent user preference > application default.
* Merges field-by-field so an override touching only one field never masks the
* caller's other persisted preferences.
*/
export function resolveEffectivePreference(
override: Partial<UserPreferenceFields> | undefined,
userPreference: UserPreferenceFields | undefined,
appDefault: UserPreferenceFields,
): UserPreferenceFields {
return { ...appDefault, ...userPreference, ...override };
}

View File

@@ -0,0 +1,80 @@
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 { PreferenceSubject, TripPreferenceOverride } from './trip.types';
function toOverride(row: {
id: string;
trip_id: string;
user_id: string | null;
traveler_id: string | null;
overrides: unknown;
created_at: Date;
updated_at: Date;
}): TripPreferenceOverride {
return {
id: row.id,
tripId: row.trip_id,
userId: row.user_id,
travelerId: row.traveler_id,
overrides: (row.overrides ?? {}) as Record<string, unknown>,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
@Injectable()
export class TripPreferenceOverridesRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async listByTrip(tripId: string): Promise<TripPreferenceOverride[]> {
const rows = await this.db
.selectFrom('trip_preference_overrides')
.selectAll()
.where('trip_id', '=', tripId)
.execute();
return rows.map(toOverride);
}
async upsert(
tripId: string,
subject: PreferenceSubject,
overrides: Record<string, unknown>,
): Promise<TripPreferenceOverride> {
const userId = subject.type === 'USER' ? subject.userId : null;
const travelerId = subject.type === 'TRAVELER' ? subject.travelerId : null;
const row = await this.db
.insertInto('trip_preference_overrides')
.values({
trip_id: tripId,
user_id: userId,
traveler_id: travelerId,
overrides: overrides,
})
.onConflict((oc) =>
(subject.type === 'USER'
? oc.columns(['trip_id', 'user_id']).where('user_id', 'is not', null)
: oc
.columns(['trip_id', 'traveler_id'])
.where('traveler_id', 'is not', null)
).doUpdateSet({
overrides: overrides,
updated_at: new Date().toISOString(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
return toOverride(row);
}
async remove(tripId: string, overrideId: string): Promise<void> {
await this.db
.deleteFrom('trip_preference_overrides')
.where('trip_id', '=', tripId)
.where('id', '=', overrideId)
.execute();
}
}

View File

@@ -0,0 +1,23 @@
import { TripPreferenceOverridesService } from './trip-preference-overrides.service';
describe('TripPreferenceOverridesService', () => {
it('forwards the subject and overrides to the repository', async () => {
const created = {
id: 'o1',
tripId: 't1',
userId: 'u1',
travelerId: null,
overrides: { preferredPace: 'fast' },
};
const repository = { upsert: jest.fn().mockResolvedValue(created) };
const service = new TripPreferenceOverridesService(repository as never);
const subject = { type: 'USER' as const, userId: 'u1' };
await expect(
service.upsertOverride('t1', subject, { preferredPace: 'fast' }),
).resolves.toEqual(created);
expect(repository.upsert).toHaveBeenCalledWith('t1', subject, {
preferredPace: 'fast',
});
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { TripPreferenceOverridesRepository } from './trip-preference-overrides.repository';
import type { PreferenceSubject, TripPreferenceOverride } from './trip.types';
@Injectable()
export class TripPreferenceOverridesService {
constructor(private readonly repository: TripPreferenceOverridesRepository) {}
listOverrides(tripId: string): Promise<TripPreferenceOverride[]> {
return this.repository.listByTrip(tripId);
}
upsertOverride(
tripId: string,
subject: PreferenceSubject,
overrides: Record<string, unknown>,
): Promise<TripPreferenceOverride> {
return this.repository.upsert(tripId, subject, overrides);
}
removeOverride(tripId: string, overrideId: string): Promise<void> {
return this.repository.remove(tripId, overrideId);
}
}

View File

@@ -122,6 +122,24 @@ export interface UpdateTravelerDto {
linkedUserId?: string | null;
}
export type PreferenceSubject =
{ type: 'USER'; userId: string } | { type: 'TRAVELER'; travelerId: string };
export interface TripPreferenceOverride {
id: string;
tripId: string;
userId: string | null;
travelerId: string | null;
overrides: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface UpsertPreferenceOverrideDto {
subject: PreferenceSubject;
overrides: Record<string, unknown>;
}
export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
webResearchEnabled: false,
periodicAgentReviewEnabled: false,

View File

@@ -11,6 +11,8 @@ import { TripInvitationsRepository } from './trip-invitations.repository';
import { TripInvitationsService } from './trip-invitations.service';
import { TravelersRepository } from './travelers.repository';
import { TravelersService } from './travelers.service';
import { TripPreferenceOverridesRepository } from './trip-preference-overrides.repository';
import { TripPreferenceOverridesService } from './trip-preference-overrides.service';
@Module({
imports: [DatabaseModule],
@@ -26,6 +28,8 @@ import { TravelersService } from './travelers.service';
TripInvitationsService,
TravelersRepository,
TravelersService,
TripPreferenceOverridesRepository,
TripPreferenceOverridesService,
],
exports: [
TripsService,
@@ -34,6 +38,7 @@ import { TravelersService } from './travelers.service';
TripMembershipGuard,
TripInvitationsService,
TravelersService,
TripPreferenceOverridesService,
],
})
export class TripsLibModule {}