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 { Seeder } from "@mikro-orm/seeder";
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { NotificationSettingSeeder } from "./notif-setting.seeder";
|
||||||
// import { PaymentGatewaySeeder } from "./payment-gateway.seeder";
|
// import { PaymentGatewaySeeder } from "./payment-gateway.seeder";
|
||||||
import { RoleSeeder } from "./role.seeder";
|
import { RoleSeeder } from "./role.seeder";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export class IndexSeeder extends Seeder {
|
|||||||
async run(em: EntityManager): Promise<void> {
|
async run(em: EntityManager): Promise<void> {
|
||||||
this.logger.log("Starting seeding process...");
|
this.logger.log("Starting seeding process...");
|
||||||
await new RoleSeeder().run(em);
|
await new RoleSeeder().run(em);
|
||||||
|
await new NotificationSettingSeeder().run(em);
|
||||||
// await new PaymentGatewaySeeder().run(em);
|
// await new PaymentGatewaySeeder().run(em);
|
||||||
this.logger.log("Seeding process completed successfully.");
|
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 { EmailModule } from "./modules/email/email.module";
|
||||||
import { MailServerModule } from "./modules/mail-server/mail-server.module";
|
import { MailServerModule } from "./modules/mail-server/mail-server.module";
|
||||||
import { MailboxModule } from "./modules/mailbox/mailbox.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 { QuotaSyncModule } from "./modules/quota-sync/quota-sync.module";
|
||||||
|
import { SettingModule } from "./modules/settings/settings.module";
|
||||||
import { EmailSignaturesModule } from "./modules/signatures/email-signatures.module";
|
import { EmailSignaturesModule } from "./modules/signatures/email-signatures.module";
|
||||||
import { TemplatesModule } from "./modules/templates/templates.module";
|
import { TemplatesModule } from "./modules/templates/templates.module";
|
||||||
import { UsersModule } from "./modules/users/users.module";
|
import { UsersModule } from "./modules/users/users.module";
|
||||||
@@ -44,6 +46,8 @@ import { UtilsModule } from "./modules/utils/utils.module";
|
|||||||
QuotaSyncModule,
|
QuotaSyncModule,
|
||||||
EmailSignaturesModule,
|
EmailSignaturesModule,
|
||||||
TemplatesModule,
|
TemplatesModule,
|
||||||
|
SettingModule,
|
||||||
|
NotificationModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const enum AuthMessage {
|
|||||||
ACCESS_DENIED = "دسترسی شما محدود شده است",
|
ACCESS_DENIED = "دسترسی شما محدود شده است",
|
||||||
USER_ACCOUNT_INACTIVE = "حساب کاربری شما غیر فعال است",
|
USER_ACCOUNT_INACTIVE = "حساب کاربری شما غیر فعال است",
|
||||||
TOKEN_MISSED_OR_EXPIRED = "توکن مفقود یا منقضی شده است",
|
TOKEN_MISSED_OR_EXPIRED = "توکن مفقود یا منقضی شده است",
|
||||||
|
OLD_PASSWORD_REQUIRED = "رمز عبور قبلی الزامی است",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum UserMessage {
|
export const enum UserMessage {
|
||||||
@@ -483,3 +484,28 @@ export const enum TemplateMessage {
|
|||||||
TEMPLATE_THUMBNAIL_URL_VALID = "آدرس تصویر قالب باید یک آدرس معتبر باشد",
|
TEMPLATE_THUMBNAIL_URL_VALID = "آدرس تصویر قالب باید یک آدرس معتبر باشد",
|
||||||
TEMPLATE_RAW_HTML_REQUIRED = "HTML خام قالب الزامی است",
|
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 { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
|
||||||
import { ApiBadRequestResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
import { ApiBadRequestResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { ChangePasswordDto } from "./DTO/change-password.dto";
|
||||||
import { LoginDto } from "./DTO/login.dto";
|
import { LoginDto } from "./DTO/login.dto";
|
||||||
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
|
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
|
||||||
import { AuthService } from "./services/auth.service";
|
import { AuthService } from "./services/auth.service";
|
||||||
@@ -39,4 +40,12 @@ export class AuthController {
|
|||||||
logout(@UserDec("id") userId: string) {
|
logout(@UserDec("id") userId: string) {
|
||||||
return this.authService.logout(userId);
|
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 { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy";
|
||||||
import { LocalJwtStrategy } from "./strategies/local-jwt.strategy";
|
import { LocalJwtStrategy } from "./strategies/local-jwt.strategy";
|
||||||
import { BusinessesModule } from "../businesses/businesses.module";
|
import { BusinessesModule } from "../businesses/businesses.module";
|
||||||
|
import { NotificationModule } from "../notifications/notifications.module";
|
||||||
import { User } from "../users/entities/user.entity";
|
import { User } from "../users/entities/user.entity";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -24,6 +25,7 @@ import { User } from "../users/entities/user.entity";
|
|||||||
JwtModule.registerAsync(jwtConfig()),
|
JwtModule.registerAsync(jwtConfig()),
|
||||||
BusinessesModule,
|
BusinessesModule,
|
||||||
MailServerModule,
|
MailServerModule,
|
||||||
|
NotificationModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy],
|
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 { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { TokensService } from "./tokens.service";
|
import { TokensService } from "./tokens.service";
|
||||||
import { AuthMessage } from "../../../common/enums/message.enum";
|
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 { PasswordService } from "../../utils/services/password.service";
|
||||||
|
import { ChangePasswordDto } from "../DTO/change-password.dto";
|
||||||
import { LoginDto } from "../DTO/login.dto";
|
import { LoginDto } from "../DTO/login.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -13,8 +13,8 @@ export class AuthService {
|
|||||||
// private readonly logger = new Logger(AuthService.name);
|
// private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
private readonly notificationQueue: NotificationQueue,
|
||||||
private readonly userRepository: EntityRepository<User>,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly tokensService: TokensService,
|
private readonly tokensService: TokensService,
|
||||||
private readonly passwordService: PasswordService,
|
private readonly passwordService: PasswordService,
|
||||||
// private readonly mailServerService: MailServerService,
|
// private readonly mailServerService: MailServerService,
|
||||||
@@ -27,23 +27,13 @@ export class AuthService {
|
|||||||
|
|
||||||
const user = await this.checkUserLoginCredentialWithEmail(email, password);
|
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);
|
const tokens = await this.tokensService.generateTokens(user);
|
||||||
|
|
||||||
|
await this.notificationQueue.addLoginNotification(user.id, {
|
||||||
|
userEmail: email,
|
||||||
|
userName: user.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: AuthMessage.PASSWORD_LOGIN_SUCCESS,
|
message: AuthMessage.PASSWORD_LOGIN_SUCCESS,
|
||||||
...tokens,
|
...tokens,
|
||||||
@@ -62,6 +52,26 @@ export class AuthService {
|
|||||||
message: AuthMessage.LOGOUT_SUCCESS,
|
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) {
|
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 = {
|
export const EMAIL_QUEUE_CONSTANTS = {
|
||||||
EMAIL_QUEUE: "email_queue",
|
EMAIL_QUEUE: "email_queue",
|
||||||
EMAIL_QUEUE_PROCESSING: "email_queue_processing",
|
EMAIL_QUEUE_PROCESSING: "email_queue_processing",
|
||||||
EMAIL_QUEUE_FAILED: "email_queue_failed",
|
EMAIL_SCHEDULER_JOB: "email_monitoring_scheduler",
|
||||||
EMAIL_QUEUE_COMPLETED: "email_queue_completed",
|
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 { EmailGateway } from "./gateways/email.gateway";
|
||||||
import { EmailMonitoringProcessor } from "./queue/email-monitoring.processor";
|
import { EmailMonitoringProcessor } from "./queue/email-monitoring.processor";
|
||||||
import { EmailHeadersService } from "./services/email-headers.service";
|
import { EmailHeadersService } from "./services/email-headers.service";
|
||||||
import { EmailMonitoringSchedulerService } from "./services/email-monitoring-scheduler.service";
|
|
||||||
import { EmailMonitoringService } from "./services/email-monitoring.service";
|
import { EmailMonitoringService } from "./services/email-monitoring.service";
|
||||||
import { EmailNotificationService } from "./services/email-notification.service";
|
import { EmailNotificationService } from "./services/email-notification.service";
|
||||||
import { EmailService } from "./services/email.service";
|
import { EmailService } from "./services/email.service";
|
||||||
@@ -39,7 +38,6 @@ import { WebSocketAuthService } from "./services/websocket-auth.service";
|
|||||||
EmailHeadersService,
|
EmailHeadersService,
|
||||||
EmailGateway,
|
EmailGateway,
|
||||||
EmailMonitoringService,
|
EmailMonitoringService,
|
||||||
EmailMonitoringSchedulerService,
|
|
||||||
EmailNotificationService,
|
EmailNotificationService,
|
||||||
EmailMonitoringProcessor,
|
EmailMonitoringProcessor,
|
||||||
WebSocketAuthService,
|
WebSocketAuthService,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
||||||
|
|
||||||
// Base interface with common properties
|
// Base interface with common properties
|
||||||
export interface BaseEventPayload {
|
export interface BaseEventPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -98,9 +100,15 @@ export interface EmailWebSocketEvents {
|
|||||||
|
|
||||||
export interface EmailMonitoringJob {
|
export interface EmailMonitoringJob {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
wildduckUserId?: string;
|
||||||
lastCheckTimestamp?: Date;
|
lastCheckTimestamp?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailSchedulerJob {
|
||||||
|
action: typeof EMAIL_QUEUE_CONSTANTS.EMAIL_MONITORING_ACTION_NAME;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NewEmailData {
|
export interface NewEmailData {
|
||||||
userId: string;
|
userId: string;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { Logger } from "@nestjs/common";
|
|||||||
import { Job } from "bullmq";
|
import { Job } from "bullmq";
|
||||||
|
|
||||||
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
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";
|
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 {
|
export class EmailMonitoringProcessor extends WorkerHost {
|
||||||
private readonly logger = new Logger(EmailMonitoringProcessor.name);
|
private readonly logger = new Logger(EmailMonitoringProcessor.name);
|
||||||
|
|
||||||
@@ -14,13 +16,19 @@ export class EmailMonitoringProcessor extends WorkerHost {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<EmailMonitoringJob>) {
|
async process(job: Job<EmailMonitoringJob | EmailSchedulerJob>) {
|
||||||
const { name, data } = job;
|
const { name, data } = job;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING:
|
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;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -28,7 +36,8 @@ export class EmailMonitoringProcessor extends WorkerHost {
|
|||||||
throw new Error(`Unknown job type: ${name}`);
|
throw new Error(`Unknown job type: ${name}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
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 { EntityManager } from "@mikro-orm/core";
|
||||||
import { InjectQueue } from "@nestjs/bullmq";
|
import { InjectQueue } from "@nestjs/bullmq";
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
@@ -10,29 +10,109 @@ import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
|
|||||||
import { User } from "../../users/entities/user.entity";
|
import { User } from "../../users/entities/user.entity";
|
||||||
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
||||||
import { EmailGateway } from "../gateways/email.gateway";
|
import { EmailGateway } from "../gateways/email.gateway";
|
||||||
import { EmailMonitoringJob, EmailPayload } from "../interfaces/email-events.interface";
|
import { EmailMonitoringJob, EmailPayload, EmailSchedulerJob } from "../interfaces/email-events.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailMonitoringService {
|
export class EmailMonitoringService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(EmailMonitoringService.name);
|
private readonly logger = new Logger(EmailMonitoringService.name);
|
||||||
private lastGlobalCheck: Date = new Date();
|
private lastGlobalCheck: Date = new Date();
|
||||||
|
private isMonitoringActive = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectQueue(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE) private readonly monitoringQueue: Queue,
|
||||||
private readonly emailGateway: EmailGateway,
|
private readonly emailGateway: EmailGateway,
|
||||||
private readonly mailboxResolver: MailboxResolverService,
|
private readonly mailboxResolver: MailboxResolverService,
|
||||||
private readonly mailServerService: MailServerService,
|
private readonly mailServerService: MailServerService,
|
||||||
private readonly em: EntityManager,
|
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() {
|
async scheduleEmailCheck() {
|
||||||
try {
|
try {
|
||||||
// Get only connected users from the websocket gateway
|
|
||||||
const connectedUserIds = this.emailGateway.getConnectedUserIds();
|
const connectedUserIds = this.emailGateway.getConnectedUserIds();
|
||||||
|
|
||||||
if (connectedUserIds.length === 0) {
|
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();
|
const em = this.em.fork();
|
||||||
@@ -55,7 +135,9 @@ export class EmailMonitoringService {
|
|||||||
lastCheckTimestamp: this.lastGlobalCheck,
|
lastCheckTimestamp: this.lastGlobalCheck,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
delay: Math.random() * 10000,
|
delay: Math.random() * 10000, // Random delay to prevent thundering herd
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
backoff: {
|
backoff: {
|
||||||
type: "exponential",
|
type: "exponential",
|
||||||
@@ -71,7 +153,6 @@ export class EmailMonitoringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//****************************************************** */
|
|
||||||
async processUserEmailCheck(job: EmailMonitoringJob) {
|
async processUserEmailCheck(job: EmailMonitoringJob) {
|
||||||
const { userId, lastCheckTimestamp } = job;
|
const { userId, lastCheckTimestamp } = job;
|
||||||
|
|
||||||
@@ -98,7 +179,6 @@ export class EmailMonitoringService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (lastCheckTimestamp) {
|
if (lastCheckTimestamp) {
|
||||||
// Handle case where BullMQ serializes Date to string
|
|
||||||
const timestamp = lastCheckTimestamp instanceof Date ? lastCheckTimestamp : new Date(lastCheckTimestamp);
|
const timestamp = lastCheckTimestamp instanceof Date ? lastCheckTimestamp : new Date(lastCheckTimestamp);
|
||||||
query.datestart = timestamp.toISOString();
|
query.datestart = timestamp.toISOString();
|
||||||
}
|
}
|
||||||
@@ -120,7 +200,6 @@ export class EmailMonitoringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//****************************************************** */
|
|
||||||
private async processNewEmailMessage(userId: string, _wildduckUserId: string, message: Record<string, any>) {
|
private async processNewEmailMessage(userId: string, _wildduckUserId: string, message: Record<string, any>) {
|
||||||
try {
|
try {
|
||||||
if (message.seen) {
|
if (message.seen) {
|
||||||
@@ -146,12 +225,10 @@ export class EmailMonitoringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//************************************************************************ */
|
|
||||||
private async updateUnreadCount(userId: string, wildduckUserId: string, userEmailAddress?: string) {
|
private async updateUnreadCount(userId: string, wildduckUserId: string, userEmailAddress?: string) {
|
||||||
try {
|
try {
|
||||||
const inboxMailboxId = await this.mailboxResolver.getInboxMailboxId(wildduckUserId);
|
const inboxMailboxId = await this.mailboxResolver.getInboxMailboxId(wildduckUserId);
|
||||||
|
|
||||||
// Get all messages to properly count unread ones
|
|
||||||
const { results } = await firstValueFrom(
|
const { results } = await firstValueFrom(
|
||||||
this.mailServerService.messages.listMessages(wildduckUserId, inboxMailboxId, {
|
this.mailServerService.messages.listMessages(wildduckUserId, inboxMailboxId, {
|
||||||
limit: 250,
|
limit: 250,
|
||||||
@@ -161,7 +238,6 @@ export class EmailMonitoringService {
|
|||||||
|
|
||||||
const unreadCount = results.length;
|
const unreadCount = results.length;
|
||||||
|
|
||||||
// Always send unread count notification
|
|
||||||
this.emailGateway.notifyUnreadCountUpdate(userId, {
|
this.emailGateway.notifyUnreadCountUpdate(userId, {
|
||||||
count: unreadCount,
|
count: unreadCount,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -172,7 +248,6 @@ export class EmailMonitoringService {
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log the unread count if there are unread messages
|
|
||||||
if (unreadCount > 0 && userEmailAddress) {
|
if (unreadCount > 0 && userEmailAddress) {
|
||||||
this.logger.log(`${userEmailAddress}: ${unreadCount} unread total`);
|
this.logger.log(`${userEmailAddress}: ${unreadCount} unread total`);
|
||||||
}
|
}
|
||||||
@@ -181,20 +256,57 @@ export class EmailMonitoringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//****************************************************** */
|
|
||||||
async checkUserEmails(userId: string): Promise<void> {
|
async checkUserEmails(userId: string): Promise<void> {
|
||||||
await this.monitoringQueue.add(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING, {
|
await this.monitoringQueue.add(
|
||||||
|
EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING,
|
||||||
|
{
|
||||||
userId,
|
userId,
|
||||||
lastCheckTimestamp: new Date(Date.now() - 60000),
|
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() {
|
async getQueueStatus() {
|
||||||
const waiting = await this.monitoringQueue.getWaiting();
|
const [waiting, active, completed, failed] = await Promise.all([
|
||||||
const active = await this.monitoringQueue.getActive();
|
this.monitoringQueue.getWaiting(),
|
||||||
const completed = await this.monitoringQueue.getCompleted();
|
this.monitoringQueue.getActive(),
|
||||||
const failed = await this.monitoringQueue.getFailed();
|
this.monitoringQueue.getCompleted(),
|
||||||
|
this.monitoringQueue.getFailed(),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
waiting: waiting.length,
|
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() {
|
async cleanupJobs() {
|
||||||
await this.monitoringQueue.clean(1000 * 60 * 60, 100); // Clean jobs older than 1 hour
|
try {
|
||||||
this.logger.log("Cleaned up old monitoring jobs");
|
// 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);
|
const inboxMailboxId = await this.mailboxResolverService.getInboxMailboxId(userId);
|
||||||
this.logger.log(`Listing messages in mailbox ${inboxMailboxId} for user: ${userId}`);
|
this.logger.log(`Listing messages in mailbox ${inboxMailboxId} for user: ${userId}`);
|
||||||
|
|
||||||
// Transform query parameters for WildDuck API compatibility
|
|
||||||
const wildDuckQuery = this.transformPaginationQuery(query);
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
this.logger.debug("Transformed query for WildDuck:", wildDuckQuery);
|
|
||||||
|
|
||||||
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, inboxMailboxId, 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> {
|
protected get<T>(path: string, params?: Record<string, any>): Observable<T> {
|
||||||
const url = this.buildUrl(path, params);
|
const url = this.buildUrl(path, params);
|
||||||
this.logger.debug(`WildDuck GET: ${url}`);
|
this.logger.debug(`WildDuck GET: ${url}`);
|
||||||
if (params) {
|
|
||||||
this.logger.debug(`WildDuck GET Params:`, params);
|
|
||||||
}
|
|
||||||
return this.handleResponse<T>(this.httpService.get(url));
|
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);
|
private readonly logger = new Logger(QuotaSyncService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectQueue(QUOTA_SYNC_QUEUE.QUEUE_NAME) private readonly quotaSyncQueue: Queue,
|
||||||
private readonly wildDuckUsersService: WildDuckUsersService,
|
private readonly wildDuckUsersService: WildDuckUsersService,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
@InjectQueue(QUOTA_SYNC_QUEUE.QUEUE_NAME) private readonly quotaSyncQueue: Queue,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +50,7 @@ export class QuotaSyncService {
|
|||||||
delay: 2000,
|
delay: 2000,
|
||||||
},
|
},
|
||||||
repeat: {
|
repeat: {
|
||||||
pattern: "0/1 * * * *", // Every hour at minute 0 (cron pattern)
|
pattern: "0 * * * *",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -80,6 +80,9 @@ export class QuotaSyncService {
|
|||||||
} else {
|
} else {
|
||||||
this.logger.debug("No existing cron job found to remove");
|
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) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to remove quota sync cron job", 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 { UpdateUserDto } from "../../mail-server/DTO";
|
||||||
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
import { QuotaSyncService } from "../../quota-sync/services/quota-sync.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 { Template } from "../../templates/entities/template.entity";
|
||||||
import { PasswordService } from "../../utils/services/password.service";
|
import { PasswordService } from "../../utils/services/password.service";
|
||||||
import { CreateEmailUserDto } from "../DTO/create-email-user.dto";
|
import { CreateEmailUserDto } from "../DTO/create-email-user.dto";
|
||||||
@@ -33,6 +34,7 @@ export class UsersService {
|
|||||||
private readonly domainsService: DomainsService,
|
private readonly domainsService: DomainsService,
|
||||||
private readonly domainAutomationService: DomainAutomationService,
|
private readonly domainAutomationService: DomainAutomationService,
|
||||||
private readonly quotaSyncService: QuotaSyncService,
|
private readonly quotaSyncService: QuotaSyncService,
|
||||||
|
private readonly userSettingsService: UserSettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getUserEmailIdFromId(userId: string) {
|
async getUserEmailIdFromId(userId: string) {
|
||||||
@@ -57,21 +59,24 @@ export class UsersService {
|
|||||||
const user = await this.userRepository.findOne({ id: userId }, { exclude: ["password"], populate: ["role"] });
|
const user = await this.userRepository.findOne({ id: userId }, { exclude: ["password"], populate: ["role"] });
|
||||||
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
|
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
|
||||||
|
|
||||||
|
const totalQuota = Number(user.emailQuota);
|
||||||
|
const usedQuota = Number(user.emailQuotaUsed);
|
||||||
|
const remainingQuota = totalQuota - usedQuota;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
quota: {
|
quota: {
|
||||||
total: Number(user.emailQuota),
|
total: totalQuota,
|
||||||
used: Number(user.emailQuotaUsed),
|
used: usedQuota,
|
||||||
remaining: Number(user.emailQuota - user.emailQuotaUsed),
|
remaining: remainingQuota,
|
||||||
totalInMB: Math.round(Number(user.emailQuota) / (1024 * 1024)),
|
totalInMB: Math.round(totalQuota / (1024 * 1024)),
|
||||||
totalInGB: Math.round(Number(user.emailQuota) / (1024 * 1024 * 1024)),
|
totalInGB: Math.round(totalQuota / (1024 * 1024 * 1024)),
|
||||||
usedInMB: Math.round(Number(user.emailQuotaUsed) / (1024 * 1024)),
|
usedInMB: Math.round(usedQuota / (1024 * 1024)),
|
||||||
usedInGB: Math.round(Number(user.emailQuotaUsed) / (1024 * 1024 * 1024)),
|
usedInGB: Math.round(usedQuota / (1024 * 1024 * 1024)),
|
||||||
remainingInMB: Math.round(Number(user.emailQuota - user.emailQuotaUsed) / (1024 * 1024)),
|
remainingInMB: Math.round(remainingQuota / (1024 * 1024)),
|
||||||
remainingInGB: Math.round(Number(user.emailQuota - user.emailQuotaUsed) / (1024 * 1024 * 1024)),
|
remainingInGB: Math.round(remainingQuota / (1024 * 1024 * 1024)),
|
||||||
usagePercentage: Math.round((Number(user.emailQuotaUsed) / Number(user.emailQuota)) * 100),
|
usagePercentage: totalQuota > 0 ? Math.round((usedQuota / totalQuota) * 100) : 0,
|
||||||
},
|
},
|
||||||
forwarders: user.forwarders || [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/*******************************/
|
/*******************************/
|
||||||
@@ -84,9 +89,13 @@ export class UsersService {
|
|||||||
|
|
||||||
/*******************************/
|
/*******************************/
|
||||||
async createEmailUser(createEmailUserDto: CreateEmailUserDto, businessId: string) {
|
async createEmailUser(createEmailUserDto: CreateEmailUserDto, businessId: string) {
|
||||||
|
const em = this.em.fork();
|
||||||
|
|
||||||
|
try {
|
||||||
|
em.begin();
|
||||||
const { username, password, domainId, displayName, quota, aliases, title, templateId, forwarders } = createEmailUserDto;
|
const { username, password, domainId, displayName, quota, aliases, title, templateId, forwarders } = createEmailUserDto;
|
||||||
|
|
||||||
const business = await this.em.findOne(Business, { id: businessId });
|
const business = await em.findOne(Business, { id: businessId });
|
||||||
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
|
||||||
// Check if business has enough quota
|
// Check if business has enough quota
|
||||||
@@ -105,15 +114,14 @@ export class UsersService {
|
|||||||
const finalUserName = `${username}-${domain.name}`;
|
const finalUserName = `${username}-${domain.name}`;
|
||||||
const finalDisplayName = `${username}`;
|
const finalDisplayName = `${username}`;
|
||||||
|
|
||||||
const existingUser = await this.userRepository.findOne({ emailAddress });
|
const existingUser = await em.findOne(User, { emailAddress });
|
||||||
if (existingUser) throw new BadRequestException(UserMessage.EMAIL_ADDRESS_ALREADY_EXISTS);
|
if (existingUser) throw new BadRequestException(UserMessage.EMAIL_ADDRESS_ALREADY_EXISTS);
|
||||||
|
|
||||||
const hashedPassword = await this.passwordService.hashPassword(password);
|
const hashedPassword = await this.passwordService.hashPassword(password);
|
||||||
|
|
||||||
const userRole = await this.em.findOne(Role, { name: RoleEnum.USER });
|
const userRole = await em.findOne(Role, { name: RoleEnum.USER });
|
||||||
if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND);
|
if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND);
|
||||||
|
|
||||||
try {
|
|
||||||
const userQuota = quota || QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES;
|
const userQuota = quota || QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES;
|
||||||
|
|
||||||
const mailServerUser = await firstValueFrom(
|
const mailServerUser = await firstValueFrom(
|
||||||
@@ -129,7 +137,7 @@ export class UsersService {
|
|||||||
|
|
||||||
if (!mailServerUser.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT);
|
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,
|
title: title || displayName || username,
|
||||||
userName: finalUserName,
|
userName: finalUserName,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
@@ -145,14 +153,15 @@ export class UsersService {
|
|||||||
forwarders: forwarders || [],
|
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.usedQuota = Number(business.usedQuota) + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION;
|
||||||
business.remainingQuota = Number(business.quota) - Number(business.usedQuota);
|
business.remainingQuota = Number(business.quota) - Number(business.usedQuota);
|
||||||
|
|
||||||
await this.em.persistAndFlush([user, business]);
|
await em.persistAndFlush([user, business]);
|
||||||
|
|
||||||
if (templateId) {
|
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);
|
if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND);
|
||||||
user.template = template;
|
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);
|
await this.quotaSyncService.scheduleUserQuotaSync(user.id, businessId, mailServerUser.id);
|
||||||
//sync business quota
|
|
||||||
await this.quotaSyncService.scheduleBusinessQuotaSync(businessId, business.name);
|
await this.quotaSyncService.scheduleBusinessQuotaSync(businessId, business.name);
|
||||||
|
|
||||||
this.logger.log(`Email user created: ${emailAddress} for business ${businessId}`);
|
this.logger.log(`Email user created: ${emailAddress} for business ${businessId}`);
|
||||||
|
|
||||||
await this.domainAutomationService.createDefaultMailboxes(mailServerUser.id);
|
await this.domainAutomationService.createDefaultMailboxes(mailServerUser.id);
|
||||||
|
|
||||||
|
await em.commit();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
message: UserMessage.EMAIL_USER_CREATED_SUCCESSFULLY,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
emailAddress: user.emailAddress!,
|
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) {
|
} 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);
|
throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class UsersController {
|
|||||||
@Get("me")
|
@Get("me")
|
||||||
@ApiOperation({ summary: "Get current user" })
|
@ApiOperation({ summary: "Get current user" })
|
||||||
@ApiResponse({ status: 200, description: "Current user retrieved successfully" })
|
@ApiResponse({ status: 200, description: "Current user retrieved successfully" })
|
||||||
getMe(@UserDec("wildduckUserId") userId: string) {
|
getMe(@UserDec("id") userId: string) {
|
||||||
return this.usersService.getMe(userId);
|
return this.usersService.getMe(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import { BusinessesModule } from "../businesses/businesses.module";
|
|||||||
import { DomainsModule } from "../domains/domains.module";
|
import { DomainsModule } from "../domains/domains.module";
|
||||||
import { MailServerModule } from "../mail-server/mail-server.module";
|
import { MailServerModule } from "../mail-server/mail-server.module";
|
||||||
import { QuotaSyncModule } from "../quota-sync/quota-sync.module";
|
import { QuotaSyncModule } from "../quota-sync/quota-sync.module";
|
||||||
|
import { SettingModule } from "../settings/settings.module";
|
||||||
import { UtilsModule } from "../utils/utils.module";
|
import { UtilsModule } from "../utils/utils.module";
|
||||||
|
|
||||||
@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],
|
controllers: [UsersController],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
|
|||||||
Reference in New Issue
Block a user