404 lines
11 KiB
TypeScript
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,
|
|
},
|
|
);
|
|
}
|
|
}
|