import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../infra/prisma.service'; import { resolveVerificationIcon } from './verification.constants'; import { SettingsService } from './settings.service'; import { NotificationsService } from './notifications.service'; import { MinioService } from '../infra/minio.service'; const INVITE_TTL_DAYS = 7; @Injectable() export class FamilyService { constructor( private readonly prisma: PrismaService, private readonly settings: SettingsService, private readonly notifications: NotificationsService, private readonly minio: MinioService ) {} async create(ownerId: string, name: string) { const trimmedName = name.trim(); if (!trimmedName) { throw new BadRequestException('Укажите название семьи'); } const group = await this.prisma.$transaction(async (tx) => { const created = await tx.familyGroup.create({ data: { ownerId, name: trimmedName, members: { create: { userId: ownerId, role: 'owner' } } }, include: { members: { include: { user: true } } } }); const generalRoom = await tx.chatRoom.create({ data: { groupId: created.id, type: 'GENERAL', name: 'Общий чат', createdById: ownerId, members: { create: { userId: ownerId } } } }); return { ...created, generalRoomId: generalRoom.id }; }); return this.toGroup(group); } async list(userId: string) { const groups = await this.prisma.familyGroup.findMany({ where: { OR: [{ ownerId: userId }, { members: { some: { userId } } }] }, include: { members: { include: { user: true } } }, orderBy: { updatedAt: 'desc' } }); return { groups: groups.map((group) => this.toGroup(group)) }; } async get(requesterId: string, groupId: string) { const group = await this.getGroupWithMembers(groupId); this.assertMember(group, requesterId); return this.toGroup(group); } async update(requesterId: string, groupId: string, name: string) { const group = await this.getGroupWithMembers(groupId); this.assertOwner(group, requesterId); const trimmedName = name.trim(); if (!trimmedName) { throw new BadRequestException('Укажите название семьи'); } const updated = await this.prisma.familyGroup.update({ where: { id: groupId }, data: { name: trimmedName }, include: { members: { include: { user: true } } } }); return this.toGroup(updated); } async delete(requesterId: string, groupId: string) { const group = await this.getGroupWithMembers(groupId); this.assertOwner(group, requesterId); await this.destroyGroupWithCleanup(group); return { count: 1 }; } async destroyOwnedGroupsForUser(userId: string) { const groups = await this.prisma.familyGroup.findMany({ where: { ownerId: userId }, include: { members: { include: { user: true } } } }); for (const group of groups) { await this.destroyGroupWithCleanup(group); } } async leaveAllMembershipsForUser(userId: string) { const memberships = await this.prisma.familyMember.findMany({ where: { userId, role: { not: 'owner' } } }); for (const member of memberships) { await this.removeMember(userId, member.id); } } private async destroyGroupWithCleanup(group: { id: string; ownerId: string; name: string; avatarStorageKey: string | null; members: Array<{ userId: string }>; }) { const rooms = await this.prisma.chatRoom.findMany({ where: { groupId: group.id }, include: { messages: { select: { storageKey: true } } } }); const storageKeys: string[] = []; if (group.avatarStorageKey) { storageKeys.push(group.avatarStorageKey); } for (const room of rooms) { if (room.avatarStorageKey) { storageKeys.push(room.avatarStorageKey); } for (const message of room.messages) { if (message.storageKey) { storageKeys.push(message.storageKey); } } } await this.minio.deleteObjects(storageKeys); await this.prisma.familyGroup.delete({ where: { id: group.id } }); for (const member of group.members) { await this.notifications.publishRealtime( member.userId, 'family_group_deleted', 'Семья удалена', `Семья «${group.name}» была удалена`, { groupId: group.id, groupName: group.name } ); if (member.userId === group.ownerId) { continue; } await this.notifications.create( member.userId, 'family_group_deleted', 'Семья удалена', `Семья «${group.name}» была удалена создателем`, { groupId: group.id, groupName: group.name } ); } } async addMember(groupId: string, userId: string, role: string) { const group = await this.prisma.familyGroup.findUnique({ where: { id: groupId }, include: { members: true } }); if (!group) { throw new BadRequestException('Семейная группа не найдена'); } const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6); if (group.members.length >= maxMembers) { throw new BadRequestException(`В группе может быть не более ${maxMembers} участников`); } const member = await this.prisma.$transaction(async (tx) => { const created = await tx.familyMember.create({ data: { groupId, userId, role } }); const rooms = await tx.chatRoom.findMany({ where: { groupId } }); for (const room of rooms) { if (room.type === 'GENERAL') { await tx.chatRoomMember.upsert({ where: { roomId_userId: { roomId: room.id, userId } }, create: { roomId: room.id, userId }, update: {} }); } } return created; }); return this.toMember(member); } async removeMember(requesterId: string, memberId: string) { const member = await this.prisma.familyMember.findUnique({ where: { id: memberId }, include: { group: { include: { members: true } } } }); if (!member) { throw new NotFoundException('Участник не найден'); } const isSelf = member.userId === requesterId; const isOwner = member.group.ownerId === requesterId; if (!isSelf && !isOwner) { throw new ForbiddenException('Недостаточно прав для удаления участника'); } if (member.role === 'owner') { throw new BadRequestException('Нельзя удалить владельца семьи'); } await this.prisma.$transaction(async (tx) => { await tx.chatRoomMember.deleteMany({ where: { userId: member.userId, room: { groupId: member.groupId } } }); await tx.familyMember.delete({ where: { id: memberId } }); }); return { count: 1 }; } async sendInvite(requesterId: string, groupId: string, options: { target?: string; inviteeUserId?: string }) { const group = await this.getGroupWithMembers(groupId); this.assertMember(group, requesterId); const invitee = options.inviteeUserId ? await this.prisma.user.findFirst({ where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null } }) : options.target ? await this.findUserByContact(options.target) : null; if (!invitee) { throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.'); } if (invitee.id === requesterId) { throw new BadRequestException('Нельзя пригласить самого себя'); } if (group.members.some((member) => member.userId === invitee.id)) { throw new BadRequestException('Пользователь уже состоит в семье'); } const existingPending = await this.prisma.familyInvite.findFirst({ where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' } }); if (existingPending) { throw new BadRequestException('Приглашение уже отправлено'); } const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } }); const invite = await this.prisma.familyInvite.create({ data: { groupId, inviterId: requesterId, inviteeUserId: invitee.id, targetEmail: invitee.email ?? undefined, targetPhone: invitee.phone ?? undefined, expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000) }, include: { group: true, inviter: true } }); await this.notifications.create( invitee.id, 'family_invite', 'Приглашение в семью', `${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`, { inviteId: invite.id, groupId, groupName: group.name } ); return this.toInvite(invite); } async searchInviteUsers(requesterId: string, groupId: string, query: string) { const group = await this.getGroupWithMembers(groupId); this.assertMember(group, requesterId); const trimmed = query.trim(); if (trimmed.length < 2) { return { users: [] }; } const excludedIds = [...group.members.map((member) => member.userId), requesterId]; const digits = trimmed.replace(/\D/g, ''); const users = await this.prisma.user.findMany({ where: { status: 'ACTIVE', deletedAt: null, id: { notIn: excludedIds }, OR: [ { displayName: { contains: trimmed, mode: 'insensitive' } }, { username: { contains: trimmed, mode: 'insensitive' } }, ...(trimmed.includes('@') ? [{ email: { contains: trimmed, mode: 'insensitive' as const } }] : []), ...(digits.length >= 4 ? [{ phone: { contains: digits } }] : []) ] }, orderBy: { displayName: 'asc' }, take: 8, select: { id: true, displayName: true, email: true, phone: true, username: true, avatarStorageKey: true, isVerified: true, verificationIcon: true } }); return { users: users.map((user) => ({ id: user.id, displayName: user.displayName, email: user.email, phone: user.phone, username: user.username, hasAvatar: Boolean(user.avatarStorageKey), isVerified: user.isVerified, verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined })) }; } async respondInvite(userId: string, inviteId: string, accept: boolean) { const invite = await this.prisma.familyInvite.findUnique({ where: { id: inviteId }, include: { group: { include: { members: true } }, inviter: true } }); if (!invite) { throw new NotFoundException('Приглашение не найдено'); } if (invite.status !== 'PENDING') { throw new BadRequestException('Приглашение уже обработано'); } if (invite.expiresAt.getTime() < Date.now()) { await this.prisma.familyInvite.update({ where: { id: inviteId }, data: { status: 'EXPIRED' } }); throw new BadRequestException('Срок действия приглашения истёк'); } if (invite.inviteeUserId && invite.inviteeUserId !== userId) { throw new ForbiddenException('Это приглашение предназначено другому пользователю'); } if (!accept) { const declined = await this.prisma.familyInvite.update({ where: { id: inviteId }, data: { status: 'DECLINED', inviteeUserId: invite.inviteeUserId ?? userId }, include: { group: true, inviter: true } }); await this.notifications.dismissFamilyInvite(userId, inviteId); return this.toInvite(declined); } const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6); if (invite.group.members.length >= maxMembers) { throw new BadRequestException(`В семье уже максимум ${maxMembers} участников`); } await this.prisma.$transaction(async (tx) => { await tx.familyInvite.update({ where: { id: inviteId }, data: { status: 'ACCEPTED', inviteeUserId: userId } }); await tx.familyMember.create({ data: { groupId: invite.groupId, userId, role: 'member' } }); const rooms = await tx.chatRoom.findMany({ where: { groupId: invite.groupId } }); for (const room of rooms) { if (room.type === 'GENERAL') { await tx.chatRoomMember.upsert({ where: { roomId_userId: { roomId: room.id, userId } }, create: { roomId: room.id, userId }, update: {} }); } } }); const user = await this.prisma.user.findUnique({ where: { id: userId } }); await this.notifications.create( invite.inviterId, 'family_invite_accepted', 'Приглашение принято', `${user?.displayName ?? 'Пользователь'} присоединился к «${invite.group.name}»`, { groupId: invite.groupId, userId } ); const updated = await this.prisma.familyInvite.findUnique({ where: { id: inviteId }, include: { group: true, inviter: true } }); await this.notifications.dismissFamilyInvite(userId, inviteId); return this.toInvite(updated!); } async listInvites(userId: string, groupId?: string) { const invites = await this.prisma.familyInvite.findMany({ where: { ...(groupId ? { groupId } : {}), inviteeUserId: userId, status: 'PENDING' }, include: { group: true, inviter: true }, orderBy: { createdAt: 'desc' } }); return { invites: invites.map((invite) => this.toInvite(invite)) }; } async assertFamilyMember(groupId: string, userId: string) { const group = await this.getGroupWithMembers(groupId); this.assertMember(group, userId); return group; } private async getGroupWithMembers(groupId: string) { const group = await this.prisma.familyGroup.findUnique({ where: { id: groupId }, include: { members: { include: { user: true } } } }); if (!group) { throw new NotFoundException('Семейная группа не найдена'); } return group; } private assertMember(group: { members: Array<{ userId: string }> }, userId: string) { if (!group.members.some((member) => member.userId === userId)) { throw new ForbiddenException('Вы не состоите в этой семье'); } } private assertOwner(group: { ownerId: string }, userId: string) { if (group.ownerId !== userId) { throw new ForbiddenException('Только владелец семьи может выполнить это действие'); } } private async findUserByContact(target: string) { const trimmed = target.trim(); if (!trimmed) { return null; } if (trimmed.includes('@')) { return this.prisma.user.findFirst({ where: { email: { equals: trimmed, mode: 'insensitive' }, status: 'ACTIVE', deletedAt: null } }); } const digits = trimmed.replace(/\D/g, ''); if (/^\+?\d/.test(trimmed) && digits.length >= 10) { return this.prisma.user.findFirst({ where: { status: 'ACTIVE', deletedAt: null, OR: [{ phone: trimmed }, { phone: { endsWith: digits.slice(-10) } }] } }); } return this.prisma.user.findFirst({ where: { status: 'ACTIVE', deletedAt: null, OR: [ { username: { equals: trimmed, mode: 'insensitive' } }, { displayName: { equals: trimmed, mode: 'insensitive' } } ] } }); } private toGroup(group: { id: string; ownerId: string; name: string; hasAvatar: boolean; createdAt: Date; updatedAt: Date; members: Array<{ id: string; groupId: string; userId: string; role: string; createdAt: Date; user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null }; }>; }) { return { id: group.id, ownerId: group.ownerId, name: group.name, hasAvatar: group.hasAvatar, createdAt: group.createdAt.toISOString(), updatedAt: group.updatedAt.toISOString(), members: group.members.map((member) => this.toMember(member)) }; } private toMember(member: { id: string; groupId: string; userId: string; role: string; createdAt: Date; user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null }; }) { return { id: member.id, groupId: member.groupId, userId: member.userId, role: member.role, displayName: member.user?.displayName ?? 'Участник', hasAvatar: Boolean(member.user?.avatarStorageKey), isVerified: member.user?.isVerified ?? false, verificationIcon: member.user?.isVerified ? resolveVerificationIcon(member.user?.verificationIcon) : undefined, createdAt: member.createdAt.toISOString() }; } private toInvite(invite: { id: string; groupId: string; inviterId: string; inviteeUserId: string | null; targetEmail: string | null; targetPhone: string | null; status: string; expiresAt: Date; createdAt: Date; group: { name: string }; inviter: { displayName: string }; }) { return { id: invite.id, groupId: invite.groupId, groupName: invite.group.name, inviterId: invite.inviterId, inviterName: invite.inviter.displayName, inviteeUserId: invite.inviteeUserId ?? undefined, targetEmail: invite.targetEmail ?? undefined, targetPhone: invite.targetPhone ?? undefined, status: invite.status, expiresAt: invite.expiresAt.toISOString(), createdAt: invite.createdAt.toISOString() }; } }