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
prisma/migrations/
CLAUDE.md
real-estate-2d71e-firebase-adminsdk-fbsvc-2cb74385ab.json

View File

@@ -132,6 +132,9 @@ model User {
conversations Conversation[]
messages Message[]
// Notifications
notifications Notification[]
@@index([email])
@@index([role])
@@index([status])
@@ -500,6 +503,31 @@ model Message {
@@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
// ===========================================

View File

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

View File

@@ -10,7 +10,6 @@ import {
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MessagesService } from './messages.service';
import { CreateMessageDto } from './dto';
import { Logger } from '@nestjs/common';
@@ -38,7 +37,6 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect
private readonly messagesService: MessagesService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
@@ -188,19 +186,6 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect
? participants.agentUserId
: participants.userId;
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 };

View File

@@ -4,13 +4,17 @@ import {
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../prisma/prisma.service';
import { CreateMessageDto } from './dto';
import { ConnectionStatus, MessageStatus, MessageType, UserRole } from '@prisma/client';
@Injectable()
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)
@@ -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;
}

View File

@@ -1,8 +1,12 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
@@ -12,6 +16,7 @@ import {
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards';
import { CurrentUser } from '../auth/decorators';
@@ -27,6 +32,63 @@ export class NotificationsController {
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')
@HttpCode(HttpStatus.OK)
@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 { 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',
},
);
}