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 { 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() { 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; } if (admin.apps.length === 0) { admin.initializeApp({ credential: admin.credential.cert({ projectId, clientEmail, privateKey, }), }); } this.firebaseInitialized = true; this.logger.log('Firebase Admin initialized for push notifications'); } 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); // Remove existing entry for this token (if re-registering) 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}`); } async sendPushNotification( userId: string, category: string, payload: NotificationPayload, ): Promise { if (!this.firebaseInitialized) { this.logger.debug('Firebase not initialized, skipping push notification'); return; } // Check user notification preferences const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { notificationPreferences: true, fcmTokens: true }, }); if (!user) return; // Check if inApp notifications are enabled for this category const prefs = user.notificationPreferences as Record | null; if (prefs?.notifications?.[category]?.inApp === false) { this.logger.debug( `Push notification skipped for user ${userId}: ${category} inApp disabled`, ); return; } const tokens = this.parseFcmTokens(user.fcmTokens); if (tokens.length === 0) { this.logger.debug(`No FCM tokens for user ${userId}`); 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 notification sent to user ${userId}: ${response.successCount} success, ${response.failureCount} failed`, ); } catch (error) { this.logger.error( `Failed to send push notification to user ${userId}: ${error.message}`, ); } } // ========================================== // EVENT LISTENERS // ========================================== @OnEvent('notification.message') async handleMessageNotification(event: { recipientUserId: string; senderName: string; messagePreview: string; conversationId: string; }) { await this.sendPushNotification(event.recipientUserId, 'message_updates', { title: `New message from ${event.senderName}`, body: event.messagePreview || 'You have a new message', data: { type: 'message', conversationId: event.conversationId, link: `/messages?conversation=${event.conversationId}`, }, }); } @OnEvent('notification.connection_request') async handleConnectionRequestNotification(event: { recipientUserId: string; senderName: string; connectionRequestId: string; }) { await this.sendPushNotification( event.recipientUserId, 'new_lead_alerts', { title: 'New Connection Request', body: `${event.senderName} wants to connect with you`, data: { type: 'connection_request', connectionRequestId: event.connectionRequestId, link: '/dashboard/connections', }, }, ); } }