diff --git a/database/migrations/add_2fa_fields_to_users.sql b/database/migrations/add_2fa_fields_to_users.sql deleted file mode 100644 index defecf6..0000000 --- a/database/migrations/add_2fa_fields_to_users.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Add 2FA fields to users table -ALTER TABLE users -ADD COLUMN is_2fa_enabled BOOLEAN DEFAULT FALSE NOT NULL, -ADD COLUMN two_factor_enabled_at TIMESTAMPTZ DEFAULT NULL; - --- Create index on is_2fa_enabled for query performance -CREATE INDEX idx_users_is_2fa_enabled ON users (is_2fa_enabled); \ No newline at end of file diff --git a/database/migrations/add_push_notifications_to_users.sql b/database/migrations/add_push_notifications_to_users.sql deleted file mode 100644 index c4e71d4..0000000 --- a/database/migrations/add_push_notifications_to_users.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Add web push notification fields to users table -ALTER TABLE users -ADD COLUMN push_tokens JSONB DEFAULT NULL, -ADD COLUMN push_notifications_enabled BOOLEAN DEFAULT true, -ADD COLUMN last_push_notification_at TIMESTAMPTZ DEFAULT NULL; - --- Add indexes for better performance -CREATE INDEX IF NOT EXISTS idx_users_push_notifications_enabled ON users (push_notifications_enabled) -WHERE - push_notifications_enabled = true - AND deleted_at IS NULL; - -CREATE INDEX IF NOT EXISTS idx_users_push_tokens ON users USING GIN (push_tokens) -WHERE - push_tokens IS NOT NULL - AND deleted_at IS NULL; - --- Add comment for documentation -COMMENT ON COLUMN users.push_tokens IS 'JSON array of web push subscription objects containing endpoint and keys'; - -COMMENT ON COLUMN users.push_notifications_enabled IS 'Whether web push notifications are enabled for this user'; - -COMMENT ON COLUMN users.last_push_notification_at IS 'Timestamp of the last push notification sent to this user'; \ No newline at end of file diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index 7ca1d08..b8ff68c 100755 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -1,5 +1,7 @@ // export const AUTH_THROTTLE = "AUTH_THROTTLE"; export const AI_CONFIG = "AI_CONFIG"; +export const MIKRO_ORM_QUERY_LOGGER = "MIKRO_ORM_QUERY_LOGGER"; +export const NAJVA_CONFIG = "NAJVA_CONFIG"; export const AUTH_THROTTLE_TTL = 1 * 60 * 1000; export const AUTH_THROTTLE_LIMIT = 5; export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000; diff --git a/src/common/providers/mikro-orm-logger.provider.ts b/src/common/providers/mikro-orm-logger.provider.ts new file mode 100644 index 0000000..5404f69 --- /dev/null +++ b/src/common/providers/mikro-orm-logger.provider.ts @@ -0,0 +1,9 @@ +import { ValueProvider } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; + +import { MIKRO_ORM_QUERY_LOGGER } from "../constants"; + +export const mikroOrmQueryLoggerProvider: ValueProvider = { + provide: MIKRO_ORM_QUERY_LOGGER, // + useValue: new Logger("mikro-orm"), +}; diff --git a/src/common/providers/mikro-orm-logger.ts b/src/common/providers/mikro-orm-logger.ts deleted file mode 100644 index 2939f1a..0000000 --- a/src/common/providers/mikro-orm-logger.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ValueProvider } from "@nestjs/common"; -import { Logger } from "@nestjs/common"; - -export const MIKRO_ORM_QUERY_LOGGER = "MikroOrmQueryLogger"; - -export const MikroOrmQueryLogger: ValueProvider = { - provide: MIKRO_ORM_QUERY_LOGGER, - useValue: new Logger("mikro-orm"), -}; diff --git a/src/common/providers/najva-config.provider.ts b/src/common/providers/najva-config.provider.ts new file mode 100644 index 0000000..18d352b --- /dev/null +++ b/src/common/providers/najva-config.provider.ts @@ -0,0 +1,24 @@ +import { FactoryProvider } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { NAJVA_CONFIG } from "../constants"; + +export interface NajvaConfigType { + apiUrl: string; + apiKey: string; + websiteId: number; + ttl: number; +} + +export const najvaConfigProvider: FactoryProvider = { + provide: NAJVA_CONFIG, + useFactory: (configService: ConfigService) => { + return { + apiUrl: configService.getOrThrow("NAJVA_API_URL"), + apiKey: configService.getOrThrow("NAJVA_API_KEY"), + websiteId: configService.getOrThrow("NAJVA_WEBSITE_ID"), + ttl: configService.getOrThrow("PUSH_TTL"), + }; + }, + inject: [ConfigService], +}; diff --git a/src/configs/mikro-orm.config.ts b/src/configs/mikro-orm.config.ts index 1feb795..38113b3 100755 --- a/src/configs/mikro-orm.config.ts +++ b/src/configs/mikro-orm.config.ts @@ -3,11 +3,12 @@ import { PostgreSqlDriver } from "@mikro-orm/postgresql"; import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { MIKRO_ORM_QUERY_LOGGER, MikroOrmQueryLogger } from "../common/providers/mikro-orm-logger"; +import { MIKRO_ORM_QUERY_LOGGER } from "../common/constants"; +import { mikroOrmQueryLoggerProvider } from "../common/providers/mikro-orm-logger.provider"; export const databaseConfig: MikroOrmModuleAsyncOptions = { inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER], - providers: [MikroOrmQueryLogger], + providers: [mikroOrmQueryLoggerProvider], useFactory: (configService: ConfigService, logger: Logger) => { const DB_PASS = configService.getOrThrow("DB_PASS"); const DB_USER = configService.getOrThrow("DB_USER"); diff --git a/src/configs/push-notifications.config.ts b/src/configs/push-notifications.config.ts deleted file mode 100644 index 7bfe6b8..0000000 --- a/src/configs/push-notifications.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ConfigService } from "@nestjs/config"; -import { ConfigType, registerAs } from "@nestjs/config"; - -export const pushNotificationsConfig = registerAs("pushNotifications", () => ({ - webPush: { - vapidPublicKey: process.env.VAPID_PUBLIC_KEY || "", - vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || "", - vapidSubject: process.env.VAPID_SUBJECT || "mailto:admin@danakcorp.com", - }, - enabled: process.env.PUSH_NOTIFICATIONS_ENABLED === "true", - retryAttempts: parseInt(process.env.PUSH_RETRY_ATTEMPTS || "3", 10), - retryDelay: parseInt(process.env.PUSH_RETRY_DELAY || "5000", 10), - ttl: parseInt(process.env.PUSH_TTL || "86400", 10), // 24 hours - urgency: process.env.PUSH_URGENCY || "normal", // low, normal, high -})); - -export type PushNotificationsConfigType = ConfigType; - -export const pushNotificationsConfigFactory = () => ({ - imports: [], - useFactory: (configService: ConfigService) => configService.get("pushNotifications"), - inject: [ConfigService], -}); diff --git a/src/modules/notifications/interfaces/INajva.ts b/src/modules/notifications/interfaces/INajva.ts new file mode 100644 index 0000000..a1a63a3 --- /dev/null +++ b/src/modules/notifications/interfaces/INajva.ts @@ -0,0 +1,35 @@ +export interface INajvaNotificationMessage { + title: string; + body: string; + icon?: string; + image?: string; + notification_click: { + click_url: string; + }; + button_1?: { + title: string; + click_url: string; + }; + button_2?: { + title: string; + click_url: string; + }; +} + +export interface INajvaPushRequest { + website_id: number; + ttl: number; + tokens: string[]; + message: INajvaNotificationMessage; +} + +export interface INajvaPushResponse { + Message: string; + Entries: { + request_id: string; + tokens: Array<{ + token: string; + status: "Sent" | "InvalidToken"; + }>; + }; +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index 724a742..b298323 100755 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -1,26 +1,27 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { HttpModule } from "@nestjs/axios"; 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 { UserLoginHandler } from "./handlers/user-login.handler"; import { NotificationController } from "./notifications.controller"; import { NotificationProcessor } from "./queue/notification.processor"; import { NotificationQueue } from "./queue/notification.queue"; +import { NajvaPushService } from "./services/najva-push.service"; import { NotificationsService } from "./services/notifications.service"; -import { pushNotificationsConfig } from "../../configs/push-notifications.config"; +import { najvaConfigProvider } from "../../common/providers/najva-config.provider"; 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.forFeature(pushNotificationsConfig), + HttpModule, MikroOrmModule.forFeature([Notification, NotificationSetting]), BullModule.registerQueue({ name: NOTIFICATION.QUEUE_NAME, @@ -40,15 +41,16 @@ import { UserLoginHandler } from "./handlers/user-login.handler"; ], providers: [ NotificationsService, + NajvaPushService, NotificationQueue, NotificationProcessor, - // notification handlers NotificationHandlerFactory, UserLoginHandler, NewEmailHandler, ChangePasswordHandler, + najvaConfigProvider, ], controllers: [NotificationController], - exports: [NotificationsService, NotificationQueue], + exports: [NotificationsService, NotificationQueue, NajvaPushService], }) export class NotificationModule {} diff --git a/src/modules/notifications/services/najva-push.service.ts b/src/modules/notifications/services/najva-push.service.ts new file mode 100644 index 0000000..a4ce021 --- /dev/null +++ b/src/modules/notifications/services/najva-push.service.ts @@ -0,0 +1,137 @@ +import { HttpService } from "@nestjs/axios"; +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; + +import { NAJVA_CONFIG } from "../../../common/constants"; +import { NajvaConfigType } from "../../../common/providers/najva-config.provider"; +import { INajvaNotificationMessage, INajvaPushRequest, INajvaPushResponse } from "../interfaces/INajva"; + +@Injectable() +export class NajvaPushService { + private readonly logger = new Logger(NajvaPushService.name); + + constructor( + @Inject(NAJVA_CONFIG) private readonly config: NajvaConfigType, + private readonly httpService: HttpService, + ) {} + + //=============================================== + + async sendPushNotification(tokens: string[], message: INajvaNotificationMessage): Promise { + if (!tokens || tokens.length === 0) { + this.logger.warn("No tokens provided for push notification"); + return null; + } + + const validTokens = tokens.filter((token) => token && token.trim() !== ""); + if (validTokens.length === 0) { + this.logger.warn("No valid tokens found for push notification"); + return null; + } + + const payload: INajvaPushRequest = { + website_id: this.config.websiteId, + ttl: this.config.ttl, + tokens: validTokens, + message, + }; + + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + apiKey: this.config.apiKey, + }; + + try { + this.logger.log(`Sending push notification to ${validTokens.length} tokens`); + + const formData = new URLSearchParams(); + formData.append("website_id", payload.website_id.toString()); + formData.append("ttl", payload.ttl.toString()); + + validTokens.forEach((token) => { + formData.append("tokens[]", token); + }); + + formData.append("message.title", message.title); + formData.append("message.body", message.body); + + formData.append("message.notification_click.click_url", message.notification_click.click_url); + + if (message.icon) { + formData.append("message.icon", message.icon); + } + + if (message.image) { + formData.append("message.image", message.image); + } + + if (message.button_1) { + formData.append("message.button_1.title", message.button_1.title); + formData.append("message.button_1.click_url", message.button_1.click_url); + } + + if (message.button_2) { + formData.append("message.button_2.title", message.button_2.title); + formData.append("message.button_2.click_url", message.button_2.click_url); + } + + const response = await firstValueFrom(this.httpService.post(this.config.apiUrl, formData.toString(), { headers })); + + this.logger.log(`Push notification sent successfully. Request ID: ${response.data.Entries.request_id}`); + + response.data.Entries.tokens.forEach((tokenInfo) => { + if (tokenInfo.status === "InvalidToken") { + this.logger.warn(`Invalid token detected: ${tokenInfo.token}`); + } + }); + + return response.data; + } catch (error) { + this.logger.error("Failed to send push notification:", error); + + if (error instanceof AxiosError) { + this.logger.error(`HTTP Status: ${error.response?.status}`); + this.logger.error(`Response: ${JSON.stringify(error.response?.data)}`); + } + + throw error; + } + } + + //=============================================== + async sendNotificationToUser(userToken: string, message: INajvaNotificationMessage): Promise { + try { + const result = await this.sendPushNotification([userToken], message); + + if (!result) { + return false; + } + + const tokenResult = result.Entries.tokens.find((t) => t.token === userToken); + return tokenResult?.status === "Sent"; + } catch (error) { + this.logger.error(`Failed to send notification to user token ${userToken}:`, error); + return false; + } + } + //=============================================== + + async sendNotificationToMultipleUsers(userTokens: string[], message: INajvaNotificationMessage): Promise<{ success: number; failed: number }> { + try { + const result = await this.sendPushNotification(userTokens, message); + + if (!result) { + return { success: 0, failed: userTokens.length }; + } + + const successCount = result.Entries.tokens.filter((t) => t.status === "Sent").length; + const failedCount = result.Entries.tokens.filter((t) => t.status === "InvalidToken").length; + + return { success: successCount, failed: failedCount }; + } catch (error) { + this.logger.error(`Failed to send notifications to multiple users:`, error); + return { success: 0, failed: userTokens.length }; + } + } +} diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts index 99e92bc..c11c1b5 100755 --- a/src/modules/notifications/services/notifications.service.ts +++ b/src/modules/notifications/services/notifications.service.ts @@ -1,6 +1,8 @@ import { EntityManager } from "@mikro-orm/postgresql"; import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { NajvaPushService } from "./najva-push.service"; 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"; @@ -8,6 +10,7 @@ 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 { INajvaNotificationMessage } from "../interfaces/INajva"; import { IBaseNotificationData, IChangePasswordNotificationData, INewEmailNotificationData } from "../interfaces/ISendNotificationData"; import { NotificationRepository } from "../repositories/notifications.repository"; @@ -18,6 +21,8 @@ export class NotificationsService { private readonly em: EntityManager, private readonly notificationRepository: NotificationRepository, private readonly userSettingsService: UserSettingsService, + private readonly najvaPushService: NajvaPushService, + private readonly configService: ConfigService, ) {} //************************ */ @@ -37,6 +42,16 @@ export class NotificationsService { }); await localEm.persistAndFlush(notification); + if (user.pushToken) { + await this.sendPushNotificationToUser(user.pushToken, { + title: createNotificationDto.title, + body: createNotificationDto.message, + notification_click: { + click_url: `${this.configService.getOrThrow("FRONTEND_URL")}/notifications`, + }, + }); + } + await localEm.commit(); return notification; @@ -59,11 +74,35 @@ export class NotificationsService { }); // await em.persistAndFlush(notification); + + // Send push notification if user has a push token + if (user.pushToken) { + await this.sendPushNotificationToUser(user.pushToken, { + title: createNotificationDto.title, + body: createNotificationDto.message, + notification_click: { + click_url: `${this.configService.getOrThrow("FRONTEND_URL")}/notifications`, + }, + }); + } + return notification; } } - //************************ */ + //=============================================== + + async sendPushNotificationToUser(userToken: string, message: INajvaNotificationMessage): Promise { + return this.najvaPushService.sendNotificationToUser(userToken, message); + } + + //=============================================== + + async sendPushNotificationToMultipleUsers(userTokens: string[], message: INajvaNotificationMessage): Promise<{ success: number; failed: number }> { + return this.najvaPushService.sendNotificationToMultipleUsers(userTokens, message); + } + + //=============================================== async markAsRead(notificationId: string, userId: string) { const notification = await this.notificationRepository.findOne({ id: notificationId, recipient: { id: userId }, deletedAt: null }); @@ -78,7 +117,7 @@ export class NotificationsService { }; } - //************************ */ + //=============================================== async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) { const [notifications, count] = await this.notificationRepository.getAllNotifications(queryDto, recipientId); @@ -86,7 +125,7 @@ export class NotificationsService { return { notifications, notificationCount, count, paginate: true }; } - //************************ */ + //=============================================== async getNotificationById(id: string) { const notification = await this.notificationRepository.findOne({ id, deletedAt: null }); @@ -94,14 +133,14 @@ export class NotificationsService { 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 }); @@ -110,7 +149,7 @@ export class NotificationsService { }; } - //************************ */ + //=============================================== async createLoginNotification(recipientId: string, _data: IBaseNotificationData) { const loginDate = new Date().toLocaleString("fa-IR"); @@ -121,7 +160,7 @@ export class NotificationsService { 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.subject); @@ -130,7 +169,7 @@ export class NotificationsService { 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; diff --git a/src/modules/users/DTO/update-user-profile.dto.ts b/src/modules/users/DTO/update-user-profile.dto.ts index 45ef79d..6764a71 100644 --- a/src/modules/users/DTO/update-user-profile.dto.ts +++ b/src/modules/users/DTO/update-user-profile.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsUrl } from "class-validator"; +import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator"; import { UserMessage } from "../../../common/enums/message.enum"; @@ -9,4 +9,9 @@ export class UpdateUserProfileDto { @IsUrl({ protocols: ["https"] }, { message: UserMessage.PROFILE_PICTURE_URL }) @ApiPropertyOptional({ description: "Profile picture URL", example: "https://example.com/profile.jpg" }) profilePic?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "Push notification token from frontend", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }) + pushToken?: string; } diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 7dbb4cd..8c88eb4 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -58,6 +58,9 @@ export class User extends BaseEntity { @Property({ type: "timestamptz", nullable: true }) twoFactorEnabledAt?: Date; + @Property({ type: "varchar", nullable: true }) + pushToken?: string; + //========================= @OneToMany(() => RefreshToken, (token) => token.user)