chore: add najva push notif service

This commit is contained in:
mahyargdz
2025-07-28 09:50:58 +03:30
parent 6e227c53fd
commit 4e32eadf9a
14 changed files with 274 additions and 79 deletions
+2
View File
@@ -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;
@@ -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"),
};
-9
View File
@@ -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"),
};
@@ -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<NajvaConfigType> = {
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],
};
+3 -2
View File
@@ -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<string>("DB_PASS");
const DB_USER = configService.getOrThrow<string>("DB_USER");
-23
View File
@@ -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<typeof pushNotificationsConfig>;
export const pushNotificationsConfigFactory = () => ({
imports: [],
useFactory: (configService: ConfigService) => configService.get("pushNotifications"),
inject: [ConfigService],
});
@@ -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";
}>;
};
}
@@ -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 {}
@@ -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<INajvaPushResponse | null> {
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<INajvaPushResponse>(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<boolean> {
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 };
}
}
}
@@ -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<boolean> {
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;
@@ -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;
}
@@ -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)