import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OnEvent } from '@nestjs/event-emitter'; import { Prisma } from '@prisma/client'; import * as admin from 'firebase-admin'; import * as fs from 'fs'; import * as path from 'path'; import { PrismaService } from '../prisma/prisma.service'; interface FcmToken { token: string; device: string; createdAt: string; } interface NotificationPayload { title: string; body: string; data?: Record; } @Injectable() export class NotificationsService implements OnModuleInit { private readonly logger = new Logger(NotificationsService.name); private firebaseInitialized = false; constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, ) {} onModuleInit() { try { if (admin.apps.length > 0) { this.firebaseInitialized = true; return; } // Try loading from service account JSON file first const serviceAccountPath = this.configService.get( 'firebase.serviceAccountKeyPath', ); if (serviceAccountPath) { const resolvedPath = path.isAbsolute(serviceAccountPath) ? serviceAccountPath : path.resolve(process.cwd(), serviceAccountPath); if (fs.existsSync(resolvedPath)) { const serviceAccount = JSON.parse( fs.readFileSync(resolvedPath, 'utf8'), ); admin.initializeApp({ credential: admin.credential.cert(serviceAccount), }); this.firebaseInitialized = true; this.logger.log( 'Firebase Admin initialized from service account file', ); return; } } // Fallback to individual env vars const projectId = this.configService.get('firebase.projectId'); const clientEmail = this.configService.get('firebase.clientEmail'); const privateKey = this.configService.get('firebase.privateKey'); if (!projectId || !clientEmail || !privateKey) { this.logger.warn( 'Firebase credentials not configured. Push notifications disabled.', ); return; } admin.initializeApp({ credential: admin.credential.cert({ projectId, clientEmail, privateKey, }), }); this.firebaseInitialized = true; this.logger.log('Firebase Admin initialized for push notifications'); } catch (error) { this.logger.error( `Failed to initialize Firebase Admin: ${error.message}`, ); } } // ========================================== // FCM TOKEN MANAGEMENT // ========================================== private parseFcmTokens(raw: Prisma.JsonValue | null | undefined): FcmToken[] { if (!raw || !Array.isArray(raw)) return []; return raw as unknown as FcmToken[]; } async registerToken( userId: string, token: string, device: string = 'web', ): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { fcmTokens: true }, }); const existingTokens = this.parseFcmTokens(user?.fcmTokens); const filtered = existingTokens.filter((t) => t.token !== token); filtered.push({ token, device, createdAt: new Date().toISOString(), }); await this.prisma.user.update({ where: { id: userId }, data: { fcmTokens: filtered as unknown as Prisma.InputJsonValue }, }); this.logger.log(`FCM token registered for user ${userId} (${device})`); } async removeToken(userId: string, token: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { fcmTokens: true }, }); const existingTokens = this.parseFcmTokens(user?.fcmTokens); const filtered = existingTokens.filter((t) => t.token !== token); await this.prisma.user.update({ where: { id: userId }, data: { fcmTokens: filtered.length > 0 ? (filtered as unknown as Prisma.InputJsonValue) : Prisma.JsonNull, }, }); this.logger.log(`FCM token removed for user ${userId}`); } // ========================================== // IN-APP NOTIFICATION CRUD // ========================================== async getNotifications( userId: string, filter: 'all' | 'unread' = 'all', page = 1, limit = 20, ) { const where: Prisma.NotificationWhereInput = { userId }; if (filter === 'unread') { where.read = false; } const [notifications, total] = await Promise.all([ this.prisma.notification.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), this.prisma.notification.count({ where }), ]); const unreadCount = await this.prisma.notification.count({ where: { userId, read: false }, }); return { notifications, unreadCount, pagination: { page, limit, total, pages: Math.ceil(total / limit), }, }; } async getUnreadCount(userId: string): Promise { return this.prisma.notification.count({ where: { userId, read: false }, }); } async markAsRead(userId: string, notificationId: string) { return this.prisma.notification.updateMany({ where: { id: notificationId, userId }, data: { read: true }, }); } async markAllAsRead(userId: string) { return this.prisma.notification.updateMany({ where: { userId, read: false }, data: { read: true }, }); } // ========================================== // CREATE & SEND NOTIFICATION // ========================================== private async createAndSendNotification( userId: string, type: string, title: string, description: string, category: string, actionUrl?: string, data?: Record, ) { // Check user notification preferences for inApp const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { notificationPreferences: true, fcmTokens: true }, }); if (!user) return; const prefs = user.notificationPreferences as Record | null; const inAppEnabled = prefs?.notifications?.[category]?.inApp !== false; // Always persist the in-app notification (if enabled) if (inAppEnabled) { await this.prisma.notification.create({ data: { userId, type, title, description, actionUrl, data: data ? (data as unknown as Prisma.InputJsonValue) : undefined, }, }); } // Send push notification (if Firebase configured and user has tokens) if (inAppEnabled) { await this.sendPushNotification(userId, user, { title, body: description, data, }); } } private async sendPushNotification( userId: string, user: { fcmTokens: Prisma.JsonValue | null }, payload: NotificationPayload, ): Promise { if (!this.firebaseInitialized) return; const tokens = this.parseFcmTokens(user.fcmTokens); if (tokens.length === 0) return; const tokenStrings = tokens.map((t) => t.token); try { const response = await admin.messaging().sendEachForMulticast({ tokens: tokenStrings, notification: { title: payload.title, body: payload.body, }, data: payload.data || {}, webpush: { fcmOptions: { link: payload.data?.link || '/', }, }, }); // Clean up invalid tokens if (response.failureCount > 0) { const invalidTokens: string[] = []; response.responses.forEach((resp, idx) => { if ( !resp.success && resp.error?.code === 'messaging/registration-token-not-registered' ) { invalidTokens.push(tokenStrings[idx]); } }); if (invalidTokens.length > 0) { const validTokens = tokens.filter( (t) => !invalidTokens.includes(t.token), ); await this.prisma.user.update({ where: { id: userId }, data: { fcmTokens: validTokens.length > 0 ? (validTokens as unknown as Prisma.InputJsonValue) : Prisma.JsonNull, }, }); this.logger.log( `Removed ${invalidTokens.length} invalid FCM tokens for user ${userId}`, ); } } this.logger.log( `Push sent to ${userId}: ${response.successCount} ok, ${response.failureCount} fail`, ); } catch (error) { this.logger.error( `Push failed for ${userId}: ${error.message}`, ); } } // ========================================== // EVENT LISTENERS // ========================================== /** * Resolve role-based route prefix for a user. * Agents use /agent/..., regular users use /user/... */ private async getRoutePrefix(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { role: true }, }); return user?.role === 'AGENT' ? '/agent' : '/user'; } @OnEvent('notification.message') async handleMessageNotification(event: { recipientUserId: string; senderName: string; messagePreview: string; conversationId: string; }) { // Check if the recipient has muted this conversation const conversation = await this.prisma.conversation.findUnique({ where: { id: event.conversationId }, include: { agentProfile: { select: { userId: true } } }, }); if (conversation) { const isUser = conversation.userId === event.recipientUserId; const isMuted = isUser ? conversation.userMuted : conversation.agentMuted; if (isMuted) return; // Skip notification for muted conversations } const prefix = await this.getRoutePrefix(event.recipientUserId); const actionUrl = `${prefix}/message?conversationId=${event.conversationId}`; await this.createAndSendNotification( event.recipientUserId, 'message', `New message from ${event.senderName}`, event.messagePreview || 'You have a new message', 'message_updates', actionUrl, { type: 'message', conversationId: event.conversationId, link: actionUrl, }, ); } @OnEvent('notification.connection_request') async handleConnectionRequestNotification(event: { recipientUserId: string; senderName: string; connectionRequestId: string; }) { // Connection requests are always sent to agents const actionUrl = '/agent/network'; await this.createAndSendNotification( event.recipientUserId, 'request', 'New Connection Request', `${event.senderName} wants to connect with you`, 'new_lead_alerts', actionUrl, { type: 'connection_request', connectionRequestId: event.connectionRequestId, link: actionUrl, }, ); } }