Files
backend/src/notifications/notifications.service.ts

625 lines
19 KiB
TypeScript

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<string, string>;
}
@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<string>(
'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<string>('firebase.projectId');
const clientEmail = this.configService.get<string>('firebase.clientEmail');
const privateKey = this.configService.get<string>('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<void> {
// 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<void> {
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<number> {
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<string, string>,
) {
// 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<string, any> | 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<void> {
try {
const frontendUrl = this.configService.get<string>('app.frontendUrl') || 'http://localhost:3000';
const fullUrl = actionUrl ? `${frontendUrl}${actionUrl}` : frontendUrl;
await this.emailService.sendEmail({
to: email,
subject: title,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #00293d; padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 20px;">RE-Quest</h1>
</div>
<div style="padding: 30px 20px;">
<h2 style="color: #00293d; font-size: 18px; margin-top: 0;">${title}</h2>
<p style="color: #333; font-size: 14px; line-height: 1.6;">${body}</p>
${actionUrl ? `<a href="${fullUrl}" style="display: inline-block; background-color: #e58625; color: white; padding: 12px 24px; text-decoration: none; border-radius: 15px; font-weight: bold; margin-top: 15px;">View Details</a>` : ''}
</div>
<div style="background-color: #f5f5f5; padding: 15px 20px; text-align: center; font-size: 12px; color: #666;">
<p>You received this email because of your notification settings on RE-Quest.</p>
<a href="${frontendUrl}/agent/settings/notifications" style="color: #e58625;">Manage notification preferences</a>
</div>
</div>
`,
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<void> {
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<void> {
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<string> {
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);
}
}
}