fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
21
src/notifications/dto/register-token.dto.ts
Normal file
21
src/notifications/dto/register-token.dto.ts
Normal 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;
|
||||
}
|
||||
3
src/notifications/index.ts
Normal file
3
src/notifications/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './notifications.module';
|
||||
export * from './notifications.service';
|
||||
export * from './notifications.controller';
|
||||
57
src/notifications/notifications.controller.ts
Normal file
57
src/notifications/notifications.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/notifications/notifications.module.ts
Normal file
12
src/notifications/notifications.module.ts
Normal 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 {}
|
||||
231
src/notifications/notifications.service.ts
Normal file
231
src/notifications/notifications.service.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user