first commit
هذا الالتزام موجود في:
250
src/modules/chat/chat.service.ts
Normal file
250
src/modules/chat/chat.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||
import { MessageQueryDto } from './dto/message-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { ChatRepository } from './chat.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly chatRepository: ChatRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
||||
const uniqueParticipantIds = Array.from(new Set([currentUserId, ...dto.participantIds]));
|
||||
if (uniqueParticipantIds.length < 2) {
|
||||
throw new BadRequestException('Conversation must include at least 2 participants');
|
||||
}
|
||||
|
||||
for (const participantId of uniqueParticipantIds) {
|
||||
if (!Types.ObjectId.isValid(participantId)) {
|
||||
throw new BadRequestException('Invalid participant id');
|
||||
}
|
||||
}
|
||||
|
||||
const users = await Promise.all(uniqueParticipantIds.map((id) => this.usersRepository.findById(id)));
|
||||
if (users.some((u) => !u || u.isDisabled)) {
|
||||
throw new BadRequestException('One or more participants are invalid or disabled');
|
||||
}
|
||||
|
||||
const isGroup = dto.isGroup ?? uniqueParticipantIds.length > 2;
|
||||
if (!isGroup && uniqueParticipantIds.length !== 2) {
|
||||
throw new BadRequestException('Direct conversation must contain exactly 2 participants');
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
const otherId = uniqueParticipantIds.find((id) => id !== currentUserId) as string;
|
||||
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, otherId);
|
||||
if (block) {
|
||||
throw new ForbiddenException('You cannot start chat with this user');
|
||||
}
|
||||
const existing = await this.chatRepository.findDirectConversation(currentUserId, otherId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
return this.chatRepository.createConversation({
|
||||
participantIds: uniqueParticipantIds,
|
||||
isGroup,
|
||||
title: dto.title,
|
||||
createdBy: currentUserId,
|
||||
});
|
||||
}
|
||||
|
||||
async getMyConversations(currentUserId: string, query: MessageQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
|
||||
this.chatRepository.countConversationsForUser(currentUserId),
|
||||
]);
|
||||
|
||||
const mappedItems = items.map((conversation) => {
|
||||
const unreadMap = (conversation.unreadCountByUser as unknown as Record<string, number>) ?? {};
|
||||
return {
|
||||
...conversation.toObject(),
|
||||
unreadCount: unreadMap[currentUserId] ?? 0,
|
||||
lastMessageAt: conversation.lastMessageAt ?? null,
|
||||
};
|
||||
});
|
||||
const nextOffset = skip + mappedItems.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items: mappedItems,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
||||
const conversation = await this.assertConversationMember(currentUserId, conversationId);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findMessages(conversation.id, skip, limit),
|
||||
this.chatRepository.countMessages(conversation.id),
|
||||
]);
|
||||
|
||||
await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
|
||||
const nextOffset = skip + items.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage(currentUserId: string, dto: SendMessageDto) {
|
||||
const conversation = await this.assertConversationMember(currentUserId, dto.conversationId);
|
||||
await this.assertNoChatBlockInConversation(currentUserId, conversation.participantIds.map((id) => id.toString()));
|
||||
const messageType = dto.messageType ?? 'text';
|
||||
const content = dto.content?.trim() ?? '';
|
||||
const mediaUrl = dto.mediaUrl?.trim() ?? '';
|
||||
|
||||
if (messageType === 'text' && !content) {
|
||||
throw new BadRequestException('Text message content is required');
|
||||
}
|
||||
|
||||
if (messageType !== 'text' && !mediaUrl) {
|
||||
throw new BadRequestException('mediaUrl is required for non-text messages');
|
||||
}
|
||||
|
||||
const message = await this.chatRepository.createMessage({
|
||||
conversationId: conversation.id,
|
||||
senderId: currentUserId,
|
||||
content,
|
||||
messageType,
|
||||
mediaUrl,
|
||||
});
|
||||
|
||||
const preview = messageType === 'text' ? content : `${messageType} message`;
|
||||
await this.chatRepository.updateConversationAfterNewMessage(
|
||||
conversation.id,
|
||||
message.id,
|
||||
currentUserId,
|
||||
preview,
|
||||
);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async markMessageSeen(currentUserId: string, messageId: string) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
|
||||
await this.assertConversationMember(currentUserId, message.conversationId.toString());
|
||||
await this.chatRepository.markMessageSeen(message.id, currentUserId);
|
||||
await this.chatRepository.clearConversationUnreadForUser(message.conversationId.toString(), currentUserId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async unsendMessage(currentUserId: string, messageId: string) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
if (message.senderId.toString() !== currentUserId) {
|
||||
throw new ForbiddenException('You can only unsend your own messages');
|
||||
}
|
||||
|
||||
const updated = await this.chatRepository.unsendMessage(messageId, currentUserId);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async blockUser(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new BadRequestException('You cannot block yourself');
|
||||
}
|
||||
|
||||
const target = await this.usersRepository.findById(targetUserId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Target user not found');
|
||||
}
|
||||
|
||||
await this.chatRepository.createBlock(currentUserId, targetUserId);
|
||||
return { blocked: true, targetUserId };
|
||||
}
|
||||
|
||||
async unblockUser(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
await this.chatRepository.removeBlock(currentUserId, targetUserId);
|
||||
return { blocked: false, targetUserId };
|
||||
}
|
||||
|
||||
async getBlockStatus(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
|
||||
const iBlocked = !!(await this.chatRepository.findBlock(currentUserId, targetUserId));
|
||||
const blockedMe = !!(await this.chatRepository.findBlock(targetUserId, currentUserId));
|
||||
|
||||
return { targetUserId, iBlocked, blockedMe };
|
||||
}
|
||||
|
||||
async getMyBlockedUsers(currentUserId: string) {
|
||||
const items = await this.chatRepository.findBlocksByBlocker(currentUserId);
|
||||
return { items };
|
||||
}
|
||||
|
||||
async assertConversationMember(userId: string, conversationId: string) {
|
||||
if (!Types.ObjectId.isValid(conversationId)) {
|
||||
throw new BadRequestException('Invalid conversation id');
|
||||
}
|
||||
|
||||
const conversation = await this.chatRepository.findConversationById(conversationId);
|
||||
if (!conversation) {
|
||||
throw new NotFoundException('Conversation not found');
|
||||
}
|
||||
|
||||
const isMember = conversation.participantIds.some((id) => id.toString() === userId);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenException('You are not a member of this conversation');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private async assertNoChatBlockInConversation(currentUserId: string, participantIds: string[]) {
|
||||
for (const participantId of participantIds) {
|
||||
if (participantId === currentUserId) {
|
||||
continue;
|
||||
}
|
||||
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, participantId);
|
||||
if (block) {
|
||||
throw new ForbiddenException('Cannot send message because one of participants is blocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم