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

885 lines
26 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,
) {}
/**
* 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<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.
* 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<boolean> {
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;
}
}