feat: add trip preference overrides with precedence resolution
This commit is contained in:
11
backend/apps/api/src/trips/current-trip-role.decorator.ts
Normal file
11
backend/apps/api/src/trips/current-trip-role.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { TripMemberRole } from '../../../../libs/trips/src';
|
||||||
|
|
||||||
|
export const CurrentTripRole = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): TripMemberRole => {
|
||||||
|
const request = ctx
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ tripMembership: { role: TripMemberRole } }>();
|
||||||
|
return request.tripMembership.role;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
|
||||||
|
describe('TripPreferenceOverridesController', () => {
|
||||||
|
const member: AuthenticatedUser = {
|
||||||
|
id: 'u1',
|
||||||
|
externalSubjectId: 'sub-1',
|
||||||
|
displayName: 'Alex',
|
||||||
|
email: 'a@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('GET /trips/:tripId/preference-overrides lists overrides', async () => {
|
||||||
|
const service = { listOverrides: jest.fn().mockResolvedValue([]) };
|
||||||
|
const controller = new TripPreferenceOverridesController(service as never);
|
||||||
|
|
||||||
|
await expect(controller.list('t1')).resolves.toEqual([]);
|
||||||
|
expect(service.listOverrides).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a member to upsert their own USER override', async () => {
|
||||||
|
const dto = {
|
||||||
|
subject: { type: 'USER' as const, userId: 'u1' },
|
||||||
|
overrides: { preferredPace: 'fast' },
|
||||||
|
};
|
||||||
|
const updated = { id: 'o1', tripId: 't1', ...dto };
|
||||||
|
const service = { upsertOverride: jest.fn().mockResolvedValue(updated) };
|
||||||
|
const controller = new TripPreferenceOverridesController(service as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.upsert('t1', member, 'MEMBER', dto),
|
||||||
|
).resolves.toEqual(updated);
|
||||||
|
expect(service.upsertOverride).toHaveBeenCalledWith(
|
||||||
|
't1',
|
||||||
|
dto.subject,
|
||||||
|
dto.overrides,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a non-owner member from setting another user override', async () => {
|
||||||
|
const dto = {
|
||||||
|
subject: { type: 'USER' as const, userId: 'someone-else' },
|
||||||
|
overrides: {},
|
||||||
|
};
|
||||||
|
const service = { upsertOverride: jest.fn() };
|
||||||
|
const controller = new TripPreferenceOverridesController(service as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.upsert('t1', member, 'MEMBER', dto),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
expect(service.upsertOverride).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a non-owner member from setting a traveler override', async () => {
|
||||||
|
const dto = {
|
||||||
|
subject: { type: 'TRAVELER' as const, travelerId: 'trav-1' },
|
||||||
|
overrides: {},
|
||||||
|
};
|
||||||
|
const service = { upsertOverride: jest.fn() };
|
||||||
|
const controller = new TripPreferenceOverridesController(service as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.upsert('t1', member, 'MEMBER', dto),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an OWNER to set overrides for any subject', async () => {
|
||||||
|
const dto = {
|
||||||
|
subject: { type: 'TRAVELER' as const, travelerId: 'trav-1' },
|
||||||
|
overrides: {},
|
||||||
|
};
|
||||||
|
const updated = { id: 'o1', tripId: 't1', ...dto };
|
||||||
|
const service = { upsertOverride: jest.fn().mockResolvedValue(updated) };
|
||||||
|
const controller = new TripPreferenceOverridesController(service as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.upsert('t1', member, 'OWNER', dto),
|
||||||
|
).resolves.toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /trips/:tripId/preference-overrides/:overrideId removes an override', async () => {
|
||||||
|
const service = { removeOverride: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const controller = new TripPreferenceOverridesController(service as never);
|
||||||
|
|
||||||
|
await controller.remove('t1', 'o1');
|
||||||
|
expect(service.removeOverride).toHaveBeenCalledWith('t1', 'o1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||||
|
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||||
|
import {
|
||||||
|
TripMembershipGuard,
|
||||||
|
TripPreferenceOverridesService,
|
||||||
|
} from '../../../../libs/trips/src';
|
||||||
|
import type {
|
||||||
|
TripMemberRole,
|
||||||
|
TripPreferenceOverride,
|
||||||
|
UpsertPreferenceOverrideDto,
|
||||||
|
} from '../../../../libs/trips/src';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { CurrentTripRole } from './current-trip-role.decorator';
|
||||||
|
|
||||||
|
@Controller('trips/:tripId/preference-overrides')
|
||||||
|
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||||
|
export class TripPreferenceOverridesController {
|
||||||
|
constructor(private readonly service: TripPreferenceOverridesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Param('tripId') tripId: string): Promise<TripPreferenceOverride[]> {
|
||||||
|
return this.service.listOverrides(tripId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
async upsert(
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@CurrentUser() currentUser: AuthenticatedUser,
|
||||||
|
@CurrentTripRole() role: TripMemberRole,
|
||||||
|
@Body() dto: UpsertPreferenceOverrideDto,
|
||||||
|
): Promise<TripPreferenceOverride> {
|
||||||
|
const isOwnUserOverride =
|
||||||
|
dto.subject.type === 'USER' && dto.subject.userId === currentUser.id;
|
||||||
|
if (role !== 'OWNER' && !isOwnUserOverride) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only the trip owner may set preference overrides for other members or travelers',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.service.upsertOverride(tripId, dto.subject, dto.overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':overrideId')
|
||||||
|
remove(
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@Param('overrideId') overrideId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.removeOverride(tripId, overrideId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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';
|
import { TravelersController } from './travelers.controller';
|
||||||
|
import { TripPreferenceOverridesController } from './trip-preference-overrides.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TripsLibModule],
|
imports: [AuthModule, TripsLibModule],
|
||||||
@@ -13,6 +14,7 @@ import { TravelersController } from './travelers.controller';
|
|||||||
TripMembersController,
|
TripMembersController,
|
||||||
TripInvitationsController,
|
TripInvitationsController,
|
||||||
TravelersController,
|
TravelersController,
|
||||||
|
TripPreferenceOverridesController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TripsApiModule {}
|
export class TripsApiModule {}
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ 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 './travelers.service';
|
||||||
|
export * from './trip-preference-overrides.service';
|
||||||
|
export * from './preference-precedence';
|
||||||
export * from './trip-roles.decorator';
|
export * from './trip-roles.decorator';
|
||||||
export * from './trips.module';
|
export * from './trips.module';
|
||||||
|
|||||||
47
backend/libs/trips/src/preference-precedence.spec.ts
Normal file
47
backend/libs/trips/src/preference-precedence.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
backend/libs/trips/src/preference-precedence.ts
Normal file
14
backend/libs/trips/src/preference-precedence.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
24
backend/libs/trips/src/trip-preference-overrides.service.ts
Normal file
24
backend/libs/trips/src/trip-preference-overrides.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,24 @@ export interface UpdateTravelerDto {
|
|||||||
linkedUserId?: string | null;
|
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'> = {
|
export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
|
||||||
webResearchEnabled: false,
|
webResearchEnabled: false,
|
||||||
periodicAgentReviewEnabled: false,
|
periodicAgentReviewEnabled: false,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { TripInvitationsRepository } from './trip-invitations.repository';
|
|||||||
import { TripInvitationsService } from './trip-invitations.service';
|
import { TripInvitationsService } from './trip-invitations.service';
|
||||||
import { TravelersRepository } from './travelers.repository';
|
import { TravelersRepository } from './travelers.repository';
|
||||||
import { TravelersService } from './travelers.service';
|
import { TravelersService } from './travelers.service';
|
||||||
|
import { TripPreferenceOverridesRepository } from './trip-preference-overrides.repository';
|
||||||
|
import { TripPreferenceOverridesService } from './trip-preference-overrides.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule],
|
imports: [DatabaseModule],
|
||||||
@@ -26,6 +28,8 @@ import { TravelersService } from './travelers.service';
|
|||||||
TripInvitationsService,
|
TripInvitationsService,
|
||||||
TravelersRepository,
|
TravelersRepository,
|
||||||
TravelersService,
|
TravelersService,
|
||||||
|
TripPreferenceOverridesRepository,
|
||||||
|
TripPreferenceOverridesService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TripsService,
|
TripsService,
|
||||||
@@ -34,6 +38,7 @@ import { TravelersService } from './travelers.service';
|
|||||||
TripMembershipGuard,
|
TripMembershipGuard,
|
||||||
TripInvitationsService,
|
TripInvitationsService,
|
||||||
TravelersService,
|
TravelersService,
|
||||||
|
TripPreferenceOverridesService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TripsLibModule {}
|
export class TripsLibModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user