fix sso core folder
This commit is contained in:
457
apps/sso-core/src/domain/chat.service.ts
Normal file
457
apps/sso-core/src/domain/chat.service.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { FamilyService } from './family.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
interface PollPayload {
|
||||
question: string;
|
||||
options: string[];
|
||||
allowsMultiple?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly family: FamilyService,
|
||||
private readonly notifications: NotificationsService
|
||||
) {}
|
||||
|
||||
async listRooms(userId: string, groupId: string) {
|
||||
await this.family.assertFamilyMember(groupId, userId);
|
||||
let generalRoom = await this.prisma.chatRoom.findFirst({ where: { groupId, type: 'GENERAL' } });
|
||||
if (!generalRoom) {
|
||||
const members = await this.prisma.familyMember.findMany({ where: { groupId } });
|
||||
generalRoom = await this.prisma.chatRoom.create({
|
||||
data: {
|
||||
groupId,
|
||||
type: 'GENERAL',
|
||||
name: 'Общий чат',
|
||||
members: { create: members.map((member) => ({ userId: member.userId })) }
|
||||
}
|
||||
});
|
||||
}
|
||||
const rooms = await this.prisma.chatRoom.findMany({
|
||||
where: {
|
||||
groupId,
|
||||
members: { some: { userId } }
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: true } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [{ type: 'asc' }, { updatedAt: 'desc' }]
|
||||
});
|
||||
|
||||
return {
|
||||
rooms: await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const membership = room.members.find((member) => member.userId === userId);
|
||||
const lastMessage = room.messages[0];
|
||||
return {
|
||||
id: room.id,
|
||||
groupId: room.groupId,
|
||||
type: room.type,
|
||||
name: room.name,
|
||||
hasAvatar: room.hasAvatar,
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
members: room.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
displayName: member.user.displayName,
|
||||
hasAvatar: Boolean(member.user.avatarStorageKey),
|
||||
notificationsMuted: member.notificationsMuted
|
||||
})),
|
||||
lastMessage: lastMessage ? await this.toMessage(lastMessage, userId) : undefined
|
||||
};
|
||||
})
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) {
|
||||
const group = await this.family.assertFamilyMember(groupId, userId);
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new BadRequestException('Укажите название группового чата');
|
||||
}
|
||||
|
||||
const uniqueMembers = Array.from(new Set([userId, ...memberUserIds]));
|
||||
for (const memberId of uniqueMembers) {
|
||||
if (!group.members.some((member) => member.userId === memberId)) {
|
||||
throw new BadRequestException('Все участники чата должны состоять в семье');
|
||||
}
|
||||
}
|
||||
|
||||
const room = await this.prisma.chatRoom.create({
|
||||
data: {
|
||||
groupId,
|
||||
type: 'GROUP',
|
||||
name: trimmedName,
|
||||
createdById: userId,
|
||||
members: {
|
||||
create: uniqueMembers.map((memberId) => ({ userId: memberId }))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: true } },
|
||||
messages: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
groupId: room.groupId,
|
||||
type: room.type,
|
||||
name: room.name,
|
||||
hasAvatar: room.hasAvatar,
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
members: room.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
displayName: member.user.displayName,
|
||||
hasAvatar: Boolean(member.user.avatarStorageKey),
|
||||
notificationsMuted: member.notificationsMuted
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async updateRoomSettings(userId: string, roomId: string, name?: string, notificationsMuted?: boolean) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
if (name !== undefined) {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new BadRequestException('Укажите название чата');
|
||||
}
|
||||
if (room.type === 'GENERAL') {
|
||||
throw new BadRequestException('Нельзя переименовать общий чат');
|
||||
}
|
||||
await this.prisma.chatRoom.update({ where: { id: roomId }, data: { name: trimmedName } });
|
||||
}
|
||||
if (notificationsMuted !== undefined) {
|
||||
await this.prisma.chatRoomMember.update({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
data: { notificationsMuted }
|
||||
});
|
||||
}
|
||||
return this.getRoomResponse(roomId, userId);
|
||||
}
|
||||
|
||||
async listMessages(userId: string, roomId: string, beforeMessageId?: string, limit = 50) {
|
||||
await this.getRoomForMember(roomId, userId);
|
||||
const take = Math.min(Math.max(limit, 1), 100);
|
||||
|
||||
let cursorDate: Date | undefined;
|
||||
if (beforeMessageId) {
|
||||
const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } });
|
||||
if (cursor) cursorDate = cursor.createdAt;
|
||||
}
|
||||
|
||||
const messages = await this.prisma.chatMessage.findMany({
|
||||
where: {
|
||||
roomId,
|
||||
...(cursorDate ? { createdAt: { lt: cursorDate } } : {})
|
||||
},
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take
|
||||
});
|
||||
|
||||
const ordered = messages.reverse();
|
||||
return {
|
||||
messages: await Promise.all(ordered.map((message) => this.toMessage(message, userId)))
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
userId: string,
|
||||
roomId: string,
|
||||
type: string,
|
||||
content?: string,
|
||||
replyToId?: string,
|
||||
storageKey?: string,
|
||||
mimeType?: string,
|
||||
metadataJson?: string,
|
||||
poll?: PollPayload
|
||||
) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL']);
|
||||
if (!allowedTypes.has(type)) {
|
||||
throw new BadRequestException('Неподдерживаемый тип сообщения');
|
||||
}
|
||||
|
||||
if (type === 'POLL') {
|
||||
if (!poll?.question?.trim() || !poll.options?.length) {
|
||||
throw new BadRequestException('Укажите вопрос и варианты опроса');
|
||||
}
|
||||
if (poll.options.length < 2) {
|
||||
throw new BadRequestException('В опросе должно быть минимум 2 варианта');
|
||||
}
|
||||
} else if (type !== 'IMAGE' && type !== 'AUDIO' && type !== 'VOICE' && type !== 'FILE') {
|
||||
if (!content?.trim()) {
|
||||
throw new BadRequestException('Сообщение не может быть пустым');
|
||||
}
|
||||
}
|
||||
|
||||
if ((type === 'IMAGE' || type === 'AUDIO' || type === 'VOICE' || type === 'FILE') && !storageKey) {
|
||||
throw new BadRequestException('Не указан файл для отправки');
|
||||
}
|
||||
|
||||
if (storageKey && !storageKey.startsWith(`chat/${roomId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ медиафайла');
|
||||
}
|
||||
|
||||
const metadata = metadataJson ? JSON.parse(metadataJson) : undefined;
|
||||
|
||||
const message = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.chatMessage.create({
|
||||
data: {
|
||||
roomId,
|
||||
senderId: userId,
|
||||
type,
|
||||
content: content?.trim() || null,
|
||||
replyToId: replyToId || null,
|
||||
storageKey: storageKey || null,
|
||||
mimeType: mimeType || null,
|
||||
metadata: metadata ?? undefined
|
||||
},
|
||||
include: { sender: true }
|
||||
});
|
||||
|
||||
if (type === 'POLL' && poll) {
|
||||
await tx.chatPoll.create({
|
||||
data: {
|
||||
messageId: created.id,
|
||||
question: poll.question.trim(),
|
||||
allowsMultiple: poll.allowsMultiple ?? false,
|
||||
isAnonymous: poll.isAnonymous ?? false,
|
||||
options: {
|
||||
create: poll.options.map((text) => ({ text: text.trim() })).filter((item) => item.text)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await tx.chatRoom.update({ where: { id: roomId }, data: { updatedAt: new Date() } });
|
||||
return created;
|
||||
});
|
||||
|
||||
const fullMessage = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: message.id },
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
}
|
||||
});
|
||||
|
||||
const response = await this.toMessage(fullMessage!, userId);
|
||||
await this.notifyRoomMembers(room, userId, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
async votePoll(userId: string, messageId: string, optionIds: string[]) {
|
||||
const message = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
poll: { include: { options: true } },
|
||||
room: { include: { members: true, group: true } }
|
||||
}
|
||||
});
|
||||
if (!message?.poll) {
|
||||
throw new NotFoundException('Опрос не найден');
|
||||
}
|
||||
await this.getRoomForMember(message.roomId, userId);
|
||||
|
||||
const validOptionIds = new Set(message.poll.options.map((option) => option.id));
|
||||
for (const optionId of optionIds) {
|
||||
if (!validOptionIds.has(optionId)) {
|
||||
throw new BadRequestException('Некорректный вариант опроса');
|
||||
}
|
||||
}
|
||||
if (!message.poll.allowsMultiple && optionIds.length > 1) {
|
||||
throw new BadRequestException('Можно выбрать только один вариант');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.chatPollVote.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
option: { pollId: message.poll!.id }
|
||||
}
|
||||
});
|
||||
for (const optionId of optionIds) {
|
||||
await tx.chatPollVote.create({ data: { optionId, userId } });
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
sender: true,
|
||||
poll: { include: { options: { include: { votes: true } } } }
|
||||
}
|
||||
});
|
||||
return this.toMessage(updated!, userId);
|
||||
}
|
||||
|
||||
async setRoomNotificationsMuted(userId: string, roomId: string, muted: boolean) {
|
||||
await this.getRoomForMember(roomId, userId);
|
||||
await this.prisma.chatRoomMember.update({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
data: { notificationsMuted: muted }
|
||||
});
|
||||
return { count: 1 };
|
||||
}
|
||||
|
||||
async assertRoomMember(roomId: string, userId: string) {
|
||||
return this.getRoomForMember(roomId, userId);
|
||||
}
|
||||
|
||||
private async getRoomForMember(roomId: string, userId: string) {
|
||||
const room = await this.prisma.chatRoom.findUnique({
|
||||
where: { id: roomId },
|
||||
include: {
|
||||
members: true,
|
||||
group: { include: { members: true } }
|
||||
}
|
||||
});
|
||||
if (!room) {
|
||||
throw new NotFoundException('Чат не найден');
|
||||
}
|
||||
if (!room.members.some((member) => member.userId === userId)) {
|
||||
throw new ForbiddenException('Вы не состоите в этом чате');
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private async getRoomResponse(roomId: string, userId: string) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
const full = await this.prisma.chatRoom.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { include: { user: true } } }
|
||||
});
|
||||
return {
|
||||
id: full!.id,
|
||||
groupId: full!.groupId,
|
||||
type: full!.type,
|
||||
name: full!.name,
|
||||
hasAvatar: full!.hasAvatar,
|
||||
updatedAt: full!.updatedAt.toISOString(),
|
||||
members: full!.members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
displayName: member.user.displayName,
|
||||
hasAvatar: Boolean(member.user.avatarStorageKey),
|
||||
notificationsMuted: member.notificationsMuted
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private async notifyRoomMembers(
|
||||
room: { id: string; name: string; members: Array<{ userId: string; notificationsMuted: boolean }> },
|
||||
senderId: string,
|
||||
message: Awaited<ReturnType<ChatService['toMessage']>>
|
||||
) {
|
||||
const sender = await this.prisma.user.findUnique({ where: { id: senderId } });
|
||||
const preview =
|
||||
message.type === 'TEXT' || message.type === 'EMOJI'
|
||||
? (message.content ?? '')
|
||||
: message.type === 'POLL'
|
||||
? '📊 Опрос'
|
||||
: message.type === 'VOICE'
|
||||
? '🎤 Голосовое'
|
||||
: message.type === 'AUDIO'
|
||||
? '🎵 Аудио'
|
||||
: message.type === 'FILE'
|
||||
? '📄 Файл'
|
||||
: message.type === 'IMAGE'
|
||||
? '📷 Фото'
|
||||
: '📎 Медиа';
|
||||
|
||||
for (const member of room.members) {
|
||||
if (member.userId === senderId) continue;
|
||||
await this.notifications.publishRealtime(member.userId, 'chat_message', room.name, preview.slice(0, 120), {
|
||||
roomId: room.id,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of room.members) {
|
||||
if (member.userId === senderId || member.notificationsMuted) continue;
|
||||
await this.notifications.create(
|
||||
member.userId,
|
||||
'chat_message',
|
||||
room.name,
|
||||
`${sender?.displayName ?? 'Участник'}: ${preview.slice(0, 120)}`,
|
||||
{ roomId: room.id, messageId: message.id },
|
||||
{ skipPublish: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async toMessage(
|
||||
message: {
|
||||
id: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
type: string;
|
||||
content: string | null;
|
||||
replyToId: string | null;
|
||||
storageKey: string | null;
|
||||
mimeType: string | null;
|
||||
metadata: unknown;
|
||||
createdAt: Date;
|
||||
sender: { displayName: string; avatarStorageKey: string | null };
|
||||
poll?: {
|
||||
id: string;
|
||||
question: string;
|
||||
allowsMultiple: boolean;
|
||||
isAnonymous: boolean;
|
||||
closedAt: Date | null;
|
||||
options: Array<{ id: string; text: string; votes: Array<{ userId: string }> }>;
|
||||
} | null;
|
||||
},
|
||||
viewerId: string
|
||||
) {
|
||||
return {
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
senderId: message.senderId,
|
||||
senderName: message.sender.displayName,
|
||||
senderHasAvatar: Boolean(message.sender.avatarStorageKey),
|
||||
type: message.type,
|
||||
content: message.content ?? undefined,
|
||||
replyToId: message.replyToId ?? undefined,
|
||||
storageKey: message.storageKey ?? undefined,
|
||||
mimeType: message.mimeType ?? undefined,
|
||||
metadataJson: message.metadata ? JSON.stringify(message.metadata) : undefined,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
poll: message.poll
|
||||
? {
|
||||
id: message.poll.id,
|
||||
question: message.poll.question,
|
||||
allowsMultiple: message.poll.allowsMultiple,
|
||||
isAnonymous: message.poll.isAnonymous,
|
||||
closedAt: message.poll.closedAt?.toISOString(),
|
||||
options: message.poll.options.map((option) => ({
|
||||
id: option.id,
|
||||
text: option.text,
|
||||
voteCount: option.votes.length,
|
||||
votedByMe: option.votes.some((vote) => vote.userId === viewerId)
|
||||
}))
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user