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:
@@ -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',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user