notification queue

This commit is contained in:
2025-12-14 10:13:34 +03:30
parent befa311e98
commit b7e41ff67d
8 changed files with 109 additions and 210 deletions
@@ -1,4 +1,5 @@
export enum NotificationQueueNameEnum { export enum NotificationQueueNameEnum {
SMS = 'sms', SMS = 'sms',
PUSH = 'push', PUSH = 'push',
IN_APP = 'in-app',
} }
@@ -1,4 +1,4 @@
import type { recipientType } from './notification.interface'; import type { NotifTitleEnum, recipientType } from './notification.interface';
export interface SmsNotificationQueueJob { export interface SmsNotificationQueueJob {
recipient: recipientType; recipient: recipientType;
@@ -16,6 +16,12 @@ export interface PushNotificationQueueJob {
pushToken?: string; // FCM token for push notifications pushToken?: string; // FCM token for push notifications
pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications
} }
export interface InAppNotificationQueueJob {
recipient: { adminId: string; restaurantId: string };
subject: NotifTitleEnum;
body: string;
notificationId: string;
}
export interface SmsNotificationQueueJobResult { export interface SmsNotificationQueueJobResult {
success: boolean; success: boolean;
@@ -71,9 +71,13 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
} }
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) { sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
const room = this.getRoom(repipient.adminId, repipient.restaurantId); const room = this.getRoom(repipient.adminId, repipient.restaurantId);
this.server.to(room).emit('notifications', payload);
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
this.server.to(room).emit('notifications', payload, async () => {
// 3. ACK (delivered)
// await this.notificationService.markAsDelivered(payload.notificationId);
});
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`); this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
} }
@@ -87,12 +91,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`); this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
void client.emit('left', { room, message: 'Successfully Admin left room' }); void client.emit('left', { room, message: 'Successfully Admin left room' });
} }
/**
* Join a room for a specific restaurant (admin/staff)
* Requires admin authentication via JWT token
* Restaurant ID is extracted from the authenticated admin's token
* Room format: `restaurant:${restId}`
*/
handleJoinRoom(client: AuthenticatedSocket) { handleJoinRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.restId!); const room = this.getRoom(client.adminId!, client.restId!);
void client.join(room); void client.join(room);
@@ -100,192 +99,6 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
void client.emit('joined', { room, message: 'Successfully Admin joined room' }); void client.emit('joined', { room, message: 'Successfully Admin joined room' });
} }
/**
* Join a room for a specific cookie/session (public user)
* Room format: `cookie:${cookieId}`
*/
// @SubscribeMessage('join:session')
// handleJoinSession(@ConnectedSocket() client: Socket, @MessageBody() data: { cookieId: string }) {
// if (!data.cookieId) {
// void client.emit('error', { message: 'Cookie ID is required' });
// return;
// }
// const room = `cookie:${data.cookieId}`;
// void client.join(room);
// this.logger.log(`Client ${client.id} joined room: ${room}`);
// void client.emit('joined', { room, message: 'Successfully joined session room' });
// }
/**
* Leave a room
*/
// @SubscribeMessage('leave:room')
// handleLeaveRoom(@ConnectedSocket() client: Socket, @MessageBody() data: { room: string }) {
// if (data.room) {
// void client.leave(data.room);
// this.logger.log(`Client ${client.id} left room: ${data.room}`);
// void client.emit('left', { room: data.room, message: 'Successfully left room' });
// }
// }
/**
* Get list of pagers for the restaurant (admin only)
* Requires admin authentication via JWT token
* Restaurant ID is extracted from the authenticated admin's token
*/
// @UseGuards(WsAdminAuthGuard)
// @SubscribeMessage('get:pagers')
// async handleGetPagers(@ConnectedSocket() client: AuthenticatedSocket, @WsRestId() restId: string) {
// try {
// const pagers = await this.pagerService.findAllByRestaurantId(restId);
// const pagersData = pagers.map(pager => ({
// id: pager.id,
// tableNumber: pager.tableNumber,
// message: pager.message,
// status: pager.status,
// cookieId: pager.cookieId,
// createdAt: pager.createdAt,
// updatedAt: pager.updatedAt,
// user: pager.user
// ? {
// id: pager.user.id,
// firstName: pager.user.firstName,
// lastName: pager.user.lastName,
// }
// : null,
// staff: pager.staff
// ? {
// id: pager.staff.id,
// firstName: pager.staff.firstName,
// lastName: pager.staff.lastName,
// }
// : null,
// }));
// void client.emit('pagers:list', { pagers: pagersData });
// this.logger.log(`Admin ${client.adminId} requested pagers list for restaurant ${restId}`);
// } catch (error) {
// this.logger.error(`Error getting pagers list: ${error instanceof Error ? error.message : 'Unknown error'}`);
// void client.emit('error', {
// message: 'Failed to get pagers list',
// code: 'GET_PAGERS_ERROR',
// details: error instanceof Error ? error.message : 'Unknown error',
// });
// }
// }
/**
* Update pager status (admin only)
* Requires admin authentication via JWT token
* Restaurant ID and Admin ID are extracted from the authenticated admin's token
*/
// @UseGuards(WsAdminAuthGuard)
// @SubscribeMessage('update:pager:status')
// async handleUpdatePagerStatus(
// @ConnectedSocket() client: AuthenticatedSocket,
// @WsRestId() restId: string,
// @WsAdminId() adminId: string,
// @MessageBody() data: { pagerId: string; status: PagerStatus },
// ) {
// try {
// if (!data.pagerId || !data.status) {
// void client.emit('error', {
// message: 'Pager ID and status are required',
// code: 'INVALID_PAYLOAD',
// });
// return;
// }
// const pager = await this.pagerService.updateStatus(data.pagerId, restId, adminId, data.status);
// const pagerData = {
// id: pager.id,
// tableNumber: pager.tableNumber,
// message: pager.message,
// status: pager.status,
// updatedAt: pager.updatedAt,
// staff: pager.staff
// ? {
// id: pager.staff.id,
// firstName: pager.staff.firstName,
// lastName: pager.staff.lastName,
// }
// : null,
// };
// void client.emit('pager:status:updated:response', { pager: pagerData });
// this.logger.log(`Admin ${adminId} updated pager ${data.pagerId} status to ${data.status}`);
// } catch (error) {
// this.logger.error(`Error updating pager status: ${error instanceof Error ? error.message : 'Unknown error'}`);
// void client.emit('error', {
// message: error instanceof Error ? error.message : 'Failed to update pager status',
// code: 'UPDATE_PAGER_STATUS_ERROR',
// details: error instanceof Error ? error.message : 'Unknown error',
// });
// }
// }
// /**
// * Emit pager created event to restaurant room
// */
// emitPagerCreated(pager: Pager) {
// const room = `restaurant:${pager.restaurant.id}`;
// this.server.to(room).emit('pager:created', {
// pager: {
// id: pager.id,
// tableNumber: pager.tableNumber,
// message: pager.message,
// status: pager.status,
// createdAt: pager.createdAt,
// user: pager.user
// ? {
// id: pager.user.id,
// firstName: pager.user.firstName,
// lastName: pager.user.lastName,
// }
// : null,
// },
// });
// this.logger.log(`Emitted pager:created to room: ${room}`);
// }
// /**
// * Emit pager status updated event to both restaurant and cookie rooms
// */
// emitPagerStatusUpdated(pager: Pager) {
// const restaurantRoom = `restaurant:${pager.restaurant.id}`;
// const cookieRoom = `cookie:${pager.cookieId}`;
// const pagerData = {
// id: pager.id,
// tableNumber: pager.tableNumber,
// message: pager.message,
// status: pager.status,
// updatedAt: pager.updatedAt,
// staff: pager.staff
// ? {
// id: pager.staff.id,
// firstName: pager.staff.firstName,
// lastName: pager.staff.lastName,
// }
// : null,
// };
// // Emit to restaurant room (admin/staff)
// this.server.to(restaurantRoom).emit('pager:status:updated', {
// pager: pagerData,
// });
// this.logger.log(`Emitted pager:status:updated to room: ${restaurantRoom}`);
// // Emit to cookie room (public user)
// this.server.to(cookieRoom).emit('pager:status:updated', {
// pager: pagerData,
// });
// this.logger.log(`Emitted pager:status:updated to room: ${cookieRoom}`);
// }
private async authenticateConnection(client: Socket): Promise<void> { private async authenticateConnection(client: Socket): Promise<void> {
const guard = this.moduleRef.get(WsAdminAuthGuard); const guard = this.moduleRef.get(WsAdminAuthGuard);
const context = { const context = {
@@ -19,6 +19,7 @@ import { NotificationQueueNameEnum } from './constants/queue';
import { UtilsModule } from '../utils/utils.module'; import { UtilsModule } from '../utils/utils.module';
import { NotificationsGateway } from './notifications.gateway'; import { NotificationsGateway } from './notifications.gateway';
import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard'; import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard';
import { InAppProcessor } from './processors/in-app.processor';
@Module({ @Module({
imports: [ imports: [
@@ -26,7 +27,11 @@ import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard';
UtilsModule, UtilsModule,
JwtModule, JwtModule,
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant]), MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant]),
BullModule.registerQueue({ name: NotificationQueueNameEnum.SMS }, { name: NotificationQueueNameEnum.PUSH }), BullModule.registerQueue(
{ name: NotificationQueueNameEnum.SMS },
{ name: NotificationQueueNameEnum.PUSH },
{ name: NotificationQueueNameEnum.IN_APP },
),
BullModule.forRootAsync({ BullModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@@ -46,6 +51,7 @@ import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard';
PushNotificationService, PushNotificationService,
PushProcessor, PushProcessor,
SmsProcessor, SmsProcessor,
InAppProcessor,
NotificationsGateway, NotificationsGateway,
WsAdminAuthGuard, WsAdminAuthGuard,
], ],
@@ -0,0 +1,50 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification } from '../entities/notification.entity';
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { NotificationQueueNameEnum } from '../constants/queue';
import { NotificationsGateway } from '../notifications.gateway';
@Processor(NotificationQueueNameEnum.IN_APP)
export class InAppProcessor extends WorkerHost {
private readonly logger = new Logger(InAppProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly em: EntityManager,
private readonly notificationsGateway: NotificationsGateway,
) {
super();
}
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
const { recipient, subject, body, notificationId } = job.data;
this.logger.log(`Processing InApp notification - Recipient: ${JSON.stringify(recipient)}, subject: ${subject}`);
try {
this.notificationsGateway.sendInAppNotification(
{ adminId: recipient.adminId, restaurantId: recipient.restaurantId },
{ subject, body, notificationId },
);
this.logger.log(`InApp notification sent successfully to ${recipient.adminId}`);
} catch (error) {
this.logger.error(`Error processing InApp notification job ${job.id}:`, error);
throw error;
}
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Notification job ${job.id} completed`);
}
@OnWorkerEvent('failed')
onFailed(job: Job, error: Error) {
this.logger.error(`Notification job ${job.id} failed:`, error);
}
}
@@ -2,7 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { NotificationQueueNameEnum } from '../constants/queue'; import { NotificationQueueNameEnum } from '../constants/queue';
import { PushNotificationQueueJob, SmsNotificationQueueJob } from '../interfaces/jobs-queue.interface'; import {
InAppNotificationQueueJob,
PushNotificationQueueJob,
SmsNotificationQueueJob,
} from '../interfaces/jobs-queue.interface';
@Injectable() @Injectable()
export class NotificationQueueService { export class NotificationQueueService {
@@ -11,6 +15,7 @@ export class NotificationQueueService {
constructor( constructor(
@InjectQueue(NotificationQueueNameEnum.SMS) private readonly smsQueue: Queue, @InjectQueue(NotificationQueueNameEnum.SMS) private readonly smsQueue: Queue,
@InjectQueue(NotificationQueueNameEnum.PUSH) private readonly pushQueue: Queue, @InjectQueue(NotificationQueueNameEnum.PUSH) private readonly pushQueue: Queue,
@InjectQueue(NotificationQueueNameEnum.IN_APP) private readonly inAppQueue: Queue,
) {} ) {}
async addSmsNotification(job: SmsNotificationQueueJob): Promise<void> { async addSmsNotification(job: SmsNotificationQueueJob): Promise<void> {
@@ -112,4 +117,26 @@ export class NotificationQueueService {
throw error; throw error;
} }
} }
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
try {
const queueJobs = jobs.map(job => ({
name: 'in-app-notification',
data: job,
opts: {
jobId: `${job.subject}-${job.notificationId}-${Date.now()}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
}));
await this.inAppQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} InApp notification jobs to queue`);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
throw error;
}
}
} }
@@ -41,18 +41,14 @@ export class NotificationService {
// send in app notification // send in app notification
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) { if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
notifications.forEach(notif => { await this.queueService.addBulkInAppNotifications(
if (notif.admin) { notifications.map(notification => ({
this.notificationGateway.sendInAppNotification( recipient: { adminId: notification.admin?.id || '', restaurantId },
{ adminId: notif.admin.id, restaurantId }, subject: message.title,
{ body: message.content,
notificationId: notif.id, notificationId: notification.id,
subject: message.title, })),
body: message.content, );
},
);
}
});
} }
// add sms notifications to queue // add sms notifications to queue
if (preference?.channels?.includes(NotifChannelEnum.SMS)) { if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
@@ -48,7 +48,7 @@ export class PagerListeners {
recipients, recipients,
metadata: { metadata: {
priority: 1, priority: 1,
}, },
}); });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(