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

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ temp/
*.pid.lock *.pid.lock
prisma/migrations/ prisma/migrations/
CLAUDE.md CLAUDE.md
real-estate-2d71e-firebase-adminsdk-fbsvc-2cb74385ab.json

View File

@@ -132,6 +132,9 @@ model User {
conversations Conversation[] conversations Conversation[]
messages Message[] messages Message[]
// Notifications
notifications Notification[]
@@index([email]) @@index([email])
@@index([role]) @@index([role])
@@index([status]) @@index([status])
@@ -500,6 +503,31 @@ model Message {
@@map("messages") @@map("messages")
} }
// ===========================================
// IN-APP NOTIFICATIONS
// ===========================================
model Notification {
id String @id @default(uuid())
userId String
type String // 'connection', 'message', 'system', 'update', 'request'
title String
description String
read Boolean @default(false)
actionUrl String?
data Json? // Extra metadata (conversationId, connectionRequestId, etc.)
// Timestamps
createdAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, read])
@@index([userId, createdAt])
@@map("notifications")
}
// =========================================== // ===========================================
// CMS CONTENT - Dynamic page content management // CMS CONTENT - Dynamic page content management
// =========================================== // ===========================================

View File

@@ -66,6 +66,7 @@ export default () => ({
// Firebase // Firebase
firebase: { firebase: {
serviceAccountKeyPath: process.env.FIREBASE_SERVICE_ACCOUNT_KEY_PATH,
projectId: process.env.FIREBASE_PROJECT_ID, projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL, clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),

View File

@@ -10,7 +10,6 @@ import {
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MessagesService } from './messages.service'; import { MessagesService } from './messages.service';
import { CreateMessageDto } from './dto'; import { CreateMessageDto } from './dto';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@@ -38,7 +37,6 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect
private readonly messagesService: MessagesService, private readonly messagesService: MessagesService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
/** /**
@@ -188,19 +186,6 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect
? participants.agentUserId ? participants.agentUserId
: participants.userId; : participants.userId;
this.sendToUser(otherUserId, 'new_message', message); this.sendToUser(otherUserId, 'new_message', message);
// Emit push notification event
const senderProfile = message.sender?.agentProfile || message.sender?.userProfile;
const senderName = senderProfile
? `${senderProfile.firstName || ''} ${senderProfile.lastName || ''}`.trim()
: 'Someone';
this.eventEmitter.emit('notification.message', {
recipientUserId: otherUserId,
senderName,
messagePreview: data.message.content?.substring(0, 100) || '',
conversationId: data.conversationId,
});
} }
return { success: true, message }; return { success: true, message };

View File

@@ -4,13 +4,17 @@ import {
ForbiddenException, ForbiddenException,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateMessageDto } from './dto'; import { CreateMessageDto } from './dto';
import { ConnectionStatus, MessageStatus, MessageType, UserRole } from '@prisma/client'; import { ConnectionStatus, MessageStatus, MessageType, UserRole } from '@prisma/client';
@Injectable() @Injectable()
export class MessagesService { export class MessagesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/** /**
* Validate if a user can message an agent (must have ACCEPTED connection) * Validate if a user can message an agent (must have ACCEPTED connection)
@@ -223,6 +227,23 @@ export class MessagesService {
}, },
}); });
// Emit push notification event for the recipient
const recipientUserId = isUser
? conversation.agentProfile.userId
: conversation.userId;
const senderProfile =
message.sender?.agentProfile || message.sender?.userProfile;
const senderName = senderProfile
? `${senderProfile.firstName || ''} ${senderProfile.lastName || ''}`.trim()
: 'Someone';
this.eventEmitter.emit('notification.message', {
recipientUserId,
senderName,
messagePreview: dto.content?.substring(0, 100) || '',
conversationId,
});
return message; return message;
} }

View File

@@ -1,8 +1,12 @@
import { import {
Controller, Controller,
Get,
Post, Post,
Patch,
Delete, Delete,
Body, Body,
Param,
Query,
UseGuards, UseGuards,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
@@ -12,6 +16,7 @@ import {
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards'; import { JwtAuthGuard } from '../auth/guards';
import { CurrentUser } from '../auth/decorators'; import { CurrentUser } from '../auth/decorators';
@@ -27,6 +32,63 @@ export class NotificationsController {
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
) {} ) {}
// ==========================================
// IN-APP NOTIFICATIONS
// ==========================================
@Get()
@ApiOperation({ summary: 'Get notifications list' })
@ApiQuery({ name: 'filter', required: false, enum: ['all', 'unread'] })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({ status: 200, description: 'Notifications list with pagination' })
async getNotifications(
@CurrentUser('id') userId: string,
@Query('filter') filter?: 'all' | 'unread',
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.notificationsService.getNotifications(
userId,
filter || 'all',
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get('unread-count')
@ApiOperation({ summary: 'Get unread notification count' })
@ApiResponse({ status: 200, description: 'Unread count' })
async getUnreadCount(@CurrentUser('id') userId: string) {
const count = await this.notificationsService.getUnreadCount(userId);
return { unreadCount: count };
}
@Patch(':id/read')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Mark a notification as read' })
@ApiResponse({ status: 200, description: 'Notification marked as read' })
async markAsRead(
@CurrentUser('id') userId: string,
@Param('id') notificationId: string,
) {
await this.notificationsService.markAsRead(userId, notificationId);
return null;
}
@Patch('read-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Mark all notifications as read' })
@ApiResponse({ status: 200, description: 'All notifications marked as read' })
async markAllAsRead(@CurrentUser('id') userId: string) {
await this.notificationsService.markAllAsRead(userId);
return null;
}
// ==========================================
// FCM TOKEN MANAGEMENT
// ==========================================
@Post('fcm-token') @Post('fcm-token')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Register FCM token for push notifications' }) @ApiOperation({ summary: 'Register FCM token for push notifications' })

View File

@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import * as admin from 'firebase-admin'; import * as admin from 'firebase-admin';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
interface FcmToken { interface FcmToken {
@@ -28,6 +30,38 @@ export class NotificationsService implements OnModuleInit {
) {} ) {}
onModuleInit() { 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 projectId = this.configService.get<string>('firebase.projectId');
const clientEmail = this.configService.get<string>('firebase.clientEmail'); const clientEmail = this.configService.get<string>('firebase.clientEmail');
const privateKey = this.configService.get<string>('firebase.privateKey'); const privateKey = this.configService.get<string>('firebase.privateKey');
@@ -39,7 +73,6 @@ export class NotificationsService implements OnModuleInit {
return; return;
} }
if (admin.apps.length === 0) {
admin.initializeApp({ admin.initializeApp({
credential: admin.credential.cert({ credential: admin.credential.cert({
projectId, projectId,
@@ -47,11 +80,18 @@ export class NotificationsService implements OnModuleInit {
privateKey, privateKey,
}), }),
}); });
}
this.firebaseInitialized = true; this.firebaseInitialized = true;
this.logger.log('Firebase Admin initialized for push notifications'); 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[] { private parseFcmTokens(raw: Prisma.JsonValue | null | undefined): FcmToken[] {
if (!raw || !Array.isArray(raw)) return []; if (!raw || !Array.isArray(raw)) return [];
@@ -69,8 +109,6 @@ export class NotificationsService implements OnModuleInit {
}); });
const existingTokens = this.parseFcmTokens(user?.fcmTokens); const existingTokens = this.parseFcmTokens(user?.fcmTokens);
// Remove existing entry for this token (if re-registering)
const filtered = existingTokens.filter((t) => t.token !== token); const filtered = existingTokens.filter((t) => t.token !== token);
filtered.push({ filtered.push({
@@ -109,17 +147,81 @@ export class NotificationsService implements OnModuleInit {
this.logger.log(`FCM token removed for user ${userId}`); this.logger.log(`FCM token removed for user ${userId}`);
} }
async sendPushNotification( // ==========================================
// IN-APP NOTIFICATION CRUD
// ==========================================
async getNotifications(
userId: string, userId: string,
category: string, filter: 'all' | 'unread' = 'all',
payload: NotificationPayload, page = 1,
): Promise<void> { limit = 20,
if (!this.firebaseInitialized) { ) {
this.logger.debug('Firebase not initialized, skipping push notification'); const where: Prisma.NotificationWhereInput = { userId };
return; 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({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { notificationPreferences: true, fcmTokens: true }, select: { notificationPreferences: true, fcmTokens: true },
@@ -127,20 +229,42 @@ export class NotificationsService implements OnModuleInit {
if (!user) return; if (!user) return;
// Check if inApp notifications are enabled for this category
const prefs = user.notificationPreferences as Record<string, any> | null; const prefs = user.notificationPreferences as Record<string, any> | null;
if (prefs?.notifications?.[category]?.inApp === false) { const inAppEnabled = prefs?.notifications?.[category]?.inApp !== false;
this.logger.debug(
`Push notification skipped for user ${userId}: ${category} inApp disabled`, // Always persist the in-app notification (if enabled)
); if (inAppEnabled) {
return; 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); // Send push notification (if Firebase configured and user has tokens)
if (tokens.length === 0) { if (inAppEnabled) {
this.logger.debug(`No FCM tokens for user ${userId}`); await this.sendPushNotification(userId, user, {
return; 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); const tokenStrings = tokens.map((t) => t.token);
@@ -191,11 +315,11 @@ export class NotificationsService implements OnModuleInit {
} }
this.logger.log( 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) { } catch (error) {
this.logger.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; messagePreview: string;
conversationId: string; conversationId: string;
}) { }) {
await this.sendPushNotification(event.recipientUserId, 'message_updates', { await this.createAndSendNotification(
title: `New message from ${event.senderName}`, event.recipientUserId,
body: event.messagePreview || 'You have a new message', 'message',
data: { `New message from ${event.senderName}`,
event.messagePreview || 'You have a new message',
'message_updates',
`/messages?conversation=${event.conversationId}`,
{
type: 'message', type: 'message',
conversationId: event.conversationId, conversationId: event.conversationId,
link: `/messages?conversation=${event.conversationId}`, link: `/messages?conversation=${event.conversationId}`,
}, },
}); );
} }
@OnEvent('notification.connection_request') @OnEvent('notification.connection_request')
@@ -228,18 +356,18 @@ export class NotificationsService implements OnModuleInit {
senderName: string; senderName: string;
connectionRequestId: string; connectionRequestId: string;
}) { }) {
await this.sendPushNotification( await this.createAndSendNotification(
event.recipientUserId, event.recipientUserId,
'request',
'New Connection Request',
`${event.senderName} wants to connect with you`,
'new_lead_alerts', 'new_lead_alerts',
'/dashboard/connections',
{ {
title: 'New Connection Request',
body: `${event.senderName} wants to connect with you`,
data: {
type: 'connection_request', type: 'connection_request',
connectionRequestId: event.connectionRequestId, connectionRequestId: event.connectionRequestId,
link: '/dashboard/connections', link: '/dashboard/connections',
}, },
},
); );
} }
} }