feat: Implement real-time messaging module with REST API endpoints and WebSocket gateway.
This commit is contained in:
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