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 { 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 { 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 { 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 { 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 { this.logger.log(`[INVITATION] 🚀 sendInvitationEmail called for ${invitation.email}`); const frontendUrl = this.configService.get('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 { 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 { const count = await this.invitationRepository.deleteExpired(); this.logger.log(`Cleaned up ${count} expired invitations`); return count; } }