chore: add new route

This commit is contained in:
mahyargdz
2025-07-20 10:15:13 +03:30
parent 066d6c8598
commit 5322b6b6c4
48 changed files with 1282 additions and 295 deletions
+23
View File
@@ -0,0 +1,23 @@
import { IsEnum, IsNotEmpty, IsString, IsUUID } from "class-validator";
import { NotificationMessage } from "../../../common/enums/message.enum";
import { NotifType } from "../../settings/enums/notif-settings.enum";
export class CreateNotificationDto {
@IsNotEmpty({ message: NotificationMessage.TITLE_IS_REQUIRED })
@IsString({ message: NotificationMessage.TITLE_STRING })
title: string;
@IsNotEmpty({ message: NotificationMessage.MESSAGE_IS_REQUIRED })
@IsString({ message: NotificationMessage.MESSAGE_STRING })
message: string;
@IsNotEmpty({ message: NotificationMessage.USERID_IS_REQUIRED })
@IsString({ message: NotificationMessage.USERID_IS_STRING })
@IsUUID("4", { message: NotificationMessage.USERID_IS_UUID })
recipientId: string;
@IsNotEmpty({ message: NotificationMessage.TYPE_IS_REQUIRED })
@IsEnum(NotifType)
type: NotifType;
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, IsOptional } from "class-validator";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
export class SearchNotificationQueryDto extends PaginationDto {
@IsOptional()
@Type(() => Number)
@IsIn([0, 1])
@ApiPropertyOptional({ description: "Notification status", example: 1 })
isRead?: number;
}
@@ -0,0 +1,16 @@
export const NOTIFICATION = Object.freeze({
QUEUE_NAME: "notification",
SEND_NOTIFICATION_JOB_NAME: "sendNotification",
SEND_LOGIN_OTP_JOB_NAME: "sendLoginOtp",
SEND_NOTIFICATION_JOB_DELAY: 1000, // delay after 1 second
SEND_NOTIFICATION_JOB_PRIORITY: 1, // high priority
SEND_NOTIFICATION_JOB_ATTEMPTS: 3, // retry 3 times
SEND_NOTIFICATION_JOB_BACKOFF: 5 * 1000, // retry after 5 seconds
SEND_NOTIFICATION_JOB_TIMEOUT: 10000, // timeout after 10 seconds
SEND_NOTIFICATION_JOB_CONCURRENCY: 2,
SEND_NOTIFICATION_JOB_LOCK_DURATION: 30000,
SEND_NOTIFICATION_JOB_STALLED_INTERVAL: 30000,
SEND_NOTIFICATION_JOB_MAX_STALLED_COUNT: 2,
});
+26
View File
@@ -0,0 +1,26 @@
import { Entity, EntityRepositoryType, Enum, ManyToOne, Opt, Property } from "@mikro-orm/core";
import { BaseEntity } from "../../../common/entities/base.entity";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { User } from "../../users/entities/user.entity";
import { NotificationRepository } from "../repositories/notifications.repository";
@Entity({ repository: () => NotificationRepository })
export class Notification extends BaseEntity {
@Property({ type: "varchar", length: 250 })
title!: string;
@Property({ type: "text" })
message!: string;
@Enum({ items: () => NotifType, nativeEnumName: "notif_type" })
type!: NotifType;
@Property({ type: "boolean", default: false })
isRead!: boolean & Opt;
@ManyToOne(() => User, { deleteRule: "cascade" })
recipient!: User;
[EntityRepositoryType]?: NotificationRepository;
}
@@ -0,0 +1,31 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Logger } from "@nestjs/common";
export abstract class BaseNotificationHandler<TData = unknown> {
protected abstract readonly logger: Logger;
/**
* Process the notification with automatic transaction management
*/
async processWithTransaction(recipientId: string, data: TData, em: EntityManager): Promise<void> {
try {
await this.handle(recipientId, data, em);
} catch (error) {
this.logger.error(
`Failed to process ${this.getHandlerName()} notification: ${error instanceof Error ? error.message : "Unknown error"}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
/**
* Handle the specific notification logic - to be implemented by each handler
*/
protected abstract handle(recipientId: string, data: TData, em: EntityManager): Promise<void>;
/**
* Get the handler name for logging purposes
*/
protected abstract getHandlerName(): string;
}
@@ -0,0 +1,23 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { BaseNotificationHandler } from "./base-notification.handler";
import { IChangePasswordNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../services/notifications.service";
@Injectable()
export class ChangePasswordHandler extends BaseNotificationHandler<IChangePasswordNotificationData> {
protected readonly logger = new Logger(ChangePasswordHandler.name);
constructor(private readonly notificationsService: NotificationsService) {
super();
}
protected async handle(recipientId: string, data: IChangePasswordNotificationData, em: EntityManager): Promise<void> {
await this.notificationsService.changePasswordNotification(recipientId, data, em);
}
protected getHandlerName(): string {
return "ChangePassword";
}
}
@@ -0,0 +1,23 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { BaseNotificationHandler } from "./base-notification.handler";
import { INewEmailNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../services/notifications.service";
@Injectable()
export class NewEmailHandler extends BaseNotificationHandler<INewEmailNotificationData> {
protected readonly logger = new Logger(NewEmailHandler.name);
constructor(private readonly notificationsService: NotificationsService) {
super();
}
protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise<void> {
await this.notificationsService.createNewEmailNotification(recipientId, data, em);
}
protected getHandlerName(): string {
return "NewEmail";
}
}
@@ -0,0 +1,47 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { BaseNotificationHandler } from "./base-notification.handler";
import { ChangePasswordHandler } from "./change-password.handler";
import { NewEmailHandler } from "./new-email.handler";
import { NotifType } from "../../settings/enums/notif-settings.enum";
@Injectable()
export class NotificationHandlerFactory {
private readonly logger = new Logger(NotificationHandlerFactory.name);
private readonly handlers: Map<NotifType, BaseNotificationHandler> = new Map();
constructor(
private readonly newEmailHandler: NewEmailHandler,
private readonly changePasswordHandler: ChangePasswordHandler,
) {
this.registerHandlers();
}
private registerHandlers(): void {
// User notifications
this.handlers.set(NotifType.NEW_EMAIL, this.newEmailHandler);
this.handlers.set(NotifType.CHANGE_PASSWORD, this.changePasswordHandler);
this.logger.log(`Registered ${this.handlers.size} notification handlers`);
}
async processNotification(type: NotifType, recipientId: string, data: unknown, em: EntityManager): Promise<void> {
const handler = this.handlers.get(type);
if (!handler) {
this.logger.warn(`No handler found for notification type: ${type}`);
throw new Error(`No handler registered for notification type: ${type}`);
}
await handler.processWithTransaction(recipientId, data, em);
}
getRegisteredTypes(): NotifType[] {
return Array.from(this.handlers.keys());
}
isTypeSupported(type: NotifType): boolean {
return this.handlers.has(type);
}
}
@@ -0,0 +1,24 @@
import { Injectable, Logger } from "@nestjs/common";
import { IBaseNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../services/notifications.service";
@Injectable()
export class UserLoginHandler {
private readonly logger = new Logger(UserLoginHandler.name);
constructor(private readonly notificationsService: NotificationsService) {}
async processUserLogin(recipientId: string, data: IBaseNotificationData): Promise<void> {
try {
await this.notificationsService.createLoginNotification(recipientId, data);
this.logger.log(`User login notification sent successfully to ${recipientId}`);
} catch (error) {
this.logger.error(
`Failed to send user login notification: ${error instanceof Error ? error.message : "Unknown error"}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
}
@@ -0,0 +1,13 @@
import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "./ISendNotificationData";
import { NotifType } from "../../settings/enums/notif-settings.enum";
export type NotificationJobData = {
type: NotifType;
recipientId: string;
data: IBaseNotificationData | IChangePasswordNotificationData | INewEmailNotificationData;
};
export type OtpJobData = {
phone: string;
code: string;
};
@@ -0,0 +1,17 @@
export interface IBaseNotificationData {
userPhone?: string;
userEmail?: string;
userName?: string;
}
export interface IChangePasswordNotificationData extends IBaseNotificationData {
newPassword: string;
}
export interface IGenericNotificationData extends IBaseNotificationData {
message: string;
}
export interface INewEmailNotificationData extends IBaseNotificationData {
newEmail: string;
}
+38
View File
@@ -0,0 +1,38 @@
import { Controller, Get, Param, Patch, Query } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { SearchNotificationQueryDto } from "./DTO/search-notification-query.dto";
import { NotificationsService } from "./services/notifications.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
@Controller("notifications")
@AuthGuards()
export class NotificationController {
constructor(private readonly notificationService: NotificationsService) {}
//********************* */
@Get()
@ApiOperation({ summary: "all notifications by user" })
getAllNotifications(@UserDec("id") userId: string, @Query() queryDto: SearchNotificationQueryDto) {
return this.notificationService.getAllNotifications(queryDto, userId);
}
//********************* */
@Patch(":id/read")
@ApiOperation({ summary: "mark user notification as read" })
markAsRead(@Param() paramDto: ParamDto, @UserDec("id") userId: string) {
return this.notificationService.markAsRead(paramDto.id, userId);
}
//********************* */
@Patch("read-all")
@ApiOperation({ summary: "mark user all notification as read" })
markAllAsRead(@UserDec("id") userId: string) {
return this.notificationService.markAllAsRead(userId);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { NOTIFICATION } from "./constants";
import { Notification } from "./entities/notification.entity";
import { ChangePasswordHandler } from "./handlers/change-password.handler";
import { NewEmailHandler } from "./handlers/new-email.handler";
import { NotificationHandlerFactory } from "./handlers/notification-handler.factory";
import { NotificationController } from "./notifications.controller";
import { NotificationProcessor } from "./queue/notification.processor";
import { NotificationQueue } from "./queue/notification.queue";
import { NotificationsService } from "./services/notifications.service";
import { NotificationSetting } from "../settings/entities/notification-setting.entity";
import { SettingModule } from "../settings/settings.module";
import { UtilsModule } from "../utils/utils.module";
import { UserLoginHandler } from "./handlers/user-login.handler";
@Module({
imports: [
ConfigModule,
MikroOrmModule.forFeature([Notification, NotificationSetting]),
BullModule.registerQueue({
name: NOTIFICATION.QUEUE_NAME,
defaultJobOptions: {
attempts: NOTIFICATION.SEND_NOTIFICATION_JOB_ATTEMPTS,
backoff: {
type: "exponential",
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_BACKOFF,
},
removeOnComplete: true,
removeOnFail: false,
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_DELAY,
},
}),
UtilsModule,
SettingModule,
],
providers: [
NotificationsService,
NotificationQueue,
NotificationProcessor,
// notification handlers
NotificationHandlerFactory,
UserLoginHandler,
NewEmailHandler,
ChangePasswordHandler,
],
controllers: [NotificationController],
exports: [NotificationsService, NotificationQueue],
})
export class NotificationModule {}
@@ -0,0 +1,71 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Processor } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { WorkerProcessor } from "../../../common/queues/worker.processor";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { NOTIFICATION } from "../constants";
import { NotificationHandlerFactory } from "../handlers/notification-handler.factory";
import { UserLoginHandler } from "../handlers/user-login.handler";
import { NotificationJobData, OtpJobData } from "../interfaces/INotification-job-data";
import { IBaseNotificationData } from "../interfaces/ISendNotificationData";
@Processor(NOTIFICATION.QUEUE_NAME, {
concurrency: NOTIFICATION.SEND_NOTIFICATION_JOB_CONCURRENCY,
lockDuration: NOTIFICATION.SEND_NOTIFICATION_JOB_LOCK_DURATION,
stalledInterval: NOTIFICATION.SEND_NOTIFICATION_JOB_STALLED_INTERVAL,
maxStalledCount: NOTIFICATION.SEND_NOTIFICATION_JOB_MAX_STALLED_COUNT,
})
export class NotificationProcessor extends WorkerProcessor {
protected readonly logger = new Logger(NotificationProcessor.name);
constructor(
private readonly em: EntityManager,
private readonly notificationHandlerFactory: NotificationHandlerFactory,
private readonly userLoginHandler: UserLoginHandler,
) {
super();
}
async process(job: Job<NotificationJobData | OtpJobData>, token?: string) {
this.logger.log(`Processing notification job: ${job.name} ${token ? `with token: ${token}` : ""}`);
try {
if (job.name === NOTIFICATION.SEND_NOTIFICATION_JOB_NAME) {
const { type, recipientId, data } = job.data as NotificationJobData;
if (type === NotifType.USER_LOGIN) {
await this.userLoginHandler.processUserLogin(recipientId, data as IBaseNotificationData);
} else {
await this.processWithTransaction(type, recipientId, data);
}
} else {
this.logger.warn(`Unknown job name: ${job.name}`);
}
return true;
} catch (error) {
this.logger.error(
`Failed to process notification: ${error instanceof Error ? error.message : "Unknown error"}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
private async processWithTransaction(type: NotifType, recipientId: string, data: IBaseNotificationData): Promise<void> {
const em = this.em.fork();
try {
await em.begin();
await this.notificationHandlerFactory.processNotification(type, recipientId, data, em);
await em.commit();
} catch (error) {
await em.rollback();
throw error;
}
}
}
@@ -0,0 +1,46 @@
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable } from "@nestjs/common";
import { Queue } from "bullmq";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { NOTIFICATION } from "../constants";
import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "../interfaces/ISendNotificationData";
@Injectable()
export class NotificationQueue {
constructor(@InjectQueue(NOTIFICATION.QUEUE_NAME) private readonly notificationsQueue: Queue) {}
async addNotificationJob(type: NotifType, recipientId: string, data: IBaseNotificationData) {
await this.notificationsQueue.add(
NOTIFICATION.SEND_NOTIFICATION_JOB_NAME,
{
type,
recipientId,
data,
},
{
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_DELAY,
attempts: NOTIFICATION.SEND_NOTIFICATION_JOB_ATTEMPTS,
backoff: {
type: "exponential",
delay: NOTIFICATION.SEND_NOTIFICATION_JOB_BACKOFF,
},
priority: NOTIFICATION.SEND_NOTIFICATION_JOB_PRIORITY,
},
);
}
//*********************************** */
async addLoginNotification(recipientId: string, data: IBaseNotificationData) {
await this.addNotificationJob(NotifType.USER_LOGIN, recipientId, data);
}
async addNewEmailNotification(recipientId: string, data: INewEmailNotificationData) {
await this.addNotificationJob(NotifType.NEW_EMAIL, recipientId, data);
}
async addChangePasswordNotification(recipientId: string, data: IChangePasswordNotificationData) {
await this.addNotificationJob(NotifType.CHANGE_PASSWORD, recipientId, data);
}
}
@@ -0,0 +1,23 @@
import { EntityRepository, FilterQuery } from "@mikro-orm/postgresql";
import { PaginationUtils } from "../../utils/services/pagination.utils";
import { SearchNotificationQueryDto } from "../DTO/search-notification-query.dto";
import { Notification } from "../entities/notification.entity";
export class NotificationRepository extends EntityRepository<Notification> {
async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) {
const { skip, limit } = PaginationUtils(queryDto);
const whereConditions: FilterQuery<Notification> = { recipient: { id: recipientId } };
if (queryDto.isRead !== undefined) {
whereConditions.isRead = queryDto.isRead === 1;
}
return this.findAndCount(whereConditions, {
orderBy: { createdAt: "DESC" },
offset: skip,
limit: limit,
});
}
}
+142
View File
@@ -0,0 +1,142 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { CommonMessage, NotificationMessage, UserMessage } from "../../../common/enums/message.enum";
import { NotifType } from "../../settings/enums/notif-settings.enum";
import { UserSettingsService } from "../../settings/services/user-settings.service";
import { User } from "../../users/entities/user.entity";
import { CreateNotificationDto } from "../DTO/create-notification.dto";
import { SearchNotificationQueryDto } from "../DTO/search-notification-query.dto";
import { Notification } from "../entities/notification.entity";
import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationRepository } from "../repositories/notifications.repository";
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly em: EntityManager,
private readonly notificationRepository: NotificationRepository,
private readonly userSettingsService: UserSettingsService,
) {}
//************************ */
async createNotification(createNotificationDto: CreateNotificationDto, em?: EntityManager) {
if (!em) {
const localEm = this.em.fork();
await localEm.begin();
try {
//
const user = await localEm.findOne(User, { id: createNotificationDto.recipientId });
if (!user) throw new NotFoundException(UserMessage.USER_NOT_FOUND);
const notification = localEm.create(Notification, {
...createNotificationDto,
recipient: user,
});
await localEm.persistAndFlush(notification);
await localEm.commit();
return notification;
//
} catch (error) {
this.logger.error("error in createNotification", error);
await localEm.rollback();
throw error;
//
}
//
} else {
//
const user = await em.findOne(User, { id: createNotificationDto.recipientId, deletedAt: null });
if (!user) throw new NotFoundException(UserMessage.USER_NOT_FOUND);
const notification = em.create(Notification, {
...createNotificationDto,
recipient: user,
});
//
await em.persistAndFlush(notification);
return notification;
}
}
//************************ */
async markAsRead(notificationId: string, userId: string) {
const notification = await this.notificationRepository.findOne({ id: notificationId, recipient: { id: userId }, deletedAt: null });
if (!notification) throw new BadRequestException(NotificationMessage.NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED);
notification.isRead = true;
await this.em.flush();
return {
message: CommonMessage.UPDATE_SUCCESS,
};
}
//************************ */
async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) {
const [notifications, count] = await this.notificationRepository.getAllNotifications(queryDto, recipientId);
const notificationCount = await this.countUserNotifications(recipientId);
return { notifications, notificationCount, count, paginate: true };
}
//************************ */
async getNotificationById(id: string) {
const notification = await this.notificationRepository.findOne({ id, deletedAt: null });
if (!notification) throw new BadRequestException(NotificationMessage.NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED);
return { notification };
}
//************************ */
async countUserNotifications(userId: string) {
const notificationCount = await this.notificationRepository.count({ recipient: { id: userId }, isRead: false, deletedAt: null });
return notificationCount;
}
//************************ */
async markAllAsRead(userId: string) {
await this.notificationRepository.nativeUpdate({ recipient: { id: userId }, deletedAt: null }, { isRead: true });
return {
message: CommonMessage.UPDATE_SUCCESS,
};
}
//************************ */
async createLoginNotification(recipientId: string, _data: IBaseNotificationData) {
const loginDate = new Date().toLocaleString("fa-IR");
const message = NotificationMessage.LOGIN_MESSAGE.replace("[loginDate]", loginDate);
//sent notification based on the userSettings
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.USER_LOGIN, recipientId);
if (isActive) {
return this.createNotification({ title: NotificationMessage.LOGIN, type: NotifType.USER_LOGIN, message, recipientId });
}
}
//************************ */
async createNewEmailNotification(recipientId: string, data: INewEmailNotificationData, em: EntityManager) {
const message = NotificationMessage.NEW_EMAIL_MESSAGE.replace("[subject]", data.newEmail);
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.NEW_EMAIL, recipientId);
if (isActive) {
return this.createNotification({ title: NotificationMessage.NEW_EMAIL, type: NotifType.NEW_EMAIL, message, recipientId }, em);
}
}
//************************ */
async changePasswordNotification(recipientId: string, _data: IChangePasswordNotificationData, em: EntityManager) {
const message = NotificationMessage.CHANGE_PASSWORD_MESSAGE;
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.CHANGE_PASSWORD, recipientId);
if (isActive) {
return this.createNotification({ title: NotificationMessage.CHANGE_PASSWORD, type: NotifType.CHANGE_PASSWORD, message, recipientId }, em);
}
}
}