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 {}
|
||||
|
||||
@@ -3,5 +3,6 @@ export * from './trips.service';
|
||||
export * from './trip-settings.service';
|
||||
export * from './trip-members.service';
|
||||
export * from './trip-membership.guard';
|
||||
export * from './trip-invitations.service';
|
||||
export * from './trip-roles.decorator';
|
||||
export * from './trips.module';
|
||||
|
||||
91
backend/libs/trips/src/trip-invitations.repository.ts
Normal file
91
backend/libs/trips/src/trip-invitations.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
119
backend/libs/trips/src/trip-invitations.service.spec.ts
Normal file
119
backend/libs/trips/src/trip-invitations.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
backend/libs/trips/src/trip-invitations.service.ts
Normal file
69
backend/libs/trips/src/trip-invitations.service.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,25 @@ export interface TripMember {
|
||||
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'> = {
|
||||
webResearchEnabled: false,
|
||||
periodicAgentReviewEnabled: false,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { TripSettingsService } from './trip-settings.service';
|
||||
import { TripMembersRepository } from './trip-members.repository';
|
||||
import { TripMembersService } from './trip-members.service';
|
||||
import { TripMembershipGuard } from './trip-membership.guard';
|
||||
import { TripInvitationsRepository } from './trip-invitations.repository';
|
||||
import { TripInvitationsService } from './trip-invitations.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
@@ -18,12 +20,15 @@ import { TripMembershipGuard } from './trip-membership.guard';
|
||||
TripMembersRepository,
|
||||
TripMembersService,
|
||||
TripMembershipGuard,
|
||||
TripInvitationsRepository,
|
||||
TripInvitationsService,
|
||||
],
|
||||
exports: [
|
||||
TripsService,
|
||||
TripSettingsService,
|
||||
TripMembersService,
|
||||
TripMembershipGuard,
|
||||
TripInvitationsService,
|
||||
],
|
||||
})
|
||||
export class TripsLibModule {}
|
||||
|
||||
Reference in New Issue
Block a user