feat: Implement real-time messaging module with REST API endpoints and WebSocket gateway.

This commit is contained in:
pradeepkumar
2026-02-08 22:44:16 +05:30
parent 895a106d1b
commit 3b1a7b999e
14 changed files with 1274 additions and 34 deletions

View File

@@ -0,0 +1,35 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MessageType } from '@prisma/client';
export class CreateMessageDto {
@ApiProperty({ description: 'Message content', maxLength: 5000 })
@IsString()
@IsNotEmpty()
@MaxLength(5000)
content: string;
@ApiPropertyOptional({ description: 'Message type', enum: MessageType, default: MessageType.TEXT })
@IsOptional()
@IsEnum(MessageType)
messageType?: MessageType = MessageType.TEXT;
@ApiPropertyOptional({ description: 'File URL for file/image messages' })
@IsOptional()
@IsString()
fileUrl?: string;
@ApiPropertyOptional({ description: 'Original file name' })
@IsOptional()
@IsString()
fileName?: string;
@ApiPropertyOptional({ description: 'File size in bytes' })
@IsOptional()
fileSize?: number;
@ApiPropertyOptional({ description: 'MIME type of the file' })
@IsOptional()
@IsString()
mimeType?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './create-message.dto';
export * from './start-conversation.dto';

View File

@@ -0,0 +1,10 @@
import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class StartConversationDto {
@ApiProperty({ description: 'Agent profile ID to start conversation with' })
@IsString()
@IsNotEmpty()
@IsUUID()
agentProfileId: string;
}

5
src/messages/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from './messages.module';
export * from './messages.service';
export * from './messages.controller';
export * from './messages.gateway';
export * from './dto';

View File

@@ -0,0 +1,218 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { MessagesService } from './messages.service';
import { CreateMessageDto, StartConversationDto } from './dto';
import { JwtAuthGuard } from '../auth/guards';
import { CurrentUser } from '../auth/decorators';
import { UserRole } from '@prisma/client';
@ApiTags('Messages')
@Controller('messages')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
// ==========================================
// GET CONVERSATIONS LIST
// ==========================================
@Get('conversations')
@ApiOperation({ summary: 'Get all conversations for the current user' })
@ApiResponse({
status: 200,
description: 'List of conversations',
schema: {
example: [
{
id: 'conv-uuid',
lastMessageAt: '2024-01-01T00:00:00.000Z',
lastMessageText: 'Hello!',
unreadCount: 2,
otherParty: {
id: 'agent-uuid',
name: 'John Doe',
avatar: 'https://...',
headline: 'Real Estate Agent',
isOnline: true,
},
},
],
},
})
async getConversations(
@CurrentUser('id') userId: string,
@CurrentUser('role') role: UserRole,
) {
return this.messagesService.getConversations(userId, role);
}
// ==========================================
// START OR GET CONVERSATION
// ==========================================
@Post('conversations')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Start or get existing conversation with an agent' })
@ApiResponse({
status: 200,
description: 'Conversation created or retrieved',
})
@ApiResponse({
status: 403,
description: 'Connection not accepted',
})
async startConversation(
@CurrentUser('id') userId: string,
@Body() dto: StartConversationDto,
) {
return this.messagesService.getOrCreateConversation(userId, dto.agentProfileId);
}
// ==========================================
// GET CONVERSATION DETAILS
// ==========================================
@Get('conversations/:id')
@ApiOperation({ summary: 'Get a specific conversation' })
@ApiParam({ name: 'id', description: 'Conversation ID' })
@ApiResponse({
status: 200,
description: 'Conversation details',
})
@ApiResponse({
status: 404,
description: 'Conversation not found',
})
async getConversation(
@Param('id') conversationId: string,
@CurrentUser('id') userId: string,
) {
return this.messagesService.getConversationById(conversationId, userId);
}
// ==========================================
// GET MESSAGES FOR CONVERSATION
// ==========================================
@Get('conversations/:id/messages')
@ApiOperation({ summary: 'Get messages for a conversation' })
@ApiParam({ name: 'id', description: 'Conversation ID' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({
status: 200,
description: 'List of messages with pagination',
schema: {
example: {
messages: [
{
id: 'msg-uuid',
content: 'Hello!',
messageType: 'TEXT',
status: 'READ',
createdAt: '2024-01-01T00:00:00.000Z',
sender: {
id: 'user-uuid',
role: 'USER',
userProfile: {
firstName: 'Jane',
lastName: 'Doe',
},
},
},
],
pagination: {
page: 1,
limit: 50,
total: 100,
pages: 2,
},
},
},
})
async getMessages(
@Param('id') conversationId: string,
@CurrentUser('id') userId: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.messagesService.getMessages(
conversationId,
userId,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
);
}
// ==========================================
// SEND MESSAGE (REST FALLBACK)
// ==========================================
@Post('conversations/:id/messages')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Send a message (REST fallback - prefer WebSocket)' })
@ApiParam({ name: 'id', description: 'Conversation ID' })
@ApiResponse({
status: 201,
description: 'Message created',
})
async sendMessage(
@Param('id') conversationId: string,
@CurrentUser('id') userId: string,
@Body() dto: CreateMessageDto,
) {
return this.messagesService.createMessage(conversationId, userId, dto);
}
// ==========================================
// MARK MESSAGES AS READ
// ==========================================
@Patch('conversations/:id/read')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Mark all messages in conversation as read' })
@ApiParam({ name: 'id', description: 'Conversation ID' })
@ApiResponse({
status: 200,
description: 'Messages marked as read',
})
async markAsRead(
@Param('id') conversationId: string,
@CurrentUser('id') userId: string,
) {
return this.messagesService.markMessagesAsRead(conversationId, userId);
}
// ==========================================
// GET UNREAD COUNT
// ==========================================
@Get('unread-count')
@ApiOperation({ summary: 'Get total unread message count' })
@ApiResponse({
status: 200,
description: 'Unread count',
schema: {
example: { unreadCount: 5 },
},
})
async getUnreadCount(
@CurrentUser('id') userId: string,
@CurrentUser('role') role: UserRole,
) {
const unreadCount = await this.messagesService.getTotalUnreadCount(userId, role);
return { unreadCount };
}
}

View File

@@ -0,0 +1,278 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { MessagesService } from './messages.service';
import { CreateMessageDto } from './dto';
import { Logger } from '@nestjs/common';
interface AuthenticatedSocket extends Socket {
userId?: string;
userRole?: string;
}
@WebSocketGateway({
cors: {
origin: '*', // Configure in production
credentials: true,
},
transports: ['websocket', 'polling'],
})
export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(MessagesGateway.name);
private userSocketMap = new Map<string, Set<string>>(); // userId -> Set of socketIds
constructor(
private readonly messagesService: MessagesService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
/**
* Handle new WebSocket connections
*/
async handleConnection(client: AuthenticatedSocket) {
this.logger.log(`New connection attempt: ${client.id}`);
// Extract token from handshake
const token =
client.handshake.auth?.token ||
client.handshake.headers?.authorization?.replace('Bearer ', '');
if (!token) {
this.logger.warn(`Connection rejected: No token provided for ${client.id}`);
client.disconnect(true);
return;
}
// Verify JWT token
let payload;
try {
payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_SECRET'),
});
this.logger.log(`Token verified for ${client.id}, user: ${payload.sub}`);
} catch (jwtError) {
this.logger.warn(`Connection rejected: Invalid token for ${client.id} - ${jwtError.message}`);
client.disconnect(true);
return;
}
// Attach user info to socket
const userId = payload.sub as string;
client.userId = userId;
client.userRole = payload.role;
// Track socket connection
if (!this.userSocketMap.has(userId)) {
this.userSocketMap.set(userId, new Set());
}
this.userSocketMap.get(userId)!.add(client.id);
this.logger.log(`Client connected successfully: ${client.id} (User: ${userId})`);
// Update user online status in background (don't await)
this.messagesService.updateOnlineStatus(userId, true)
.then(() => {
this.broadcastUserStatus(userId, true);
this.logger.log(`Online status updated for ${userId}`);
})
.catch((err) => {
this.logger.warn(`Failed to update online status for ${userId}: ${err.message}`);
});
}
/**
* Handle WebSocket disconnections
*/
async handleDisconnect(client: AuthenticatedSocket) {
if (client.userId) {
// Remove socket from tracking
const sockets = this.userSocketMap.get(client.userId);
if (sockets) {
sockets.delete(client.id);
// Only mark offline if no more sockets connected
if (sockets.size === 0) {
this.userSocketMap.delete(client.userId);
await this.messagesService.updateOnlineStatus(client.userId, false);
this.broadcastUserStatus(client.userId, false);
}
}
this.logger.log(`Client disconnected: ${client.id} (User: ${client.userId})`);
}
}
/**
* Join a conversation room
*/
@SubscribeMessage('join_conversation')
async handleJoinConversation(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { conversationId: string },
) {
if (!client.userId) {
return { error: 'Unauthorized' };
}
try {
// Verify user is part of the conversation
await this.messagesService.getConversationById(data.conversationId, client.userId);
// Join the room
client.join(`conversation:${data.conversationId}`);
this.logger.log(`User ${client.userId} joined conversation ${data.conversationId}`);
return { success: true };
} catch (error) {
return { error: error.message };
}
}
/**
* Leave a conversation room
*/
@SubscribeMessage('leave_conversation')
handleLeaveConversation(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { conversationId: string },
) {
client.leave(`conversation:${data.conversationId}`);
this.logger.log(`User ${client.userId} left conversation ${data.conversationId}`);
return { success: true };
}
/**
* Send a message via WebSocket
*/
@SubscribeMessage('send_message')
async handleSendMessage(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { conversationId: string; message: CreateMessageDto },
) {
if (!client.userId) {
return { error: 'Unauthorized' };
}
try {
const message = await this.messagesService.createMessage(
data.conversationId,
client.userId,
data.message,
);
// Broadcast to all clients in the conversation room
this.server
.to(`conversation:${data.conversationId}`)
.emit('new_message', message);
return { success: true, message };
} catch (error) {
return { error: error.message };
}
}
/**
* Handle typing indicator start
*/
@SubscribeMessage('typing_start')
handleTypingStart(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { conversationId: string },
) {
if (!client.userId) return;
// Broadcast to others in the room
client.to(`conversation:${data.conversationId}`).emit('typing_start', {
userId: client.userId,
conversationId: data.conversationId,
});
}
/**
* Handle typing indicator stop
*/
@SubscribeMessage('typing_stop')
handleTypingStop(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { conversationId: string },
) {
if (!client.userId) return;
client.to(`conversation:${data.conversationId}`).emit('typing_stop', {
userId: client.userId,
conversationId: data.conversationId,
});
}
/**
* Mark messages as read
*/
@SubscribeMessage('mark_read')
async handleMarkRead(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { conversationId: string },
) {
if (!client.userId) {
return { error: 'Unauthorized' };
}
try {
await this.messagesService.markMessagesAsRead(data.conversationId, client.userId);
// Notify the other party that messages have been read
client.to(`conversation:${data.conversationId}`).emit('messages_read', {
conversationId: data.conversationId,
readBy: client.userId,
readAt: new Date(),
});
return { success: true };
} catch (error) {
return { error: error.message };
}
}
/**
* Broadcast user online/offline status
*/
private broadcastUserStatus(userId: string, isOnline: boolean) {
this.server.emit('user_status_change', {
userId,
isOnline,
lastSeenAt: isOnline ? null : new Date(),
});
}
/**
* Send message to a specific user (by userId)
*/
sendToUser(userId: string, event: string, data: any) {
const sockets = this.userSocketMap.get(userId);
if (sockets) {
sockets.forEach((socketId) => {
this.server.to(socketId).emit(event, data);
});
}
}
/**
* Check if a user is online
*/
isUserOnline(userId: string): boolean {
const sockets = this.userSocketMap.get(userId);
return sockets ? sockets.size > 0 : false;
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MessagesController } from './messages.controller';
import { MessagesService } from './messages.service';
import { MessagesGateway } from './messages.gateway';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [
PrismaModule,
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'fallback-secret',
signOptions: {
expiresIn: '15m',
},
}),
}),
],
controllers: [MessagesController],
providers: [MessagesService, MessagesGateway],
exports: [MessagesService, MessagesGateway],
})
export class MessagesModule {}

View File

@@ -0,0 +1,586 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
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) {}
/**
* Validate if a user can message an agent (must have ACCEPTED connection)
*/
async validateCanMessage(userId: string, agentProfileId: string): Promise<boolean> {
const connection = await this.prisma.connectionRequest.findUnique({
where: {
userId_agentProfileId: {
userId,
agentProfileId,
},
},
});
return connection?.status === ConnectionStatus.ACCEPTED;
}
/**
* Get or create a conversation between user and agent
*/
async getOrCreateConversation(userId: string, agentProfileId: string) {
// Validate connection first
const canMessage = await this.validateCanMessage(userId, agentProfileId);
if (!canMessage) {
throw new ForbiddenException(
'You can only message agents with accepted connection requests',
);
}
// Check if conversation already exists
let conversation = await this.prisma.conversation.findUnique({
where: {
userId_agentProfileId: {
userId,
agentProfileId,
},
},
include: {
agentProfile: {
select: {
id: true,
firstName: true,
lastName: true,
avatar: true,
headline: true,
userId: true,
user: {
select: {
isOnline: true,
lastSeenAt: true,
},
},
},
},
user: {
select: {
id: true,
isOnline: true,
lastSeenAt: true,
userProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
},
},
},
});
// Create new conversation if it doesn't exist
if (!conversation) {
conversation = await this.prisma.conversation.create({
data: {
userId,
agentProfileId,
},
include: {
agentProfile: {
select: {
id: true,
firstName: true,
lastName: true,
avatar: true,
headline: true,
userId: true,
user: {
select: {
isOnline: true,
lastSeenAt: true,
},
},
},
},
user: {
select: {
id: true,
isOnline: true,
lastSeenAt: true,
userProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
},
},
},
});
}
// Transform to include otherParty (for the user, other party is the agent)
return {
...conversation,
unreadCount: conversation.userUnreadCount,
otherParty: {
id: conversation.agentProfile.id,
userId: conversation.agentProfile.userId,
name: `${conversation.agentProfile.firstName || ''} ${conversation.agentProfile.lastName || ''}`.trim() || 'Agent',
avatar: conversation.agentProfile.avatar,
headline: conversation.agentProfile.headline,
isOnline: conversation.agentProfile.user?.isOnline || false,
lastSeenAt: conversation.agentProfile.user?.lastSeenAt || null,
},
};
}
/**
* Create a new message in a conversation
*/
async createMessage(
conversationId: string,
senderId: string,
dto: CreateMessageDto,
) {
// Verify conversation exists and user is part of it
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
include: {
agentProfile: {
select: { userId: true },
},
},
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
// Check if sender is part of the conversation
const isUser = conversation.userId === senderId;
const isAgent = conversation.agentProfile.userId === senderId;
if (!isUser && !isAgent) {
throw new ForbiddenException('You are not part of this conversation');
}
// Create the message
const message = await this.prisma.message.create({
data: {
conversationId,
senderId,
content: dto.content,
messageType: dto.messageType || MessageType.TEXT,
fileUrl: dto.fileUrl,
fileName: dto.fileName,
fileSize: dto.fileSize,
mimeType: dto.mimeType,
status: MessageStatus.SENT,
},
include: {
sender: {
select: {
id: true,
role: true,
userProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
agentProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
},
},
},
});
// Update conversation with last message info and unread counts
const truncatedContent =
dto.content.length > 255 ? dto.content.substring(0, 252) + '...' : dto.content;
await this.prisma.conversation.update({
where: { id: conversationId },
data: {
lastMessageAt: new Date(),
lastMessageText: truncatedContent,
// Increment unread count for the other party
...(isUser
? { agentUnreadCount: { increment: 1 } }
: { userUnreadCount: { increment: 1 } }),
},
});
return message;
}
/**
* Get all conversations for a user (either as user or agent)
*/
async getConversations(userId: string, role: UserRole) {
let conversations;
if (role === UserRole.AGENT) {
// Get agent profile for this user
const agentProfile = await this.prisma.agentProfile.findUnique({
where: { userId },
});
if (!agentProfile) {
return [];
}
conversations = await this.prisma.conversation.findMany({
where: { agentProfileId: agentProfile.id },
include: {
user: {
select: {
id: true,
isOnline: true,
lastSeenAt: true,
userProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
},
},
messages: {
take: 1,
orderBy: { createdAt: 'desc' },
},
},
orderBy: { lastMessageAt: 'desc' },
});
// Map to include unread count from agent perspective
return conversations.map((conv) => ({
...conv,
unreadCount: conv.agentUnreadCount,
otherParty: {
id: conv.user.id,
name: `${conv.user.userProfile?.firstName || ''} ${conv.user.userProfile?.lastName || ''}`.trim() || 'User',
avatar: conv.user.userProfile?.avatar,
isOnline: conv.user.isOnline,
lastSeenAt: conv.user.lastSeenAt,
},
}));
} else {
// Regular user
conversations = await this.prisma.conversation.findMany({
where: { userId },
include: {
agentProfile: {
select: {
id: true,
firstName: true,
lastName: true,
avatar: true,
headline: true,
userId: true,
user: {
select: {
isOnline: true,
lastSeenAt: true,
},
},
},
},
messages: {
take: 1,
orderBy: { createdAt: 'desc' },
},
},
orderBy: { lastMessageAt: 'desc' },
});
// Map to include unread count from user perspective
return conversations.map((conv) => ({
...conv,
unreadCount: conv.userUnreadCount,
otherParty: {
id: conv.agentProfile.id,
userId: conv.agentProfile.userId,
name: `${conv.agentProfile.firstName || ''} ${conv.agentProfile.lastName || ''}`.trim() || 'Agent',
avatar: conv.agentProfile.avatar,
headline: conv.agentProfile.headline,
isOnline: conv.agentProfile.user.isOnline,
lastSeenAt: conv.agentProfile.user.lastSeenAt,
},
}));
}
}
/**
* Get messages for a conversation with pagination
*/
async getMessages(
conversationId: string,
userId: string,
page: number = 1,
limit: number = 50,
) {
// Verify user is part of the conversation
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
include: {
agentProfile: {
select: { userId: true },
},
},
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
const isUser = conversation.userId === userId;
const isAgent = conversation.agentProfile.userId === userId;
if (!isUser && !isAgent) {
throw new ForbiddenException('You are not part of this conversation');
}
const skip = (page - 1) * limit;
const [messages, total] = await Promise.all([
this.prisma.message.findMany({
where: { conversationId },
include: {
sender: {
select: {
id: true,
role: true,
userProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
agentProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.message.count({ where: { conversationId } }),
]);
return {
messages: messages.reverse(), // Return in chronological order
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
};
}
/**
* Mark messages as read in a conversation
*/
async markMessagesAsRead(conversationId: string, userId: string) {
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
include: {
agentProfile: {
select: { userId: true },
},
},
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
const isUser = conversation.userId === userId;
const isAgent = conversation.agentProfile.userId === userId;
if (!isUser && !isAgent) {
throw new ForbiddenException('You are not part of this conversation');
}
// Update all unread messages from the other party
await this.prisma.message.updateMany({
where: {
conversationId,
senderId: { not: userId },
status: { not: MessageStatus.READ },
},
data: {
status: MessageStatus.READ,
readAt: new Date(),
},
});
// Reset unread count for this user
await this.prisma.conversation.update({
where: { id: conversationId },
data: isUser ? { userUnreadCount: 0 } : { agentUnreadCount: 0 },
});
return { success: true };
}
/**
* Update user's online status
*/
async updateOnlineStatus(userId: string, isOnline: boolean) {
await this.prisma.user.update({
where: { id: userId },
data: {
isOnline,
lastSeenAt: isOnline ? undefined : new Date(),
},
});
}
/**
* Mark a message as delivered
*/
async markMessageDelivered(messageId: string) {
return this.prisma.message.update({
where: { id: messageId },
data: {
status: MessageStatus.DELIVERED,
deliveredAt: new Date(),
},
});
}
/**
* Get conversation by ID with full details
*/
async getConversationById(conversationId: string, userId: string) {
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
include: {
agentProfile: {
select: {
id: true,
firstName: true,
lastName: true,
avatar: true,
headline: true,
userId: true,
user: {
select: {
isOnline: true,
lastSeenAt: true,
},
},
},
},
user: {
select: {
id: true,
isOnline: true,
lastSeenAt: true,
userProfile: {
select: {
firstName: true,
lastName: true,
avatar: true,
},
},
},
},
},
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
const isUser = conversation.userId === userId;
const isAgent = conversation.agentProfile.userId === userId;
if (!isUser && !isAgent) {
throw new ForbiddenException('You are not part of this conversation');
}
// Transform to include otherParty based on who is requesting
if (isUser) {
// User is viewing - other party is agent
return {
...conversation,
unreadCount: conversation.userUnreadCount,
otherParty: {
id: conversation.agentProfile.id,
userId: conversation.agentProfile.userId,
name: `${conversation.agentProfile.firstName || ''} ${conversation.agentProfile.lastName || ''}`.trim() || 'Agent',
avatar: conversation.agentProfile.avatar,
headline: conversation.agentProfile.headline,
isOnline: conversation.agentProfile.user?.isOnline || false,
lastSeenAt: conversation.agentProfile.user?.lastSeenAt || null,
},
};
} else {
// Agent is viewing - other party is user
return {
...conversation,
unreadCount: conversation.agentUnreadCount,
otherParty: {
id: conversation.user.id,
name: `${conversation.user.userProfile?.firstName || ''} ${conversation.user.userProfile?.lastName || ''}`.trim() || 'User',
avatar: conversation.user.userProfile?.avatar,
isOnline: conversation.user.isOnline,
lastSeenAt: conversation.user.lastSeenAt,
},
};
}
}
/**
* Get total unread count for a user across all conversations
*/
async getTotalUnreadCount(userId: string, role: UserRole) {
if (role === UserRole.AGENT) {
const agentProfile = await this.prisma.agentProfile.findUnique({
where: { userId },
});
if (!agentProfile) {
return 0;
}
const result = await this.prisma.conversation.aggregate({
where: { agentProfileId: agentProfile.id },
_sum: { agentUnreadCount: true },
});
return result._sum.agentUnreadCount || 0;
} else {
const result = await this.prisma.conversation.aggregate({
where: { userId },
_sum: { userUnreadCount: true },
});
return result._sum.userUnreadCount || 0;
}
}
}