import { EntityManager } from "@mikro-orm/postgresql"; import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; import { BusinessMessage, CommonMessage, DomainMessage, MailServerMessage, RoleMessage, TemplateMessage, UserMessage, } from "../../../common/enums/message.enum"; import { QUOTA_CONSTANTS } from "../../businesses/constant"; import { Business } from "../../businesses/entities/business.entity"; import { DomainStatus } from "../../domains/enums/domain-status.enum"; import { DomainAutomationService } from "../../domains/services/domain-automation.service"; import { DomainsService } from "../../domains/services/domains.service"; import { UpdateUserDto } from "../../mail-server/DTO/update-user.dto"; import { MailServerService } from "../../mail-server/services/mail-server.service"; import { PushNotificationService } from "../../notifications/services/push-notification.service"; import { QuotaSyncService } from "../../quota-sync/services/quota-sync.service"; import { UserSettingsService } from "../../settings/services/user-settings.service"; import { Template } from "../../templates/entities/template.entity"; import { PasswordService } from "../../utils/services/password.service"; import { CreateEmailUserDto } from "../DTO/create-email-user.dto"; import { RegisterPushSubscriptionDto, TestPushNotificationDto, UpdatePushNotificationSettingsDto, UpdatePushSubscriptionsDto, } from "../DTO/push-notification.dto"; import { UpdateEmailUserDto } from "../DTO/update-email-user.dto"; import { UpdateUserProfileDto } from "../DTO/update-user-profile.dto"; import { UserListQueryDto } from "../DTO/user-list-query.dto"; import { Role } from "../entities/role.entity"; import { User } from "../entities/user.entity"; import { RoleEnum } from "../enums/role.enum"; import { UserRepository } from "../repositories/user.repository"; @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); constructor( private readonly userRepository: UserRepository, private readonly em: EntityManager, private readonly passwordService: PasswordService, private readonly mailServerService: MailServerService, private readonly domainsService: DomainsService, private readonly domainAutomationService: DomainAutomationService, private readonly quotaSyncService: QuotaSyncService, private readonly userSettingsService: UserSettingsService, private readonly pushNotificationService: PushNotificationService, ) {} async getUserEmailIdFromId(userId: string) { const user = await this.userRepository.findOne({ id: userId }, { populate: ["role"] }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); return user.wildduckUserId; } async findOneWithEmail(email: string, businessId: string) { const user = await this.userRepository.findOne({ emailAddress: email, business: { id: businessId } }, { populate: ["role"] }); return user; } async findOneById(id: string) { const user = await this.userRepository.findOne({ id }, { populate: ["role"] }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); return { user }; } /*******************************/ async getMe(userId: string) { const user = await this.userRepository.findOne({ id: userId }, { exclude: ["password"], populate: ["role"] }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); const totalQuota = Number(user.emailQuota); const usedQuota = Number(user.emailQuotaUsed); const remainingQuota = totalQuota - usedQuota; return { user, quota: { total: totalQuota, used: usedQuota, remaining: remainingQuota, totalInMB: Math.round(totalQuota / (1024 * 1024)), totalInGB: Math.round(totalQuota / (1024 * 1024 * 1024)), usedInMB: Math.round(usedQuota / (1024 * 1024)), usedInGB: Math.round(usedQuota / (1024 * 1024 * 1024)), remainingInMB: Math.round(remainingQuota / (1024 * 1024)), remainingInGB: Math.round(remainingQuota / (1024 * 1024 * 1024)), usagePercentage: totalQuota > 0 ? Math.round((usedQuota / totalQuota) * 100) : 0, }, }; } /*******************************/ async updateProfile(userId: string, updateMeDto: UpdateUserProfileDto) { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); this.userRepository.assign(user, updateMeDto); await this.em.flush(); return { message: UserMessage.USER_UPDATED_SUCCESSFULLY, user }; } /*******************************/ async findOneByIdWithEntityManager(id: string, em: EntityManager) { const user = await em.findOne(User, { id }, { populate: ["role"] }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); return user; } /*******************************/ async createEmailUser(createEmailUserDto: CreateEmailUserDto, businessId: string) { const em = this.em.fork(); try { em.begin(); const { username, password, domainId, displayName, quota, aliases, title, templateId, forwarders } = createEmailUserDto; const business = await em.findOne(Business, { id: businessId }); if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); // Check if business has enough quota const remainingQuota = Number(business.remainingQuota || 0); if (remainingQuota < QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION) { throw new BadRequestException("Insufficient quota to create user. Please upgrade your plan."); } const domain = await this.domainsService.getDomainById(domainId, businessId); if (domain.business.id !== businessId) throw new BadRequestException(DomainMessage.NOT_BELONG_TO_BUSINESS); if (domain.status !== DomainStatus.VERIFIED) throw new BadRequestException(DomainMessage.NOT_VERIFIED); const emailAddress = `${username}@${domain.name}`; const finalUserName = `${username}-${domain.name}`; const finalDisplayName = `${username}`; const existingUser = await em.findOne(User, { emailAddress }); if (existingUser) throw new BadRequestException(UserMessage.EMAIL_ADDRESS_ALREADY_EXISTS); const hashedPassword = await this.passwordService.hashPassword(password); const userRole = await em.findOne(Role, { name: RoleEnum.USER }); if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND); const userQuota = quota || QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES; const mailServerUser = await firstValueFrom( this.mailServerService.users.createUser({ username: finalUserName, password, address: emailAddress, name: displayName || username, quota: userQuota, targets: forwarders || [], }), ); if (!mailServerUser.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT); const user = em.create(User, { title: title || displayName || username, userName: finalUserName, password: hashedPassword, emailAddress, emailEnabled: true, emailQuota: userQuota, wildduckUserId: mailServerUser.id, displayName: finalDisplayName || displayName || username, isActive: true, business, domain, role: userRole, forwarders: forwarders || [], }); await this.userSettingsService.createUserSettings(user.id, em); business.usedQuota = Number(business.usedQuota) + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION; business.remainingQuota = Number(business.quota) - Number(business.usedQuota); await em.persistAndFlush([user, business]); if (templateId) { const template = await em.findOne(Template, { id: templateId, business: { id: businessId } }); if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND); user.template = template; } if (aliases && aliases.length > 0) { for (const alias of aliases) { try { await firstValueFrom( this.mailServerService.addresses.createUserAddress(mailServerUser.id, { address: alias, main: false, }), ); } catch (error) { this.logger.warn(`Failed to create alias ${alias} for user ${emailAddress}:`, error); } } } await em.flush(); await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, mailServerUser.id); await this.quotaSyncService.scheduleBusinessQuotaSync(businessId, business.name); this.logger.log(`Email user created: ${emailAddress} for business ${businessId}`); await this.domainAutomationService.createDefaultMailboxes(mailServerUser.id); await em.commit(); return { message: UserMessage.EMAIL_USER_CREATED_SUCCESSFULLY, user: { id: user.id, emailAddress: user.emailAddress!, }, }; } catch (error) { await em.rollback(); this.logger.error(`Failed to create email user ${createEmailUserDto.username}@${createEmailUserDto.domainId}:`, error); throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT); } } /*******************************/ async getEmailUsers(businessId: string, queryDto: UserListQueryDto) { const [users, count] = await this.userRepository.getUserListForAdmin(businessId, queryDto); return { users, count, paginate: true }; } /*******************************/ async deleteEmailUser(userId: string, businessId: string) { const user = await this.userRepository.findOne({ id: userId, business: { id: businessId }, emailEnabled: true, }); if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); try { if (user.wildduckUserId) { await firstValueFrom(this.mailServerService.users.deleteUser(user.wildduckUserId)); } user.deletedAt = new Date(); user.isActive = false; user.emailEnabled = false; await this.em.persistAndFlush(user); //sync quota await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, user.wildduckUserId); //sync business quota await this.quotaSyncService.scheduleBusinessQuotaSync(businessId, user.business.name); this.logger.log(`Email user deleted: ${user.emailAddress} for business ${businessId}`); return { message: UserMessage.EMAIL_USER_DELETED_SUCCESSFULLY }; } catch (error) { this.logger.error(`Failed to delete email user ${user.emailAddress}:`, error); throw new BadRequestException(UserMessage.FAILED_TO_DELETE_EMAIL_USER); } } /*******************************/ async updateEmailUser(userId: string, businessId: string, updateEmailUserDto: UpdateEmailUserDto) { const { password, displayName, quota, aliases, title, templateId, forwarders } = updateEmailUserDto; const user = await this.userRepository.findOne( { id: userId, business: { id: businessId }, emailEnabled: true, }, { populate: ["business"] }, ); if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); if (templateId) { const template = await this.em.findOne(Template, { id: templateId, business: { id: businessId } }); if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND); user.template = template; } // Validate business quota if quota is being updated if (quota) { const currentQuota = user.emailQuota || 0; this.validateBusinessQuotaAvailability(user.business, currentQuota, quota); } try { const updateData: UpdateUserDto = {}; if (password) { updateData.password = password; const hashedPassword = await this.passwordService.hashPassword(password); user.password = hashedPassword; } if (displayName) updateData.name = displayName; if (quota) updateData.quota = quota; if (forwarders !== undefined) { updateData.targets = forwarders; user.forwarders = forwarders; } if (Object.keys(updateData).length > 0) { await firstValueFrom(this.mailServerService.users.updateUser(user.wildduckUserId!, updateData)); } if (aliases && aliases.length > 0) { const currentAliases = await firstValueFrom(this.mailServerService.addresses.listUserAddresses(user.wildduckUserId!)); for (const alias of currentAliases.results) { if (!alias.main && alias.address !== user.emailAddress) { try { await firstValueFrom(this.mailServerService.addresses.deleteUserAddress(user.wildduckUserId!, alias.id)); } catch (error) { this.logger.warn(`Failed to delete alias ${alias.address}:`, error); } } } for (const alias of aliases) { try { await firstValueFrom( this.mailServerService.addresses.createUserAddress(user.wildduckUserId!, { address: alias, main: false, }), ); } catch (error) { this.logger.warn(`Failed to create alias ${alias}:`, error); } } } if (displayName) user.displayName = displayName; if (quota) { const currentQuota = user.emailQuota || 0; user.emailQuota = quota; // Update business quota tracking this.updateBusinessQuotaTracking(user.business, currentQuota, quota); } if (title) user.title = title; await this.em.flush(); //sync quota if (user.wildduckUserId) { await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, user.wildduckUserId); } //sync business quota await this.quotaSyncService.scheduleBusinessQuotaSync(businessId, user.business.name); this.logger.log(`Email user updated: ${user.emailAddress} for business ${businessId}`); return { message: UserMessage.EMAIL_USER_UPDATED_SUCCESSFULLY, user: { id: user.id, emailAddress: user.emailAddress!, displayName: user.displayName!, title: user.title, emailQuota: user.emailQuota!, emailEnabled: user.emailEnabled, forwarders: user.forwarders || [], isActive: user.isActive, updatedAt: user.updatedAt, }, }; } catch (error) { this.logger.error(`Failed to update email user ${user.emailAddress}:`, error); throw new BadRequestException("Failed to update email user"); } } /** * Update email user quota */ async updateEmailUserQuota(userId: string, businessId: string, newQuota: number) { const user = await this.userRepository.findOne( { id: userId, business: { id: businessId }, emailEnabled: true, }, { populate: ["business"] }, ); if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); // Validate business quota before updating const currentQuota = user.emailQuota || 0; this.validateBusinessQuotaAvailability(user.business, currentQuota, newQuota); try { if (user.wildduckUserId) { await firstValueFrom( this.mailServerService.users.updateUser(user.wildduckUserId, { quota: newQuota, }), ); } user.emailQuota = newQuota; // Update business quota tracking this.updateBusinessQuotaTracking(user.business, currentQuota, newQuota); await this.em.persistAndFlush([user, user.business]); //sync quota if (user.wildduckUserId) { await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, user.wildduckUserId); } //sync business quota await this.quotaSyncService.scheduleBusinessQuotaSync(businessId, user.business.name); this.logger.log(`Email user quota updated: ${user.emailAddress} to ${newQuota} bytes`); return { message: UserMessage.EMAIL_USER_QUOTA_UPDATED_SUCCESSFULLY, user }; } catch (error) { this.logger.error(`Failed to update quota for email user ${user.emailAddress}:`, error); throw new BadRequestException(UserMessage.FAILED_TO_UPDATE_EMAIL_USER_QUOTA); } } //===================================================== async updateEmailUserStatus(userId: string, businessId: string) { const user = await this.userRepository.findOne({ id: userId, business: { id: businessId }, emailEnabled: true }); if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); user.isActive = !user.isActive; await this.em.flush(); return { message: UserMessage.STATUS_UPDATED, status: user.isActive, }; } /*******************************/ private validateBusinessQuotaAvailability(business: Business, currentUserQuota: number, newUserQuota: number): void { // Prevent quota downgrade - users can only upgrade their quota if (newUserQuota < currentUserQuota) { throw new BadRequestException(BusinessMessage.QUOTA_DOWNGRADE_NOT_ALLOWED); } const quotaDifference = newUserQuota - currentUserQuota; if (quotaDifference > 0) { const remainingQuota = Number(business.remainingQuota) || 0; if (quotaDifference > remainingQuota) { throw new BadRequestException(BusinessMessage.INSUFFICIENT_QUOTA); } } } //===================================================== private updateBusinessQuotaTracking(business: Business, currentUserQuota: number, newUserQuota: number): void { const quotaDifference = newUserQuota - currentUserQuota; business.usedQuota = Number(business.usedQuota) + quotaDifference; business.remainingQuota = Number(business.quota) - Number(business.usedQuota); this.logger.log(`Updated business quota for ${business.name}: used=${business.usedQuota}, remaining=${business.remainingQuota}`); } // Push Notification Methods async registerPushSubscription(userId: string, registerPushSubscriptionDto: RegisterPushSubscriptionDto) { try { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) { throw new NotFoundException(UserMessage.USER_NOT_FOUND); } // Validate the subscription if (!this.pushNotificationService.validateSubscription(registerPushSubscriptionDto.subscription)) { throw new BadRequestException("Invalid push subscription format"); } // Initialize pushTokens array if it doesn't exist (now stores subscriptions) if (!user.pushTokens) { user.pushTokens = []; } // Check if subscription endpoint already exists const existingIndex = user.pushTokens.findIndex((sub: any) => sub.endpoint === registerPushSubscriptionDto.subscription.endpoint); if (existingIndex !== -1) { // Update existing subscription user.pushTokens[existingIndex] = { ...registerPushSubscriptionDto.subscription, deviceType: registerPushSubscriptionDto.deviceType, deviceName: registerPushSubscriptionDto.deviceName, registeredAt: new Date(), }; } else { // Add new subscription (limit to 10 subscriptions per user) if (user.pushTokens.length >= 10) { // Remove oldest subscription user.pushTokens.shift(); } user.pushTokens.push({ ...registerPushSubscriptionDto.subscription, deviceType: registerPushSubscriptionDto.deviceType, deviceName: registerPushSubscriptionDto.deviceName, registeredAt: new Date(), }); } await this.em.flush(); this.logger.log(`Push subscription registered for user ${userId}: ${registerPushSubscriptionDto.deviceType || "unknown device"}`); return { message: "Subscription registered successfully", subscriptionCount: user.pushTokens.length, endpoint: registerPushSubscriptionDto.subscription.endpoint, }; } catch (error) { this.logger.error(`Error registering push subscription for user ${userId}:`, error); throw error; } } async updatePushSubscriptions(userId: string, updatePushSubscriptionsDto: UpdatePushSubscriptionsDto) { try { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) { throw new NotFoundException(UserMessage.USER_NOT_FOUND); } // Validate all subscriptions for (const subscription of updatePushSubscriptionsDto.subscriptions) { if (!this.pushNotificationService.validateSubscription(subscription)) { throw new BadRequestException(`Invalid subscription format for endpoint: ${(subscription as any).endpoint}`); } } user.pushTokens = updatePushSubscriptionsDto.subscriptions.map((sub) => ({ ...sub, updatedAt: new Date(), })); await this.em.flush(); this.logger.log(`Push subscriptions updated for user ${userId}: ${updatePushSubscriptionsDto.subscriptions.length} subscriptions`); return { message: CommonMessage.UPDATE_SUCCESS, subscriptionCount: user.pushTokens.length, }; } catch (error) { this.logger.error(`Error updating push subscriptions for user ${userId}:`, error); throw error; } } async removePushSubscription(userId: string, endpoint: string) { try { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) { throw new NotFoundException(UserMessage.USER_NOT_FOUND); } if (!user.pushTokens || user.pushTokens.length === 0) { throw new BadRequestException("No subscriptions found"); } const subscriptions = user.pushTokens; const initialCount = subscriptions.length; user.pushTokens = subscriptions.filter((sub) => sub.endpoint !== endpoint); if (user.pushTokens.length === initialCount) { throw new BadRequestException("Subscription not found"); } await this.em.flush(); this.logger.log(`Push subscription removed for user ${userId}`); return { message: "Subscription removed successfully", subscriptionCount: user.pushTokens.length, }; } catch (error) { this.logger.error(`Error removing push subscription for user ${userId}:`, error); throw error; } } async updatePushNotificationSettings(userId: string, settingsDto: UpdatePushNotificationSettingsDto) { try { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) { throw new NotFoundException(UserMessage.USER_NOT_FOUND); } user.pushNotificationsEnabled = settingsDto.enabled; await this.em.flush(); this.logger.log(`Push notification settings updated for user ${userId}: ${settingsDto.enabled ? "enabled" : "disabled"}`); return { message: CommonMessage.UPDATE_SUCCESS, pushNotificationsEnabled: user.pushNotificationsEnabled, }; } catch (error) { this.logger.error(`Error updating push notification settings for user ${userId}:`, error); throw error; } } async testPushNotification(userId: string, _testDto: TestPushNotificationDto) { try { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) { throw new NotFoundException(UserMessage.USER_NOT_FOUND); } if (!user.pushNotificationsEnabled) { throw new BadRequestException("Push notifications are disabled for this user"); } if (!user.pushTokens || user.pushTokens.length === 0) { throw new BadRequestException("No push subscriptions registered for this user"); } if (!this.pushNotificationService.isEnabled()) { throw new BadRequestException("Push notification service is disabled"); } // Convert stored subscriptions to proper format const subscriptions = user.pushTokens.map((sub) => ({ endpoint: sub.endpoint, keys: sub.keys, })); const success = await this.pushNotificationService.sendTestNotification(subscriptions, user.id); if (!success) { throw new BadRequestException("Failed to send test notification"); } this.logger.log(`Test push notification sent for user ${userId}`); return { message: "Test notification sent successfully", subscriptionCount: subscriptions.length, serviceInfo: this.pushNotificationService.getServiceInfo(), }; } catch (error) { this.logger.error(`Error sending test push notification for user ${userId}:`, error); throw error; } } async getPushNotificationInfo(userId: string) { try { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) { throw new NotFoundException(UserMessage.USER_NOT_FOUND); } return { pushNotificationsEnabled: user.pushNotificationsEnabled, subscriptionCount: user.pushTokens?.length || 0, lastPushNotificationAt: user.lastPushNotificationAt, serviceEnabled: this.pushNotificationService.isEnabled(), vapidPublicKey: this.pushNotificationService.getVAPIDPublicKey(), serviceInfo: this.pushNotificationService.getServiceInfo(), setup: { instructions: "Register your browser for push notifications using the service worker", documentation: { webPushAPI: "https://developer.mozilla.org/en-US/docs/Web/API/Push_API", serviceWorkers: "https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API", }, }, }; } catch (error) { this.logger.error(`Error getting push notification info for user ${userId}:`, error); throw error; } } async getVAPIDPublicKey() { try { const vapidPublicKey = this.pushNotificationService.getVAPIDPublicKey(); if (!vapidPublicKey) { throw new BadRequestException("VAPID public key is not configured"); } return { vapidPublicKey, serviceInfo: this.pushNotificationService.getServiceInfo(), }; } catch (error) { this.logger.error("Error getting VAPID public key:", error); throw error; } } }