diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index fa4d448c..f0634229 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -1,15 +1,21 @@ -import { Injectable, Optional } from '@nestjs/common'; +import { Injectable, Optional, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, MoreThan } from 'typeorm'; +import { Repository, MoreThan, In } from 'typeorm'; import { Notification, NotificationType, NotificationStatus } from './entities/notification.entity'; import { PaginationService } from '../common/services/pagination.service'; import { PaginationQueryDto } from '../common/dto/pagination.dto'; +import { CreateNotificationDto } from './dto/notification.dto'; +import { SendTemplatedNotificationDto } from './dto/preferences.dto'; +import { PreferencesService } from './preferences/preferences.service'; +import { NotificationTemplateService } from './templates/notification-template.service'; @Injectable() export class NotificationsService { constructor( @InjectRepository(Notification) private notificationRepository: Repository, + private readonly preferencesService: PreferencesService, + private readonly templateService: NotificationTemplateService, @Optional() private paginationService: PaginationService = new PaginationService(), ) {} @@ -46,19 +52,113 @@ export class NotificationsService { return this.notificationRepository.find({ where: { userId } }); } - // Stubs for other methods (to satisfy typecheck) - async send(_dto: any) { - return null; + async create(dto: CreateNotificationDto) { + const notification = 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, + }); + + return this.notificationRepository.save(notification); } - async sendTemplated(_dto: any) { - return []; + + async send(dto: CreateNotificationDto) { + const notification = await this.create(dto); + + const prefs = await this.preferencesService.getPreferences(dto.userId); + + if (prefs.globalUnsubscribe) { + return notification; + } + + const channels: { enabled: boolean; type: NotificationType }[] = [ + { enabled: prefs.inAppEnabled, type: NotificationType.IN_APP }, + { enabled: prefs.emailEnabled, type: NotificationType.EMAIL }, + { enabled: prefs.pushEnabled, type: NotificationType.PUSH }, + { enabled: prefs.smsEnabled, type: NotificationType.SMS }, + ]; + + const dispatches: Promise[] = []; + + for (const channel of channels) { + if (channel.enabled) { + const channelNotification = this.notificationRepository.create({ + userId: dto.userId, + title: dto.title, + content: dto.content, + type: channel.type, + priority: dto.priority, + metadata: dto.metadata, + status: NotificationStatus.SENT, + }); + dispatches.push(this.notificationRepository.save(channelNotification)); + } + } + + await Promise.all(dispatches); + + return notification; } - async unsubscribe(_userId: string, _eventType: string) { - return; + + async sendTemplated(dto: SendTemplatedNotificationDto) { + const prefs = await this.preferencesService.getPreferences(dto.userId); + + if (prefs.globalUnsubscribe) { + throw new BadRequestException('User has globally unsubscribed from notifications'); + } + + if ( + prefs.eventFrequency?.[dto.eventType] === 'never' + ) { + throw new BadRequestException( + `User has unsubscribed from event type "${dto.eventType}"`, + ); + } + + const rendered = await this.templateService.renderByName( + dto.templateName, + dto.context, + dto.templateVersion, + ); + + const channels: { enabled: boolean; type: NotificationType }[] = [ + { enabled: prefs.inAppEnabled, type: NotificationType.IN_APP }, + { enabled: prefs.emailEnabled, type: NotificationType.EMAIL }, + { enabled: prefs.pushEnabled, type: NotificationType.PUSH }, + { enabled: prefs.smsEnabled, type: NotificationType.SMS }, + ]; + + const saved: Notification[] = []; + + for (const channel of channels) { + if (channel.enabled) { + const notification = this.notificationRepository.create({ + userId: dto.userId, + title: rendered.subject ?? dto.templateName, + content: rendered.body, + type: channel.type, + status: NotificationStatus.SENT, + metadata: { + templateName: dto.templateName, + templateVersion: rendered.templateVersion, + eventType: dto.eventType, + }, + }); + saved.push(await this.notificationRepository.save(notification)); + } + } + + return saved; } + async findForUser(userId: string, query?: PaginationQueryDto) { const limit = query?.limit ?? 20; - const offset = query?.offset ?? (query?.cursor ? undefined : ((query?.page ?? 1) - 1) * limit); + const offset = + query?.offset ?? (query?.cursor ? undefined : ((query?.page ?? 1) - 1) * limit); const qb = this.notificationRepository .createQueryBuilder('notification') @@ -66,13 +166,55 @@ export class NotificationsService { return this.paginationService.paginate(qb, query?.cursor, limit, offset, 'createdAt'); } - async create(_dto: any) { - return null; + + async markRead(id: string, userId: string) { + const notification = await this.notificationRepository.findOne({ + where: { id }, + }); + + if (!notification) { + throw new NotFoundException(`Notification ${id} not found`); + } + + if (notification.userId !== userId) { + throw new BadRequestException('You do not own this notification'); + } + + if (notification.isRead) { + return notification; + } + + notification.isRead = true; + notification.readAt = new Date(); + + return this.notificationRepository.save(notification); } - async markRead(_id: string, _userId: string) { - return null; + + async markManyRead(ids: string[], userId: string) { + if (!ids.length) { + return; + } + + const notifications = await this.notificationRepository.find({ + where: { id: In(ids) }, + }); + + if (notifications.length !== ids.length) { + throw new NotFoundException('One or more notifications not found'); + } + + const owned = notifications.filter((n) => n.userId === userId); + if (owned.length !== ids.length) { + throw new BadRequestException('You do not own one or more of these notifications'); + } + + await this.notificationRepository.update( + { id: In(ids), userId }, + { isRead: true, readAt: new Date() }, + ); } - async markManyRead(_ids: string[], _userId: string) { - return; + + async unsubscribe(userId: string, eventType: string) { + return this.preferencesService.unsubscribe(userId, eventType); } } diff --git a/src/notifications/preferences/preferences.service.ts b/src/notifications/preferences/preferences.service.ts index 4b466b3c..00da3dd6 100644 --- a/src/notifications/preferences/preferences.service.ts +++ b/src/notifications/preferences/preferences.service.ts @@ -57,4 +57,27 @@ export class PreferencesService { preferences[channel] = !preferences[channel]; return this.preferencesRepository.save(preferences); } + + /** + * Unsubscribe user from a specific event type or all notifications. + * If eventType is 'all', sets globalUnsubscribe to true. + * Otherwise, sets the event frequency to 'never' for that event type. + */ + async unsubscribe( + userId: string, + eventType: string, + ): Promise { + const preferences = await this.getPreferences(userId); + + if (eventType === 'all') { + preferences.globalUnsubscribe = true; + } else { + if (!preferences.eventFrequency) { + preferences.eventFrequency = {}; + } + preferences.eventFrequency[eventType] = 'never'; + } + + return this.preferencesRepository.save(preferences); + } }