up
This commit is contained in:
@@ -1,31 +1,24 @@
|
|||||||
import { IsString, IsNotEmpty, IsBoolean, IsOptional, ValidateNested } from 'class-validator';
|
import { IsNotEmpty, IsEnum } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { NotificationTitle } from '../entities/notification.entity';
|
import { NotificationTitle } from '../entities/notification.entity';
|
||||||
import { Enum } from '@mikro-orm/core';
|
|
||||||
import { NotificationType } from '../interfaces/notification.interface';
|
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 {
|
export class CreatePreferenceDto {
|
||||||
@ApiProperty({ description: 'Notification type (e.g., ORDER_CONFIRMED, PAYMENT_FAILED)' })
|
@ApiProperty({
|
||||||
@IsString()
|
description: 'Notification type (SMS, PUSH, BOTH, or NONE)',
|
||||||
|
enum: NotificationType,
|
||||||
|
example: NotificationType.SMS
|
||||||
|
})
|
||||||
|
@IsEnum(NotificationType)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Enum(() => NotificationType)
|
|
||||||
notificationType!: NotificationType;
|
notificationType!: NotificationType;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Notification channels configuration', type: ChannelsDto })
|
@ApiProperty({
|
||||||
@ValidateNested()
|
description: 'Notification title/type (e.g., order.created, review.created)',
|
||||||
|
enum: NotificationTitle,
|
||||||
|
example: NotificationTitle.ORDER_CREATED
|
||||||
|
})
|
||||||
|
@IsEnum(NotificationTitle)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Enum(() => NotificationTitle)
|
|
||||||
title!: NotificationTitle;
|
title!: NotificationTitle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface NotificationQueueJob {
|
|||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
notificationId?: string; // For retries
|
notificationId?: string; // For retries
|
||||||
notificationType: NotificationType;
|
notificationType: NotificationType;
|
||||||
|
pushToken?: string; // FCM token for push notifications
|
||||||
|
pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationQueueJobResult {
|
export interface NotificationQueueJobResult {
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { InjectRepository } from '@mikro-orm/nestjs';
|
|||||||
import { EntityRepository, EntityManager } from '@mikro-orm/postgresql';
|
import { EntityRepository, EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Notification } from '../entities/notification.entity';
|
import { Notification } from '../entities/notification.entity';
|
||||||
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { NotificationQueueNameEnum } from '../constants/queue';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { PushNotificationService } from '../services/push-notification.service';
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
|
||||||
|
|
||||||
@Processor(NotificationQueueNameEnum.PUSH)
|
@Processor(NotificationQueueNameEnum.PUSH)
|
||||||
export class PushProcessor extends WorkerHost {
|
export class PushProcessor extends WorkerHost {
|
||||||
@@ -17,6 +16,7 @@ export class PushProcessor extends WorkerHost {
|
|||||||
@InjectRepository(Notification)
|
@InjectRepository(Notification)
|
||||||
private readonly notificationRepository: EntityRepository<Notification>,
|
private readonly notificationRepository: EntityRepository<Notification>,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
|
private readonly pushNotificationService: PushNotificationService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -24,65 +24,121 @@ export class PushProcessor extends WorkerHost {
|
|||||||
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
|
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
|
||||||
const { restaurantId, userId, title, content, idempotencyKey, notificationId } = job.data;
|
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 {
|
try {
|
||||||
// Find or create notification record
|
// Find existing notification record (should already exist from NotificationService.sendNotification)
|
||||||
let notification: Notification | null = null;
|
let notification: Notification | null = null;
|
||||||
|
|
||||||
if (notificationId) {
|
if (notificationId) {
|
||||||
// Retry case - find existing notification
|
notification = await this.notificationRepository.findOne(
|
||||||
notification = await this.notificationRepository.findOne({ id: notificationId });
|
{ id: notificationId },
|
||||||
|
{ populate: ['user', 'restaurant'] },
|
||||||
|
);
|
||||||
if (!notification) {
|
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 {
|
} else {
|
||||||
// Check for duplicate using idempotency key
|
throw new Error('Push token or pushTokens array is required');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new notification if not found
|
if (!pushResult.success) {
|
||||||
if (!notification) {
|
throw new Error(pushResult.error || 'Failed to send push 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark notification as sent
|
// Mark notification as sent
|
||||||
notification.sentAt = new Date();
|
notification.sentAt = new Date();
|
||||||
await this.em.persistAndFlush(notification);
|
await this.em.persistAndFlush(notification);
|
||||||
|
|
||||||
this.logger.log(`Notification ${notification.id} processed successfully`);
|
this.logger.log(`Push notification ${notification.id} sent successfully`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
notificationId: notification.id,
|
notificationId: notification.id,
|
||||||
|
providerResponse: { messageId: pushResult.messageId },
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { InjectRepository } from '@mikro-orm/nestjs';
|
|||||||
import { EntityRepository, EntityManager } from '@mikro-orm/postgresql';
|
import { EntityRepository, EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Notification } from '../entities/notification.entity';
|
import { Notification } from '../entities/notification.entity';
|
||||||
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
import { NotificationQueueNameEnum } from '../constants/queue';
|
||||||
|
import { SmsNotificationService } from '../services/sms-notification.service';
|
||||||
|
|
||||||
@Processor(NotificationQueueNameEnum.SMS)
|
@Processor(NotificationQueueNameEnum.SMS)
|
||||||
export class SmsProcessor extends WorkerHost {
|
export class SmsProcessor extends WorkerHost {
|
||||||
@@ -17,6 +17,7 @@ export class SmsProcessor extends WorkerHost {
|
|||||||
@InjectRepository(Notification)
|
@InjectRepository(Notification)
|
||||||
private readonly notificationRepository: EntityRepository<Notification>,
|
private readonly notificationRepository: EntityRepository<Notification>,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
|
private readonly smsNotificationService: SmsNotificationService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -24,65 +25,74 @@ export class SmsProcessor extends WorkerHost {
|
|||||||
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
|
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
|
||||||
const { restaurantId, userId, title, content, idempotencyKey, notificationId } = job.data;
|
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 {
|
try {
|
||||||
// Find or create notification record
|
// Find existing notification record (should already exist from NotificationService.sendNotification)
|
||||||
let notification: Notification | null = null;
|
let notification: Notification | null = null;
|
||||||
|
|
||||||
if (notificationId) {
|
if (notificationId) {
|
||||||
// Retry case - find existing notification
|
notification = await this.notificationRepository.findOne(
|
||||||
notification = await this.notificationRepository.findOne({ id: notificationId });
|
{ id: notificationId },
|
||||||
|
{ populate: ['user', 'restaurant'] },
|
||||||
|
);
|
||||||
if (!notification) {
|
if (!notification) {
|
||||||
throw new Error(`Notification ${notificationId} not found for retry`);
|
throw new Error(`Notification ${notificationId} not found`);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (idempotencyKey) {
|
||||||
// Check for duplicate using idempotency key
|
// Fallback: find by idempotency key if notificationId not provided
|
||||||
if (idempotencyKey) {
|
notification = await this.notificationRepository.findOne(
|
||||||
const existing = await this.notificationRepository.findOne({ idempotencyKey });
|
{ idempotencyKey },
|
||||||
if (existing && existing.sentAt) {
|
{ populate: ['user', 'restaurant'] },
|
||||||
this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`);
|
);
|
||||||
return {
|
if (notification && notification.sentAt) {
|
||||||
success: true,
|
this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`);
|
||||||
notificationId: existing.id,
|
return {
|
||||||
};
|
success: true,
|
||||||
}
|
notificationId: notification.id,
|
||||||
if (existing) {
|
};
|
||||||
notification = existing;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create new notification if not found
|
if (!notification) {
|
||||||
if (!notification) {
|
throw new Error(`Notification not found for job ${job.id}. notificationId or idempotencyKey required.`);
|
||||||
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, {
|
// Get user phone number for SMS
|
||||||
restaurant,
|
if (!userId || !notification.user) {
|
||||||
user,
|
this.logger.warn(`No user found for SMS notification ${notification.id}`);
|
||||||
title,
|
throw new Error('User is required for SMS notifications');
|
||||||
content,
|
}
|
||||||
idempotencyKey,
|
|
||||||
});
|
const user = await this.em.findOne(User, { id: userId });
|
||||||
await this.em.persistAndFlush(notification);
|
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
|
// Mark notification as sent
|
||||||
notification.sentAt = new Date();
|
notification.sentAt = new Date();
|
||||||
await this.em.persistAndFlush(notification);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
notificationId: notification.id,
|
notificationId: notification.id,
|
||||||
|
providerResponse: { messageId: smsResult.messageId },
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class SmsNotificationService {
|
|||||||
|
|
||||||
private async sendTemplateSms(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
|
private async sendTemplateSms(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
|
||||||
const templateId = payload.templateId || this.defaultTemplateId;
|
const templateId = payload.templateId || this.defaultTemplateId;
|
||||||
|
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
throw new HttpException('Template ID is required for template SMS', HttpStatus.BAD_REQUEST);
|
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