Files
dating-app-backend/src/modules/chat/chat.service.ts
Oscar cd98f04987 feat(database-schema): добавляет ссылку на активный чат в схему профиля пользователя
 feat(likes.service): обновляет логику создания лайков с учетом активного чата

 feat(likes.module): импортирует модуль Gateways для работы с чатами

 feat(feed.service): добавляет условие для фильтрации профилей без активного чата

 feat(storage.service): устанавливает политику доступа к бакету S3 для получения объектов

 feat(likes-response.dto): добавляет поле чата в ответ на лайк

 feat(media.controller): добавляет описание для загрузки медиафайлов

 feat(chat.service): добавляет возможность закрытия чата с отчетом или сообщением

 feat(chat.controller): обновляет метод закрытия чата для обработки отчетов

 feat(app.module): добавляет модуль Dev для разработки в не продакшн среде
2026-06-09 15:39:38 +03:00

241 lines
7.4 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { chat, match, message, profile, report, user } from '../../database/schema';
import { NotificationsService } from '../../notifications/notifications.service';
import { CreateChatDto } from './dto/create-chat.dto';
import { CloseChatDto, CloseChatType } from './dto/close-chat.dto';
import { SendMessageDto } from './dto/send-message.dto';
@Injectable()
export class ChatService {
constructor(
private readonly drizzleService: DrizzleService,
private readonly notificationsService: NotificationsService,
) {}
async createChat(userId: string, dto: CreateChatDto) {
await this.assertProfileOwnership(userId, dto.profileId);
const [foundMatch] = await this.drizzleService.db
.select()
.from(match)
.where(
and(
eq(match.id, dto.matchId),
or(eq(match.profile1Id, dto.profileId), eq(match.profile2Id, dto.profileId)),
),
)
.limit(1);
if (!foundMatch) throw new NotFoundException('Match not found');
const existingChat = await this.drizzleService.db
.select()
.from(chat)
.where(
or(
and(eq(chat.profile1Id, foundMatch.profile1Id), eq(chat.profile2Id, foundMatch.profile2Id)),
and(eq(chat.profile1Id, foundMatch.profile2Id), eq(chat.profile2Id, foundMatch.profile1Id)),
),
)
.limit(1);
if (existingChat.length > 0) return existingChat[0];
const [currentProfile] = await this.drizzleService.db
.select({ activeChatId: profile.activeChatId })
.from(profile)
.where(eq(profile.id, dto.profileId))
.limit(1);
if (currentProfile?.activeChatId) {
throw new BadRequestException(
'Profile already has an active chat. Close it before opening a new one.',
);
}
const [newChat] = await this.drizzleService.db
.insert(chat)
.values({
profile1Id: foundMatch.profile1Id,
profile2Id: foundMatch.profile2Id,
status: 'active',
} as any)
.returning();
await this.drizzleService.db
.update(profile)
.set({ activeChatId: newChat.id } as any)
.where(or(eq(profile.id, foundMatch.profile1Id), eq(profile.id, foundMatch.profile2Id)));
return newChat;
}
async closeChat(userId: string, profileId: string, chatId: string, dto: CloseChatDto) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db
.select()
.from(chat)
.where(eq(chat.id, chatId))
.limit(1);
if (!foundChat) throw new NotFoundException('Chat not found');
if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant');
}
const otherProfileId = foundChat.profile1Id === profileId
? foundChat.profile2Id
: foundChat.profile1Id;
let messageText: string;
if (dto.type === CloseChatType.Report) {
if (!dto.reportReason) throw new BadRequestException('reportReason is required for type=report');
messageText = dto.reportDescription
? `Жалоба: ${dto.reportReason}. ${dto.reportDescription}`
: `Жалоба: ${dto.reportReason}`;
await this.drizzleService.db
.insert(report)
.values({
sourceProfileId: profileId,
entityId: otherProfileId,
entityType: 'profile',
description: messageText,
} as any);
} else {
messageText = dto.cancelMessage?.trim() || 'Диалог завершён';
}
await this.drizzleService.db
.insert(message)
.values({
chatId,
profileId,
text: messageText,
mediaUrl: null,
mediaType: null,
} as any);
await this.drizzleService.db
.update(chat)
.set({ status: 'closed' } as any)
.where(eq(chat.id, chatId));
await this.drizzleService.db
.update(profile)
.set({ activeChatId: null } as any)
.where(or(eq(profile.id, foundChat.profile1Id), eq(profile.id, foundChat.profile2Id)));
return { message: 'Chat closed' };
}
async getChatsForProfile(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db
.select()
.from(chat)
.where(or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)));
}
async getChatMessages(userId: string, profileId: string, chatId: string, page = 1, limit = 50) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db
.select()
.from(chat)
.where(eq(chat.id, chatId))
.limit(1);
if (!foundChat) throw new NotFoundException('Chat not found');
if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant');
}
const offset = (page - 1) * limit;
return this.drizzleService.db
.select()
.from(message)
.where(eq(message.chatId, chatId))
.orderBy(message.createdAt)
.limit(limit)
.offset(offset);
}
async sendMessage(userId: string, profileId: string, chatId: string, dto: SendMessageDto) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db
.select()
.from(chat)
.where(and(eq(chat.id, chatId), eq(chat.status, 'active')))
.limit(1);
if (!foundChat) throw new NotFoundException('Active chat not found');
if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant');
}
if (!dto.text && !dto.mediaUrl) {
throw new BadRequestException('Message must have text or media');
}
const [newMessage] = await this.drizzleService.db
.insert(message)
.values({
chatId,
profileId,
text: dto.text || null,
mediaUrl: dto.mediaUrl || null,
mediaType: dto.mediaType || null,
} as any)
.returning();
const recipientProfileId =
foundChat.profile1Id === profileId ? foundChat.profile2Id : foundChat.profile1Id;
const [recipientProfile] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, recipientProfileId))
.limit(1);
if (recipientProfile) {
const [recipientUser] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken })
.from(user)
.where(eq(user.id, recipientProfile.userId))
.limit(1);
if (recipientUser?.fcmToken) {
await this.notificationsService.sendPushNotification(
recipientUser.fcmToken,
'New message',
dto.text?.substring(0, 100) || 'Media message',
{ chatId, messageId: newMessage.id, type: 'message' },
);
}
}
return newMessage;
}
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
}