✨ 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 для разработки в не продакшн среде
241 lines
7.4 KiB
TypeScript
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');
|
|
}
|
|
}
|