diff --git a/backend/apps/api/src/trips/trip-invitations.controller.spec.ts b/backend/apps/api/src/trips/trip-invitations.controller.spec.ts new file mode 100644 index 0000000..5ff7bf2 --- /dev/null +++ b/backend/apps/api/src/trips/trip-invitations.controller.spec.ts @@ -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', + ); + }); +}); diff --git a/backend/apps/api/src/trips/trip-invitations.controller.ts b/backend/apps/api/src/trips/trip-invitations.controller.ts new file mode 100644 index 0000000..f286197 --- /dev/null +++ b/backend/apps/api/src/trips/trip-invitations.controller.ts @@ -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 { + 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 { + return this.invitationsService.removeInvitation(tripId, invitationId); + } + + @Post('invitations/:token/accept') + @UseGuards(OidcAuthGuard) + accept( + @Param('token') token: string, + @CurrentUser() currentUser: AuthenticatedUser, + ): Promise { + return this.invitationsService.acceptInvitation(token, currentUser.id); + } +} diff --git a/backend/apps/api/src/trips/trips.module.ts b/backend/apps/api/src/trips/trips.module.ts index cf95983..0d43e53 100644 --- a/backend/apps/api/src/trips/trips.module.ts +++ b/backend/apps/api/src/trips/trips.module.ts @@ -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 {} diff --git a/backend/libs/trips/src/index.ts b/backend/libs/trips/src/index.ts index d04ef17..9b607d7 100644 --- a/backend/libs/trips/src/index.ts +++ b/backend/libs/trips/src/index.ts @@ -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'; diff --git a/backend/libs/trips/src/trip-invitations.repository.ts b/backend/libs/trips/src/trip-invitations.repository.ts new file mode 100644 index 0000000..d9921a8 --- /dev/null +++ b/backend/libs/trips/src/trip-invitations.repository.ts @@ -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) {} + + async create( + tripId: string, + fields: CreateTripInvitationFields, + ): Promise { + 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 { + const row = await this.db + .selectFrom('trip_invitations') + .selectAll() + .where('token_hash', '=', tokenHash) + .executeTakeFirst(); + return row ? toTripInvitation(row) : undefined; + } + + async listByTrip(tripId: string): Promise { + const rows = await this.db + .selectFrom('trip_invitations') + .selectAll() + .where('trip_id', '=', tripId) + .execute(); + return rows.map(toTripInvitation); + } + + async markAccepted(invitationId: string): Promise { + 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 { + await this.db + .deleteFrom('trip_invitations') + .where('trip_id', '=', tripId) + .where('id', '=', invitationId) + .execute(); + } +} diff --git a/backend/libs/trips/src/trip-invitations.service.spec.ts b/backend/libs/trips/src/trip-invitations.service.spec.ts new file mode 100644 index 0000000..93cb518 --- /dev/null +++ b/backend/libs/trips/src/trip-invitations.service.spec.ts @@ -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) => + 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); + }); +}); diff --git a/backend/libs/trips/src/trip-invitations.service.ts b/backend/libs/trips/src/trip-invitations.service.ts new file mode 100644 index 0000000..c29cb5f --- /dev/null +++ b/backend/libs/trips/src/trip-invitations.service.ts @@ -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 { + return this.invitationsRepository.listByTrip(tripId); + } + + removeInvitation(tripId: string, invitationId: string): Promise { + return this.invitationsRepository.remove(tripId, invitationId); + } + + async acceptInvitation( + rawToken: string, + acceptingUserId: string, + ): Promise { + 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', + ); + } +} diff --git a/backend/libs/trips/src/trip.types.ts b/backend/libs/trips/src/trip.types.ts index b21014e..06d73f4 100644 --- a/backend/libs/trips/src/trip.types.ts +++ b/backend/libs/trips/src/trip.types.ts @@ -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 = { webResearchEnabled: false, periodicAgentReviewEnabled: false, diff --git a/backend/libs/trips/src/trips.module.ts b/backend/libs/trips/src/trips.module.ts index cdcb8b3..ea6f6da 100644 --- a/backend/libs/trips/src/trips.module.ts +++ b/backend/libs/trips/src/trips.module.ts @@ -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 {}