diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dfb9442..51b38b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,6 +106,9 @@ model User { notificationPreferences Json? // { email: {...}, push: {...} } privacyPreferences Json? // { privacySettings: {...}, dataSettings: {...} } + // Push Notification Tokens + fcmTokens Json? // [{ token, device, createdAt }] + // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app.module.ts b/src/app.module.ts index 259dd9a..6db9b1b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { ProfileFieldsModule } from './profile-fields'; import { ConnectionRequestsModule } from './connection-requests'; import { MessagesModule } from './messages'; import { CmsModule } from './cms'; +import { NotificationsModule } from './notifications'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -57,6 +58,7 @@ import { AppService } from './app.service'; ConnectionRequestsModule, MessagesModule, CmsModule, + NotificationsModule, ], controllers: [AppController], providers: [ diff --git a/src/connection-requests/connection-requests.service.ts b/src/connection-requests/connection-requests.service.ts index d4f310c..096117b 100644 --- a/src/connection-requests/connection-requests.service.ts +++ b/src/connection-requests/connection-requests.service.ts @@ -5,13 +5,17 @@ import { ForbiddenException, BadRequestException, } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { PrismaService } from '../prisma/prisma.service'; import { ConnectionStatus } from '@prisma/client'; import { CreateConnectionRequestDto, RespondConnectionRequestDto } from './dto'; @Injectable() export class ConnectionRequestsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private eventEmitter: EventEmitter2, + ) {} /** * Create a new connection request from user to agent @@ -66,7 +70,7 @@ export class ConnectionRequestsService { } if (existingRequest.status === ConnectionStatus.REJECTED) { // Allow re-requesting after rejection by updating the existing request - return this.prisma.connectionRequest.update({ + const updated = await this.prisma.connectionRequest.update({ where: { id: existingRequest.id }, data: { status: ConnectionStatus.PENDING, @@ -85,11 +89,28 @@ export class ConnectionRequestsService { }, }, }); + + // Get sender name for notification + const userProfile = await this.prisma.userProfile.findUnique({ + where: { userId }, + select: { firstName: true, lastName: true }, + }); + const senderName = userProfile + ? `${userProfile.firstName || ''} ${userProfile.lastName || ''}`.trim() + : 'A user'; + + this.eventEmitter.emit('notification.connection_request', { + recipientUserId: agentProfile.userId, + senderName, + connectionRequestId: updated.id, + }); + + return updated; } } // Create new connection request - return this.prisma.connectionRequest.create({ + const request = await this.prisma.connectionRequest.create({ data: { userId, agentProfileId: dto.agentProfileId, @@ -105,8 +126,30 @@ export class ConnectionRequestsService { avatar: true, }, }, + user: { + select: { + userProfile: { + select: { firstName: true, lastName: true }, + }, + }, + }, }, }); + + // Emit push notification event for the agent + const senderName = request.user?.userProfile + ? `${request.user.userProfile.firstName || ''} ${request.user.userProfile.lastName || ''}`.trim() + : 'A user'; + + this.eventEmitter.emit('notification.connection_request', { + recipientUserId: agentProfile.userId, + senderName, + connectionRequestId: request.id, + }); + + // Remove user data from response (not needed by caller) + const { user: _user, ...result } = request; + return result; } /** diff --git a/src/messages/messages.gateway.ts b/src/messages/messages.gateway.ts index 265015f..46fb0d6 100644 --- a/src/messages/messages.gateway.ts +++ b/src/messages/messages.gateway.ts @@ -10,6 +10,7 @@ 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'; @@ -37,6 +38,7 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect private readonly messagesService: MessagesService, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, ) {} /** @@ -186,6 +188,19 @@ 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/notifications/dto/register-token.dto.ts b/src/notifications/dto/register-token.dto.ts new file mode 100644 index 0000000..4673cb4 --- /dev/null +++ b/src/notifications/dto/register-token.dto.ts @@ -0,0 +1,21 @@ +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RegisterTokenDto { + @ApiProperty({ description: 'FCM registration token' }) + @IsString() + @IsNotEmpty() + token: string; + + @ApiPropertyOptional({ description: 'Device identifier', default: 'web' }) + @IsString() + @IsOptional() + device?: string = 'web'; +} + +export class RemoveTokenDto { + @ApiProperty({ description: 'FCM registration token to remove' }) + @IsString() + @IsNotEmpty() + token: string; +} diff --git a/src/notifications/index.ts b/src/notifications/index.ts new file mode 100644 index 0000000..61660f3 --- /dev/null +++ b/src/notifications/index.ts @@ -0,0 +1,3 @@ +export * from './notifications.module'; +export * from './notifications.service'; +export * from './notifications.controller'; diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..295f867 --- /dev/null +++ b/src/notifications/notifications.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Post, + Delete, + Body, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards'; +import { CurrentUser } from '../auth/decorators'; +import { NotificationsService } from './notifications.service'; +import { RegisterTokenDto, RemoveTokenDto } from './dto/register-token.dto'; + +@ApiTags('Notifications') +@Controller('notifications') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth('JWT-auth') +export class NotificationsController { + constructor( + private readonly notificationsService: NotificationsService, + ) {} + + @Post('fcm-token') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Register FCM token for push notifications' }) + @ApiResponse({ status: 200, description: 'Token registered' }) + async registerToken( + @CurrentUser('id') userId: string, + @Body() dto: RegisterTokenDto, + ) { + await this.notificationsService.registerToken( + userId, + dto.token, + dto.device, + ); + return null; + } + + @Delete('fcm-token') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Remove FCM token' }) + @ApiResponse({ status: 200, description: 'Token removed' }) + async removeToken( + @CurrentUser('id') userId: string, + @Body() dto: RemoveTokenDto, + ) { + await this.notificationsService.removeToken(userId, dto.token); + return null; + } +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..917d4b4 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts new file mode 100644 index 0000000..e170ead --- /dev/null +++ b/src/notifications/notifications.service.ts @@ -0,0 +1,231 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OnEvent } from '@nestjs/event-emitter'; +import * as admin from 'firebase-admin'; +import { PrismaService } from '../prisma/prisma.service'; + +interface FcmToken { + token: string; + device: string; + createdAt: string; +} + +interface NotificationPayload { + title: string; + body: string; + data?: Record; +} + +@Injectable() +export class NotificationsService implements OnModuleInit { + private readonly logger = new Logger(NotificationsService.name); + private firebaseInitialized = false; + + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + onModuleInit() { + 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; + } + + if (admin.apps.length === 0) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId, + clientEmail, + privateKey, + }), + }); + } + + this.firebaseInitialized = true; + this.logger.log('Firebase Admin initialized for push notifications'); + } + + async registerToken( + userId: string, + token: string, + device: string = 'web', + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { fcmTokens: true }, + }); + + const existingTokens: FcmToken[] = (user?.fcmTokens as FcmToken[]) || []; + + // Remove existing entry for this token (if re-registering) + const filtered = existingTokens.filter((t) => t.token !== token); + + filtered.push({ + token, + device, + createdAt: new Date().toISOString(), + }); + + await this.prisma.user.update({ + where: { id: userId }, + data: { fcmTokens: filtered }, + }); + + this.logger.log(`FCM token registered for user ${userId} (${device})`); + } + + async removeToken(userId: string, token: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { fcmTokens: true }, + }); + + const existingTokens: FcmToken[] = (user?.fcmTokens as FcmToken[]) || []; + const filtered = existingTokens.filter((t) => t.token !== token); + + await this.prisma.user.update({ + where: { id: userId }, + data: { fcmTokens: filtered.length > 0 ? filtered : null }, + }); + + this.logger.log(`FCM token removed for user ${userId}`); + } + + async sendPushNotification( + userId: string, + category: string, + payload: NotificationPayload, + ): Promise { + if (!this.firebaseInitialized) { + this.logger.debug('Firebase not initialized, skipping push notification'); + return; + } + + // Check user notification preferences + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { notificationPreferences: true, fcmTokens: true }, + }); + + 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 tokens: FcmToken[] = (user.fcmTokens as FcmToken[]) || []; + if (tokens.length === 0) { + this.logger.debug(`No FCM tokens for user ${userId}`); + return; + } + + const tokenStrings = tokens.map((t) => t.token); + + try { + const response = await admin.messaging().sendEachForMulticast({ + tokens: tokenStrings, + notification: { + title: payload.title, + body: payload.body, + }, + data: payload.data || {}, + webpush: { + fcmOptions: { + link: payload.data?.link || '/', + }, + }, + }); + + // Clean up invalid tokens + if (response.failureCount > 0) { + const invalidTokens: string[] = []; + response.responses.forEach((resp, idx) => { + if ( + !resp.success && + resp.error?.code === 'messaging/registration-token-not-registered' + ) { + invalidTokens.push(tokenStrings[idx]); + } + }); + + if (invalidTokens.length > 0) { + const validTokens = tokens.filter( + (t) => !invalidTokens.includes(t.token), + ); + await this.prisma.user.update({ + where: { id: userId }, + data: { + fcmTokens: validTokens.length > 0 ? validTokens : null, + }, + }); + this.logger.log( + `Removed ${invalidTokens.length} invalid FCM tokens for user ${userId}`, + ); + } + } + + this.logger.log( + `Push notification sent to user ${userId}: ${response.successCount} success, ${response.failureCount} failed`, + ); + } catch (error) { + this.logger.error( + `Failed to send push notification to user ${userId}: ${error.message}`, + ); + } + } + + // ========================================== + // EVENT LISTENERS + // ========================================== + + @OnEvent('notification.message') + async handleMessageNotification(event: { + recipientUserId: string; + senderName: string; + 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: { + type: 'message', + conversationId: event.conversationId, + link: `/messages?conversation=${event.conversationId}`, + }, + }); + } + + @OnEvent('notification.connection_request') + async handleConnectionRequestNotification(event: { + recipientUserId: string; + senderName: string; + connectionRequestId: string; + }) { + await this.sendPushNotification( + event.recipientUserId, + 'new_lead_alerts', + { + title: 'New Connection Request', + body: `${event.senderName} wants to connect with you`, + data: { + type: 'connection_request', + connectionRequestId: event.connectionRequestId, + link: '/dashboard/connections', + }, + }, + ); + } +}