chore: add new route

This commit is contained in:
mahyargdz
2025-07-20 10:15:13 +03:30
parent 066d6c8598
commit 5322b6b6c4
48 changed files with 1282 additions and 295 deletions
+2
View File
@@ -2,6 +2,7 @@ import { EntityManager } from "@mikro-orm/postgresql";
import { Seeder } from "@mikro-orm/seeder";
import { Logger } from "@nestjs/common";
import { NotificationSettingSeeder } from "./notif-setting.seeder";
// import { PaymentGatewaySeeder } from "./payment-gateway.seeder";
import { RoleSeeder } from "./role.seeder";
@@ -11,6 +12,7 @@ export class IndexSeeder extends Seeder {
async run(em: EntityManager): Promise<void> {
this.logger.log("Starting seeding process...");
await new RoleSeeder().run(em);
await new NotificationSettingSeeder().run(em);
// await new PaymentGatewaySeeder().run(em);
this.logger.log("Seeding process completed successfully.");
}
+33
View File
@@ -0,0 +1,33 @@
import { EntityManager, RequiredEntityData } from "@mikro-orm/postgresql";
import { Seeder } from "@mikro-orm/seeder";
import { Logger } from "@nestjs/common";
import { NotificationSetting } from "../../src/modules/settings/entities/notification-setting.entity";
import { NotifDescriptions, NotifType } from "../../src/modules/settings/enums/notif-settings.enum";
export class NotificationSettingSeeder extends Seeder {
private readonly logger = new Logger(NotificationSettingSeeder.name);
async run(em: EntityManager): Promise<void> {
const notifSettingsData: RequiredEntityData<NotificationSetting>[] = Object.entries(NotifDescriptions).map(([type, { category, fa }]) => ({
type: type as NotifType,
category,
description: fa,
createdAt: new Date(),
updatedAt: new Date(),
}));
for (const notifSettingData of notifSettingsData) {
const existingSetting = await em.findOne(NotificationSetting, { type: notifSettingData.type });
if (!existingSetting) {
const notifSetting = em.create(NotificationSetting, notifSettingData);
await em.persistAndFlush(notifSetting);
this.logger.log(`[NotificationSettingSeeder] Created notification setting: ${notifSettingData.type}`);
} else {
this.logger.log(`[NotificationSettingSeeder] Notification setting already exists: ${notifSettingData.type}`);
}
}
this.logger.log(`[NotificationSettingSeeder] Seeding completed for ${notifSettingsData.length} notification settings.`);
}
}
+4
View File
@@ -19,7 +19,9 @@ import { DomainsModule } from "./modules/domains/domains.module";
import { EmailModule } from "./modules/email/email.module";
import { MailServerModule } from "./modules/mail-server/mail-server.module";
import { MailboxModule } from "./modules/mailbox/mailbox.module";
import { NotificationModule } from "./modules/notifications/notifications.module";
import { QuotaSyncModule } from "./modules/quota-sync/quota-sync.module";
import { SettingModule } from "./modules/settings/settings.module";
import { EmailSignaturesModule } from "./modules/signatures/email-signatures.module";
import { TemplatesModule } from "./modules/templates/templates.module";
import { UsersModule } from "./modules/users/users.module";
@@ -44,6 +46,8 @@ import { UtilsModule } from "./modules/utils/utils.module";
QuotaSyncModule,
EmailSignaturesModule,
TemplatesModule,
SettingModule,
NotificationModule,
],
})
export class AppModule implements NestModule {
+26
View File
@@ -63,6 +63,7 @@ export const enum AuthMessage {
ACCESS_DENIED = "دسترسی شما محدود شده است",
USER_ACCOUNT_INACTIVE = "حساب کاربری شما غیر فعال است",
TOKEN_MISSED_OR_EXPIRED = "توکن مفقود یا منقضی شده است",
OLD_PASSWORD_REQUIRED = "رمز عبور قبلی الزامی است",
}
export const enum UserMessage {
@@ -483,3 +484,28 @@ export const enum TemplateMessage {
TEMPLATE_THUMBNAIL_URL_VALID = "آدرس تصویر قالب باید یک آدرس معتبر باشد",
TEMPLATE_RAW_HTML_REQUIRED = "HTML خام قالب الزامی است",
}
export const enum NotificationMessage {
TITLE_IS_REQUIRED = "عنوان اعلان الزامی است",
TITLE_STRING = "عنوان اعلان باید یک رشته باشد",
MESSAGE_IS_REQUIRED = "متن اعلان الزامی است",
MESSAGE_STRING = "متن اعلان باید یک رشته باشد",
USERID_IS_REQUIRED = "شناسه کاربر الزامی است",
USERID_IS_STRING = "شناسه کاربر باید یک رشته باشد",
USERID_IS_UUID = "شناسه کاربر باید یک UUID معتبر باشد",
TYPE_IS_REQUIRED = "نوع اعلان الزامی است",
TYPE_ENUM = "نوع اعلان باید یک enum معتبر باشد",
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = "اعلان یافت نشد یا دسترسی آن را ندارید",
LOGIN_MESSAGE = "ورود با موفقیت انجام شد در تاریخ [loginDate]",
LOGIN = "ورود",
NEW_EMAIL = "ایمیل جدید",
NEW_EMAIL_MESSAGE = "یک ایمیل جدید با موضوع [subject] دریافت شد",
CHANGE_PASSWORD_MESSAGE = "رمز عبور شما با موفقیت تغییر کرد",
CHANGE_PASSWORD = "تغییر رمز عبور",
}
export const enum SettingMessageEnum {
SMS_MUST_BE_BOOLEAN = "تنظیمات ارسال پیامک باید یک boolean باشد",
EMAIL_MUST_BE_BOOLEAN = "تنظیمات ارسال ایمیل باید یک boolean باشد",
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = "اعلان یافت نشد یا دسترسی آن را ندارید",
}
@@ -0,0 +1,24 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString, MinLength } from "class-validator";
import { AuthMessage } from "../../../common/enums/message.enum";
export class ChangePasswordDto {
@IsNotEmpty({ message: AuthMessage.OLD_PASSWORD_REQUIRED })
@IsString({ message: AuthMessage.INVALID_PASS_FORMAT })
@MinLength(8, { message: AuthMessage.PASSWORD_LENGTH })
@ApiProperty({ description: "The old password of the user" })
oldPassword: string;
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
@IsString({ message: AuthMessage.INVALID_PASS_FORMAT })
@MinLength(8, { message: AuthMessage.PASSWORD_LENGTH })
@ApiProperty({ description: "The new password of the user" })
newPassword: string;
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
@IsString({ message: AuthMessage.INVALID_PASS_FORMAT })
@MinLength(8, { message: AuthMessage.PASSWORD_LENGTH })
@ApiProperty({ description: "The repeat password of the user" })
repeatPassword: string;
}
+9
View File
@@ -1,6 +1,7 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
import { ApiBadRequestResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger";
import { ChangePasswordDto } from "./DTO/change-password.dto";
import { LoginDto } from "./DTO/login.dto";
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
import { AuthService } from "./services/auth.service";
@@ -39,4 +40,12 @@ export class AuthController {
logout(@UserDec("id") userId: string) {
return this.authService.logout(userId);
}
@AuthGuards()
@ApiOperation({ summary: "change the user password" })
@HttpCode(HttpStatus.OK)
@Post("change-password")
changePassword(@UserDec("id") userId: string, @Body() changePasswordDto: ChangePasswordDto) {
return this.authService.changePassword(userId, changePasswordDto);
}
}
+2
View File
@@ -13,6 +13,7 @@ import { UsersModule } from "../users/users.module";
import { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy";
import { LocalJwtStrategy } from "./strategies/local-jwt.strategy";
import { BusinessesModule } from "../businesses/businesses.module";
import { NotificationModule } from "../notifications/notifications.module";
import { User } from "../users/entities/user.entity";
@Module({
@@ -24,6 +25,7 @@ import { User } from "../users/entities/user.entity";
JwtModule.registerAsync(jwtConfig()),
BusinessesModule,
MailServerModule,
NotificationModule,
],
controllers: [AuthController],
providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy],
+30 -20
View File
@@ -1,11 +1,11 @@
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { BadRequestException, Injectable } from "@nestjs/common";
import { TokensService } from "./tokens.service";
import { AuthMessage } from "../../../common/enums/message.enum";
import { User } from "../../users/entities/user.entity";
import { NotificationQueue } from "../../notifications/queue/notification.queue";
import { UserRepository } from "../../users/repositories/user.repository";
import { PasswordService } from "../../utils/services/password.service";
import { ChangePasswordDto } from "../DTO/change-password.dto";
import { LoginDto } from "../DTO/login.dto";
@Injectable()
@@ -13,8 +13,8 @@ export class AuthService {
// private readonly logger = new Logger(AuthService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: EntityRepository<User>,
private readonly notificationQueue: NotificationQueue,
private readonly userRepository: UserRepository,
private readonly tokensService: TokensService,
private readonly passwordService: PasswordService,
// private readonly mailServerService: MailServerService,
@@ -27,23 +27,13 @@ export class AuthService {
const user = await this.checkUserLoginCredentialWithEmail(email, password);
// // Fetch user's mailboxes from WildDuck
// if (user.wildduckUserId) {
// try {
// this.logger.log(`Fetching mailboxes for user ${user.id} (WildDuck ID: ${user.wildduckUserId})`);
// const mailboxesResponse = await firstValueFrom(this.mailServerService.mailboxes.listMailboxes(user.wildduckUserId));
// if (mailboxesResponse.success && mailboxesResponse.results) {
// this.logger.log(`Successfully fetched ${mailboxesResponse.results.length} mailboxes for user ${user.id}`);
// }
// } catch (error: any) {
// this.logger.error(`Failed to fetch mailboxes for user ${user.id}: ${error.message}`);
// // Continue with login even if mailbox fetch fails
// }
// }
const tokens = await this.tokensService.generateTokens(user);
await this.notificationQueue.addLoginNotification(user.id, {
userEmail: email,
userName: user.displayName,
});
return {
message: AuthMessage.PASSWORD_LOGIN_SUCCESS,
...tokens,
@@ -62,6 +52,26 @@ export class AuthService {
message: AuthMessage.LOGOUT_SUCCESS,
};
}
//==============================================
//==============================================
async changePassword(userId: string, changePasswordDto: ChangePasswordDto) {
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
if (!user) throw new BadRequestException(AuthMessage.USER_NOT_FOUND);
const passCompare = await this.passwordService.comparePasswords(changePasswordDto.oldPassword, user.password);
if (!passCompare) throw new BadRequestException(AuthMessage.CURRENT_PASSWORD_INVALID);
if (changePasswordDto.newPassword !== changePasswordDto.repeatPassword) throw new BadRequestException(AuthMessage.INVALID_REPEAT_PASSWORD);
const hashedPassword = await this.passwordService.hashPassword(changePasswordDto.newPassword);
await this.userRepository.nativeUpdate({ id: userId }, { password: hashedPassword });
return {
message: AuthMessage.PASSWORD_CHANGED_SUCCESSFULLY,
};
}
//==============================================
private async checkUserLoginCredentialWithEmail(email: string, password: string) {
@@ -40,6 +40,7 @@ export type WebSocketNamespace = (typeof WEBSOCKET_NAMESPACES)[keyof typeof WEBS
export const EMAIL_QUEUE_CONSTANTS = {
EMAIL_QUEUE: "email_queue",
EMAIL_QUEUE_PROCESSING: "email_queue_processing",
EMAIL_QUEUE_FAILED: "email_queue_failed",
EMAIL_QUEUE_COMPLETED: "email_queue_completed",
};
EMAIL_SCHEDULER_JOB: "email_monitoring_scheduler",
EMAIL_MONITORING_ACTION_NAME: "schedule_email_monitoring",
EMAIL_MONITORING_JOB_PATTERN: "*/5 * * * * *", // Every 5 seconds
} as const;
@@ -1,101 +0,0 @@
// import { Body, Controller, Get, Param, Post } from "@nestjs/common";
// import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
// import { AuthGuards } from "../../../common/decorators/auth-guard.decorator";
// import { UserDec } from "../../../common/decorators/user.decorator";
// import { EmailMonitoringService } from "../services/email-monitoring.service";
// import { EmailNotificationService } from "../services/email-notification.service";
// @ApiTags("Email Gateway")
// @AuthGuards()
// @Controller("email/gateway")
// export class EmailGatewayController {
// constructor(
// private readonly emailMonitoringService: EmailMonitoringService,
// private readonly emailNotificationService: EmailNotificationService,
// ) {}
// @Get("status")
// @ApiOperation({ summary: "Get email gateway status" })
// @ApiResponse({ status: 200, description: "Gateway status retrieved successfully" })
// async getGatewayStatus() {
// const [gatewayStats, queueStatus] = await Promise.all([
// this.emailNotificationService.getGatewayStats(),
// this.emailMonitoringService.getQueueStatus(),
// ]);
// return {
// message: "Email gateway status retrieved successfully",
// data: {
// gateway: gatewayStats,
// queue: queueStatus,
// },
// };
// }
// @Post("check-emails")
// @ApiOperation({ summary: "Manually trigger email check for current user" })
// @ApiResponse({ status: 200, description: "Email check initiated successfully" })
// async checkUserEmails(@UserDec("id") userId: string) {
// await this.emailMonitoringService.checkUserEmails(userId);
// return {
// message: "Email check initiated successfully",
// userId,
// };
// }
// @Get("connected-users")
// @ApiOperation({ summary: "Get connected users count" })
// @ApiResponse({ status: 200, description: "Connected users count retrieved successfully" })
// async getConnectedUsers() {
// const stats = await this.emailNotificationService.getGatewayStats();
// return {
// message: "Connected users retrieved successfully",
// data: stats,
// };
// }
// @Post("test-notification")
// @ApiOperation({ summary: "Send test notification to current user" })
// @ApiResponse({ status: 200, description: "Test notification sent successfully" })
// async sendTestNotification(@UserDec("id") userId: string, @Body() body: { message?: string }) {
// await this.emailNotificationService.broadcastSystemNotification("test", {
// message: body.message || "Test notification from Email Gateway",
// userId,
// });
// return {
// message: "Test notification sent successfully",
// userId,
// };
// }
// @Get("business/:businessId/users")
// @ApiOperation({ summary: "Get connected users for a business" })
// @ApiResponse({ status: 200, description: "Business connected users retrieved successfully" })
// async getBusinessConnectedUsers(@Param("businessId") businessId: string) {
// const connectedUsers = await this.emailNotificationService.getBusinessConnectedUsers(businessId);
// return {
// message: "Business connected users retrieved successfully",
// businessId,
// data: {
// connectedUsers,
// count: connectedUsers.length,
// },
// };
// }
// @Post("cleanup")
// @ApiOperation({ summary: "Clean up old monitoring jobs" })
// @ApiResponse({ status: 200, description: "Cleanup completed successfully" })
// async cleanupJobs() {
// await this.emailMonitoringService.cleanupJobs();
// return {
// message: "Cleanup completed successfully",
// };
// }
// }
-2
View File
@@ -8,7 +8,6 @@ import { EmailController } from "./email.controller";
import { EmailGateway } from "./gateways/email.gateway";
import { EmailMonitoringProcessor } from "./queue/email-monitoring.processor";
import { EmailHeadersService } from "./services/email-headers.service";
import { EmailMonitoringSchedulerService } from "./services/email-monitoring-scheduler.service";
import { EmailMonitoringService } from "./services/email-monitoring.service";
import { EmailNotificationService } from "./services/email-notification.service";
import { EmailService } from "./services/email.service";
@@ -39,7 +38,6 @@ import { WebSocketAuthService } from "./services/websocket-auth.service";
EmailHeadersService,
EmailGateway,
EmailMonitoringService,
EmailMonitoringSchedulerService,
EmailNotificationService,
EmailMonitoringProcessor,
WebSocketAuthService,
@@ -1,3 +1,5 @@
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
// Base interface with common properties
export interface BaseEventPayload {
userId: string;
@@ -98,9 +100,15 @@ export interface EmailWebSocketEvents {
export interface EmailMonitoringJob {
userId: string;
wildduckUserId?: string;
lastCheckTimestamp?: Date;
}
export interface EmailSchedulerJob {
action: typeof EMAIL_QUEUE_CONSTANTS.EMAIL_MONITORING_ACTION_NAME;
timestamp: string;
}
export interface NewEmailData {
userId: string;
messageId: number;
@@ -3,10 +3,12 @@ import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
import { EmailMonitoringJob } from "../interfaces/email-events.interface";
import { EmailMonitoringJob, EmailSchedulerJob } from "../interfaces/email-events.interface";
import { EmailMonitoringService } from "../services/email-monitoring.service";
@Processor(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE)
@Processor(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE, {
concurrency: 5,
})
export class EmailMonitoringProcessor extends WorkerHost {
private readonly logger = new Logger(EmailMonitoringProcessor.name);
@@ -14,13 +16,19 @@ export class EmailMonitoringProcessor extends WorkerHost {
super();
}
async process(job: Job<EmailMonitoringJob>) {
async process(job: Job<EmailMonitoringJob | EmailSchedulerJob>) {
const { name, data } = job;
try {
switch (name) {
case EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING:
await this.emailMonitoringService.processUserEmailCheck(data);
this.logger.debug(`Processing user email check for: ${(data as EmailMonitoringJob).userId}`);
await this.emailMonitoringService.processUserEmailCheck(data as EmailMonitoringJob);
break;
case EMAIL_QUEUE_CONSTANTS.EMAIL_SCHEDULER_JOB:
this.logger.debug("Processing scheduler job - triggering email monitoring checks");
await this.emailMonitoringService.scheduleEmailCheck();
break;
default:
@@ -28,7 +36,8 @@ export class EmailMonitoringProcessor extends WorkerHost {
throw new Error(`Unknown job type: ${name}`);
}
} catch (error) {
this.logger.error(`Failed to process email monitoring job ${name} for user ${data.userId}:`, error);
const userId = "userId" in data ? (data as EmailMonitoringJob).userId : "scheduler";
this.logger.error(`Failed to process email monitoring job ${name} for ${userId}:`, error);
throw error;
}
}
@@ -1,73 +0,0 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { EmailMonitoringService } from "./email-monitoring.service";
@Injectable()
export class EmailMonitoringSchedulerService implements OnModuleInit {
private readonly logger = new Logger(EmailMonitoringSchedulerService.name);
private monitoringInterval: NodeJS.Timeout | null = null;
constructor(private readonly emailMonitoringService: EmailMonitoringService) {}
async onModuleInit() {
this.logger.log("Initializing Email Monitoring Scheduler");
await this.startMonitoring();
}
//************************************************************************ */
async startMonitoring() {
if (this.monitoringInterval) {
this.logger.warn("Email monitoring is already running");
return;
}
this.logger.log("Starting email monitoring scheduler - checking every 5 seconds");
await this.runMonitoringCheck();
this.monitoringInterval = setInterval(async () => {
try {
await this.runMonitoringCheck();
} catch (error) {
this.logger.error("Error in scheduled email monitoring:", error);
}
}, 5000);
this.logger.log("Email monitoring scheduler started successfully");
}
//************************************************************************ */
stopMonitoring() {
if (this.monitoringInterval) {
clearInterval(this.monitoringInterval);
this.monitoringInterval = null;
this.logger.log("Email monitoring scheduler stopped");
}
}
//************************************************************************ */
private async runMonitoringCheck() {
try {
this.logger.log("Running scheduled email monitoring check");
await this.emailMonitoringService.scheduleEmailCheck();
} catch (error) {
this.logger.error("Failed to run email monitoring check:", error);
}
}
//************************************************************************ */
getStatus() {
return {
isRunning: this.monitoringInterval !== null,
checkInterval: 5000,
timestamp: new Date().toISOString(),
};
}
//************************************************************************ */
async restartMonitoring() {
this.logger.log("Restarting email monitoring scheduler");
this.stopMonitoring();
await this.startMonitoring();
}
}
@@ -1,6 +1,6 @@
import { EntityManager } from "@mikro-orm/core";
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { Queue } from "bullmq";
import { firstValueFrom } from "rxjs";
@@ -10,29 +10,109 @@ import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
import { User } from "../../users/entities/user.entity";
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
import { EmailGateway } from "../gateways/email.gateway";
import { EmailMonitoringJob, EmailPayload } from "../interfaces/email-events.interface";
import { EmailMonitoringJob, EmailPayload, EmailSchedulerJob } from "../interfaces/email-events.interface";
@Injectable()
export class EmailMonitoringService {
export class EmailMonitoringService implements OnModuleInit {
private readonly logger = new Logger(EmailMonitoringService.name);
private lastGlobalCheck: Date = new Date();
private isMonitoringActive = false;
constructor(
@InjectQueue(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE) private readonly monitoringQueue: Queue,
private readonly emailGateway: EmailGateway,
private readonly mailboxResolver: MailboxResolverService,
private readonly mailServerService: MailServerService,
private readonly em: EntityManager,
@InjectQueue(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE) private readonly monitoringQueue: Queue,
) {}
async onModuleInit() {
this.logger.log("Initializing integrated email monitoring service");
await this.startMonitoring();
}
//****************************************************** */
// MONITORING SCHEDULER FUNCTIONALITY
//****************************************************** */
async startMonitoring() {
if (this.isMonitoringActive) {
this.logger.warn("Email monitoring is already running");
return;
}
try {
await this.stopMonitoring();
this.logger.log("Starting integrated email monitoring with BullMQ cron scheduler");
await this.monitoringQueue.add(
EMAIL_QUEUE_CONSTANTS.EMAIL_SCHEDULER_JOB,
{
action: EMAIL_QUEUE_CONSTANTS.EMAIL_MONITORING_ACTION_NAME,
timestamp: new Date().toISOString(),
} as EmailSchedulerJob,
{
removeOnComplete: true,
removeOnFail: false,
repeat: {
pattern: EMAIL_QUEUE_CONSTANTS.EMAIL_MONITORING_JOB_PATTERN,
},
},
);
this.isMonitoringActive = true;
this.logger.log("Integrated BullMQ email monitoring scheduler started successfully");
// Run initial check immediately
await this.scheduleEmailCheck();
} catch (error) {
this.logger.error("Failed to start email monitoring scheduler:", error);
throw error;
}
}
async stopMonitoring() {
try {
// Remove all repeatable jobs with this name
const repeatableJobs = await this.monitoringQueue.getJobSchedulers();
const existingJobs = repeatableJobs.filter((job) => job.name === EMAIL_QUEUE_CONSTANTS.EMAIL_SCHEDULER_JOB);
if (existingJobs.length > 0) {
for (const job of existingJobs) {
await this.monitoringQueue.removeJobScheduler(job.key);
this.logger.debug(`Removed repeatable job: ${job.key}`);
}
this.logger.log("Email monitoring scheduler stopped and cleaned up");
} else {
this.logger.debug("No existing scheduler jobs found to remove");
}
// Clean up old jobs
await this.cleanupJobs();
this.isMonitoringActive = false;
} catch (error) {
this.logger.error("Error stopping email monitoring scheduler:", error);
}
}
async restartMonitoring() {
this.logger.log("Restarting integrated email monitoring");
await this.stopMonitoring();
await this.startMonitoring();
}
//****************************************************** */
// CORE MONITORING FUNCTIONALITY
//****************************************************** */
async scheduleEmailCheck() {
try {
// Get only connected users from the websocket gateway
const connectedUserIds = this.emailGateway.getConnectedUserIds();
if (connectedUserIds.length === 0) {
return; // No connected users, skip monitoring
this.logger.debug("No connected users - skipping email check");
return;
}
const em = this.em.fork();
@@ -55,7 +135,9 @@ export class EmailMonitoringService {
lastCheckTimestamp: this.lastGlobalCheck,
},
{
delay: Math.random() * 10000,
delay: Math.random() * 10000, // Random delay to prevent thundering herd
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
@@ -71,7 +153,6 @@ export class EmailMonitoringService {
}
}
//****************************************************** */
async processUserEmailCheck(job: EmailMonitoringJob) {
const { userId, lastCheckTimestamp } = job;
@@ -98,7 +179,6 @@ export class EmailMonitoringService {
};
if (lastCheckTimestamp) {
// Handle case where BullMQ serializes Date to string
const timestamp = lastCheckTimestamp instanceof Date ? lastCheckTimestamp : new Date(lastCheckTimestamp);
query.datestart = timestamp.toISOString();
}
@@ -120,7 +200,6 @@ export class EmailMonitoringService {
}
}
//****************************************************** */
private async processNewEmailMessage(userId: string, _wildduckUserId: string, message: Record<string, any>) {
try {
if (message.seen) {
@@ -146,12 +225,10 @@ export class EmailMonitoringService {
}
}
//************************************************************************ */
private async updateUnreadCount(userId: string, wildduckUserId: string, userEmailAddress?: string) {
try {
const inboxMailboxId = await this.mailboxResolver.getInboxMailboxId(wildduckUserId);
// Get all messages to properly count unread ones
const { results } = await firstValueFrom(
this.mailServerService.messages.listMessages(wildduckUserId, inboxMailboxId, {
limit: 250,
@@ -161,7 +238,6 @@ export class EmailMonitoringService {
const unreadCount = results.length;
// Always send unread count notification
this.emailGateway.notifyUnreadCountUpdate(userId, {
count: unreadCount,
timestamp: new Date().toISOString(),
@@ -172,7 +248,6 @@ export class EmailMonitoringService {
userId,
});
// Log the unread count if there are unread messages
if (unreadCount > 0 && userEmailAddress) {
this.logger.log(`${userEmailAddress}: ${unreadCount} unread total`);
}
@@ -181,20 +256,57 @@ export class EmailMonitoringService {
}
}
//****************************************************** */
async checkUserEmails(userId: string): Promise<void> {
await this.monitoringQueue.add(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING, {
userId,
lastCheckTimestamp: new Date(Date.now() - 60000),
});
await this.monitoringQueue.add(
EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING,
{
userId,
lastCheckTimestamp: new Date(Date.now() - 60000), // Check emails from last minute
},
{
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
);
}
//****************************************************** */
// STATUS AND MANAGEMENT FUNCTIONALITY
//****************************************************** */
async getMonitoringStatus() {
try {
const repeatableJobs = await this.monitoringQueue.getJobSchedulers();
const schedulerJob = repeatableJobs.find((job) => job.name === EMAIL_QUEUE_CONSTANTS.EMAIL_SCHEDULER_JOB);
const queueStatus = await this.getQueueStatus();
return {
isRunning: this.isMonitoringActive && !!schedulerJob,
cronPattern: schedulerJob?.pattern || null,
nextRunTime: schedulerJob?.next ? new Date(schedulerJob.next) : null,
checkInterval: EMAIL_QUEUE_CONSTANTS.EMAIL_MONITORING_JOB_PATTERN,
lastGlobalCheck: this.lastGlobalCheck,
queueStatus,
timestamp: new Date().toISOString(),
};
} catch (error) {
this.logger.error("Error getting monitoring status:", error);
return {
isRunning: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date().toISOString(),
};
}
}
async getQueueStatus() {
const waiting = await this.monitoringQueue.getWaiting();
const active = await this.monitoringQueue.getActive();
const completed = await this.monitoringQueue.getCompleted();
const failed = await this.monitoringQueue.getFailed();
const [waiting, active, completed, failed] = await Promise.all([
this.monitoringQueue.getWaiting(),
this.monitoringQueue.getActive(),
this.monitoringQueue.getCompleted(),
this.monitoringQueue.getFailed(),
]);
return {
waiting: waiting.length,
@@ -204,9 +316,58 @@ export class EmailMonitoringService {
};
}
//****************************************************** */
async getDetailedQueueInfo() {
try {
const [waiting, active, completed, failed, repeatableJobs] = await Promise.all([
this.monitoringQueue.getWaiting(),
this.monitoringQueue.getActive(),
this.monitoringQueue.getCompleted(0, 10),
this.monitoringQueue.getFailed(0, 10),
this.monitoringQueue.getJobSchedulers(),
]);
return {
counts: {
waiting: waiting.length,
active: active.length,
completed: completed.length,
failed: failed.length,
repeatable: repeatableJobs.length,
},
repeatableJobs: repeatableJobs.map((job) => ({
name: job.name,
pattern: job.pattern,
next: job.next ? new Date(job.next) : null,
})),
recentCompleted: completed.map((job) => ({
id: job.id,
processedOn: job.processedOn ? new Date(job.processedOn) : null,
finishedOn: job.finishedOn ? new Date(job.finishedOn) : null,
})),
recentFailed: failed.map((job) => ({
id: job.id,
failedReason: job.failedReason,
processedOn: job.processedOn ? new Date(job.processedOn) : null,
})),
};
} catch (error) {
this.logger.error("Error getting detailed queue info:", error);
throw error;
}
}
async cleanupJobs() {
await this.monitoringQueue.clean(1000 * 60 * 60, 100); // Clean jobs older than 1 hour
this.logger.log("Cleaned up old monitoring jobs");
try {
// Clean completed jobs older than 30 minutes
await this.monitoringQueue.clean(1000 * 60 * 30, 50, "completed");
// Clean failed jobs older than 2 hours
await this.monitoringQueue.clean(1000 * 60 * 120, 20, "failed");
// Clean active jobs older than 10 minutes (stuck jobs)
await this.monitoringQueue.clean(1000 * 60 * 10, 10, "active");
this.logger.log("Successfully cleaned up old monitoring jobs");
} catch (error) {
this.logger.error("Error cleaning up jobs:", error);
}
}
}
@@ -156,9 +156,7 @@ export class EmailService {
const inboxMailboxId = await this.mailboxResolverService.getInboxMailboxId(userId);
this.logger.log(`Listing messages in mailbox ${inboxMailboxId} for user: ${userId}`);
// Transform query parameters for WildDuck API compatibility
const wildDuckQuery = this.transformPaginationQuery(query);
this.logger.debug("Transformed query for WildDuck:", wildDuckQuery);
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, inboxMailboxId, wildDuckQuery));
@@ -65,9 +65,7 @@ export class WildDuckBaseService {
protected get<T>(path: string, params?: Record<string, any>): Observable<T> {
const url = this.buildUrl(path, params);
this.logger.debug(`WildDuck GET: ${url}`);
if (params) {
this.logger.debug(`WildDuck GET Params:`, params);
}
return this.handleResponse<T>(this.httpService.get(url));
}
+23
View File
@@ -0,0 +1,23 @@
import { IsEnum, IsNotEmpty, IsString, IsUUID } from "class-validator";
import { NotificationMessage } from "../../../common/enums/message.enum";
import { NotifType } from "../../settings/enums/notif-settings.enum";
export class CreateNotificationDto {
@IsNotEmpty({ message: NotificationMessage.TITLE_IS_REQUIRED })
@IsString({ message: NotificationMessage.TITLE_STRING })
title: string;
@IsNotEmpty({ message: NotificationMessage.MESSAGE_IS_REQUIRED })
@IsString({ message: NotificationMessage.MESSAGE_STRING })
message: string;
@IsNotEmpty({ message: NotificationMessage.USERID_IS_REQUIRED })
@IsString({ message: NotificationMessage.USERID_IS_STRING })
@IsUUID("4", { message: NotificationMessage.USERID_IS_UUID })
recipientId: string;
@IsNotEmpty({ message: NotificationMessage.TYPE_IS_REQUIRED })
@IsEnum(NotifType)
type: NotifType;
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, IsOptional } from "class-validator";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
export class SearchNotificationQueryDto extends PaginationDto {
@IsOptional()
@Type(() => Number)
@IsIn([0, 1])
@ApiPropertyOptional({ description: "Notification status", example: 1 })
isRead?: number;
}
@@ -0,0 +1,16 @@
export const NOTIFICATION = Object.freeze({
QUEUE_NAME: "notification",
SEND_NOTIFICATION_JOB_NAME: "sendNotification",
SEND_LOGIN_OTP_JOB_NAME: "sendLoginOtp",
SEND_NOTIFICATION_JOB_DELAY: 1000, // delay after 1 second
SEND_NOTIFICATION_JOB_PRIORITY: 1, // high priority
SEND_NOTIFICATION_JOB_ATTEMPTS: 3, // retry 3 times
SEND_NOTIFICATION_JOB_BACKOFF: 5 * 1000, // retry after 5 seconds
SEND_NOTIFICATION_JOB_TIMEOUT: 10000, // timeout after 10 seconds
SEND_NOTIFICATION_JOB_CONCURRENCY: 2,
SEND_NOTIFICATION_JOB_LOCK_DURATION: 30000,
SEND_NOTIFICATION_JOB_STALLED_INTERVAL: 30000,
SEND_NOTIFICATION_JOB_MAX_STALLED_COUNT: 2,
});
+26
View File
@@ -0,0 +1,26 @@
import { Entity, EntityRepositoryType, Enum, ManyToOne, Opt, Property } from "@mikro-orm/core";
import { BaseEntity } from "../../../common/entities/base.entity";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { User } from "../../users/entities/user.entity";
import { NotificationRepository } from "../repositories/notifications.repository";
@Entity({ repository: () => NotificationRepository })
export class Notification extends BaseEntity {
@Property({ type: "varchar", length: 250 })
title!: string;
@Property({ type: "text" })
message!: string;
@Enum({ items: () => NotifType, nativeEnumName: "notif_type" })
type!: NotifType;
@Property({ type: "boolean", default: false })
isRead!: boolean & Opt;
@ManyToOne(() => User, { deleteRule: "cascade" })
recipient!: User;
[EntityRepositoryType]?: NotificationRepository;
}
@@ -0,0 +1,31 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Logger } from "@nestjs/common";
export abstract class BaseNotificationHandler<TData = unknown> {
protected abstract readonly logger: Logger;
/**
* Process the notification with automatic transaction management
*/
async processWithTransaction(recipientId: string, data: TData, em: EntityManager): Promise<void> {
try {
await this.handle(recipientId, data, em);
} catch (error) {
this.logger.error(
`Failed to process ${this.getHandlerName()} notification: ${error instanceof Error ? error.message : "Unknown error"}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
/**
* Handle the specific notification logic - to be implemented by each handler
*/
protected abstract handle(recipientId: string, data: TData, em: EntityManager): Promise<void>;
/**
* Get the handler name for logging purposes
*/
protected abstract getHandlerName(): string;
}
@@ -0,0 +1,23 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { BaseNotificationHandler } from "./base-notification.handler";
import { IChangePasswordNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../services/notifications.service";
@Injectable()
export class ChangePasswordHandler extends BaseNotificationHandler<IChangePasswordNotificationData> {
protected readonly logger = new Logger(ChangePasswordHandler.name);
constructor(private readonly notificationsService: NotificationsService) {
super();
}
protected async handle(recipientId: string, data: IChangePasswordNotificationData, em: EntityManager): Promise<void> {
await this.notificationsService.changePasswordNotification(recipientId, data, em);
}
protected getHandlerName(): string {
return "ChangePassword";
}
}
@@ -0,0 +1,23 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { BaseNotificationHandler } from "./base-notification.handler";
import { INewEmailNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../services/notifications.service";
@Injectable()
export class NewEmailHandler extends BaseNotificationHandler<INewEmailNotificationData> {
protected readonly logger = new Logger(NewEmailHandler.name);
constructor(private readonly notificationsService: NotificationsService) {
super();
}
protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise<void> {
await this.notificationsService.createNewEmailNotification(recipientId, data, em);
}
protected getHandlerName(): string {
return "NewEmail";
}
}
@@ -0,0 +1,47 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { BaseNotificationHandler } from "./base-notification.handler";
import { ChangePasswordHandler } from "./change-password.handler";
import { NewEmailHandler } from "./new-email.handler";
import { NotifType } from "../../settings/enums/notif-settings.enum";
@Injectable()
export class NotificationHandlerFactory {
private readonly logger = new Logger(NotificationHandlerFactory.name);
private readonly handlers: Map<NotifType, BaseNotificationHandler> = new Map();
constructor(
private readonly newEmailHandler: NewEmailHandler,
private readonly changePasswordHandler: ChangePasswordHandler,
) {
this.registerHandlers();
}
private registerHandlers(): void {
// User notifications
this.handlers.set(NotifType.NEW_EMAIL, this.newEmailHandler);
this.handlers.set(NotifType.CHANGE_PASSWORD, this.changePasswordHandler);
this.logger.log(`Registered ${this.handlers.size} notification handlers`);
}
async processNotification(type: NotifType, recipientId: string, data: unknown, em: EntityManager): Promise<void> {
const handler = this.handlers.get(type);
if (!handler) {
this.logger.warn(`No handler found for notification type: ${type}`);
throw new Error(`No handler registered for notification type: ${type}`);
}
await handler.processWithTransaction(recipientId, data, em);
}
getRegisteredTypes(): NotifType[] {
return Array.from(this.handlers.keys());
}
isTypeSupported(type: NotifType): boolean {
return this.handlers.has(type);
}
}
@@ -0,0 +1,24 @@
import { Injectable, Logger } from "@nestjs/common";
import { IBaseNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../services/notifications.service";
@Injectable()
export class UserLoginHandler {
private readonly logger = new Logger(UserLoginHandler.name);
constructor(private readonly notificationsService: NotificationsService) {}
async processUserLogin(recipientId: string, data: IBaseNotificationData): Promise<void> {
try {
await this.notificationsService.createLoginNotification(recipientId, data);
this.logger.log(`User login notification sent successfully to ${recipientId}`);
} catch (error) {
this.logger.error(
`Failed to send user login notification: ${error instanceof Error ? error.message : "Unknown error"}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
}
@@ -0,0 +1,13 @@
import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "./ISendNotificationData";
import { NotifType } from "../../settings/enums/notif-settings.enum";
export type NotificationJobData = {
type: NotifType;
recipientId: string;
data: IBaseNotificationData | IChangePasswordNotificationData | INewEmailNotificationData;
};
export type OtpJobData = {
phone: string;
code: string;
};
@@ -0,0 +1,17 @@
export interface IBaseNotificationData {
userPhone?: string;
userEmail?: string;
userName?: string;
}
export interface IChangePasswordNotificationData extends IBaseNotificationData {
newPassword: string;
}
export interface IGenericNotificationData extends IBaseNotificationData {
message: string;
}
export interface INewEmailNotificationData extends IBaseNotificationData {
newEmail: string;
}
+38
View File
@@ -0,0 +1,38 @@
import { Controller, Get, Param, Patch, Query } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { SearchNotificationQueryDto } from "./DTO/search-notification-query.dto";
import { NotificationsService } from "./services/notifications.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
@Controller("notifications")
@AuthGuards()
export class NotificationController {
constructor(private readonly notificationService: NotificationsService) {}
//********************* */
@Get()
@ApiOperation({ summary: "all notifications by user" })
getAllNotifications(@UserDec("id") userId: string, @Query() queryDto: SearchNotificationQueryDto) {
return this.notificationService.getAllNotifications(queryDto, userId);
}
//********************* */
@Patch(":id/read")
@ApiOperation({ summary: "mark user notification as read" })
markAsRead(@Param() paramDto: ParamDto, @UserDec("id") userId: string) {
return this.notificationService.markAsRead(paramDto.id, userId);
}
//********************* */
@Patch("read-all")
@ApiOperation({ summary: "mark user all notification as read" })
markAllAsRead(@UserDec("id") userId: string) {
return this.notificationService.markAllAsRead(userId);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { NOTIFICATION } from "./constants";
import { Notification } from "./entities/notification.entity";
import { ChangePasswordHandler } from "./handlers/change-password.handler";
import { NewEmailHandler } from "./handlers/new-email.handler";
import { NotificationHandlerFactory } from "./handlers/notification-handler.factory";
import { NotificationController } from "./notifications.controller";
import { NotificationProcessor } from "./queue/notification.processor";
import { NotificationQueue } from "./queue/notification.queue";
import { NotificationsService } from "./services/notifications.service";
import { NotificationSetting } from "../settings/entities/notification-setting.entity";
import { SettingModule } from "../settings/settings.module";
import { UtilsModule } from "../utils/utils.module";
import { UserLoginHandler } from "./handlers/user-login.handler";
@Module({
imports: [
ConfigModule,
MikroOrmModule.forFeature([Notification, NotificationSetting]),
BullModule.registerQueue({
name: NOTIFICATION.QUEUE_NAME,
defaultJobOptions: {
attempts: NOTIFICATION.SEND_NOTIFICATION_JOB_ATTEMPTS,
backoff: {
type: "exponential",
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_BACKOFF,
},
removeOnComplete: true,
removeOnFail: false,
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_DELAY,
},
}),
UtilsModule,
SettingModule,
],
providers: [
NotificationsService,
NotificationQueue,
NotificationProcessor,
// notification handlers
NotificationHandlerFactory,
UserLoginHandler,
NewEmailHandler,
ChangePasswordHandler,
],
controllers: [NotificationController],
exports: [NotificationsService, NotificationQueue],
})
export class NotificationModule {}
@@ -0,0 +1,71 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Processor } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { WorkerProcessor } from "../../../common/queues/worker.processor";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { NOTIFICATION } from "../constants";
import { NotificationHandlerFactory } from "../handlers/notification-handler.factory";
import { UserLoginHandler } from "../handlers/user-login.handler";
import { NotificationJobData, OtpJobData } from "../interfaces/INotification-job-data";
import { IBaseNotificationData } from "../interfaces/ISendNotificationData";
@Processor(NOTIFICATION.QUEUE_NAME, {
concurrency: NOTIFICATION.SEND_NOTIFICATION_JOB_CONCURRENCY,
lockDuration: NOTIFICATION.SEND_NOTIFICATION_JOB_LOCK_DURATION,
stalledInterval: NOTIFICATION.SEND_NOTIFICATION_JOB_STALLED_INTERVAL,
maxStalledCount: NOTIFICATION.SEND_NOTIFICATION_JOB_MAX_STALLED_COUNT,
})
export class NotificationProcessor extends WorkerProcessor {
protected readonly logger = new Logger(NotificationProcessor.name);
constructor(
private readonly em: EntityManager,
private readonly notificationHandlerFactory: NotificationHandlerFactory,
private readonly userLoginHandler: UserLoginHandler,
) {
super();
}
async process(job: Job<NotificationJobData | OtpJobData>, token?: string) {
this.logger.log(`Processing notification job: ${job.name} ${token ? `with token: ${token}` : ""}`);
try {
if (job.name === NOTIFICATION.SEND_NOTIFICATION_JOB_NAME) {
const { type, recipientId, data } = job.data as NotificationJobData;
if (type === NotifType.USER_LOGIN) {
await this.userLoginHandler.processUserLogin(recipientId, data as IBaseNotificationData);
} else {
await this.processWithTransaction(type, recipientId, data);
}
} else {
this.logger.warn(`Unknown job name: ${job.name}`);
}
return true;
} catch (error) {
this.logger.error(
`Failed to process notification: ${error instanceof Error ? error.message : "Unknown error"}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
private async processWithTransaction(type: NotifType, recipientId: string, data: IBaseNotificationData): Promise<void> {
const em = this.em.fork();
try {
await em.begin();
await this.notificationHandlerFactory.processNotification(type, recipientId, data, em);
await em.commit();
} catch (error) {
await em.rollback();
throw error;
}
}
}
@@ -0,0 +1,46 @@
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable } from "@nestjs/common";
import { Queue } from "bullmq";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { NOTIFICATION } from "../constants";
import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "../interfaces/ISendNotificationData";
@Injectable()
export class NotificationQueue {
constructor(@InjectQueue(NOTIFICATION.QUEUE_NAME) private readonly notificationsQueue: Queue) {}
async addNotificationJob(type: NotifType, recipientId: string, data: IBaseNotificationData) {
await this.notificationsQueue.add(
NOTIFICATION.SEND_NOTIFICATION_JOB_NAME,
{
type,
recipientId,
data,
},
{
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_DELAY,
attempts: NOTIFICATION.SEND_NOTIFICATION_JOB_ATTEMPTS,
backoff: {
type: "exponential",
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_BACKOFF,
},
priority: NOTIFICATION.SEND_NOTIFICATION_JOB_PRIORITY,
},
);
}
//*********************************** */
async addLoginNotification(recipientId: string, data: IBaseNotificationData) {
await this.addNotificationJob(NotifType.USER_LOGIN, recipientId, data);
}
async addNewEmailNotification(recipientId: string, data: INewEmailNotificationData) {
await this.addNotificationJob(NotifType.NEW_EMAIL, recipientId, data);
}
async addChangePasswordNotification(recipientId: string, data: IChangePasswordNotificationData) {
await this.addNotificationJob(NotifType.CHANGE_PASSWORD, recipientId, data);
}
}
@@ -0,0 +1,23 @@
import { EntityRepository, FilterQuery } from "@mikro-orm/postgresql";
import { PaginationUtils } from "../../utils/services/pagination.utils";
import { SearchNotificationQueryDto } from "../DTO/search-notification-query.dto";
import { Notification } from "../entities/notification.entity";
export class NotificationRepository extends EntityRepository<Notification> {
async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) {
const { skip, limit } = PaginationUtils(queryDto);
const whereConditions: FilterQuery<Notification> = { recipient: { id: recipientId } };
if (queryDto.isRead !== undefined) {
whereConditions.isRead = queryDto.isRead === 1;
}
return this.findAndCount(whereConditions, {
orderBy: { createdAt: "DESC" },
offset: skip,
limit: limit,
});
}
}
+142
View File
@@ -0,0 +1,142 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { CommonMessage, NotificationMessage, UserMessage } from "../../../common/enums/message.enum";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { UserSettingsService } from "../../settings/services/user-settings.service";
import { User } from "../../users/entities/user.entity";
import { CreateNotificationDto } from "../DTO/create-notification.dto";
import { SearchNotificationQueryDto } from "../DTO/search-notification-query.dto";
import { Notification } from "../entities/notification.entity";
import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationRepository } from "../repositories/notifications.repository";
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly em: EntityManager,
private readonly notificationRepository: NotificationRepository,
private readonly userSettingsService: UserSettingsService,
) {}
//************************ */
async createNotification(createNotificationDto: CreateNotificationDto, em?: EntityManager) {
if (!em) {
const localEm = this.em.fork();
await localEm.begin();
try {
//
const user = await localEm.findOne(User, { id: createNotificationDto.recipientId });
if (!user) throw new NotFoundException(UserMessage.USER_NOT_FOUND);
const notification = localEm.create(Notification, {
...createNotificationDto,
recipient: user,
});
await localEm.persistAndFlush(notification);
await localEm.commit();
return notification;
//
} catch (error) {
this.logger.error("error in createNotification", error);
await localEm.rollback();
throw error;
//
}
//
} else {
//
const user = await em.findOne(User, { id: createNotificationDto.recipientId, deletedAt: null });
if (!user) throw new NotFoundException(UserMessage.USER_NOT_FOUND);
const notification = em.create(Notification, {
...createNotificationDto,
recipient: user,
});
//
await em.persistAndFlush(notification);
return notification;
}
}
//************************ */
async markAsRead(notificationId: string, userId: string) {
const notification = await this.notificationRepository.findOne({ id: notificationId, recipient: { id: userId }, deletedAt: null });
if (!notification) throw new BadRequestException(NotificationMessage.NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED);
notification.isRead = true;
await this.em.flush();
return {
message: CommonMessage.UPDATE_SUCCESS,
};
}
//************************ */
async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) {
const [notifications, count] = await this.notificationRepository.getAllNotifications(queryDto, recipientId);
const notificationCount = await this.countUserNotifications(recipientId);
return { notifications, notificationCount, count, paginate: true };
}
//************************ */
async getNotificationById(id: string) {
const notification = await this.notificationRepository.findOne({ id, deletedAt: null });
if (!notification) throw new BadRequestException(NotificationMessage.NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED);
return { notification };
}
//************************ */
async countUserNotifications(userId: string) {
const notificationCount = await this.notificationRepository.count({ recipient: { id: userId }, isRead: false, deletedAt: null });
return notificationCount;
}
//************************ */
async markAllAsRead(userId: string) {
await this.notificationRepository.nativeUpdate({ recipient: { id: userId }, deletedAt: null }, { isRead: true });
return {
message: CommonMessage.UPDATE_SUCCESS,
};
}
//************************ */
async createLoginNotification(recipientId: string, _data: IBaseNotificationData) {
const loginDate = new Date().toLocaleString("fa-IR");
const message = NotificationMessage.LOGIN_MESSAGE.replace("[loginDate]", loginDate);
//sent notification based on the userSettings
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.USER_LOGIN, recipientId);
if (isActive) {
return this.createNotification({ title: NotificationMessage.LOGIN, type: NotifType.USER_LOGIN, message, recipientId });
}
}
//************************ */
async createNewEmailNotification(recipientId: string, data: INewEmailNotificationData, em: EntityManager) {
const message = NotificationMessage.NEW_EMAIL_MESSAGE.replace("[subject]", data.newEmail);
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.NEW_EMAIL, recipientId);
if (isActive) {
return this.createNotification({ title: NotificationMessage.NEW_EMAIL, type: NotifType.NEW_EMAIL, message, recipientId }, em);
}
}
//************************ */
async changePasswordNotification(recipientId: string, _data: IChangePasswordNotificationData, em: EntityManager) {
const message = NotificationMessage.CHANGE_PASSWORD_MESSAGE;
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.CHANGE_PASSWORD, recipientId);
if (isActive) {
return this.createNotification({ title: NotificationMessage.CHANGE_PASSWORD, type: NotifType.CHANGE_PASSWORD, message, recipientId }, em);
}
}
}
@@ -23,9 +23,9 @@ export class QuotaSyncService {
private readonly logger = new Logger(QuotaSyncService.name);
constructor(
@InjectQueue(QUOTA_SYNC_QUEUE.QUEUE_NAME) private readonly quotaSyncQueue: Queue,
private readonly wildDuckUsersService: WildDuckUsersService,
private readonly em: EntityManager,
@InjectQueue(QUOTA_SYNC_QUEUE.QUEUE_NAME) private readonly quotaSyncQueue: Queue,
) {}
/**
@@ -50,7 +50,7 @@ export class QuotaSyncService {
delay: 2000,
},
repeat: {
pattern: "0/1 * * * *", // Every hour at minute 0 (cron pattern)
pattern: "0 * * * *",
},
},
);
@@ -80,6 +80,9 @@ export class QuotaSyncService {
} else {
this.logger.debug("No existing cron job found to remove");
}
await this.quotaSyncQueue.clean(0, 100, "completed");
await this.quotaSyncQueue.clean(0, 100, "failed");
} catch (error) {
this.logger.error("Failed to remove quota sync cron job", error);
}
+16
View File
@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional } from "class-validator";
import { SettingMessageEnum } from "../../../common/enums/message.enum";
export class UpdateUserSettingsDto {
@IsOptional()
@IsNotEmpty({ message: SettingMessageEnum.SMS_MUST_BE_BOOLEAN })
@ApiPropertyOptional({ description: "Sms Setting", example: true })
sms: boolean;
@IsOptional()
@IsNotEmpty({ message: SettingMessageEnum.EMAIL_MUST_BE_BOOLEAN })
@ApiPropertyOptional({ description: "email Setting", example: false })
email: boolean;
}
@@ -0,0 +1,23 @@
import { Collection, Entity, EntityRepositoryType, Enum, OneToMany, Property } from "@mikro-orm/core";
import { UserSetting } from "./user-setting.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { NotifCategory, NotifType } from "../enums/notif-settings.enum";
import { NotifSettingsRepository } from "../repositories/notif-settings.repository";
@Entity({ repository: () => NotifSettingsRepository })
export class NotificationSetting extends BaseEntity {
@Enum({ items: () => NotifType, unique: true })
type!: NotifType;
@Enum({ items: () => NotifCategory })
category!: NotifCategory;
@Property({ type: "text" })
description!: string;
@OneToMany(() => UserSetting, (userSetting) => userSetting.notificationSetting)
userSettings = new Collection<UserSetting>(this);
[EntityRepositoryType]?: NotifSettingsRepository;
}
+20
View File
@@ -0,0 +1,20 @@
import { Entity, EntityRepositoryType, ManyToOne, Property, Ref } from "@mikro-orm/core";
import { NotificationSetting } from "./notification-setting.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
import { UserSettingsRepository } from "../repositories/user-settings.repository";
@Entity({ repository: () => UserSettingsRepository })
export class UserSetting extends BaseEntity {
@Property({ type: "boolean", default: true })
isActive!: boolean;
@ManyToOne(() => NotificationSetting, { nullable: false })
notificationSetting!: Ref<NotificationSetting>;
@ManyToOne(() => User, { nullable: false, deleteRule: "cascade" })
user!: User;
[EntityRepositoryType]?: UserSettingsRepository;
}
+15
View File
@@ -0,0 +1,15 @@
export enum NotifCategory {
ACCOUNT = "ACCOUNT",
}
export enum NotifType {
USER_LOGIN = "USER_LOGIN",
NEW_EMAIL = "NEW_EMAIL",
CHANGE_PASSWORD = "CHANGE_PASSWORD",
}
export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifCategory }> = {
[NotifType.USER_LOGIN]: { fa: "ورود به ناحیه کاربری", category: NotifCategory.ACCOUNT },
[NotifType.NEW_EMAIL]: { fa: "ایمیل جدید", category: NotifCategory.ACCOUNT },
[NotifType.CHANGE_PASSWORD]: { fa: "تغییر رمز عبور", category: NotifCategory.ACCOUNT },
};
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { NotificationSetting } from "../entities/notification-setting.entity";
export class NotifSettingsRepository extends EntityRepository<NotificationSetting> {}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { UserSetting } from "../entities/user-setting.entity";
export class UserSettingsRepository extends EntityRepository<UserSetting> {}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable } from "@nestjs/common";
import { NotifSettingsRepository } from "../repositories/notif-settings.repository";
@Injectable()
export class NotifSettingsService {
constructor(private readonly notifSettingsRepository: NotifSettingsRepository) {}
async getAll() {
const notifSettings = await this.notifSettingsRepository.find({ deletedAt: null });
return { notifSettings };
}
}
+94
View File
@@ -0,0 +1,94 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, Injectable } from "@nestjs/common";
import { CommonMessage, SettingMessageEnum, UserMessage } from "../../../common/enums/message.enum";
import { User } from "../../users/entities/user.entity";
import { NotificationSetting } from "../entities/notification-setting.entity";
import { UserSetting } from "../entities/user-setting.entity";
import { NotifType } from "../enums/notif-settings.enum";
import { UserSettingsRepository } from "../repositories/user-settings.repository";
@Injectable()
export class UserSettingsService {
constructor(
private readonly userSettingsRepository: UserSettingsRepository,
private readonly em: EntityManager,
) {}
//*+*******************************************
async createUserSettings(userId: string, em: EntityManager) {
const user = await em.findOne(User, { id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
const notifSettings = await em.find(NotificationSetting, {
type: { $in: Object.values(NotifType) },
});
const userSettings = notifSettings.map((notifSetting) =>
em.create(UserSetting, {
isActive: true,
notificationSetting: notifSetting,
user,
}),
);
await em.persistAndFlush(userSettings);
return { message: CommonMessage.CREATED };
}
//*+*******************************************
async getSettings(userId: string) {
const query = `
SELECT
notif.category AS category,
jsonb_agg(
jsonb_build_object(
'id', user_setting.id,
'type', notif.type,
'description', notif.description,
'isActive', user_setting.is_active
)
) AS settings
FROM user_setting
INNER JOIN notification_setting notif ON user_setting.notification_setting_id = notif.id
WHERE user_setting.user_id = ? AND user_setting.deleted_at IS NULL
GROUP BY notif.category
`;
const groupedSettings = await this.em.getConnection().execute(query, [userId]);
return { settings: groupedSettings };
}
//*+*******************************************
async getUserNotificationSettingWithType(type: NotifType, userId: string, em?: EntityManager) {
const entityManager = em || this.em.fork();
const repository = entityManager.getRepository(UserSetting);
const setting = await repository.findOne(
{
user: { id: userId },
notificationSetting: { type },
},
{ populate: ["notificationSetting"] },
);
if (!setting) throw new BadRequestException(SettingMessageEnum.NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED);
return setting;
}
//*+*******************************************
async toggleUserNotificationSetting(settingId: string) {
const setting = await this.userSettingsRepository.findOne({ id: settingId, deletedAt: null });
if (!setting) throw new BadRequestException(SettingMessageEnum.NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED);
setting.isActive = !setting.isActive;
await this.em.persistAndFlush(setting);
return { message: CommonMessage.UPDATE_SUCCESS, setting };
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Controller, Get, HttpCode, HttpStatus, Param, Patch } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { UserSettingsService } from "./services/user-settings.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
@Controller("settings")
@ApiTags("Settings")
export class SettingsController {
constructor(private readonly userSettingsService: UserSettingsService) {}
@AuthGuards()
@ApiOperation({ summary: "Get all settings" })
@Get()
async getSettings(@UserDec("id") userId: string) {
return await this.userSettingsService.getSettings(userId);
}
@AuthGuards()
@ApiOperation({ summary: "update status of setting" })
@HttpCode(HttpStatus.OK)
@Patch(":id/toggle")
updateStatus(@Param() paramDto: ParamDto) {
return this.userSettingsService.toggleUserNotificationSetting(paramDto.id);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { NotificationSetting } from "./entities/notification-setting.entity";
import { UserSetting } from "./entities/user-setting.entity";
import { NotifSettingsService } from "./services/notif-settings.service";
import { UserSettingsService } from "./services/user-settings.service";
import { SettingsController } from "./settings.controller";
@Module({
imports: [MikroOrmModule.forFeature([UserSetting, NotificationSetting])],
providers: [UserSettingsService, NotifSettingsService],
controllers: [SettingsController],
exports: [UserSettingsService],
})
export class SettingModule {}
+58 -56
View File
@@ -11,6 +11,7 @@ import { DomainsService } from "../../domains/services/domains.service";
import { UpdateUserDto } from "../../mail-server/DTO";
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";
@@ -33,6 +34,7 @@ export class UsersService {
private readonly domainsService: DomainsService,
private readonly domainAutomationService: DomainAutomationService,
private readonly quotaSyncService: QuotaSyncService,
private readonly userSettingsService: UserSettingsService,
) {}
async getUserEmailIdFromId(userId: string) {
@@ -57,21 +59,24 @@ export class UsersService {
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: Number(user.emailQuota),
used: Number(user.emailQuotaUsed),
remaining: Number(user.emailQuota - user.emailQuotaUsed),
totalInMB: Math.round(Number(user.emailQuota) / (1024 * 1024)),
totalInGB: Math.round(Number(user.emailQuota) / (1024 * 1024 * 1024)),
usedInMB: Math.round(Number(user.emailQuotaUsed) / (1024 * 1024)),
usedInGB: Math.round(Number(user.emailQuotaUsed) / (1024 * 1024 * 1024)),
remainingInMB: Math.round(Number(user.emailQuota - user.emailQuotaUsed) / (1024 * 1024)),
remainingInGB: Math.round(Number(user.emailQuota - user.emailQuotaUsed) / (1024 * 1024 * 1024)),
usagePercentage: Math.round((Number(user.emailQuotaUsed) / Number(user.emailQuota)) * 100),
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,
},
forwarders: user.forwarders || [],
};
}
/*******************************/
@@ -84,36 +89,39 @@ export class UsersService {
/*******************************/
async createEmailUser(createEmailUserDto: CreateEmailUserDto, businessId: string) {
const { username, password, domainId, displayName, quota, aliases, title, templateId, forwarders } = createEmailUserDto;
const business = await this.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 this.userRepository.findOne({ emailAddress });
if (existingUser) throw new BadRequestException(UserMessage.EMAIL_ADDRESS_ALREADY_EXISTS);
const hashedPassword = await this.passwordService.hashPassword(password);
const userRole = await this.em.findOne(Role, { name: RoleEnum.USER });
if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND);
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(
@@ -129,7 +137,7 @@ export class UsersService {
if (!mailServerUser.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT);
const user = this.userRepository.create({
const user = em.create(User, {
title: title || displayName || username,
userName: finalUserName,
password: hashedPassword,
@@ -145,14 +153,15 @@ export class UsersService {
forwarders: forwarders || [],
});
// Update business quota after successful user creation
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 this.em.persistAndFlush([user, business]);
await em.persistAndFlush([user, business]);
if (templateId) {
const template = await this.em.findOne(Template, { id: templateId, business: { id: businessId } });
const template = await em.findOne(Template, { id: templateId, business: { id: businessId } });
if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND);
user.template = template;
}
@@ -172,34 +181,27 @@ export class UsersService {
}
}
await this.em.flush();
await em.flush();
//sync quota
await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, mailServerUser.id);
//sync business quota
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!,
displayName: user.displayName!,
wildduckUserId: user.wildduckUserId!,
emailQuota: user.emailQuota!,
emailEnabled: user.emailEnabled,
domain: domain.name,
forwarders: user.forwarders || [],
createdAt: user.createdAt,
isActive: user.isActive,
},
message: UserMessage.EMAIL_USER_CREATED_SUCCESSFULLY,
};
} catch (error) {
this.logger.error(`Failed to create email user ${emailAddress}:`, 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);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ export class UsersController {
@Get("me")
@ApiOperation({ summary: "Get current user" })
@ApiResponse({ status: 200, description: "Current user retrieved successfully" })
getMe(@UserDec("wildduckUserId") userId: string) {
getMe(@UserDec("id") userId: string) {
return this.usersService.getMe(userId);
}
+10 -1
View File
@@ -10,10 +10,19 @@ import { BusinessesModule } from "../businesses/businesses.module";
import { DomainsModule } from "../domains/domains.module";
import { MailServerModule } from "../mail-server/mail-server.module";
import { QuotaSyncModule } from "../quota-sync/quota-sync.module";
import { SettingModule } from "../settings/settings.module";
import { UtilsModule } from "../utils/utils.module";
@Module({
imports: [MikroOrmModule.forFeature([User, RefreshToken, Role]), UtilsModule, MailServerModule, DomainsModule, BusinessesModule, QuotaSyncModule],
imports: [
MikroOrmModule.forFeature([User, RefreshToken, Role]),
UtilsModule,
MailServerModule,
DomainsModule,
BusinessesModule,
QuotaSyncModule,
SettingModule,
],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],