xpeditis2.0/apps/backend/src/application/services/invitation.service.ts
David d65cb721b5
Some checks are pending
CD Production (Hetzner k3s) / Promote Images (preprod → prod) (push) Waiting to run
CD Production (Hetzner k3s) / Deploy to k3s (xpeditis-prod) (push) Blocked by required conditions
CD Production (Hetzner k3s) / Smoke Tests (push) Blocked by required conditions
CD Production (Hetzner k3s) / Deployment Summary (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Success (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Failure (push) Blocked by required conditions
chore: sync full codebase from cicd branch
Aligns main with the complete application codebase (cicd branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:56:44 +02:00

251 lines
8.2 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;
}
}