import { EntityManager } from "@mikro-orm/postgresql"; import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; import { BusinessMessage, 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 { AddressInfo } from "../../mail-server/interfaces/addresses-response.interface"; import { MailServerService } from "../../mail-server/services/mail-server.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 { AddPushTokenDto, RemovePushTokenDto } from "../DTO/manage-push-token.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, ) {} 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 addPushToken(userId: string, addPushTokenDto: AddPushTokenDto) { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); if (!user.pushTokens) { user.pushTokens = []; } if (!user.pushTokens.includes(addPushTokenDto.pushToken)) { user.pushTokens.push(addPushTokenDto.pushToken); await this.em.flush(); this.logger.log(`Push token added for user ${userId}. Total tokens: ${user.pushTokens.length}`); } return { message: UserMessage.PUSH_TOKEN_ADDED_SUCCESSFULLY, totalTokens: user.pushTokens.length, }; } /*******************************/ async removePushToken(userId: string, removePushTokenDto: RemovePushTokenDto) { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); if (!user.pushTokens || user.pushTokens.length === 0) { throw new BadRequestException(UserMessage.NO_PUSH_TOKENS_FOUND); } const initialLength = user.pushTokens.length; user.pushTokens = user.pushTokens.filter((token) => token !== removePushTokenDto.pushToken); if (user.pushTokens.length === initialLength) { throw new BadRequestException(UserMessage.PUSH_TOKEN_NOT_FOUND); } await this.em.flush(); this.logger.log(`Push token removed for user ${userId}. Remaining tokens: ${user.pushTokens.length}`); return { message: UserMessage.PUSH_TOKEN_REMOVED_SUCCESSFULLY, totalTokens: user.pushTokens.length, }; } /*******************************/ async getPushTokens(userId: string) { const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); return { pushTokens: user.pushTokens || [], totalTokens: user.pushTokens?.length || 0, }; } /*******************************/ async cleanupInvalidPushTokens(userId: string, invalidTokens: string[]) { if (!invalidTokens || invalidTokens.length === 0) return; const user = await this.userRepository.findOne({ id: userId, deletedAt: null }); if (!user || !user.pushTokens) return; const initialLength = user.pushTokens.length; user.pushTokens = user.pushTokens.filter((token) => !invalidTokens.includes(token)); if (user.pushTokens.length !== initialLength) { await this.em.flush(); this.logger.log(`Cleaned up ${initialLength - user.pushTokens.length} invalid push tokens for user ${userId}`); } } /*******************************/ 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(BusinessMessage.INSUFFICIENT_QUOTA); } 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) + userQuota; 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)); } await this.em.nativeDelete(User, { id: user.id }); await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, user.wildduckUserId); 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 em = this.em.fork(); const { password, displayName, quota, aliases, title, templateId, forwarders } = updateEmailUserDto; try { em.begin(); const user = await em.findOne(User, { id: userId, business: { id: businessId }, emailEnabled: true }, { populate: ["business"] }); if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); 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 (quota) { const currentQuota = user.emailQuota || 0; this.validateBusinessQuotaAvailability(user.business, currentQuota, quota); } const updateData: UpdateUserDto = {}; if (password) { // updateData.password = password; const hashedPassword = await this.passwordService.hashPassword(password); user.password = hashedPassword; updateData.password = hashedPassword; } if (displayName) updateData.name = displayName; if (quota) updateData.quota = quota; if (forwarders !== undefined && forwarders.length > 0) { updateData.targets = forwarders; user.forwarders = forwarders; } this.logger.warn(`Updating email user ${user.emailAddress} with data: ${JSON.stringify(updateData)}`); if (Object.keys(updateData).length > 0) { await firstValueFrom(this.mailServerService.users.updateUser(user.wildduckUserId, { ...updateData, hashedPassword: true })); } if (aliases && aliases.length > 0) { const currentAliases = await firstValueFrom(this.mailServerService.addresses.listUserAddresses(user.wildduckUserId)); await this.deleteUserAliases(user, currentAliases.results); await this.createUserAliases(user, aliases); } if (displayName) user.displayName = displayName; if (title) user.title = title; if (quota) { const currentQuota = user.emailQuota || 0; user.emailQuota = quota; this.updateBusinessQuotaTracking(user.business, currentQuota, quota); } await em.flush(); await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, user.wildduckUserId); await this.quotaSyncService.scheduleBusinessQuotaSync(businessId, user.business.name); this.logger.log(`Email user updated: ${user.emailAddress} for business ${businessId}`); await em.commit(); return { message: UserMessage.EMAIL_USER_UPDATED_SUCCESSFULLY, user: { id: user.id, emailAddress: user.emailAddress } }; } catch (error) { await em.rollback(); this.logger.error(`Failed to update email user ${userId}:`, error); throw new BadRequestException("Failed to update email user"); } } /*******************************/ 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); 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; this.updateBusinessQuotaTracking(user.business, currentQuota, newQuota); await this.em.persistAndFlush([user, user.business]); if (user.wildduckUserId) { await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, user.wildduckUserId); } 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}`); } //##################################################### private async createUserAliases(user: User, aliases: string[]) { 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} for user ${user.emailAddress}:`, error); } } } //##################################################### private async deleteUserAliases(user: User, aliases: AddressInfo[]) { for (const alias of aliases) { 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} for user ${user.emailAddress}:`, error); } } } } }