From a5f29956e64b03aa8c1746ec2330a7be9b3036b1 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Tue, 9 Dec 2025 10:40:16 +0330 Subject: [PATCH] up --- .../dto/create-preference.dto.ts | 35 ++--- .../notification-queue.interface.ts | 2 + .../processors/push.processor.ts | 138 ++++++++++++------ .../notifications/processors/sms.processor.ts | 86 ++++++----- .../services/sms-notification.service.ts | 3 +- 5 files changed, 162 insertions(+), 102 deletions(-) diff --git a/src/modules/notifications/dto/create-preference.dto.ts b/src/modules/notifications/dto/create-preference.dto.ts index b7b253c..0777fd4 100644 --- a/src/modules/notifications/dto/create-preference.dto.ts +++ b/src/modules/notifications/dto/create-preference.dto.ts @@ -1,31 +1,24 @@ -import { IsString, IsNotEmpty, IsBoolean, IsOptional, ValidateNested } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsEnum } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; import { NotificationTitle } from '../entities/notification.entity'; -import { Enum } from '@mikro-orm/core'; import { NotificationType } from '../interfaces/notification.interface'; -class ChannelsDto { - @ApiPropertyOptional({ description: 'Enable SMS channel' }) - @IsBoolean() - @IsOptional() - sms?: boolean; - - @ApiPropertyOptional({ description: 'Enable push notification channel' }) - @IsBoolean() - @IsOptional() - push?: boolean; -} - export class CreatePreferenceDto { - @ApiProperty({ description: 'Notification type (e.g., ORDER_CONFIRMED, PAYMENT_FAILED)' }) - @IsString() + @ApiProperty({ + description: 'Notification type (SMS, PUSH, BOTH, or NONE)', + enum: NotificationType, + example: NotificationType.SMS + }) + @IsEnum(NotificationType) @IsNotEmpty() - @Enum(() => NotificationType) notificationType!: NotificationType; - @ApiProperty({ description: 'Notification channels configuration', type: ChannelsDto }) - @ValidateNested() + @ApiProperty({ + description: 'Notification title/type (e.g., order.created, review.created)', + enum: NotificationTitle, + example: NotificationTitle.ORDER_CREATED + }) + @IsEnum(NotificationTitle) @IsNotEmpty() - @Enum(() => NotificationTitle) title!: NotificationTitle; } diff --git a/src/modules/notifications/interfaces/notification-queue.interface.ts b/src/modules/notifications/interfaces/notification-queue.interface.ts index 9a1909d..73005a5 100644 --- a/src/modules/notifications/interfaces/notification-queue.interface.ts +++ b/src/modules/notifications/interfaces/notification-queue.interface.ts @@ -9,6 +9,8 @@ export interface NotificationQueueJob { idempotencyKey?: string; notificationId?: string; // For retries notificationType: NotificationType; + pushToken?: string; // FCM token for push notifications + pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications } export interface NotificationQueueJobResult { diff --git a/src/modules/notifications/processors/push.processor.ts b/src/modules/notifications/processors/push.processor.ts index dc6461a..685c006 100644 --- a/src/modules/notifications/processors/push.processor.ts +++ b/src/modules/notifications/processors/push.processor.ts @@ -5,9 +5,8 @@ import { InjectRepository } from '@mikro-orm/nestjs'; import { EntityRepository, EntityManager } from '@mikro-orm/postgresql'; import { Notification } from '../entities/notification.entity'; import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface'; -import { Restaurant } from '../../restaurants/entities/restaurant.entity'; -import { User } from '../../users/entities/user.entity'; -import { NotificationQueueNameEnum } from '../constants/queue'; + import { NotificationQueueNameEnum } from '../constants/queue'; +import { PushNotificationService } from '../services/push-notification.service'; @Processor(NotificationQueueNameEnum.PUSH) export class PushProcessor extends WorkerHost { @@ -17,6 +16,7 @@ export class PushProcessor extends WorkerHost { @InjectRepository(Notification) private readonly notificationRepository: EntityRepository, private readonly em: EntityManager, + private readonly pushNotificationService: PushNotificationService, ) { super(); } @@ -24,65 +24,121 @@ export class PushProcessor extends WorkerHost { async process(job: Job): Promise { const { restaurantId, userId, title, content, idempotencyKey, notificationId } = job.data; - this.logger.log(`Processing notification job: ${job.id} - Title: ${title}, Restaurant: ${restaurantId}`); + this.logger.log(`Processing push notification job: ${job.id} - Title: ${title}, Restaurant: ${restaurantId}`); try { - // Find or create notification record + // Find existing notification record (should already exist from NotificationService.sendNotification) let notification: Notification | null = null; if (notificationId) { - // Retry case - find existing notification - notification = await this.notificationRepository.findOne({ id: notificationId }); + notification = await this.notificationRepository.findOne( + { id: notificationId }, + { populate: ['user', 'restaurant'] }, + ); if (!notification) { - throw new Error(`Notification ${notificationId} not found for retry`); + throw new Error(`Notification ${notificationId} not found`); } + } else if (idempotencyKey) { + // Fallback: find by idempotency key if notificationId not provided + notification = await this.notificationRepository.findOne( + { idempotencyKey }, + { populate: ['user', 'restaurant'] }, + ); + if (notification && notification.sentAt) { + this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`); + return { + success: true, + notificationId: notification.id, + }; + } + } + + if (!notification) { + throw new Error(`Notification not found for job ${job.id}. notificationId or idempotencyKey required.`); + } + + // Check if push notification service is configured + if (!this.pushNotificationService.isConfigured()) { + this.logger.warn(`Push notification service not configured. Skipping notification ${notification.id}`); + // Mark as sent even though we didn't send it (service unavailable) + notification.sentAt = new Date(); + await this.em.persistAndFlush(notification); + return { + success: false, + notificationId: notification.id, + error: 'Push notification service not configured', + }; + } + + // For push notifications, we need a push token + if (!userId || !notification.user) { + this.logger.warn(`No user found for push notification ${notification.id}`); + throw new Error('User is required for push notifications'); + } + + // Get push token from job data + const pushToken = job.data.pushToken; + const pushTokens = job.data.pushTokens; + + if (!pushToken && (!pushTokens || pushTokens.length === 0)) { + this.logger.warn(`Push token(s) not provided for notification ${notification.id}`); + throw new Error('Push token or pushTokens array is required for push notifications'); + } + + // Send push notification + // Convert NotificationTitle enum to a readable string for the notification title + const notificationTitle = String(title) + .replace(/\./g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + + let pushResult; + if (pushTokens && pushTokens.length > 0) { + // Send to multiple tokens + pushResult = await this.pushNotificationService.sendToMultipleTokens({ + title: notificationTitle, + body: content, + tokens: pushTokens, + data: { + notificationId: notification.id, + restaurantId, + userId, + title: String(title), + }, + }); + } else if (pushToken) { + // Send to single token + pushResult = await this.pushNotificationService.sendToToken({ + title: notificationTitle, + body: content, + token: pushToken, + data: { + notificationId: notification.id, + restaurantId, + userId, + title: String(title), + }, + }); } else { - // Check for duplicate using idempotency key - if (idempotencyKey) { - const existing = await this.notificationRepository.findOne({ idempotencyKey }); - if (existing && existing.sentAt) { - this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`); - return { - success: true, - notificationId: existing.id, - }; - } - if (existing) { - notification = existing; - } - } + throw new Error('Push token or pushTokens array is required'); + } - // Create new notification if not found - if (!notification) { - const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); - if (!restaurant) { - throw new Error(`Restaurant ${restaurantId} not found`); - } - const user = userId ? await this.em.findOne(User, { id: userId }) : undefined; - - notification = this.em.create(Notification, { - restaurant, - user, - title, - content, - idempotencyKey, - }); - await this.em.persistAndFlush(notification); - } + if (!pushResult.success) { + throw new Error(pushResult.error || 'Failed to send push notification'); } // Mark notification as sent notification.sentAt = new Date(); await this.em.persistAndFlush(notification); - this.logger.log(`Notification ${notification.id} processed successfully`); + this.logger.log(`Push notification ${notification.id} sent successfully`); return { success: true, notificationId: notification.id, + providerResponse: { messageId: pushResult.messageId }, }; } catch (error) { - this.logger.error(`Error processing notification job ${job.id}:`, error); + this.logger.error(`Error processing push notification job ${job.id}:`, error); throw error; } } diff --git a/src/modules/notifications/processors/sms.processor.ts b/src/modules/notifications/processors/sms.processor.ts index 681a49c..6d2a49c 100644 --- a/src/modules/notifications/processors/sms.processor.ts +++ b/src/modules/notifications/processors/sms.processor.ts @@ -5,9 +5,9 @@ import { InjectRepository } from '@mikro-orm/nestjs'; import { EntityRepository, EntityManager } from '@mikro-orm/postgresql'; import { Notification } from '../entities/notification.entity'; import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface'; -import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { User } from '../../users/entities/user.entity'; import { NotificationQueueNameEnum } from '../constants/queue'; +import { SmsNotificationService } from '../services/sms-notification.service'; @Processor(NotificationQueueNameEnum.SMS) export class SmsProcessor extends WorkerHost { @@ -17,6 +17,7 @@ export class SmsProcessor extends WorkerHost { @InjectRepository(Notification) private readonly notificationRepository: EntityRepository, private readonly em: EntityManager, + private readonly smsNotificationService: SmsNotificationService, ) { super(); } @@ -24,65 +25,74 @@ export class SmsProcessor extends WorkerHost { async process(job: Job): Promise { const { restaurantId, userId, title, content, idempotencyKey, notificationId } = job.data; - this.logger.log(`Processing notification job: ${job.id} - Title: ${title}, Restaurant: ${restaurantId}`); + this.logger.log(`Processing SMS notification job: ${job.id} - Title: ${title}, Restaurant: ${restaurantId}`); try { - // Find or create notification record + // Find existing notification record (should already exist from NotificationService.sendNotification) let notification: Notification | null = null; if (notificationId) { - // Retry case - find existing notification - notification = await this.notificationRepository.findOne({ id: notificationId }); + notification = await this.notificationRepository.findOne( + { id: notificationId }, + { populate: ['user', 'restaurant'] }, + ); if (!notification) { - throw new Error(`Notification ${notificationId} not found for retry`); + throw new Error(`Notification ${notificationId} not found`); } - } else { - // Check for duplicate using idempotency key - if (idempotencyKey) { - const existing = await this.notificationRepository.findOne({ idempotencyKey }); - if (existing && existing.sentAt) { - this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`); - return { - success: true, - notificationId: existing.id, - }; - } - if (existing) { - notification = existing; - } + } else if (idempotencyKey) { + // Fallback: find by idempotency key if notificationId not provided + notification = await this.notificationRepository.findOne( + { idempotencyKey }, + { populate: ['user', 'restaurant'] }, + ); + if (notification && notification.sentAt) { + this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`); + return { + success: true, + notificationId: notification.id, + }; } + } - // Create new notification if not found - if (!notification) { - const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); - if (!restaurant) { - throw new Error(`Restaurant ${restaurantId} not found`); - } - const user = userId ? await this.em.findOne(User, { id: userId }) : undefined; + if (!notification) { + throw new Error(`Notification not found for job ${job.id}. notificationId or idempotencyKey required.`); + } - notification = this.em.create(Notification, { - restaurant, - user, - title, - content, - idempotencyKey, - }); - await this.em.persistAndFlush(notification); - } + // Get user phone number for SMS + if (!userId || !notification.user) { + this.logger.warn(`No user found for SMS notification ${notification.id}`); + throw new Error('User is required for SMS notifications'); + } + + const user = await this.em.findOne(User, { id: userId }); + if (!user || !user.phone) { + this.logger.warn(`User ${userId} not found or has no phone number`); + throw new Error('User phone number is required for SMS notifications'); + } + + // Send SMS notification + const smsResult = await this.smsNotificationService.send({ + phoneNumber: user.phone, + message: content, + }); + + if (!smsResult.success) { + throw new Error(smsResult.error || 'Failed to send SMS notification'); } // Mark notification as sent notification.sentAt = new Date(); await this.em.persistAndFlush(notification); - this.logger.log(`Notification ${notification.id} processed successfully`); + this.logger.log(`SMS notification ${notification.id} sent successfully to ${user.phone}`); return { success: true, notificationId: notification.id, + providerResponse: { messageId: smsResult.messageId }, }; } catch (error) { - this.logger.error(`Error processing notification job ${job.id}:`, error); + this.logger.error(`Error processing SMS notification job ${job.id}:`, error); throw error; } } diff --git a/src/modules/notifications/services/sms-notification.service.ts b/src/modules/notifications/services/sms-notification.service.ts index 1a187ed..8d24fdc 100644 --- a/src/modules/notifications/services/sms-notification.service.ts +++ b/src/modules/notifications/services/sms-notification.service.ts @@ -48,7 +48,7 @@ export class SmsNotificationService { private async sendTemplateSms(payload: SmsNotificationPayload): Promise { const templateId = payload.templateId || this.defaultTemplateId; - + if (!templateId) { throw new HttpException('Template ID is required for template SMS', HttpStatus.BAD_REQUEST); } @@ -93,4 +93,3 @@ export class SmsNotificationService { }); } } -