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,93 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { NotificationPreference, NotificationChannels } from '../entities/notification-preference.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
@Injectable()
export class NotificationPreferenceService {
constructor(private readonly em: EntityManager) {}
async createOrUpdate(
restaurantId: string,
notificationType: string,
channels: NotificationChannels,
enabled: boolean = true,
): Promise<NotificationPreference> {
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
let preference = await this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId },
notificationType,
});
if (preference) {
preference.channels = channels;
preference.enabled = enabled;
} else {
preference = this.em.create(NotificationPreference, {
restaurant,
notificationType,
channels,
enabled,
});
}
await this.em.persistAndFlush(preference);
return preference;
}
async findByRestaurant(restaurantId: string): Promise<NotificationPreference[]> {
return this.em.find(NotificationPreference, {
restaurant: { id: restaurantId },
});
}
async findByRestaurantAndType(
restaurantId: string,
notificationType: string,
): Promise<NotificationPreference | null> {
return this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId },
notificationType,
});
}
async updateEnabled(restaurantId: string, notificationType: string, enabled: boolean): Promise<NotificationPreference> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
preference.enabled = enabled;
await this.em.persistAndFlush(preference);
return preference;
}
async updateChannels(
restaurantId: string,
notificationType: string,
channels: NotificationChannels,
): Promise<NotificationPreference> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
preference.channels = channels;
await this.em.persistAndFlush(preference);
return preference;
}
async delete(restaurantId: string, notificationType: string): Promise<void> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
await this.em.removeAndFlush(preference);
}
}
@@ -0,0 +1,63 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
import { NotificationChannel } from '../entities/notification.entity';
@Injectable()
export class NotificationQueueService {
private readonly logger = new Logger(NotificationQueueService.name);
constructor(@InjectQueue('notification-queue') private readonly notificationQueue: Queue) {}
async addNotification(job: NotificationQueueJob): Promise<void> {
try {
const jobId = job.idempotencyKey || `${job.restaurantId}-${job.notificationType}-${job.channel}-${Date.now()}`;
await this.notificationQueue.add('send-notification', job, {
jobId,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 1000,
},
removeOnFail: {
age: 7 * 24 * 3600, // Keep failed jobs for 7 days
},
});
this.logger.log(`Notification job added to queue: ${jobId}`);
} catch (error) {
this.logger.error(`Failed to add notification to queue:`, error);
throw error;
}
}
async addBulkNotifications(jobs: NotificationQueueJob[]): Promise<void> {
try {
const queueJobs = jobs.map(job => ({
name: 'send-notification',
data: job,
opts: {
jobId: job.idempotencyKey || `${job.restaurantId}-${job.notificationType}-${job.channel}-${Date.now()}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
}));
await this.notificationQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} notification jobs to queue`);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
throw error;
}
}
}
@@ -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}`);
}
}
@@ -0,0 +1,148 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
export interface PushNotificationPayload {
title: string;
body: string;
data?: Record<string, any>;
token?: string;
tokens?: string[];
}
export interface PushNotificationResponse {
success: boolean;
messageId?: string;
error?: string;
}
@Injectable()
export class PushNotificationService {
private readonly logger = new Logger(PushNotificationService.name);
private firebaseApp: admin.app.App | null = null;
constructor(private readonly configService: ConfigService) {
this.initializeFirebase();
}
private initializeFirebase() {
try {
const firebaseConfig = this.configService.get<string>('FIREBASE_SERVICE_ACCOUNT');
if (!firebaseConfig) {
this.logger.warn('Firebase service account not configured. Push notifications will be disabled.');
return;
}
// Parse the service account JSON
const serviceAccount = JSON.parse(firebaseConfig);
if (!this.firebaseApp) {
this.firebaseApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
this.logger.log('Firebase initialized successfully');
}
} catch (error) {
this.logger.error('Failed to initialize Firebase:', error);
this.logger.warn('Push notifications will be disabled');
}
}
async sendToToken(payload: PushNotificationPayload): Promise<PushNotificationResponse> {
if (!this.firebaseApp || !payload.token) {
throw new HttpException('Push notification service not configured or token missing', HttpStatus.SERVICE_UNAVAILABLE);
}
try {
const message: admin.messaging.Message = {
notification: {
title: payload.title,
body: payload.body,
},
data: this.convertDataToString(payload.data || {}),
token: payload.token,
android: {
priority: 'high',
},
apns: {
headers: {
'apns-priority': '10',
},
},
};
const response = await admin.messaging().send(message);
this.logger.log(`Push notification sent successfully: ${response}`);
return {
success: true,
messageId: response,
};
} catch (error) {
this.logger.error(`Failed to send push notification: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async sendToMultipleTokens(payload: PushNotificationPayload): Promise<PushNotificationResponse> {
if (!this.firebaseApp || !payload.tokens || payload.tokens.length === 0) {
throw new HttpException(
'Push notification service not configured or tokens missing',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
try {
const message: admin.messaging.MulticastMessage = {
notification: {
title: payload.title,
body: payload.body,
},
data: this.convertDataToString(payload.data || {}),
tokens: payload.tokens,
android: {
priority: 'high',
},
apns: {
headers: {
'apns-priority': '10',
},
},
};
const response = await admin.messaging().sendEachForMulticast(message);
this.logger.log(
`Push notifications sent: ${response.successCount} successful, ${response.failureCount} failed`,
);
return {
success: response.successCount > 0,
messageId: response.responses[0]?.messageId,
error: response.failureCount > 0 ? `${response.failureCount} notifications failed` : undefined,
};
} catch (error) {
this.logger.error(`Failed to send push notifications: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private convertDataToString(data: Record<string, any>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(data)) {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
}
return result;
}
isConfigured(): boolean {
return this.firebaseApp !== null;
}
}
@@ -0,0 +1,96 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
export interface SmsNotificationPayload {
phoneNumber: string;
message: string;
templateId?: string;
parameters?: Array<{ name: string; value: string }>;
}
export interface SmsNotificationResponse {
success: boolean;
messageId?: string;
error?: string;
}
@Injectable()
export class SmsNotificationService {
private readonly logger = new Logger(SmsNotificationService.name);
private readonly baseURL: string;
private readonly apiKey: string;
private readonly defaultTemplateId?: string;
constructor(private readonly configService: ConfigService) {
this.baseURL = this.configService.getOrThrow<string>('SMS_BASE_URL');
this.apiKey = this.configService.getOrThrow<string>('SMS_API_KEY');
this.defaultTemplateId = this.configService.get<string>('SMS_PATTERN_OTP');
}
async send(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
try {
// If templateId is provided, use template-based SMS
if (payload.templateId || this.defaultTemplateId) {
return await this.sendTemplateSms(payload);
}
// Otherwise, send simple SMS
return await this.sendSimpleSms(payload);
} catch (error) {
this.logger.error(`Failed to send SMS: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
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);
}
const smsData = {
Parameters: payload.parameters || [{ name: 'message', value: payload.message }],
Mobile: payload.phoneNumber,
TemplateId: templateId,
};
const url = `${this.baseURL}/send/verify`;
try {
const { data } = await axios.post<{ status: number; message: string }>(url, smsData, {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': this.apiKey,
},
});
if (data.status !== 1) {
throw new HttpException(data.message || 'SMS sending failed', HttpStatus.BAD_GATEWAY);
}
this.logger.log(`SMS sent successfully to ${payload.phoneNumber}`);
return {
success: true,
messageId: data.message,
};
} catch (error) {
this.logger.error(`SMS sending failed: ${JSON.stringify(error)}`);
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
}
}
private async sendSimpleSms(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
// If your SMS provider supports simple text messages, implement it here
// For now, we'll use the template method as fallback
return this.sendTemplateSms({
...payload,
parameters: [{ name: 'message', value: payload.message }],
});
}
}