import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { EntityManager, FilterQuery } from '@mikro-orm/postgresql'; import { Notification } from '../entities/notification.entity'; import { NotificationPreferenceService } from './notification-preference.service'; import { NotificationQueueService } from './notification-queue.service'; import { NotificationsGateway } from '../notifications.gateway'; import { NotifChannelEnum, NotifRequest, NotifTitleEnum } from '../interfaces/notification.interface'; import { SmsLog } from '../entities/smsLogs.entity'; import { SmsLogRepository } from '../repositories/sms-log.repository'; import { PaginatedResult } from '../../../common/interfaces/pagination.interface'; @Injectable() export class NotificationService { private readonly logger = new Logger(NotificationService.name); constructor( private readonly em: EntityManager, private readonly preferenceService: NotificationPreferenceService, private readonly queueService: NotificationQueueService, private readonly notificationGateway: NotificationsGateway, private readonly smsLogRepository: SmsLogRepository, ) { } async sendNotification(params: NotifRequest): Promise { const { recipients, message, metadata, restaurantId } = params; // create Database notifications const notifications = await this.createAdminBulkNotifications( recipients.map(recipient => ({ restaurantId, title: message.title, content: message.content, adminId: 'adminId' in recipient ? recipient.adminId : null, userId: 'userId' in recipient ? recipient.userId : null, })), ); // get admin prefrences const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title); if (preference?.channels?.length === 0) { this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`); return notifications; } // send in app notification if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) { await this.queueService.addBulkInAppNotifications( notifications.map(notification => ({ recipient: { adminId: notification.admin?.id || '', restaurantId }, subject: message.title, body: message.content, notificationId: notification.id, })), ); } // add sms notifications to queue if (preference?.channels?.includes(NotifChannelEnum.SMS)) { await this.queueService.addBulkSmsNotifications( recipients.map(recipient => ({ recipient, templateId: message.sms.templateId, parameters: message.sms.parameters, restaurantId, })), ); } this.logger.log(`Queued notification for restaurant ${restaurantId}, title ${message.title}`); return notifications; } async createAdminBulkNotifications( params: { restaurantId: string; title: NotifTitleEnum; content: string; adminId: string | null; userId: string | null; }[], ): Promise { const notifications = params.map(param => { return this.em.create(Notification, { restaurant: param.restaurantId, admin: param.adminId, user: param.userId && param.userId.trim() !== '' ? param.userId : null, title: param.title, content: param.content, }); }); await this.em.persistAndFlush(notifications); return notifications; } async findOne(id: string): Promise { 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, adminId: string, limit = 50, cursor?: string, status?: 'seen' | 'unseen', ): Promise<{ data: Notification[]; nextCursor: string | null }> { const where: FilterQuery = { restaurant: { id: restaurantId }, admin: { id: adminId }, }; // Filter by status (seen/unseen) if (status === 'seen') { where.seenAt = { $ne: null }; } else if (status === 'unseen') { where.seenAt = null; } // Cursor-based pagination: if cursor is provided, get items with id < cursor if (cursor) { where.id = { $lt: cursor }; } const notifications = await this.em.find(Notification, where, { orderBy: { createdAt: 'DESC', id: 'DESC' }, limit: limit + 1, // fetch one extra to determine next page populate: ['user'], }); const hasNextPage = notifications.length > limit; const data = hasNextPage ? notifications.slice(0, limit) : notifications; const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null; return { data, nextCursor: nextCursor ?? null, }; } async findByUserAndRestaurant( userId: string, restaurantId: string, limit = 50, cursor?: string, status?: 'seen' | 'unseen', ): Promise<{ data: Notification[]; nextCursor: string | null }> { const where: FilterQuery = { user: { id: userId }, restaurant: { id: restaurantId }, }; // Filter by status (seen/unseen) if (status === 'seen') { where.seenAt = { $ne: null }; } else if (status === 'unseen') { where.seenAt = null; } // Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered) if (cursor) { where.id = { $lt: cursor }; } const notifications = await this.em.find(Notification, where, { orderBy: { createdAt: 'DESC', id: 'DESC' }, limit: limit + 1, // Fetch one extra to determine if there's a next page }); // Check if there's a next page const hasNextPage = notifications.length > limit; const data = hasNextPage ? notifications.slice(0, limit) : notifications; const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null; return { data, nextCursor: nextCursor ?? null, }; } async readNotificationAdmin(id: string, adminId: string, restaurantId: string): Promise { const notification = await this.em.findOne(Notification, { id, admin: { id: adminId }, restaurant: { id: restaurantId }, }); if (!notification) { throw new NotFoundException('Notification not found'); } notification.seenAt = new Date(); await this.em.persistAndFlush(notification); } async readNotificationAsUser(id: string, userId: string, restaurantId: string): Promise { const notification = await this.em.findOne(Notification, { id, user: { id: userId }, restaurant: { id: restaurantId }, }); if (!notification) { throw new NotFoundException('Notification not found'); } notification.seenAt = new Date(); await this.em.persistAndFlush(notification); } async findByRestaurantAndType(restaurantId: string, title: NotifTitleEnum, limit = 50): Promise { return this.em.find( Notification, { restaurant: { id: restaurantId }, title, }, { orderBy: { createdAt: 'DESC' }, limit, populate: ['user'], }, ); } async findByAdminAndRestaurant(adminId: string, restaurantId: string, limit = 50): Promise { return this.em.find( Notification, { admin: { id: adminId }, restaurant: { id: restaurantId } }, { orderBy: { createdAt: 'DESC' }, limit, }, ); } async countUnseenByUserAndRestaurant(userId: string, restaurantId: string): Promise { const where: FilterQuery = { user: { id: userId }, restaurant: { id: restaurantId }, seenAt: null, }; return this.em.count(Notification, where); } async countUnseenByRestaurant(adminId: string, restaurantId: string): Promise { const where: FilterQuery = { admin: { id: adminId }, restaurant: { id: restaurantId }, seenAt: null, }; return this.em.count(Notification, where); } readAllNotifsAsUser(userId: string, restaurantId: string): Promise { const where: FilterQuery = { user: { id: userId }, restaurant: { id: restaurantId }, seenAt: null, }; return this.em.nativeUpdate(Notification, where, { seenAt: new Date() }); } readAllNotifsAsAdmin(adminId: string, restaurantId: string): Promise { const where: FilterQuery = { admin: { id: adminId }, restaurant: { id: restaurantId }, seenAt: null, }; return this.em.nativeUpdate(Notification, where, { seenAt: new Date() }); } async getSmsCountByRestaurant( page: number = 1, limit: number = 10, ): Promise> { return this.smsLogRepository.getSmsCountByRestaurant(page, limit); } async getSmsCountByRestaurantId(restaurantId: string): Promise { return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId); } }