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 { 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;
} }
} }
@@ -93,4 +93,3 @@ export class SmsNotificationService {
}); });
} }
} }