All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
254 lines
8.3 KiB
TypeScript
254 lines
8.3 KiB
TypeScript
import {
|
|
Injectable,
|
|
Inject,
|
|
Logger,
|
|
ConflictException,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import {
|
|
InvitationTokenRepository,
|
|
INVITATION_TOKEN_REPOSITORY,
|
|
} from '@domain/ports/out/invitation-token.repository';
|
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
import {
|
|
OrganizationRepository,
|
|
ORGANIZATION_REPOSITORY,
|
|
} from '@domain/ports/out/organization.repository';
|
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
|
import { InvitationToken } from '@domain/entities/invitation-token.entity';
|
|
import { UserRole } from '@domain/entities/user.entity';
|
|
import { SubscriptionService } from './subscription.service';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import * as crypto from 'crypto';
|
|
|
|
@Injectable()
|
|
export class InvitationService {
|
|
private readonly logger = new Logger(InvitationService.name);
|
|
|
|
constructor(
|
|
@Inject(INVITATION_TOKEN_REPOSITORY)
|
|
private readonly invitationRepository: InvitationTokenRepository,
|
|
@Inject(USER_REPOSITORY)
|
|
private readonly userRepository: UserRepository,
|
|
@Inject(ORGANIZATION_REPOSITORY)
|
|
private readonly organizationRepository: OrganizationRepository,
|
|
@Inject(EMAIL_PORT)
|
|
private readonly emailService: EmailPort,
|
|
private readonly configService: ConfigService,
|
|
private readonly subscriptionService: SubscriptionService
|
|
) {}
|
|
|
|
/**
|
|
* Create an invitation and send email
|
|
*/
|
|
async createInvitation(
|
|
email: string,
|
|
firstName: string,
|
|
lastName: string,
|
|
role: UserRole,
|
|
organizationId: string,
|
|
invitedById: string,
|
|
inviterRole?: string
|
|
): Promise<InvitationToken> {
|
|
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
|
|
|
|
// Check if user already exists
|
|
const existingUser = await this.userRepository.findByEmail(email);
|
|
if (existingUser) {
|
|
throw new ConflictException('A user with this email already exists');
|
|
}
|
|
|
|
// Check if there's already an active invitation for this email
|
|
const existingInvitation = await this.invitationRepository.findActiveByEmail(email);
|
|
if (existingInvitation) {
|
|
throw new ConflictException(
|
|
'An active invitation for this email already exists. Please wait for it to expire or be used.'
|
|
);
|
|
}
|
|
|
|
// Check if licenses are available for this organization
|
|
const canInviteResult = await this.subscriptionService.canInviteUser(
|
|
organizationId,
|
|
inviterRole
|
|
);
|
|
if (!canInviteResult.canInvite) {
|
|
this.logger.warn(
|
|
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
|
|
);
|
|
throw new ForbiddenException(
|
|
canInviteResult.message ||
|
|
`License limit reached. Please upgrade your subscription to invite more users.`
|
|
);
|
|
}
|
|
|
|
// Generate unique token
|
|
const token = this.generateToken();
|
|
|
|
// Set expiration date (7 days from now)
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
|
|
// Create invitation token
|
|
const invitation = InvitationToken.create({
|
|
id: uuidv4(),
|
|
token,
|
|
email,
|
|
firstName,
|
|
lastName,
|
|
role,
|
|
organizationId,
|
|
invitedById,
|
|
expiresAt,
|
|
});
|
|
|
|
// Save invitation
|
|
const savedInvitation = await this.invitationRepository.save(invitation);
|
|
|
|
// Send invitation email (async - don't block on email sending)
|
|
this.logger.log(`[INVITATION] About to send email to ${email}...`);
|
|
this.sendInvitationEmail(savedInvitation).catch(err => {
|
|
this.logger.error(`[INVITATION] ❌ Failed to send invitation email to ${email}`, err);
|
|
this.logger.error(`[INVITATION] Error message: ${err?.message}`);
|
|
this.logger.error(`[INVITATION] Error stack: ${err?.stack?.substring(0, 500)}`);
|
|
});
|
|
|
|
this.logger.log(`Invitation created successfully for ${email}`);
|
|
|
|
return savedInvitation;
|
|
}
|
|
|
|
/**
|
|
* Verify invitation token
|
|
*/
|
|
async verifyInvitation(token: string): Promise<InvitationToken> {
|
|
const invitation = await this.invitationRepository.findByToken(token);
|
|
|
|
if (!invitation) {
|
|
throw new NotFoundException('Invitation not found');
|
|
}
|
|
|
|
if (invitation.isUsed) {
|
|
throw new BadRequestException('This invitation has already been used');
|
|
}
|
|
|
|
if (invitation.isExpired()) {
|
|
throw new BadRequestException('This invitation has expired');
|
|
}
|
|
|
|
return invitation;
|
|
}
|
|
|
|
/**
|
|
* Mark invitation as used
|
|
*/
|
|
async markInvitationAsUsed(token: string): Promise<void> {
|
|
const invitation = await this.verifyInvitation(token);
|
|
|
|
invitation.markAsUsed();
|
|
|
|
await this.invitationRepository.update(invitation);
|
|
|
|
this.logger.log(`Invitation ${token} marked as used`);
|
|
}
|
|
|
|
/**
|
|
* Get all invitations for an organization
|
|
*/
|
|
async getOrganizationInvitations(organizationId: string): Promise<InvitationToken[]> {
|
|
return this.invitationRepository.findByOrganization(organizationId);
|
|
}
|
|
|
|
/**
|
|
* Generate a secure random token
|
|
*/
|
|
private generateToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
/**
|
|
* Send invitation email
|
|
*/
|
|
private async sendInvitationEmail(invitation: InvitationToken): Promise<void> {
|
|
this.logger.log(`[INVITATION] 🚀 sendInvitationEmail called for ${invitation.email}`);
|
|
|
|
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
|
|
const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;
|
|
|
|
this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);
|
|
this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`);
|
|
|
|
// Get organization details
|
|
this.logger.log(`[INVITATION] Fetching organization ${invitation.organizationId}...`);
|
|
const organization = await this.organizationRepository.findById(invitation.organizationId);
|
|
if (!organization) {
|
|
this.logger.error(`[INVITATION] ❌ Organization not found: ${invitation.organizationId}`);
|
|
throw new NotFoundException('Organization not found');
|
|
}
|
|
this.logger.log(`[INVITATION] ✅ Organization found: ${organization.name}`);
|
|
|
|
// Get inviter details
|
|
this.logger.log(`[INVITATION] Fetching inviter ${invitation.invitedById}...`);
|
|
const inviter = await this.userRepository.findById(invitation.invitedById);
|
|
if (!inviter) {
|
|
this.logger.error(`[INVITATION] ❌ Inviter not found: ${invitation.invitedById}`);
|
|
throw new NotFoundException('Inviter user not found');
|
|
}
|
|
|
|
const inviterName = `${inviter.firstName} ${inviter.lastName}`;
|
|
this.logger.log(`[INVITATION] ✅ Inviter found: ${inviterName}`);
|
|
|
|
try {
|
|
this.logger.log(`[INVITATION] 📧 Calling emailService.sendInvitationWithToken...`);
|
|
await this.emailService.sendInvitationWithToken(
|
|
invitation.email,
|
|
invitation.firstName,
|
|
invitation.lastName,
|
|
organization.name,
|
|
inviterName,
|
|
invitationLink,
|
|
invitation.expiresAt
|
|
);
|
|
|
|
this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`);
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`,
|
|
error
|
|
);
|
|
this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel (delete) a pending invitation
|
|
*/
|
|
async cancelInvitation(invitationId: string, organizationId: string): Promise<void> {
|
|
const invitations = await this.invitationRepository.findByOrganization(organizationId);
|
|
const invitation = invitations.find(inv => inv.id === invitationId);
|
|
|
|
if (!invitation) {
|
|
throw new NotFoundException('Invitation not found');
|
|
}
|
|
|
|
if (invitation.isUsed) {
|
|
throw new BadRequestException('Cannot delete an invitation that has already been used');
|
|
}
|
|
|
|
await this.invitationRepository.deleteById(invitationId);
|
|
this.logger.log(`Invitation ${invitationId} cancelled`);
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired invitations (can be called by a cron job)
|
|
*/
|
|
async cleanupExpiredInvitations(): Promise<number> {
|
|
const count = await this.invitationRepository.deleteExpired();
|
|
this.logger.log(`Cleaned up ${count} expired invitations`);
|
|
return count;
|
|
}
|
|
}
|