feat: add trip invitation creation and acceptance flow
This commit is contained in:
@@ -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