import { Inject, Injectable, Logger, OnModuleInit, forwardRef } 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'; import { EmailService } from '../email/email.service'; import { MessagesGateway } from '../messages/messages.gateway'; 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, private readonly emailService: EmailService, @Inject(forwardRef(() => MessagesGateway)) private readonly messagesGateway: MessagesGateway, ) {} 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 { // First, remove this token from ALL other users to prevent cross-user notifications // This handles the case where the same device switches between accounts const allUsersWithToken = await this.prisma.user.findMany({ where: { id: { not: userId }, fcmTokens: { not: Prisma.JsonNull }, }, select: { id: true, fcmTokens: true }, }); for (const otherUser of allUsersWithToken) { const otherTokens = this.parseFcmTokens(otherUser.fcmTokens); const hasToken = otherTokens.some((t) => t.token === token); if (hasToken) { const cleaned = otherTokens.filter((t) => t.token !== token); await this.prisma.user.update({ where: { id: otherUser.id }, data: { fcmTokens: cleaned.length > 0 ? (cleaned as unknown as Prisma.InputJsonValue) : Prisma.JsonNull, }, }); this.logger.log(`Removed FCM token from previous user ${otherUser.id}`); } } // Now register the token for the current user 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 const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { notificationPreferences: true, fcmTokens: true, email: true }, }); if (!user) return; const prefs = user.notificationPreferences as Record | null; const inAppEnabled = prefs?.notifications?.[category]?.inApp !== false; const emailEnabled = prefs?.notifications?.[category]?.email === true; // Persist 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 via FCM (if enabled and user has tokens) if (inAppEnabled) { await this.sendPushNotification(userId, user, { title, body: description, data, }); } // Send email notification (if email preference enabled) if (emailEnabled && user.email) { await this.sendEmailNotification(user.email, title, description, actionUrl); } } private async sendEmailNotification( email: string, title: string, body: string, actionUrl?: string, ): Promise { try { const frontendUrl = this.configService.get('app.frontendUrl') || 'http://localhost:3000'; const fullUrl = actionUrl ? `${frontendUrl}${actionUrl}` : frontendUrl; await this.emailService.sendEmail({ to: email, subject: title, html: `

RE-Quest

${title}

${body}

${actionUrl ? `View Details` : ''}

You received this email because of your notification settings on RE-Quest.

Manage notification preferences
`, text: `${title}\n\n${body}\n\n${actionUrl ? `View details: ${fullUrl}` : ''}`, }); } catch (error) { this.logger.error(`Failed to send email notification to ${email}: ${error.message}`); } } 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 || {}, android: { priority: 'high', notification: { channelId: 'high_importance_channel', priority: 'high', defaultSound: true, defaultVibrateTimings: true, }, }, apns: { payload: { aps: { sound: 'default', badge: 1, }, }, }, 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}`, ); } } // ========================================== // TEST PUSH NOTIFICATION // ========================================== async sendTestPushNotification( userId: string, title?: string, body?: string, ): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { fcmTokens: true }, }); if (!user) { this.logger.warn(`Test push: user ${userId} not found`); return; } const tokens = this.parseFcmTokens(user.fcmTokens); if (tokens.length === 0) { this.logger.warn(`Test push: no FCM tokens for user ${userId}`); return; } await this.sendPushNotification(userId, user, { title: title || 'Test Notification 🔔', body: body || 'This is a test push notification from RE-Quest!', data: { type: 'test', link: '/' }, }); this.logger.log(`Test push notification sent to user ${userId}`); } // ========================================== // 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'; // Send real-time socket event so agent sees it instantly this.messagesGateway.sendToUser(event.recipientUserId, 'connection_request', { type: 'connection_request', connectionRequestId: event.connectionRequestId, senderName: event.senderName, }); 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, }, ); } @OnEvent('notification.connection_response') async handleConnectionResponseNotification(event: { recipientUserId: string; agentName: string; status: string; connectionRequestId: string; }) { const isAccepted = event.status === 'ACCEPTED'; const prefix = await this.getRoutePrefix(event.recipientUserId); const actionUrl = isAccepted ? `${prefix}/message` : `${prefix}/profiles`; // Send real-time socket event so user sees response instantly this.messagesGateway.sendToUser(event.recipientUserId, 'connection_response', { type: 'connection_response', status: event.status, connectionRequestId: event.connectionRequestId, agentName: event.agentName, }); await this.createAndSendNotification( event.recipientUserId, 'connection', isAccepted ? 'Connection Accepted' : 'Connection Declined', isAccepted ? `${event.agentName} accepted your connection request. You can now send messages.` : `${event.agentName} declined your connection request.`, 'new_lead_alerts', actionUrl, { type: 'connection_response', status: event.status, connectionRequestId: event.connectionRequestId, link: actionUrl, }, ); } @OnEvent('notification.verification') async handleVerificationNotification(event: { recipientUserId: string; status: string; note?: string; agentName: string; }) { const isApproved = event.status === 'APPROVED'; const actionUrl = '/agent/dashboard'; const title = isApproved ? 'Profile Verified!' : 'Profile Verification Update'; const description = isApproved ? 'Congratulations! Your profile has been verified. You now have the verified badge on your profile.' : `Your profile verification was not approved.${event.note ? ` Reason: ${event.note}` : ''} Please review and resubmit.`; // Create in-app notification + push (always for verification) const user = await this.prisma.user.findUnique({ where: { id: event.recipientUserId }, select: { email: true, fcmTokens: true }, }); if (!user) return; // Always create in-app notification await this.prisma.notification.create({ data: { userId: event.recipientUserId, type: 'system', title, description, actionUrl, data: { type: 'verification', status: event.status, link: actionUrl }, }, }); // Always send push notification await this.sendPushNotification(event.recipientUserId, user, { title, body: description, data: { type: 'verification', status: event.status, link: actionUrl }, }); // Always send email — forced, ignores preferences if (user.email) { await this.sendEmailNotification(user.email, title, description, actionUrl); } } }