This commit is contained in:
pradeepkumar
2026-02-24 22:36:42 +05:30
parent d30358755e
commit 7ab5330a88
9 changed files with 390 additions and 3 deletions

View File

@@ -106,6 +106,9 @@ model User {
notificationPreferences Json? // { email: {...}, push: {...} } notificationPreferences Json? // { email: {...}, push: {...} }
privacyPreferences Json? // { privacySettings: {...}, dataSettings: {...} } privacyPreferences Json? // { privacySettings: {...}, dataSettings: {...} }
// Push Notification Tokens
fcmTokens Json? // [{ token, device, createdAt }]
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -17,6 +17,7 @@ import { ProfileFieldsModule } from './profile-fields';
import { ConnectionRequestsModule } from './connection-requests'; import { ConnectionRequestsModule } from './connection-requests';
import { MessagesModule } from './messages'; import { MessagesModule } from './messages';
import { CmsModule } from './cms'; import { CmsModule } from './cms';
import { NotificationsModule } from './notifications';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
@@ -57,6 +58,7 @@ import { AppService } from './app.service';
ConnectionRequestsModule, ConnectionRequestsModule,
MessagesModule, MessagesModule,
CmsModule, CmsModule,
NotificationsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@@ -5,13 +5,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 { ConnectionStatus } from '@prisma/client'; import { ConnectionStatus } from '@prisma/client';
import { CreateConnectionRequestDto, RespondConnectionRequestDto } from './dto'; import { CreateConnectionRequestDto, RespondConnectionRequestDto } from './dto';
@Injectable() @Injectable()
export class ConnectionRequestsService { export class ConnectionRequestsService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private eventEmitter: EventEmitter2,
) {}
/** /**
* Create a new connection request from user to agent * Create a new connection request from user to agent
@@ -66,7 +70,7 @@ export class ConnectionRequestsService {
} }
if (existingRequest.status === ConnectionStatus.REJECTED) { if (existingRequest.status === ConnectionStatus.REJECTED) {
// Allow re-requesting after rejection by updating the existing request // 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 }, where: { id: existingRequest.id },
data: { data: {
status: ConnectionStatus.PENDING, 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 // Create new connection request
return this.prisma.connectionRequest.create({ const request = await this.prisma.connectionRequest.create({
data: { data: {
userId, userId,
agentProfileId: dto.agentProfileId, agentProfileId: dto.agentProfileId,
@@ -105,8 +126,30 @@ export class ConnectionRequestsService {
avatar: true, 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;
} }
/** /**

View File

@@ -10,6 +10,7 @@ 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';
@@ -37,6 +38,7 @@ 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,
) {} ) {}
/** /**
@@ -186,6 +188,19 @@ 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

@@ -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;
}

View File

@@ -0,0 +1,3 @@
export * from './notifications.module';
export * from './notifications.service';
export * from './notifications.controller';

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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<string, string>;
}
@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<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;
}
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<void> {
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<void> {
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<void> {
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<string, any> | 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',
},
},
);
}
}