feat: Implement in-app notifications with new database model, enhance Firebase initialization to support service account files, and add message read functionalities.

This commit is contained in:
pradeepkumar
2026-02-25 06:45:27 +05:30
parent 36ccc8c303
commit 7b8d6e3db8
7 changed files with 289 additions and 63 deletions

View File

@@ -3,6 +3,8 @@ 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 {
@@ -28,18 +30,49 @@ export class NotificationsService implements OnModuleInit {
) {}
onModuleInit() {
const projectId = this.configService.get<string>('firebase.projectId');
const clientEmail = this.configService.get<string>('firebase.clientEmail');
const privateKey = this.configService.get<string>('firebase.privateKey');
try {
if (admin.apps.length > 0) {
this.firebaseInitialized = true;
return;
}
if (!projectId || !clientEmail || !privateKey) {
this.logger.warn(
'Firebase credentials not configured. Push notifications disabled.',
// Try loading from service account JSON file first
const serviceAccountPath = this.configService.get<string>(
'firebase.serviceAccountKeyPath',
);
return;
}
if (admin.apps.length === 0) {
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,
@@ -47,12 +80,19 @@ export class NotificationsService implements OnModuleInit {
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}`,
);
}
this.firebaseInitialized = true;
this.logger.log('Firebase Admin initialized for push notifications');
}
// ==========================================
// FCM TOKEN MANAGEMENT
// ==========================================
private parseFcmTokens(raw: Prisma.JsonValue | null | undefined): FcmToken[] {
if (!raw || !Array.isArray(raw)) return [];
return raw as unknown as FcmToken[];
@@ -69,8 +109,6 @@ export class NotificationsService implements OnModuleInit {
});
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({
@@ -109,17 +147,81 @@ export class NotificationsService implements OnModuleInit {
this.logger.log(`FCM token removed for user ${userId}`);
}
async sendPushNotification(
// ==========================================
// IN-APP NOTIFICATION CRUD
// ==========================================
async getNotifications(
userId: string,
category: string,
payload: NotificationPayload,
): Promise<void> {
if (!this.firebaseInitialized) {
this.logger.debug('Firebase not initialized, skipping push notification');
return;
filter: 'all' | 'unread' = 'all',
page = 1,
limit = 20,
) {
const where: Prisma.NotificationWhereInput = { userId };
if (filter === 'unread') {
where.read = false;
}
// Check user notification preferences
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 },
@@ -127,20 +229,42 @@ export class NotificationsService implements OnModuleInit {
if (!user) return;
// Check if inApp notifications are enabled for this category
const prefs = user.notificationPreferences as Record<string, any> | null;
if (prefs?.notifications?.[category]?.inApp === false) {
this.logger.debug(
`Push notification skipped for user ${userId}: ${category} inApp disabled`,
);
return;
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,
},
});
}
const tokens = this.parseFcmTokens(user.fcmTokens);
if (tokens.length === 0) {
this.logger.debug(`No FCM tokens for user ${userId}`);
return;
// 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);
@@ -191,11 +315,11 @@ export class NotificationsService implements OnModuleInit {
}
this.logger.log(
`Push notification sent to user ${userId}: ${response.successCount} success, ${response.failureCount} failed`,
`Push sent to ${userId}: ${response.successCount} ok, ${response.failureCount} fail`,
);
} catch (error) {
this.logger.error(
`Failed to send push notification to user ${userId}: ${error.message}`,
`Push failed for ${userId}: ${error.message}`,
);
}
}
@@ -211,15 +335,19 @@ export class NotificationsService implements OnModuleInit {
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: {
await this.createAndSendNotification(
event.recipientUserId,
'message',
`New message from ${event.senderName}`,
event.messagePreview || 'You have a new message',
'message_updates',
`/messages?conversation=${event.conversationId}`,
{
type: 'message',
conversationId: event.conversationId,
link: `/messages?conversation=${event.conversationId}`,
},
});
);
}
@OnEvent('notification.connection_request')
@@ -228,17 +356,17 @@ export class NotificationsService implements OnModuleInit {
senderName: string;
connectionRequestId: string;
}) {
await this.sendPushNotification(
await this.createAndSendNotification(
event.recipientUserId,
'request',
'New Connection Request',
`${event.senderName} wants to connect with you`,
'new_lead_alerts',
'/dashboard/connections',
{
title: 'New Connection Request',
body: `${event.senderName} wants to connect with you`,
data: {
type: 'connection_request',
connectionRequestId: event.connectionRequestId,
link: '/dashboard/connections',
},
type: 'connection_request',
connectionRequestId: event.connectionRequestId,
link: '/dashboard/connections',
},
);
}