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 { 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 {}
|
||||
|
||||
Reference in New Issue
Block a user