notification

This commit is contained in:
2025-12-08 22:04:01 +03:30
parent 060570f847
commit 9c93feee68
19 changed files with 2603 additions and 88 deletions
@@ -0,0 +1,190 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification, NotificationStatus, NotificationChannel } from '../entities/notification.entity';
import { NotificationPreferenceService } from './notification-preference.service';
import { NotificationQueueService } from './notification-queue.service';
import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
export interface SendNotificationParams {
restaurantId: string;
userId?: string;
notificationType: string;
payload: {
title?: string;
message: string;
body?: string;
phoneNumber?: string;
pushToken?: string;
data?: Record<string, any>;
templateId?: string;
parameters?: Array<{ name: string; value: string }>;
};
idempotencyKey?: string;
}
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly em: EntityManager,
private readonly preferenceService: NotificationPreferenceService,
private readonly queueService: NotificationQueueService,
) {}
async sendNotification(params: SendNotificationParams): Promise<Notification[]> {
const { restaurantId, userId, notificationType, payload, idempotencyKey } = params;
// Verify restaurant exists
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
// Get notification preferences
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, notificationType);
if (!preference || !preference.enabled) {
this.logger.warn(
`Notification preference not found or disabled for restaurant ${restaurantId}, type ${notificationType}`,
);
return [];
}
// Determine which channels to use
const channels: NotificationChannel[] = [];
if (preference.channels.sms && payload.phoneNumber) {
channels.push(NotificationChannel.SMS);
}
if (preference.channels.push && payload.pushToken) {
channels.push(NotificationChannel.PUSH);
}
if (channels.length === 0) {
this.logger.warn(`No enabled channels for notification type ${notificationType}`);
return [];
}
// Create notification records and queue jobs
const notifications: Notification[] = [];
const jobs: NotificationQueueJob[] = [];
for (const channel of channels) {
const channelIdempotencyKey = idempotencyKey
? `${idempotencyKey}-${channel}`
: `${restaurantId}-${notificationType}-${channel}-${Date.now()}`;
// Create notification record
const user = userId ? await this.em.findOne(User, { id: userId }) : undefined;
const notification = this.em.create(Notification, {
restaurant,
user,
notificationType,
channel,
payload,
status: NotificationStatus.PENDING,
idempotencyKey: channelIdempotencyKey,
attemptCount: 0,
});
await this.em.persistAndFlush(notification);
notifications.push(notification);
// Add to queue
jobs.push({
restaurantId,
userId,
notificationType,
channel,
payload,
idempotencyKey: channelIdempotencyKey,
notificationId: notification.id,
});
}
// Add all jobs to queue
if (jobs.length === 1) {
await this.queueService.addNotification(jobs[0]);
} else {
await this.queueService.addBulkNotifications(jobs);
}
this.logger.log(
`Queued ${notifications.length} notification(s) for restaurant ${restaurantId}, type ${notificationType}`,
);
return notifications;
}
async findOne(id: string): Promise<Notification> {
const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
if (!notification) {
throw new NotFoundException('Notification not found');
}
return notification;
}
async findByRestaurant(restaurantId: string, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{ restaurant: { id: restaurantId } },
{
orderBy: { createdAt: 'DESC' },
limit,
populate: ['user'],
},
);
}
async findByUserId(userId: string, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{ user: { id: userId } },
{
orderBy: { createdAt: 'DESC' },
limit,
populate: ['restaurant'],
},
);
}
async findByRestaurantAndType(restaurantId: string, notificationType: string, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{
restaurant: { id: restaurantId },
notificationType,
},
{
orderBy: { createdAt: 'DESC' },
limit,
populate: ['user'],
},
);
}
async retryFailedNotification(notificationId: string): Promise<void> {
const notification = await this.findOne(notificationId);
if (notification.status !== NotificationStatus.FAILED) {
throw new BadRequestException('Only failed notifications can be retried');
}
// Reset status and add to queue
notification.status = NotificationStatus.PENDING;
await this.em.persistAndFlush(notification);
await this.queueService.addNotification({
restaurantId: notification.restaurant.id,
notificationType: notification.notificationType,
channel: notification.channel,
payload: notification.payload,
idempotencyKey: notification.idempotencyKey,
notificationId: notification.id,
});
this.logger.log(`Retry queued for notification ${notificationId}`);
}
}