feat: Implement real-time messaging module with REST API endpoints and WebSocket gateway.
This commit is contained in:
35
src/messages/dto/create-message.dto.ts
Normal file
35
src/messages/dto/create-message.dto.ts
Normal 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;
|
||||
}
|
||||
2
src/messages/dto/index.ts
Normal file
2
src/messages/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './create-message.dto';
|
||||
export * from './start-conversation.dto';
|
||||
10
src/messages/dto/start-conversation.dto.ts
Normal file
10
src/messages/dto/start-conversation.dto.ts
Normal 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
5
src/messages/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './messages.module';
|
||||
export * from './messages.service';
|
||||
export * from './messages.controller';
|
||||
export * from './messages.gateway';
|
||||
export * from './dto';
|
||||
218
src/messages/messages.controller.ts
Normal file
218
src/messages/messages.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
278
src/messages/messages.gateway.ts
Normal file
278
src/messages/messages.gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/messages/messages.module.ts
Normal file
28
src/messages/messages.module.ts
Normal 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 {}
|
||||
586
src/messages/messages.service.ts
Normal file
586
src/messages/messages.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user