chore: add new route
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
|
||||
Executable
+33
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+16
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
Reference in New Issue
Block a user