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