feat: add trip invitation creation and acceptance flow
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { TripInvitationsController } from './trip-invitations.controller';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
|
||||
describe('TripInvitationsController', () => {
|
||||
const currentUser: AuthenticatedUser = {
|
||||
id: 'owner-1',
|
||||
externalSubjectId: 'sub-1',
|
||||
displayName: 'Owner',
|
||||
email: 'owner@example.com',
|
||||
};
|
||||
|
||||
it('POST /trips/:tripId/invitations creates an invitation and returns the raw token', async () => {
|
||||
const result = {
|
||||
invitation: { id: 'inv-1', tripId: 't1', email: 'friend@example.com' },
|
||||
rawToken: 'raw-token',
|
||||
};
|
||||
const invitationsService = {
|
||||
createInvitation: jest.fn().mockResolvedValue(result),
|
||||
};
|
||||
const controller = new TripInvitationsController(
|
||||
invitationsService as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.create('t1', currentUser, { email: 'friend@example.com' }),
|
||||
).resolves.toEqual(result);
|
||||
expect(invitationsService.createInvitation).toHaveBeenCalledWith(
|
||||
't1',
|
||||
'owner-1',
|
||||
'friend@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /trips/:tripId/invitations lists invitations', async () => {
|
||||
const invitationsService = {
|
||||
listInvitations: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const controller = new TripInvitationsController(
|
||||
invitationsService as never,
|
||||
);
|
||||
|
||||
await expect(controller.list('t1')).resolves.toEqual([]);
|
||||
expect(invitationsService.listInvitations).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('DELETE /trips/:tripId/invitations/:invitationId removes an invitation', async () => {
|
||||
const invitationsService = {
|
||||
removeInvitation: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const controller = new TripInvitationsController(
|
||||
invitationsService as never,
|
||||
);
|
||||
|
||||
await controller.remove('t1', 'inv-1');
|
||||
expect(invitationsService.removeInvitation).toHaveBeenCalledWith(
|
||||
't1',
|
||||
'inv-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('POST /invitations/:token/accept accepts the invitation for the current user', async () => {
|
||||
const member = { id: 'm1', tripId: 't1', userId: 'owner-1' };
|
||||
const invitationsService = {
|
||||
acceptInvitation: jest.fn().mockResolvedValue(member),
|
||||
};
|
||||
const controller = new TripInvitationsController(
|
||||
invitationsService as never,
|
||||
);
|
||||
|
||||
await expect(controller.accept('raw-token', currentUser)).resolves.toEqual(
|
||||
member,
|
||||
);
|
||||
expect(invitationsService.acceptInvitation).toHaveBeenCalledWith(
|
||||
'raw-token',
|
||||
'owner-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
68
backend/apps/api/src/trips/trip-invitations.controller.ts
Normal file
68
backend/apps/api/src/trips/trip-invitations.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OidcAuthGuard } from '../../../../libs/auth/src';
|
||||
import type { AuthenticatedUser } from '../../../../libs/auth/src';
|
||||
import {
|
||||
TripInvitationsService,
|
||||
TripMembershipGuard,
|
||||
TripRoles,
|
||||
} from '../../../../libs/trips/src';
|
||||
import type { TripInvitation, TripMember } from '../../../../libs/trips/src';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
interface CreateTripInvitationDto {
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class TripInvitationsController {
|
||||
constructor(private readonly invitationsService: TripInvitationsService) {}
|
||||
|
||||
@Post('trips/:tripId/invitations')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@TripRoles('OWNER')
|
||||
create(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
@Body() dto: CreateTripInvitationDto,
|
||||
): Promise<{ invitation: TripInvitation; rawToken: string }> {
|
||||
return this.invitationsService.createInvitation(
|
||||
tripId,
|
||||
currentUser.id,
|
||||
dto.email,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('trips/:tripId/invitations')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@TripRoles('OWNER')
|
||||
list(@Param('tripId') tripId: string): Promise<TripInvitation[]> {
|
||||
return this.invitationsService.listInvitations(tripId);
|
||||
}
|
||||
|
||||
@Delete('trips/:tripId/invitations/:invitationId')
|
||||
@UseGuards(OidcAuthGuard, TripMembershipGuard)
|
||||
@TripRoles('OWNER')
|
||||
remove(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('invitationId') invitationId: string,
|
||||
): Promise<void> {
|
||||
return this.invitationsService.removeInvitation(tripId, invitationId);
|
||||
}
|
||||
|
||||
@Post('invitations/:token/accept')
|
||||
@UseGuards(OidcAuthGuard)
|
||||
accept(
|
||||
@Param('token') token: string,
|
||||
@CurrentUser() currentUser: AuthenticatedUser,
|
||||
): Promise<TripMember> {
|
||||
return this.invitationsService.acceptInvitation(token, currentUser.id);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,14 @@ import { AuthModule } from '../../../../libs/auth/src';
|
||||
import { TripsLibModule } from '../../../../libs/trips/src';
|
||||
import { TripsController } from './trips.controller';
|
||||
import { TripMembersController } from './trip-members.controller';
|
||||
import { TripInvitationsController } from './trip-invitations.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TripsLibModule],
|
||||
controllers: [TripsController, TripMembersController],
|
||||
controllers: [
|
||||
TripsController,
|
||||
TripMembersController,
|
||||
TripInvitationsController,
|
||||
],
|
||||
})
|
||||
export class TripsApiModule {}
|
||||
|
||||
Reference in New Issue
Block a user