From 5322b6b6c4dd6ed004dbbfa474cd1c512eac23ff Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Sun, 20 Jul 2025 10:15:13 +0330 Subject: [PATCH] chore: add new route --- database/seeders/index.seeder.ts | 2 + database/seeders/notif-setting.seeder.ts | 33 +++ src/app.module.ts | 4 + src/common/enums/message.enum.ts | 26 +++ src/modules/auth/DTO/change-password.dto.ts | 24 ++ src/modules/auth/auth.controller.ts | 9 + src/modules/auth/auth.module.ts | 2 + src/modules/auth/services/auth.service.ts | 50 ++-- .../email/constants/email-events.constant.ts | 7 +- .../controllers/email-gateway.controller.ts | 101 --------- src/modules/email/email.module.ts | 2 - .../interfaces/email-events.interface.ts | 8 + .../email/queue/email-monitoring.processor.ts | 19 +- .../email-monitoring-scheduler.service.ts | 73 ------ .../services/email-monitoring.service.ts | 213 +++++++++++++++--- src/modules/email/services/email.service.ts | 2 - .../services/wildduck-base.service.ts | 4 +- .../DTO/create-notification.dto.ts | 23 ++ .../DTO/search-notification-query.dto.ts | 13 ++ src/modules/notifications/constants/index.ts | 16 ++ .../entities/notification.entity.ts | 26 +++ .../handlers/base-notification.handler.ts | 31 +++ .../handlers/change-password.handler.ts | 23 ++ .../handlers/new-email.handler.ts | 23 ++ .../handlers/notification-handler.factory.ts | 47 ++++ .../handlers/user-login.handler.ts | 24 ++ .../interfaces/INotification-job-data.ts | 13 ++ .../interfaces/ISendNotificationData.ts | 17 ++ .../notifications/notifications.controller.ts | 38 ++++ .../notifications/notifications.module.ts | 53 +++++ .../queue/notification.processor.ts | 71 ++++++ .../notifications/queue/notification.queue.ts | 46 ++++ .../repositories/notifications.repository.ts | 23 ++ .../services/notifications.service.ts | 142 ++++++++++++ .../quota-sync/services/quota-sync.service.ts | 7 +- .../settings/DTO/update-settings.dto.ts | 16 ++ .../entities/notification-setting.entity.ts | 23 ++ .../settings/entities/user-setting.entity.ts | 20 ++ .../settings/enums/notif-settings.enum.ts | 15 ++ .../repositories/notif-settings.repository.ts | 5 + .../repositories/user-settings.repository.ts | 5 + .../services/notif-settings.service.ts | 13 ++ .../services/user-settings.service.ts | 94 ++++++++ src/modules/settings/settings.controller.ts | 28 +++ src/modules/settings/settings.module.ts | 16 ++ src/modules/users/services/users.service.ts | 114 +++++----- src/modules/users/users.controller.ts | 2 +- src/modules/users/users.module.ts | 11 +- 48 files changed, 1282 insertions(+), 295 deletions(-) create mode 100755 database/seeders/notif-setting.seeder.ts create mode 100644 src/modules/auth/DTO/change-password.dto.ts delete mode 100644 src/modules/email/controllers/email-gateway.controller.ts delete mode 100644 src/modules/email/services/email-monitoring-scheduler.service.ts create mode 100755 src/modules/notifications/DTO/create-notification.dto.ts create mode 100755 src/modules/notifications/DTO/search-notification-query.dto.ts create mode 100644 src/modules/notifications/constants/index.ts create mode 100755 src/modules/notifications/entities/notification.entity.ts create mode 100644 src/modules/notifications/handlers/base-notification.handler.ts create mode 100644 src/modules/notifications/handlers/change-password.handler.ts create mode 100644 src/modules/notifications/handlers/new-email.handler.ts create mode 100644 src/modules/notifications/handlers/notification-handler.factory.ts create mode 100644 src/modules/notifications/handlers/user-login.handler.ts create mode 100644 src/modules/notifications/interfaces/INotification-job-data.ts create mode 100755 src/modules/notifications/interfaces/ISendNotificationData.ts create mode 100755 src/modules/notifications/notifications.controller.ts create mode 100755 src/modules/notifications/notifications.module.ts create mode 100644 src/modules/notifications/queue/notification.processor.ts create mode 100644 src/modules/notifications/queue/notification.queue.ts create mode 100755 src/modules/notifications/repositories/notifications.repository.ts create mode 100755 src/modules/notifications/services/notifications.service.ts create mode 100755 src/modules/settings/DTO/update-settings.dto.ts create mode 100755 src/modules/settings/entities/notification-setting.entity.ts create mode 100755 src/modules/settings/entities/user-setting.entity.ts create mode 100755 src/modules/settings/enums/notif-settings.enum.ts create mode 100755 src/modules/settings/repositories/notif-settings.repository.ts create mode 100755 src/modules/settings/repositories/user-settings.repository.ts create mode 100755 src/modules/settings/services/notif-settings.service.ts create mode 100755 src/modules/settings/services/user-settings.service.ts create mode 100755 src/modules/settings/settings.controller.ts create mode 100755 src/modules/settings/settings.module.ts diff --git a/database/seeders/index.seeder.ts b/database/seeders/index.seeder.ts index 2a95618..43dce12 100644 --- a/database/seeders/index.seeder.ts +++ b/database/seeders/index.seeder.ts @@ -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 { 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."); } diff --git a/database/seeders/notif-setting.seeder.ts b/database/seeders/notif-setting.seeder.ts new file mode 100755 index 0000000..afa7a56 --- /dev/null +++ b/database/seeders/notif-setting.seeder.ts @@ -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 { + const notifSettingsData: RequiredEntityData[] = 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.`); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 1970bef..a6dc28f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 { diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 60ed64f..b2585e7 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -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 = "اعلان یافت نشد یا دسترسی آن را ندارید", +} diff --git a/src/modules/auth/DTO/change-password.dto.ts b/src/modules/auth/DTO/change-password.dto.ts new file mode 100644 index 0000000..2da72f1 --- /dev/null +++ b/src/modules/auth/DTO/change-password.dto.ts @@ -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; +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d00b340..e44e286 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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); + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 0be210f..7653fc8 100755 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -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], diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 6d2f955..b1a3171 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -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, + 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) { diff --git a/src/modules/email/constants/email-events.constant.ts b/src/modules/email/constants/email-events.constant.ts index 23bcf02..69379a2 100644 --- a/src/modules/email/constants/email-events.constant.ts +++ b/src/modules/email/constants/email-events.constant.ts @@ -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; diff --git a/src/modules/email/controllers/email-gateway.controller.ts b/src/modules/email/controllers/email-gateway.controller.ts deleted file mode 100644 index d3d8ea5..0000000 --- a/src/modules/email/controllers/email-gateway.controller.ts +++ /dev/null @@ -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", -// }; -// } -// } diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts index 0cccc61..e463ee9 100644 --- a/src/modules/email/email.module.ts +++ b/src/modules/email/email.module.ts @@ -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, diff --git a/src/modules/email/interfaces/email-events.interface.ts b/src/modules/email/interfaces/email-events.interface.ts index 48da1d1..f15599f 100644 --- a/src/modules/email/interfaces/email-events.interface.ts +++ b/src/modules/email/interfaces/email-events.interface.ts @@ -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; diff --git a/src/modules/email/queue/email-monitoring.processor.ts b/src/modules/email/queue/email-monitoring.processor.ts index 91cedc5..e7f6dcc 100644 --- a/src/modules/email/queue/email-monitoring.processor.ts +++ b/src/modules/email/queue/email-monitoring.processor.ts @@ -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) { + async process(job: Job) { 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; } } diff --git a/src/modules/email/services/email-monitoring-scheduler.service.ts b/src/modules/email/services/email-monitoring-scheduler.service.ts deleted file mode 100644 index ddb3b13..0000000 --- a/src/modules/email/services/email-monitoring-scheduler.service.ts +++ /dev/null @@ -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(); - } -} diff --git a/src/modules/email/services/email-monitoring.service.ts b/src/modules/email/services/email-monitoring.service.ts index 6bec000..df80128 100644 --- a/src/modules/email/services/email-monitoring.service.ts +++ b/src/modules/email/services/email-monitoring.service.ts @@ -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) { 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 { - 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); + } } } diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts index d2c80f6..7f42e24 100644 --- a/src/modules/email/services/email.service.ts +++ b/src/modules/email/services/email.service.ts @@ -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)); diff --git a/src/modules/mail-server/services/wildduck-base.service.ts b/src/modules/mail-server/services/wildduck-base.service.ts index 20f8be2..812401f 100644 --- a/src/modules/mail-server/services/wildduck-base.service.ts +++ b/src/modules/mail-server/services/wildduck-base.service.ts @@ -65,9 +65,7 @@ export class WildDuckBaseService { protected get(path: string, params?: Record): Observable { const url = this.buildUrl(path, params); this.logger.debug(`WildDuck GET: ${url}`); - if (params) { - this.logger.debug(`WildDuck GET Params:`, params); - } + return this.handleResponse(this.httpService.get(url)); } diff --git a/src/modules/notifications/DTO/create-notification.dto.ts b/src/modules/notifications/DTO/create-notification.dto.ts new file mode 100755 index 0000000..014385d --- /dev/null +++ b/src/modules/notifications/DTO/create-notification.dto.ts @@ -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; +} diff --git a/src/modules/notifications/DTO/search-notification-query.dto.ts b/src/modules/notifications/DTO/search-notification-query.dto.ts new file mode 100755 index 0000000..7f9570c --- /dev/null +++ b/src/modules/notifications/DTO/search-notification-query.dto.ts @@ -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; +} diff --git a/src/modules/notifications/constants/index.ts b/src/modules/notifications/constants/index.ts new file mode 100644 index 0000000..d666efd --- /dev/null +++ b/src/modules/notifications/constants/index.ts @@ -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, +}); diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/modules/notifications/entities/notification.entity.ts new file mode 100755 index 0000000..36a2bc0 --- /dev/null +++ b/src/modules/notifications/entities/notification.entity.ts @@ -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; +} diff --git a/src/modules/notifications/handlers/base-notification.handler.ts b/src/modules/notifications/handlers/base-notification.handler.ts new file mode 100644 index 0000000..7a1a19f --- /dev/null +++ b/src/modules/notifications/handlers/base-notification.handler.ts @@ -0,0 +1,31 @@ +import { EntityManager } from "@mikro-orm/postgresql"; +import { Logger } from "@nestjs/common"; + +export abstract class BaseNotificationHandler { + protected abstract readonly logger: Logger; + + /** + * Process the notification with automatic transaction management + */ + async processWithTransaction(recipientId: string, data: TData, em: EntityManager): Promise { + 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; + + /** + * Get the handler name for logging purposes + */ + protected abstract getHandlerName(): string; +} diff --git a/src/modules/notifications/handlers/change-password.handler.ts b/src/modules/notifications/handlers/change-password.handler.ts new file mode 100644 index 0000000..83b6ee6 --- /dev/null +++ b/src/modules/notifications/handlers/change-password.handler.ts @@ -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 { + protected readonly logger = new Logger(ChangePasswordHandler.name); + + constructor(private readonly notificationsService: NotificationsService) { + super(); + } + + protected async handle(recipientId: string, data: IChangePasswordNotificationData, em: EntityManager): Promise { + await this.notificationsService.changePasswordNotification(recipientId, data, em); + } + + protected getHandlerName(): string { + return "ChangePassword"; + } +} diff --git a/src/modules/notifications/handlers/new-email.handler.ts b/src/modules/notifications/handlers/new-email.handler.ts new file mode 100644 index 0000000..25a5ec1 --- /dev/null +++ b/src/modules/notifications/handlers/new-email.handler.ts @@ -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 { + protected readonly logger = new Logger(NewEmailHandler.name); + + constructor(private readonly notificationsService: NotificationsService) { + super(); + } + + protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise { + await this.notificationsService.createNewEmailNotification(recipientId, data, em); + } + + protected getHandlerName(): string { + return "NewEmail"; + } +} diff --git a/src/modules/notifications/handlers/notification-handler.factory.ts b/src/modules/notifications/handlers/notification-handler.factory.ts new file mode 100644 index 0000000..1a37b55 --- /dev/null +++ b/src/modules/notifications/handlers/notification-handler.factory.ts @@ -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 = 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 { + 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); + } +} diff --git a/src/modules/notifications/handlers/user-login.handler.ts b/src/modules/notifications/handlers/user-login.handler.ts new file mode 100644 index 0000000..82dc4b9 --- /dev/null +++ b/src/modules/notifications/handlers/user-login.handler.ts @@ -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 { + 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; + } + } +} diff --git a/src/modules/notifications/interfaces/INotification-job-data.ts b/src/modules/notifications/interfaces/INotification-job-data.ts new file mode 100644 index 0000000..8c9e055 --- /dev/null +++ b/src/modules/notifications/interfaces/INotification-job-data.ts @@ -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; +}; diff --git a/src/modules/notifications/interfaces/ISendNotificationData.ts b/src/modules/notifications/interfaces/ISendNotificationData.ts new file mode 100755 index 0000000..24483bd --- /dev/null +++ b/src/modules/notifications/interfaces/ISendNotificationData.ts @@ -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; +} diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts new file mode 100755 index 0000000..efbe9a4 --- /dev/null +++ b/src/modules/notifications/notifications.controller.ts @@ -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); + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100755 index 0000000..1de017c --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -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 {} diff --git a/src/modules/notifications/queue/notification.processor.ts b/src/modules/notifications/queue/notification.processor.ts new file mode 100644 index 0000000..1830100 --- /dev/null +++ b/src/modules/notifications/queue/notification.processor.ts @@ -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, 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 { + 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; + } + } +} diff --git a/src/modules/notifications/queue/notification.queue.ts b/src/modules/notifications/queue/notification.queue.ts new file mode 100644 index 0000000..1f0d81c --- /dev/null +++ b/src/modules/notifications/queue/notification.queue.ts @@ -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); + } +} diff --git a/src/modules/notifications/repositories/notifications.repository.ts b/src/modules/notifications/repositories/notifications.repository.ts new file mode 100755 index 0000000..4bcc70a --- /dev/null +++ b/src/modules/notifications/repositories/notifications.repository.ts @@ -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 { + async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) { + const { skip, limit } = PaginationUtils(queryDto); + + const whereConditions: FilterQuery = { recipient: { id: recipientId } }; + + if (queryDto.isRead !== undefined) { + whereConditions.isRead = queryDto.isRead === 1; + } + + return this.findAndCount(whereConditions, { + orderBy: { createdAt: "DESC" }, + offset: skip, + limit: limit, + }); + } +} diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts new file mode 100755 index 0000000..693bbc2 --- /dev/null +++ b/src/modules/notifications/services/notifications.service.ts @@ -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); + } + } +} diff --git a/src/modules/quota-sync/services/quota-sync.service.ts b/src/modules/quota-sync/services/quota-sync.service.ts index 6fe1eb0..cb9009b 100644 --- a/src/modules/quota-sync/services/quota-sync.service.ts +++ b/src/modules/quota-sync/services/quota-sync.service.ts @@ -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); } diff --git a/src/modules/settings/DTO/update-settings.dto.ts b/src/modules/settings/DTO/update-settings.dto.ts new file mode 100755 index 0000000..68d3846 --- /dev/null +++ b/src/modules/settings/DTO/update-settings.dto.ts @@ -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; +} diff --git a/src/modules/settings/entities/notification-setting.entity.ts b/src/modules/settings/entities/notification-setting.entity.ts new file mode 100755 index 0000000..7851a2e --- /dev/null +++ b/src/modules/settings/entities/notification-setting.entity.ts @@ -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(this); + + [EntityRepositoryType]?: NotifSettingsRepository; +} diff --git a/src/modules/settings/entities/user-setting.entity.ts b/src/modules/settings/entities/user-setting.entity.ts new file mode 100755 index 0000000..26c4f5b --- /dev/null +++ b/src/modules/settings/entities/user-setting.entity.ts @@ -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; + + @ManyToOne(() => User, { nullable: false, deleteRule: "cascade" }) + user!: User; + + [EntityRepositoryType]?: UserSettingsRepository; +} diff --git a/src/modules/settings/enums/notif-settings.enum.ts b/src/modules/settings/enums/notif-settings.enum.ts new file mode 100755 index 0000000..27159d2 --- /dev/null +++ b/src/modules/settings/enums/notif-settings.enum.ts @@ -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.USER_LOGIN]: { fa: "ورود به ناحیه کاربری", category: NotifCategory.ACCOUNT }, + [NotifType.NEW_EMAIL]: { fa: "ایمیل جدید", category: NotifCategory.ACCOUNT }, + [NotifType.CHANGE_PASSWORD]: { fa: "تغییر رمز عبور", category: NotifCategory.ACCOUNT }, +}; diff --git a/src/modules/settings/repositories/notif-settings.repository.ts b/src/modules/settings/repositories/notif-settings.repository.ts new file mode 100755 index 0000000..08253ed --- /dev/null +++ b/src/modules/settings/repositories/notif-settings.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { NotificationSetting } from "../entities/notification-setting.entity"; + +export class NotifSettingsRepository extends EntityRepository {} diff --git a/src/modules/settings/repositories/user-settings.repository.ts b/src/modules/settings/repositories/user-settings.repository.ts new file mode 100755 index 0000000..0a31813 --- /dev/null +++ b/src/modules/settings/repositories/user-settings.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { UserSetting } from "../entities/user-setting.entity"; + +export class UserSettingsRepository extends EntityRepository {} diff --git a/src/modules/settings/services/notif-settings.service.ts b/src/modules/settings/services/notif-settings.service.ts new file mode 100755 index 0000000..7341f48 --- /dev/null +++ b/src/modules/settings/services/notif-settings.service.ts @@ -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 }; + } +} diff --git a/src/modules/settings/services/user-settings.service.ts b/src/modules/settings/services/user-settings.service.ts new file mode 100755 index 0000000..5c5e25b --- /dev/null +++ b/src/modules/settings/services/user-settings.service.ts @@ -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 }; + } +} diff --git a/src/modules/settings/settings.controller.ts b/src/modules/settings/settings.controller.ts new file mode 100755 index 0000000..a6d66e3 --- /dev/null +++ b/src/modules/settings/settings.controller.ts @@ -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); + } +} diff --git a/src/modules/settings/settings.module.ts b/src/modules/settings/settings.module.ts new file mode 100755 index 0000000..5bbf043 --- /dev/null +++ b/src/modules/settings/settings.module.ts @@ -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 {} diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index 9e322e5..ea26b21 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -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); } } diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 0b8e19f..140ffe2 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -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); } diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 26f64a9..fb86359 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -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],