import { Injectable, NotFoundException, ForbiddenException, BadRequestException, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; 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, private readonly eventEmitter: EventEmitter2, ) {} /** * Check if a user's activity status allows showing online status. * Connected users always see online status for 'connections' setting. */ private shouldShowOnlineStatus(privacyPreferences: any, isConnected = true): boolean { const activityStatus = (privacyPreferences as any)?.privacySettings?.activity_status || 'public'; if (activityStatus === 'public') return true; if (activityStatus === 'connections' && isConnected) return true; return false; } /** * 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. * Supports both user-initiated (provides agentProfileId) and agent-initiated (provides userId). */ async getOrCreateConversation( callerUserId: string, callerRole: UserRole, dto: { agentProfileId?: string; userId?: string }, ) { let userId: string; let agentProfileId: string; if (callerRole === UserRole.AGENT) { // Agent is starting the conversation — they provide the target userId if (!dto.userId) { throw new BadRequestException('userId is required for agents to start a conversation'); } const agentProfile = await this.prisma.agentProfile.findUnique({ where: { userId: callerUserId }, }); if (!agentProfile) { throw new BadRequestException('Agent profile not found'); } userId = dto.userId; agentProfileId = agentProfile.id; } else { // User is starting the conversation — they provide the target agentProfileId if (!dto.agentProfileId) { throw new BadRequestException('agentProfileId is required for users to start a conversation'); } userId = callerUserId; agentProfileId = dto.agentProfileId; } // Validate connection first const canMessage = await this.validateCanMessage(userId, agentProfileId); if (!canMessage) { throw new ForbiddenException( 'You can only message users with accepted connection requests', ); } const conversationInclude = { agentProfile: { select: { id: true, firstName: true, lastName: true, avatar: true, headline: true, userId: true, user: { select: { isOnline: true, lastSeenAt: true, privacyPreferences: true, }, }, }, }, user: { select: { id: true, isOnline: true, lastSeenAt: true, privacyPreferences: true, userProfile: { select: { firstName: true, lastName: true, avatar: true, }, }, }, }, }; // Check if conversation already exists let conversation = await this.prisma.conversation.findUnique({ where: { userId_agentProfileId: { userId, agentProfileId, }, }, include: conversationInclude, }); // Create new conversation if it doesn't exist if (!conversation) { conversation = await this.prisma.conversation.create({ data: { userId, agentProfileId, }, include: conversationInclude, }); } // Transform to include otherParty based on caller role // Conversations only exist between connected users, so isConnected=true if (callerRole === UserRole.AGENT) { // Agent is viewing — other party is the user const showStatus = this.shouldShowOnlineStatus(conversation.user.privacyPreferences); 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: showStatus ? conversation.user.isOnline : false, lastSeenAt: showStatus ? conversation.user.lastSeenAt : null, }, }; } else { // User is viewing — other party is the agent const showStatus = this.shouldShowOnlineStatus(conversation.agentProfile.user?.privacyPreferences); 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: showStatus ? (conversation.agentProfile.user?.isOnline || false) : false, lastSeenAt: showStatus ? (conversation.agentProfile.user?.lastSeenAt || null) : 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'); } // Verify connection is still active before allowing message const canMessage = await this.validateCanMessage( conversation.userId, conversation.agentProfileId, ); if (!canMessage) { throw new ForbiddenException( 'You can only message users with accepted connection requests', ); } // 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 let truncatedContent: string; if (dto.messageType === 'IMAGE') { truncatedContent = 'Sent an image'; } else if (dto.messageType === 'FILE') { truncatedContent = 'Sent a file'; } else { 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 } }), }, }); // Emit push notification event for the recipient const recipientUserId = isUser ? conversation.agentProfile.userId : conversation.userId; const senderProfile = message.sender?.agentProfile || message.sender?.userProfile; const senderName = senderProfile ? `${senderProfile.firstName || ''} ${senderProfile.lastName || ''}`.trim() : 'Someone'; this.eventEmitter.emit('notification.message', { recipientUserId, senderName, messagePreview: truncatedContent.substring(0, 100) || '', conversationId, }); 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, privacyPreferences: 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) => { const showStatus = this.shouldShowOnlineStatus(conv.user.privacyPreferences); const cleared = conv.agentClearedAt; const isCleared = cleared && conv.lastMessageAt && new Date(cleared) >= new Date(conv.lastMessageAt); return { ...conv, lastMessageText: isCleared ? null : conv.lastMessageText, lastMessageAt: isCleared ? null : conv.lastMessageAt, 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: showStatus ? conv.user.isOnline : false, lastSeenAt: showStatus ? conv.user.lastSeenAt : null, }, }; }); } 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, privacyPreferences: true, }, }, }, }, messages: { take: 1, orderBy: { createdAt: 'desc' }, }, }, orderBy: { lastMessageAt: 'desc' }, }); // Map to include unread count from user perspective return conversations.map((conv) => { const showStatus = this.shouldShowOnlineStatus(conv.agentProfile.user.privacyPreferences); const cleared = conv.userClearedAt; const isCleared = cleared && conv.lastMessageAt && new Date(cleared) >= new Date(conv.lastMessageAt); return { ...conv, lastMessageText: isCleared ? null : conv.lastMessageText, lastMessageAt: isCleared ? null : conv.lastMessageAt, 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: showStatus ? conv.agentProfile.user.isOnline : false, lastSeenAt: showStatus ? conv.agentProfile.user.lastSeenAt : null, }, }; }); } } /** * 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; // Filter out messages cleared by this user const clearedAt = isUser ? conversation.userClearedAt : conversation.agentClearedAt; const messageWhere: any = { conversationId }; if (clearedAt) { messageWhere.createdAt = { gt: clearedAt }; } const [messages, total] = await Promise.all([ this.prisma.message.findMany({ where: messageWhere, 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: messageWhere }), ]); 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(), }, }); } /** * Mark all SENT messages addressed to a user as DELIVERED (when they come online) * Returns the updated messages grouped by conversation for notification purposes */ async markPendingMessagesAsDelivered(userId: string): Promise<{ conversationId: string; senderId: string; messageIds: string[]; deliveredAt: Date }[]> { const now = new Date(); // Find all conversations where this user is a participant const conversations = await this.prisma.conversation.findMany({ where: { OR: [ { userId }, { agentProfile: { userId } }, ], }, select: { id: true, userId: true, agentProfile: { select: { userId: true } } }, }); const results: { conversationId: string; senderId: string; messageIds: string[]; deliveredAt: Date }[] = []; for (const conv of conversations) { const otherUserId = conv.userId === userId ? conv.agentProfile.userId : conv.userId; // Find SENT messages from the other user in this conversation const sentMessages = await this.prisma.message.findMany({ where: { conversationId: conv.id, senderId: otherUserId, status: MessageStatus.SENT, }, select: { id: true }, }); if (sentMessages.length > 0) { const messageIds = sentMessages.map((m) => m.id); await this.prisma.message.updateMany({ where: { id: { in: messageIds } }, data: { status: MessageStatus.DELIVERED, deliveredAt: now }, }); results.push({ conversationId: conv.id, senderId: otherUserId, messageIds, deliveredAt: now }); } } return results; } /** * 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 participant user IDs for a conversation (lightweight query) */ async getConversationParticipants(conversationId: string): Promise<{ userId: string; agentUserId: string } | null> { const conversation = await this.prisma.conversation.findUnique({ where: { id: conversationId }, select: { userId: true, agentProfile: { select: { userId: true }, }, }, }); if (!conversation) return null; return { userId: conversation.userId, agentUserId: conversation.agentProfile.userId, }; } /** * Clear chat for the requesting user only (messages are hidden, not deleted) * The other party still sees all messages. */ async clearChat(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'); } const now = new Date(); // Set clearedAt timestamp for the requesting user only await this.prisma.conversation.update({ where: { id: conversationId }, data: { ...(isUser ? { userClearedAt: now, userUnreadCount: 0 } : {}), ...(isAgent ? { agentClearedAt: now, agentUnreadCount: 0 } : {}), }, }); return null; } /** * Delete a conversation and all its messages */ async deleteConversation(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'); } // Delete all messages first, then the conversation await this.prisma.message.deleteMany({ where: { conversationId }, }); await this.prisma.conversation.delete({ where: { id: conversationId }, }); return null; } /** * 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; } } /** * Toggle mute status for a conversation */ async toggleMute(conversationId: string, userId: string, muted: boolean) { 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 updated = await this.prisma.conversation.update({ where: { id: conversationId }, data: isUser ? { userMuted: muted } : { agentMuted: muted }, }); return { id: updated.id, muted: isUser ? updated.userMuted : updated.agentMuted }; } /** * Toggle favorite status for a conversation */ async toggleFavorite(conversationId: string, userId: string, favorited: boolean) { 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 updated = await this.prisma.conversation.update({ where: { id: conversationId }, data: isUser ? { userFavorited: favorited } : { agentFavorited: favorited }, }); return { id: updated.id, favorited: isUser ? updated.userFavorited : updated.agentFavorited }; } /** * Check if a conversation is muted for a specific user */ async isConversationMuted(conversationId: string, userId: string): Promise { const conversation = await this.prisma.conversation.findUnique({ where: { id: conversationId }, include: { agentProfile: { select: { userId: true } } }, }); if (!conversation) return false; const isUser = conversation.userId === userId; return isUser ? conversation.userMuted : conversation.agentMuted; } }