up
This commit is contained in:
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user