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

404 lines
11 KiB
TypeScript

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<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,
) {}
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> {
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 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<string, any> | 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<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 || {},
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<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';
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,
},
);
}
}