diff --git a/backend/apps/api/src/trips/current-trip-role.decorator.ts b/backend/apps/api/src/trips/current-trip-role.decorator.ts new file mode 100644 index 0000000..9b6c884 --- /dev/null +++ b/backend/apps/api/src/trips/current-trip-role.decorator.ts @@ -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; + }, +); diff --git a/backend/apps/api/src/trips/trip-preference-overrides.controller.spec.ts b/backend/apps/api/src/trips/trip-preference-overrides.controller.spec.ts new file mode 100644 index 0000000..70e1447 --- /dev/null +++ b/backend/apps/api/src/trips/trip-preference-overrides.controller.spec.ts @@ -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'); + }); +}); diff --git a/backend/apps/api/src/trips/trip-preference-overrides.controller.ts b/backend/apps/api/src/trips/trip-preference-overrides.controller.ts new file mode 100644 index 0000000..21af7c1 --- /dev/null +++ b/backend/apps/api/src/trips/trip-preference-overrides.controller.ts @@ -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 { + return this.service.listOverrides(tripId); + } + + @Put() + async upsert( + @Param('tripId') tripId: string, + @CurrentUser() currentUser: AuthenticatedUser, + @CurrentTripRole() role: TripMemberRole, + @Body() dto: UpsertPreferenceOverrideDto, + ): Promise { + 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 { + return this.service.removeOverride(tripId, overrideId); + } +} diff --git a/backend/apps/api/src/trips/trips.module.ts b/backend/apps/api/src/trips/trips.module.ts index 7e4da2c..4df7732 100644 --- a/backend/apps/api/src/trips/trips.module.ts +++ b/backend/apps/api/src/trips/trips.module.ts @@ -5,6 +5,7 @@ import { TripsController } from './trips.controller'; import { TripMembersController } from './trip-members.controller'; import { TripInvitationsController } from './trip-invitations.controller'; import { TravelersController } from './travelers.controller'; +import { TripPreferenceOverridesController } from './trip-preference-overrides.controller'; @Module({ imports: [AuthModule, TripsLibModule], @@ -13,6 +14,7 @@ import { TravelersController } from './travelers.controller'; TripMembersController, TripInvitationsController, TravelersController, + TripPreferenceOverridesController, ], }) export class TripsApiModule {} diff --git a/backend/libs/trips/src/index.ts b/backend/libs/trips/src/index.ts index f721243..ebc3dda 100644 --- a/backend/libs/trips/src/index.ts +++ b/backend/libs/trips/src/index.ts @@ -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'; diff --git a/backend/libs/trips/src/preference-precedence.spec.ts b/backend/libs/trips/src/preference-precedence.spec.ts new file mode 100644 index 0000000..277bcf8 --- /dev/null +++ b/backend/libs/trips/src/preference-precedence.spec.ts @@ -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); + }); +}); diff --git a/backend/libs/trips/src/preference-precedence.ts b/backend/libs/trips/src/preference-precedence.ts new file mode 100644 index 0000000..fb211a8 --- /dev/null +++ b/backend/libs/trips/src/preference-precedence.ts @@ -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 | undefined, + userPreference: UserPreferenceFields | undefined, + appDefault: UserPreferenceFields, +): UserPreferenceFields { + return { ...appDefault, ...userPreference, ...override }; +} diff --git a/backend/libs/trips/src/trip-preference-overrides.repository.ts b/backend/libs/trips/src/trip-preference-overrides.repository.ts new file mode 100644 index 0000000..72a41a5 --- /dev/null +++ b/backend/libs/trips/src/trip-preference-overrides.repository.ts @@ -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, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +@Injectable() +export class TripPreferenceOverridesRepository { + constructor(@Inject(KYSELY_DB) private readonly db: Kysely) {} + + async listByTrip(tripId: string): Promise { + 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, + ): Promise { + 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 { + await this.db + .deleteFrom('trip_preference_overrides') + .where('trip_id', '=', tripId) + .where('id', '=', overrideId) + .execute(); + } +} diff --git a/backend/libs/trips/src/trip-preference-overrides.service.spec.ts b/backend/libs/trips/src/trip-preference-overrides.service.spec.ts new file mode 100644 index 0000000..6cf483c --- /dev/null +++ b/backend/libs/trips/src/trip-preference-overrides.service.spec.ts @@ -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', + }); + }); +}); diff --git a/backend/libs/trips/src/trip-preference-overrides.service.ts b/backend/libs/trips/src/trip-preference-overrides.service.ts new file mode 100644 index 0000000..61fea1b --- /dev/null +++ b/backend/libs/trips/src/trip-preference-overrides.service.ts @@ -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 { + return this.repository.listByTrip(tripId); + } + + upsertOverride( + tripId: string, + subject: PreferenceSubject, + overrides: Record, + ): Promise { + return this.repository.upsert(tripId, subject, overrides); + } + + removeOverride(tripId: string, overrideId: string): Promise { + return this.repository.remove(tripId, overrideId); + } +} diff --git a/backend/libs/trips/src/trip.types.ts b/backend/libs/trips/src/trip.types.ts index bf9259a..fb80c98 100644 --- a/backend/libs/trips/src/trip.types.ts +++ b/backend/libs/trips/src/trip.types.ts @@ -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; + createdAt: Date; + updatedAt: Date; +} + +export interface UpsertPreferenceOverrideDto { + subject: PreferenceSubject; + overrides: Record; +} + export const DEFAULT_TRIP_SETTINGS: Omit = { webResearchEnabled: false, periodicAgentReviewEnabled: false, diff --git a/backend/libs/trips/src/trips.module.ts b/backend/libs/trips/src/trips.module.ts index 35544f3..502b2a9 100644 --- a/backend/libs/trips/src/trips.module.ts +++ b/backend/libs/trips/src/trips.module.ts @@ -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 {}