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

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

View File

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

View File

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

View File

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