fix
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
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