Files
dmail-api/src/modules/users/services/users.service.ts
T

731 lines
26 KiB
TypeScript

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