import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { PrismaService } from '../../common/prisma.service'; import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto'; import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); private readonly channels: Map; constructor( private prisma: PrismaService, private emailClient: EmailClientService, private smsClient: SmsClientService, private pushAdapter: PushAdapter, ) { this.channels = new Map([ ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }], ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], ['PUSH', this.pushAdapter as NotificationChannel], ]); } /** * Send notification using template key and context * @param templateKey - Template code from NotificationTemplate table * @param recipient - User/Passenger ID or email/phone * @param context - Variables to interpolate in template * @param channels - Optional array of channels to use (defaults to user preferences) */ async send( templateKey: string, recipient: string, context: Record, channels?: NotificationChannelType[], ): Promise<{ sent: boolean; channels: string[] }> { const template = await this.prisma.notificationTemplate.findUnique({ where: { code: templateKey }, }); if (!template || !template.active) { this.logger.warn(`Template ${templateKey} not found or inactive`); return { sent: false, channels: [] }; } const { subject, body } = this.interpolate(template, context); const targetChannels = channels || await this.getUserPreferredChannels(recipient); const sentChannels: string[] = []; // Always create in-app notification if (targetChannels.includes('IN_APP')) { await this.createInAppNotification(recipient, subject, body, context); sentChannels.push('IN_APP'); } // Send via other channels for (const channelType of targetChannels) { if (channelType === 'IN_APP') continue; const adapter = this.channels.get(channelType); if (!adapter) { this.logger.warn(`No adapter for channel: ${channelType}`); continue; } const recipientAddress = await this.getRecipientAddress(recipient, channelType); if (!recipientAddress) { this.logger.warn(`No ${channelType} address for recipient: ${recipient}`); continue; } const success = await adapter.send(recipientAddress, subject, body, context); if (success) { sentChannels.push(channelType); } } return { sent: sentChannels.length > 0, channels: sentChannels }; } /** * Legacy method for backward compatibility */ async sendDirect(dto: SendNotificationDto) { const notification = await this.prisma.notification.create({ data: { passengerId: dto.passengerId, title: dto.title, body: dto.body, category: dto.category as any, deepLink: dto.deepLink, metadata: dto.metadata, }, }); const passenger = await this.prisma.passenger.findUnique({ where: { id: dto.passengerId }, include: { user: true }, }); if (passenger?.user) { await this.emailClient.sendEmail({ to: passenger.user.email, subject: this.sanitize(dto.title), text: this.sanitize(dto.body), }); } return notification; } private async createInAppNotification( recipient: string, title: string, body: string, context: Record, ): Promise { // Try to find passenger by ID or email let passengerId = recipient; if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) { const user = await this.prisma.user.findFirst({ where: { OR: [{ email: recipient }, { phone: recipient }], }, include: { passenger: true }, }); if (user?.passenger) { passengerId = user.passenger.id; } else { this.logger.warn(`Could not find passenger for recipient: ${recipient}`); return; } } await this.prisma.notification.create({ data: { passengerId, title, body, category: (context.category as any) || 'SYSTEM', deepLink: context.deepLink as string, metadata: context as any, }, }); } private interpolate( template: { subject?: string | null; bodyTemplate: string }, context: Record, ): { subject: string; body: string } { const subject = template.subject || 'Notification'; let body = template.bodyTemplate; // Simple template interpolation: {{variable}} for (const [key, value] of Object.entries(context)) { const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); body = body.replace(regex, String(value)); } return { subject, body }; } private async getUserPreferredChannels(recipient: string): Promise { const user = await this.prisma.user.findFirst({ where: { OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], }, include: { preferences: true }, }); if (!user?.preferences) { return ['IN_APP', 'EMAIL']; } const channels: NotificationChannelType[] = ['IN_APP']; if (user.preferences.emailEnabled) channels.push('EMAIL'); if (user.preferences.smsEnabled) channels.push('SMS'); if (user.preferences.pushEnabled) channels.push('PUSH'); return channels; } private async getRecipientAddress( recipient: string, channel: NotificationChannelType, ): Promise { const user = await this.prisma.user.findFirst({ where: { OR: [{ id: recipient }, { email: recipient }, { phone: recipient }], }, }); if (!user) return null; switch (channel) { case 'EMAIL': return user.email; case 'SMS': return user.phone; case 'PUSH': // Would need to fetch device push token return user.id; default: return null; } } private sanitize(value: string): string { return value .replace(/[\r\n]/g, ' ') .replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c)); } getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, orderBy: { createdAt: 'desc' }, take: 50, }); } markRead(id: string) { return this.prisma.notification.update({ where: { id }, data: { read: true } }); } async markAllRead(passengerId: string) { await this.prisma.notification.updateMany({ where: { passengerId, read: false }, data: { read: true }, }); return { updated: true }; } @OnEvent('booking.created') async onBookingCreated(payload: any) { await this.send( 'booking.created', payload.booking.passengerId, { bookingRef: payload.booking.bookingRef, category: 'BOOKING', deepLink: `edr://bookings/${payload.booking.bookingRef}`, }, ); } @OnEvent('payment.succeeded') async onPaymentSucceeded(payload: any) { await this.send( 'payment.succeeded', payload.booking.passengerId, { bookingRef: payload.booking.bookingRef, category: 'PAYMENT', deepLink: `edr://tickets/${payload.booking.bookingRef}`, }, ); } }