Files
backend/src/messages/messages.service.ts

630 lines
17 KiB
TypeScript

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,
) {}
/**
* 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 } }),
},
});
// 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: dto.content?.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,
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 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,
};
}
/**
* 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;
}
}
}