522 lines
20 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|