diff --git a/.gitignore b/.gitignore index 5173cf4..3cfc751 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ temp/ *.pid.lock prisma/migrations/ CLAUDE.md +real-estate-2d71e-firebase-adminsdk-fbsvc-2cb74385ab.json \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51b38b4..6c5bf74 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 // =========================================== diff --git a/src/config/configuration.ts b/src/config/configuration.ts index f58c20d..c28909e 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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'), diff --git a/src/messages/messages.gateway.ts b/src/messages/messages.gateway.ts index 46fb0d6..265015f 100644 --- a/src/messages/messages.gateway.ts +++ b/src/messages/messages.gateway.ts @@ -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 }; diff --git a/src/messages/messages.service.ts b/src/messages/messages.service.ts index be9afaa..2422cf4 100644 --- a/src/messages/messages.service.ts +++ b/src/messages/messages.service.ts @@ -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; } diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts index 295f867..2479fe3 100644 --- a/src/notifications/notifications.controller.ts +++ b/src/notifications/notifications.controller.ts @@ -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' }) diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 39e0c14..e6d5615 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -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('firebase.projectId'); - const clientEmail = this.configService.get('firebase.clientEmail'); - const privateKey = this.configService.get('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( + '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('firebase.projectId'); + const clientEmail = this.configService.get('firebase.clientEmail'); + const privateKey = this.configService.get('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 { - 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 { + 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, + ) { + // 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 | 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 { + 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', }, ); }