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 { TripsLibModule } from '../../../../libs/trips/src';
import { TripsController } from './trips.controller'; import { TripsController } from './trips.controller';
import { TripMembersController } from './trip-members.controller'; import { TripMembersController } from './trip-members.controller';
import { TripInvitationsController } from './trip-invitations.controller';
@Module({ @Module({
imports: [AuthModule, TripsLibModule], imports: [AuthModule, TripsLibModule],
controllers: [TripsController, TripMembersController], controllers: [
TripsController,
TripMembersController,
TripInvitationsController,
],
}) })
export class TripsApiModule {} export class TripsApiModule {}

View File

@@ -3,5 +3,6 @@ export * from './trips.service';
export * from './trip-settings.service'; export * from './trip-settings.service';
export * from './trip-members.service'; export * from './trip-members.service';
export * from './trip-membership.guard'; export * from './trip-membership.guard';
export * from './trip-invitations.service';
export * from './trip-roles.decorator'; export * from './trip-roles.decorator';
export * from './trips.module'; export * from './trips.module';

View File

@@ -0,0 +1,91 @@
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 { CreateTripInvitationFields, TripInvitation } from './trip.types';
function toTripInvitation(row: {
id: string;
trip_id: string;
email: string;
invited_by_user_id: string;
token_hash: string;
expires_at: Date;
accepted_at: Date | null;
created_at: Date;
updated_at: Date;
}): TripInvitation {
return {
id: row.id,
tripId: row.trip_id,
email: row.email,
invitedByUserId: row.invited_by_user_id,
tokenHash: row.token_hash,
expiresAt: row.expires_at,
acceptedAt: row.accepted_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
@Injectable()
export class TripInvitationsRepository {
constructor(@Inject(KYSELY_DB) private readonly db: Kysely<Database>) {}
async create(
tripId: string,
fields: CreateTripInvitationFields,
): Promise<TripInvitation> {
const row = await this.db
.insertInto('trip_invitations')
.values({
trip_id: tripId,
email: fields.email,
invited_by_user_id: fields.invitedByUserId,
token_hash: fields.tokenHash,
expires_at: fields.expiresAt.toISOString(),
})
.returningAll()
.executeTakeFirstOrThrow();
return toTripInvitation(row);
}
async findByTokenHash(
tokenHash: string,
): Promise<TripInvitation | undefined> {
const row = await this.db
.selectFrom('trip_invitations')
.selectAll()
.where('token_hash', '=', tokenHash)
.executeTakeFirst();
return row ? toTripInvitation(row) : undefined;
}
async listByTrip(tripId: string): Promise<TripInvitation[]> {
const rows = await this.db
.selectFrom('trip_invitations')
.selectAll()
.where('trip_id', '=', tripId)
.execute();
return rows.map(toTripInvitation);
}
async markAccepted(invitationId: string): Promise<void> {
await this.db
.updateTable('trip_invitations')
.set({
accepted_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.where('id', '=', invitationId)
.execute();
}
async remove(tripId: string, invitationId: string): Promise<void> {
await this.db
.deleteFrom('trip_invitations')
.where('trip_id', '=', tripId)
.where('id', '=', invitationId)
.execute();
}
}

View File

@@ -0,0 +1,119 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { TripInvitationsService } from './trip-invitations.service';
describe('TripInvitationsService.acceptInvitation', () => {
const future = new Date(Date.now() + 60_000);
const past = new Date(Date.now() - 60_000);
it('rejects accepting the same invitation twice', async () => {
const repo = {
findByTokenHash: jest.fn().mockResolvedValue({
id: 'inv-1',
tripId: 't1',
expiresAt: future,
acceptedAt: new Date(),
}),
};
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
await expect(
service.acceptInvitation('raw-token', 'user-3'),
).rejects.toThrow(ConflictException);
expect(members.upsertActiveMember).not.toHaveBeenCalled();
});
it('rejects an expired invitation', async () => {
const repo = {
findByTokenHash: jest.fn().mockResolvedValue({
id: 'inv-1',
tripId: 't1',
expiresAt: past,
acceptedAt: null,
}),
};
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
await expect(
service.acceptInvitation('raw-token', 'user-2'),
).rejects.toThrow(BadRequestException);
expect(members.upsertActiveMember).not.toHaveBeenCalled();
});
it('rejects an unknown token without leaking whether the trip exists', async () => {
const repo = { findByTokenHash: jest.fn().mockResolvedValue(undefined) };
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
await expect(
service.acceptInvitation('does-not-exist', 'user-2'),
).rejects.toThrow(NotFoundException);
});
it('accepts a fresh, unexpired invitation and activates trip membership', async () => {
const repo = {
findByTokenHash: jest.fn().mockResolvedValue({
id: 'inv-1',
tripId: 't1',
expiresAt: future,
acceptedAt: null,
}),
markAccepted: jest.fn().mockResolvedValue(undefined),
};
const members = {
upsertActiveMember: jest
.fn()
.mockResolvedValue({ id: 'm1', tripId: 't1', userId: 'user-2' }),
};
const service = new TripInvitationsService(repo as never, members as never);
const member = await service.acceptInvitation('raw-token', 'user-2');
expect(repo.markAccepted).toHaveBeenCalledWith('inv-1');
expect(members.upsertActiveMember).toHaveBeenCalledWith(
't1',
'user-2',
'MEMBER',
);
expect(member).toEqual({ id: 'm1', tripId: 't1', userId: 'user-2' });
});
});
describe('TripInvitationsService.createInvitation', () => {
it('stores only a hash of the generated token and returns the raw token once', async () => {
const repo = {
create: jest
.fn()
.mockImplementation((tripId: string, fields: Record<string, unknown>) =>
Promise.resolve({ id: 'inv-1', tripId, ...fields }),
),
};
const members = { upsertActiveMember: jest.fn() };
const service = new TripInvitationsService(repo as never, members as never);
const result = await service.createInvitation(
't1',
'owner-1',
'friend@example.com',
);
expect(result.rawToken).toEqual(expect.any(String));
expect(repo.create).toHaveBeenCalledWith(
't1',
expect.objectContaining({
email: 'friend@example.com',
invitedByUserId: 'owner-1',
}),
);
const [, fields] = repo.create.mock.calls[0] as [
string,
{ tokenHash: string },
];
expect(fields.tokenHash).not.toBe(result.rawToken);
});
});

View File

@@ -0,0 +1,69 @@
import { randomBytes, createHash } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TripInvitationsRepository } from './trip-invitations.repository';
import { TripMembersRepository } from './trip-members.repository';
import type { TripInvitation, TripMember } from './trip.types';
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function hashToken(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
@Injectable()
export class TripInvitationsService {
constructor(
private readonly invitationsRepository: TripInvitationsRepository,
private readonly membersRepository: TripMembersRepository,
) {}
async createInvitation(
tripId: string,
invitedByUserId: string,
email: string,
ttlMs = DEFAULT_TTL_MS,
): Promise<{ invitation: TripInvitation; rawToken: string }> {
const rawToken = randomBytes(32).toString('base64url');
const invitation = await this.invitationsRepository.create(tripId, {
email,
invitedByUserId,
tokenHash: hashToken(rawToken),
expiresAt: new Date(Date.now() + ttlMs),
});
return { invitation, rawToken };
}
listInvitations(tripId: string): Promise<TripInvitation[]> {
return this.invitationsRepository.listByTrip(tripId);
}
removeInvitation(tripId: string, invitationId: string): Promise<void> {
return this.invitationsRepository.remove(tripId, invitationId);
}
async acceptInvitation(
rawToken: string,
acceptingUserId: string,
): Promise<TripMember> {
const invitation = await this.invitationsRepository.findByTokenHash(
hashToken(rawToken),
);
if (!invitation) throw new NotFoundException('Invitation not found');
if (invitation.acceptedAt)
throw new ConflictException('Invitation has already been accepted');
if (invitation.expiresAt.getTime() < Date.now())
throw new BadRequestException('Invitation has expired');
await this.invitationsRepository.markAccepted(invitation.id);
return this.membersRepository.upsertActiveMember(
invitation.tripId,
acceptingUserId,
'MEMBER',
);
}
}

View File

@@ -78,6 +78,25 @@ export interface TripMember {
updatedAt: Date; updatedAt: Date;
} }
export interface TripInvitation {
id: string;
tripId: string;
email: string;
invitedByUserId: string;
tokenHash: string;
expiresAt: Date;
acceptedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTripInvitationFields {
email: string;
invitedByUserId: string;
tokenHash: string;
expiresAt: Date;
}
export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = { export const DEFAULT_TRIP_SETTINGS: Omit<TripSettings, 'tripId'> = {
webResearchEnabled: false, webResearchEnabled: false,
periodicAgentReviewEnabled: false, periodicAgentReviewEnabled: false,

View File

@@ -7,6 +7,8 @@ import { TripSettingsService } from './trip-settings.service';
import { TripMembersRepository } from './trip-members.repository'; import { TripMembersRepository } from './trip-members.repository';
import { TripMembersService } from './trip-members.service'; import { TripMembersService } from './trip-members.service';
import { TripMembershipGuard } from './trip-membership.guard'; import { TripMembershipGuard } from './trip-membership.guard';
import { TripInvitationsRepository } from './trip-invitations.repository';
import { TripInvitationsService } from './trip-invitations.service';
@Module({ @Module({
imports: [DatabaseModule], imports: [DatabaseModule],
@@ -18,12 +20,15 @@ import { TripMembershipGuard } from './trip-membership.guard';
TripMembersRepository, TripMembersRepository,
TripMembersService, TripMembersService,
TripMembershipGuard, TripMembershipGuard,
TripInvitationsRepository,
TripInvitationsService,
], ],
exports: [ exports: [
TripsService, TripsService,
TripSettingsService, TripSettingsService,
TripMembersService, TripMembersService,
TripMembershipGuard, TripMembershipGuard,
TripInvitationsService,
], ],
}) })
export class TripsLibModule {} export class TripsLibModule {}