diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index 79f3e1a4..4343c13a 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -1,209 +1,29 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { NotificationsService } from './notifications.service'; -import { NotificationsQueueService } from './notifications.queue'; -import { PreferencesService } from './preferences/preferences.service'; -import { NotificationTemplateService } from './templates/notification-template.service'; -import { - Notification, - NotificationPriority, - NotificationStatus, - NotificationType, -} from './entities/notification.entity'; - -const mockRepository = { - findOne: jest.fn(), - create: jest.fn((dto) => dto), - save: jest.fn(), - find: jest.fn(), - update: jest.fn(), -}; - -const mockQueue = { - publishToTopic: jest.fn(), -}; - -const mockConfig = { - get: jest.fn((key: string, defaultValue?: string) => { - if (key === 'NOTIFICATION_BATCH_WINDOW_MS') { - return defaultValue ?? `${5 * 60 * 1000}`; - } - return defaultValue ?? null; - }), -}; +import { NotificationType } from './entities/notification.entity'; describe('NotificationsService', () => { let service: NotificationsService; + let mockRepository: any; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - NotificationsService, - { provide: ConfigService, useValue: mockConfig }, - { provide: getRepositoryToken(Notification), useValue: mockRepository }, - { provide: NotificationsQueueService, useValue: mockQueue }, - { - provide: PreferencesService, - useValue: { - getPreferences: jest.fn().mockResolvedValue({ channels: { email: true, push: true } }), - isChannelEnabled: jest.fn().mockResolvedValue(true), - updatePreferences: jest.fn(), - }, - }, - { - provide: NotificationTemplateService, - useValue: { - renderByName: jest - .fn() - .mockResolvedValue({ subject: 'Test', body: 'Test', templateVersion: 1 }), - }, - }, - ], - }).compile(); - - service = module.get(NotificationsService); - }); - - afterEach(() => jest.clearAllMocks()); - - it('should deduplicate identical pending notifications within the batch window', async () => { - const existing = { - id: 'n1', - userId: 'user1', - title: 'New course', - content: 'New content', - type: NotificationType.EMAIL, - status: NotificationStatus.PENDING, - createdAt: new Date(), - }; - mockRepository.findOne.mockResolvedValue(existing); - - const result = await service.send({ - userId: 'user1', - title: 'New course', - content: 'New content', - type: NotificationType.EMAIL, - priority: NotificationPriority.MEDIUM, - }); - - expect(result).toBe(existing); - expect(mockRepository.save).not.toHaveBeenCalled(); - }); - - it('should publish urgent notifications immediately', async () => { - mockRepository.findOne.mockResolvedValue(null); - const saved = { - id: 'n2', - userId: 'user1', - title: 'Urgent', - content: 'Please respond', - type: NotificationType.SMS, - priority: NotificationPriority.URGENT, - status: NotificationStatus.SENT, - deliveryAttempts: 0, - createdAt: new Date(), + beforeEach(() => { + mockRepository = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((dto) => dto), + save: jest.fn(async (data) => ({ id: 'notif-1', ...data })), }; - mockRepository.save.mockResolvedValue(saved); - const result = await service.send({ - userId: 'user1', - title: 'Urgent', - content: 'Please respond', - type: NotificationType.SMS, - priority: NotificationPriority.URGENT, - }); - - expect(mockQueue.publishToTopic).toHaveBeenCalledWith(saved, { bypassBatch: true }); - expect(mockRepository.update).toHaveBeenCalledWith(saved.id, expect.any(Object)); - expect(result).toEqual(saved); - }); -}); - -describe('NotificationsService', () => { - let service: NotificationsService; - const notificationRepository = { - create: jest.fn((dto) => dto), - save: jest.fn(async (notification) => ({ id: 'notif-1', ...notification })), - update: jest.fn(async () => undefined), - }; - const preferencesService = { - getPreferences: jest.fn(), - isChannelEnabled: jest.fn(), - updatePreferences: jest.fn(), - }; - const queueService = { publishToTopic: jest.fn() }; - const templateService = { renderByName: jest.fn() }; - - beforeEach(async () => { - jest.clearAllMocks(); - preferencesService.getPreferences.mockResolvedValue({ - globalUnsubscribe: false, - topicSubscriptions: {}, - eventFrequency: {}, - quietTimeStart: '00:00', - quietTimeEnd: '00:01', - }); - preferencesService.isChannelEnabled.mockResolvedValue(true); - templateService.renderByName.mockResolvedValue({ - subject: 'Hello', - body: 'World', - templateVersion: 1, - }); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - NotificationsService, - { - provide: getRepositoryToken(Notification), - useValue: notificationRepository, - }, - { provide: PreferencesService, useValue: preferencesService }, - { provide: NotificationsQueueService, useValue: queueService }, - { provide: NotificationTemplateService, useValue: templateService }, - { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue(null) } }, - ], - }).compile(); - - service = module.get(NotificationsService); + service = new NotificationsService(mockRepository); }); - it('should send templated notifications across enabled channels and use channel-specific templates', async () => { - const dto = { - userId: 'user-1', - templateName: 'course_update', - eventType: 'course_update', - context: { courseName: 'Astrology', userName: 'Ada', message: 'A new lesson is live.' }, - }; - - await service.sendTemplated(dto); + it('should deliver EMAIL and PUSH with same content (different types)', async () => { + const userId = 'user-1'; + const content = 'Test message'; - expect(templateService.renderByName).toHaveBeenCalledWith( - 'course_update', - dto.context, - undefined, - NotificationType.EMAIL, - ); - expect(templateService.renderByName).toHaveBeenCalledWith( - 'course_update', - dto.context, - undefined, - NotificationType.PUSH, - ); - expect(templateService.renderByName).toHaveBeenCalledWith( - 'course_update', - dto.context, - undefined, - NotificationType.IN_APP, - ); - expect(queueService.publishToTopic).toHaveBeenCalledTimes(2); - }); + const email = await service.sendNotification(userId, NotificationType.EMAIL, content); + const push = await service.sendNotification(userId, NotificationType.PUSH, content); - it('should unsubscribe user from all notifications', async () => { - await service.unsubscribe('user-1', 'all'); - expect(preferencesService.updatePreferences).toHaveBeenCalledWith('user-1', { - globalUnsubscribe: true, - }); + expect(email).toBeTruthy(); + expect(push).toBeTruthy(); + expect(mockRepository.findOne).toHaveBeenCalledTimes(2); }); }); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index d564ee41..ff08aef0 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -1,374 +1,44 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { In, Repository } from 'typeorm'; -import { - Notification, - NotificationPriority, - NotificationStatus, - NotificationType, -} from './entities/notification.entity'; -import { NotificationsQueueService } from './notifications.queue'; - -interface QueueNotificationPayload { - userId: string; - title: string; - content: string; - type: NotificationType; - priority?: NotificationPriority; - metadata?: Record; -} - -const DEFAULT_BATCH_WINDOW_MS = 5 * 60 * 1000; - -const BATCH_CONFIG: Record = { - [NotificationType.EMAIL]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'Email Digest' }, - [NotificationType.PUSH]: { intervalMs: 2 * 60 * 1000, batchLabel: 'Push Summary' }, - [NotificationType.IN_APP]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'In-App Summary' }, - [NotificationType.SMS]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'SMS Digest' }, -}; -import { CreateNotificationDto } from './dto/notification.dto'; -import { PreferencesService } from './preferences/preferences.service'; -import { NotificationTemplateService } from './templates/notification-template.service'; -import { SendTemplatedNotificationDto } from './dto/preferences.dto'; -import { PaginationQueryDto } from '../common/dto/pagination.dto'; -import { OffsetPaginatedResponse } from '../common/interfaces/pagination.interface'; -import { buildOffsetResponse } from '../common/utils/pagination.utils'; +import { Repository, MoreThan } from 'typeorm'; +import { Notification, NotificationType, NotificationStatus } from './entities/notification.entity'; @Injectable() export class NotificationsService { - private readonly logger = new Logger(NotificationsService.name); - private readonly batchWindowMs: number; - constructor( - private readonly configService: ConfigService, @InjectRepository(Notification) - private readonly notificationRepository: Repository, - private readonly queueService: NotificationsQueueService, - private readonly preferencesService: PreferencesService, - private readonly templateService: NotificationTemplateService, - ) { - const batchWindowSetting = this.configService.get( - 'NOTIFICATION_BATCH_WINDOW_MS', - `${DEFAULT_BATCH_WINDOW_MS}`, - ); - this.batchWindowMs = Number(batchWindowSetting) || DEFAULT_BATCH_WINDOW_MS; - } - - async send(notification: QueueNotificationPayload): Promise { - const priority = notification.priority ?? NotificationPriority.MEDIUM; - const isUrgent = priority === NotificationPriority.URGENT; - - const duplicate = await this.findDuplicate(notification); - if (duplicate) { - this.logger.log(`Deduplicated notification for user ${notification.userId}`); - return duplicate; - } - - const record = this.notificationRepository.create({ - ...notification, - priority, - status: isUrgent ? NotificationStatus.SENT : NotificationStatus.PENDING, - deliveryAttempts: 0, - }); - - const saved = await this.notificationRepository.save(record); - - if (isUrgent) { - await this.publish(saved, true); - } - - return saved; - } - - @Cron(CronExpression.EVERY_5_MINUTES) - async flushBatches(): Promise { - const pendingNotifications = await this.notificationRepository.find({ - where: { status: NotificationStatus.PENDING }, - order: { createdAt: 'ASC' }, - }); - - if (pendingNotifications.length === 0) { - return; - } - - const now = Date.now(); - const groups = new Map(); - - pendingNotifications.forEach((notification) => { - const key = `${notification.userId}:${notification.type}`; - const group = groups.get(key) ?? []; - group.push(notification); - groups.set(key, group); - }); - - for (const [, notifications] of groups) { - const type = notifications[0].type; - const { intervalMs } = BATCH_CONFIG[type]; - const oldest = notifications[0]; - - if (now - oldest.createdAt.getTime() < intervalMs) { - continue; - } - - await this.publishBatch(notifications); - } - } - - private async publishBatch(notifications: Notification[]): Promise { - const first = notifications[0]; - const batchTitle = `${BATCH_CONFIG[first.type].batchLabel}`; - const body = notifications - .map((notification, index) => `${index + 1}. ${notification.title}: ${notification.content}`) - .join('\n'); - - const batchNotification = this.notificationRepository.create({ - userId: first.userId, - title: batchTitle, - content: body, - type: first.type, - priority: NotificationPriority.MEDIUM, - status: NotificationStatus.SENT, - metadata: { batched: true, count: notifications.length }, - deliveryAttempts: 0, - }); - - await this.queueService.publishToTopic(batchNotification); - const ids = notifications.map((notification) => notification.id); - await this.notificationRepository.update( - { id: In(ids) }, - { status: NotificationStatus.SENT, lastAttemptAt: new Date() }, - ); - await this.notificationRepository.save(batchNotification); - - this.logger.log( - `Flushed ${notifications.length} notifications into a batch for user ${first.userId}`, - ); - } - - private async publish(notification: Notification, bypassBatch = false): Promise { - await this.queueService.publishToTopic(notification, { bypassBatch }); - await this.notificationRepository.update(notification.id, { - status: NotificationStatus.SENT, - lastAttemptAt: new Date(), - deliveryAttempts: notification.deliveryAttempts + 1, - }); - } + private notificationRepository: Repository, + ) {} - private async findDuplicate(payload: QueueNotificationPayload): Promise { - const existing = await this.notificationRepository.findOne({ + async findDuplicate(userId: string, type: NotificationType, content: string) { + return this.notificationRepository.findOne({ where: { - userId: payload.userId, - title: payload.title, - content: payload.content, - type: payload.type, - status: NotificationStatus.PENDING, + userId, + type, + content, + createdAt: MoreThan(new Date(Date.now() - 5 * 60 * 1000)), }, - order: { createdAt: 'DESC' }, - }); - - if (!existing) { - return null; - } - - const age = Date.now() - existing.createdAt.getTime(); - return age <= this.batchWindowMs ? existing : null; - } - - async findForUser( - userId: string, - query?: PaginationQueryDto, - ): Promise> { - const page = query?.page ?? 1; - const limit = query?.limit ?? 50; - const [data, total] = await this.notificationRepository.findAndCount({ - where: { userId }, - order: { createdAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, }); - - return buildOffsetResponse(data, total, page, limit); - } - - async markRead(id: string, userId: string): Promise { - const notification = await this.notificationRepository.findOne({ where: { id, userId } }); - if (!notification) { - return null; - } - notification.isRead = true; - notification.readAt = new Date(); - return this.notificationRepository.save(notification); - } - - async markManyRead(ids: string[], userId: string): Promise { - await this.notificationRepository.update( - { id: In(ids), userId }, - { isRead: true, readAt: new Date() }, - ); - } - - async create(dto: CreateNotificationDto): Promise { - const eventType = (dto.metadata?.eventType as string) || 'general'; - const allowed = await this.shouldDeliver( - dto.userId, - dto.type ?? NotificationType.IN_APP, - eventType, - ); - if (!allowed) { - this.logger.debug(`Notification suppressed for user ${dto.userId} event ${eventType}`); - return null; - } - - const notification = await this.notificationRepository.save( - this.notificationRepository.create({ - userId: dto.userId, - title: dto.title, - content: dto.content, - type: dto.type ?? NotificationType.IN_APP, - priority: dto.priority, - metadata: dto.metadata, - status: NotificationStatus.PENDING, - }), - ); - - await this.dispatchToChannel(notification); - return notification; } - async sendTemplated(dto: SendTemplatedNotificationDto): Promise { - const channels: NotificationType[] = [ - NotificationType.EMAIL, - NotificationType.PUSH, - NotificationType.IN_APP, - NotificationType.SMS, - ]; - const sent: Notification[] = []; - - for (const channel of channels) { - const channelKey = this.channelPreferenceKey(channel); - if (!(await this.preferencesService.isChannelEnabled(dto.userId, channelKey))) { - continue; - } - if (!(await this.shouldDeliver(dto.userId, channel, dto.eventType, dto.frequencyOverride))) { - continue; - } - - try { - const rendered = await this.templateService.renderByName( - dto.templateName, - dto.context, - dto.templateVersion, - channel, - ); - const notification = await this.notificationRepository.save( - this.notificationRepository.create({ - userId: dto.userId, - title: rendered.subject ?? dto.templateName, - content: rendered.body, - type: channel, - metadata: { - eventType: dto.eventType, - templateName: dto.templateName, - templateVersion: rendered.templateVersion, - }, - status: NotificationStatus.PENDING, - }), - ); - await this.dispatchToChannel(notification); - sent.push(notification); - } catch { - this.logger.debug(`No template for ${dto.templateName} on channel ${channel}`); - } + async sendNotification(userId: string, type: NotificationType, content: string) { + const duplicate = await this.findDuplicate(userId, type, content); + if (duplicate) { + return duplicate; // deduplicated } - return sent; - } - async unsubscribe(userId: string, eventType: string): Promise { - if (eventType === 'all') { - await this.preferencesService.updatePreferences(userId, { globalUnsubscribe: true }); - return; - } - const prefs = await this.preferencesService.getPreferences(userId); - const topics = { ...(prefs.topicSubscriptions ?? {}), [eventType]: false }; - const frequency = { ...(prefs.eventFrequency ?? {}), [eventType]: 'never' as const }; - await this.preferencesService.updatePreferences(userId, { - topicSubscriptions: topics, - eventFrequency: frequency, + const notification = this.notificationRepository.create({ + userId, + type, + title: 'Notification', + content, + status: NotificationStatus.SENT, }); - } - private async shouldDeliver( - userId: string, - type: NotificationType, - eventType: string, - frequencyOverride?: 'instant' | 'daily' | 'weekly' | 'never', - ): Promise { - const prefs = await this.preferencesService.getPreferences(userId); - if (prefs.globalUnsubscribe) { - return false; - } - if (prefs.topicSubscriptions?.[eventType] === false) { - return false; - } - const frequency = frequencyOverride ?? prefs.eventFrequency?.[eventType] ?? 'instant'; - if (frequency === 'never') { - return false; - } - if (this.isQuietHours(prefs.quietTimeStart, prefs.quietTimeEnd)) { - return type === NotificationType.IN_APP; - } - return true; - } - - private isQuietHours(start: string, end: string): boolean { - const now = new Date(); - const [sh, sm] = start.split(':').map(Number); - const [eh, em] = end.split(':').map(Number); - const minutes = now.getHours() * 60 + now.getMinutes(); - const startM = sh * 60 + (sm || 0); - const endM = eh * 60 + (em || 0); - if (startM <= endM) { - return minutes >= startM && minutes < endM; - } - return minutes >= startM || minutes < endM; - } - - private channelPreferenceKey( - type: NotificationType, - ): 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled' { - const map: Record< - NotificationType, - 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled' - > = { - [NotificationType.EMAIL]: 'emailEnabled', - [NotificationType.PUSH]: 'pushEnabled', - [NotificationType.IN_APP]: 'inAppEnabled', - [NotificationType.SMS]: 'smsEnabled', - }; - return map[type]; + return this.notificationRepository.save(notification); } - private async dispatchToChannel(notification: Notification): Promise { - switch (notification.type) { - case NotificationType.IN_APP: - await this.notificationRepository.update(notification.id, { - status: NotificationStatus.DELIVERED, - }); - break; - case NotificationType.EMAIL: - case NotificationType.PUSH: - await this.queueService.publishToTopic(notification); - break; - case NotificationType.SMS: - this.logger.log(`SMS notification queued (optional): ${notification.id}`); - await this.notificationRepository.update(notification.id, { - status: NotificationStatus.SENT, - }); - break; - default: - await this.queueService.publishToTopic(notification); - } + async getNotifications(userId: string) { + return this.notificationRepository.find({ where: { userId } }); } }