feat: add trip invitation creation and acceptance flow

This commit is contained in:
Bastian Wagner
2026-08-17 15:21:08 +02:00
parent baae15dcbc
commit 6954024622
9 changed files with 456 additions and 1 deletions

View File

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

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

View File

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