This commit is contained in:
2025-12-09 10:40:16 +03:30
parent ead1e096a5
commit a5f29956e6
5 changed files with 162 additions and 102 deletions
@@ -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;
}
@@ -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 {
@@ -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<Notification>,
private readonly em: EntityManager,
private readonly pushNotificationService: PushNotificationService,
) {
super();
}
@@ -24,65 +24,121 @@ export class PushProcessor extends WorkerHost {
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
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;
}
}
@@ -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<Notification>,
private readonly em: EntityManager,
private readonly smsNotificationService: SmsNotificationService,
) {
super();
}
@@ -24,65 +25,74 @@ export class SmsProcessor extends WorkerHost {
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
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;
}
}
@@ -48,7 +48,7 @@ export class SmsNotificationService {
private async sendTemplateSms(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
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 {
});
}
}