Files
dmail-api/src/modules/users/services/users.service.ts
T
2025-09-04 11:33:21 +03:30

522 lines
20 KiB
TypeScript

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);
}
}
}
}
}