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