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'; import { BotFatherSeedService } from './bot/bot-father-seed.service'; import { BotFatherService } from './bot/bot-father.service'; import { BOTFATHER_BOT_USERNAME, BOTFATHER_DISPLAY_NAME, BOTFATHER_USER_USERNAME, GET_MY_ID_BOT_USERNAME, GET_MY_ID_DISPLAY_NAME, GET_MY_ID_USER_USERNAME } from './bot/bot-father.constants'; import { normalizeBotUsername } from './bot/bot-token.util'; import { HUMAN_USER_WHERE, isProtectedSystemAccount } from './system-account.util'; 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, private readonly botFatherSeed: BotFatherSeedService, private readonly botFather: BotFatherService ) {} 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: this.memberUserInclude() } } }); 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, ownerId); } async list(userId: string) { const groups = await this.prisma.familyGroup.findMany({ where: { OR: [{ ownerId: userId }, { members: { some: { userId } } }] }, include: { members: { include: this.memberUserInclude() } }, orderBy: { updatedAt: 'desc' } }); return { groups: await Promise.all(groups.map((group) => this.toGroup(group, userId))) }; } async get(requesterId: string, groupId: string) { const group = await this.getGroupWithMembers(groupId); this.assertMember(group, requesterId); return this.toGroup(group, requesterId); } 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: this.memberUserInclude() } } }); return this.toGroup(updated, requesterId); } 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: this.memberUserInclude() } } }); 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.leaveFamily(userId, member.groupId); } } async leaveFamily(requesterId: string, groupId: string, newOwnerUserId?: string) { const group = await this.getGroupWithMembers(groupId); this.assertMember(group, requesterId); const member = group.members.find((item) => item.userId === requesterId); if (!member) { throw new NotFoundException('Участник не найден'); } const isOwner = group.ownerId === requesterId; const otherHumans = this.getHumanMembers(group.members).filter((item) => item.userId !== requesterId); if (isOwner && otherHumans.length === 0) { await this.destroyGroupWithCleanup(group); return { count: 1, deleted: true }; } if (isOwner && otherHumans.length > 0) { if (!newOwnerUserId) { throw new BadRequestException('Перед выходом передайте управление семьёй другому участнику'); } if (!otherHumans.some((item) => item.userId === newOwnerUserId)) { throw new BadRequestException('Новый глава семьи должен быть участником семьи'); } await this.transferOwnership(group, requesterId, newOwnerUserId); const updatedGroup = await this.getGroupWithMembers(groupId); const updatedMember = updatedGroup.members.find((item) => item.userId === requesterId); if (!updatedMember) { throw new NotFoundException('Участник не найден'); } await this.executeMemberLeave(updatedGroup, updatedMember, { voluntary: true }); return { count: 1, deleted: false }; } await this.executeMemberLeave(group, member, { voluntary: true }); return { count: 1, deleted: false }; } 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 targetUser = await this.prisma.user.findUnique({ where: { id: userId }, select: { isSystemAccount: true, linkedBotId: true } }); if (!targetUser) { throw new BadRequestException('Пользователь не найден'); } if (isProtectedSystemAccount(targetUser)) { if (role !== 'bot' || !targetUser.linkedBotId) { throw new BadRequestException('Системные учётные записи нельзя добавить в семью напрямую'); } } 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' && role !== 'bot') { await tx.chatRoomMember.upsert({ where: { roomId_userId: { roomId: room.id, userId } }, create: { roomId: room.id, userId }, update: {} }); } } return created; }); const user = await this.loadMemberUser(member.userId); return this.toMember({ ...member, user: user ?? undefined }); } async removeMember(requesterId: string, memberId: string) { const member = await this.prisma.familyMember.findUnique({ where: { id: memberId }, include: { group: { include: { members: { include: this.memberUserInclude() } } } } }); if (!member) { throw new NotFoundException('Участник не найден'); } const isSelf = member.userId === requesterId; const isOwner = member.group.ownerId === requesterId; if (isSelf) { throw new BadRequestException('Для выхода из семьи используйте соответствующую функцию'); } if (!isOwner) { throw new ForbiddenException('Недостаточно прав для удаления участника'); } if (member.role === 'owner') { throw new BadRequestException('Нельзя удалить владельца семьи'); } await this.executeMemberLeave(member.group, member, { voluntary: false }); return { count: 1 }; } private async transferOwnership( group: { id: string; name: string; members: Array<{ userId: string; user?: { displayName: string } }>; }, fromUserId: string, toUserId: string ) { await this.prisma.$transaction(async (tx) => { await tx.familyGroup.update({ where: { id: group.id }, data: { ownerId: toUserId } }); await tx.familyMember.updateMany({ where: { groupId: group.id, userId: fromUserId }, data: { role: 'member' } }); await tx.familyMember.updateMany({ where: { groupId: group.id, userId: toUserId }, data: { role: 'owner' } }); }); const oldOwnerName = group.members.find((item) => item.userId === fromUserId)?.user?.displayName ?? 'Участник'; const title = 'Вы стали главой семьи'; const body = `${oldOwnerName} передал(а) вам управление семьёй «${group.name}»`; await this.notifications.create( toUserId, 'family_ownership_transferred', title, body, { groupId: group.id, groupName: group.name, previousOwnerId: fromUserId } ); await this.notifications.publishRealtime( toUserId, 'family_ownership_transferred', title, body, { groupId: group.id, groupName: group.name, previousOwnerId: fromUserId } ); } private async executeMemberLeave( group: { id: string; name: string; members: Array<{ id: string; userId: string; role: string; user?: { displayName: string; linkedBotId?: string | null } }>; }, member: { id: string; userId: string; user?: { displayName: string } }, options: { voluntary: boolean } ) { const displayName = member.user?.displayName ?? (await this.loadMemberUser(member.userId))?.displayName ?? 'Участник'; await this.prisma.$transaction(async (tx) => { await tx.chatRoomMember.deleteMany({ where: { userId: member.userId, room: { groupId: group.id } } }); await tx.familyMember.delete({ where: { id: member.id } }); }); const recipients = group.members.filter( (item) => item.userId !== member.userId && item.role !== 'bot' && !item.user?.linkedBotId ); const eventType = options.voluntary ? 'family_member_left' : 'family_member_removed'; const title = options.voluntary ? 'Участник покинул семью' : 'Участник удалён из семьи'; const body = options.voluntary ? `${displayName} покинул(а) семью «${group.name}»` : `${displayName} был(а) удалён(а) из семьи «${group.name}»`; for (const recipient of recipients) { await this.notifications.create( recipient.userId, eventType, title, body, { groupId: group.id, groupName: group.name, userId: member.userId, userName: displayName } ); await this.notifications.publishRealtime( recipient.userId, eventType, title, body, { groupId: group.id, groupName: group.name, userId: member.userId, userName: displayName } ); } await this.notifications.publishRealtime( member.userId, 'family_left_self', 'Вы покинули семью', `Вы покинули семью «${group.name}»`, { groupId: group.id, groupName: group.name } ); } private getHumanMembers( members: Array<{ userId: string; role: string; user?: { linkedBotId?: string | null } }> ) { return members.filter((member) => member.role !== 'bot' && !member.user?.linkedBotId); } 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('Пользователь не найден. Выберите его из результатов поиска.'); } const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } }); const isBotInvite = isProtectedSystemAccount(invitee); const inviteeAlreadyInFamily = group.members.some((member) => member.userId === invitee.id); if (invitee.id === requesterId) { throw new BadRequestException('Нельзя пригласить самого себя'); } if (isBotInvite) { const visibleBotUserIds = await this.getVisibleBotUserIds(groupId, requesterId); if (visibleBotUserIds.has(invitee.id)) { throw new BadRequestException('Этот бот уже доступен вам в семье'); } } else if (inviteeAlreadyInFamily) { throw new BadRequestException('Пользователь уже состоит в семье'); } const existingPending = await this.prisma.familyInvite.findFirst({ where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' } }); if (existingPending && !isBotInvite) { throw new BadRequestException('Приглашение уже отправлено'); } if (isBotInvite) { const invite = await this.prisma.familyInvite.create({ data: { groupId, inviterId: requesterId, inviteeUserId: invitee.id, targetEmail: invitee.email ?? undefined, targetPhone: invitee.phone ?? undefined, status: 'ACCEPTED', expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000) }, include: { group: true, inviter: true } }); if (!inviteeAlreadyInFamily) { await this.addMember(groupId, invitee.id, 'bot'); } await this.notifications.create( requesterId, 'family_bot_added', 'Бот добавлен в семью', `${invitee.displayName} добавлен(а) в «${group.name}» по приглашению`, { groupId, inviteId: invite.id, botUserId: invitee.id }, { skipPublish: true } ); return this.toInvite(invite); } 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.filter((member) => !member.user?.linkedBotId).map((member) => member.userId), requesterId]; const visibleBotUserIds = await this.getVisibleBotUserIds(groupId, requesterId); const botFatherUserId = await this.botFatherSeed.getBotFatherUserId(); const botFatherBotId = await this.botFatherSeed.getBotFatherBotId(); const getMyIdUserId = await this.botFatherSeed.getGetMyIdBotUserId(); const getMyIdBotId = await this.botFatherSeed.getGetMyIdBotId(); const botFatherMatches = /bot\s*father|бот\s*father|botfather/i.test(trimmed) && !visibleBotUserIds.has(botFatherUserId); const getMyIdMatches = /get\s*my\s*id|getmyid|мой\s*id|айди|chat[_\s-]*id/i.test(trimmed) && !visibleBotUserIds.has(getMyIdUserId); const digits = trimmed.replace(/\D/g, ''); const normalizedBotQuery = normalizeBotUsername(trimmed.replace(/^@/, '')); const [users, matchedBots] = await Promise.all([ this.prisma.user.findMany({ where: { status: 'ACTIVE', deletedAt: null, isSystemAccount: false, linkedBotId: 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 } }), normalizedBotQuery.length >= 2 ? this.prisma.bot.findMany({ where: { isActive: true, OR: [ { name: { contains: trimmed, mode: 'insensitive' } }, { username: { contains: normalizedBotQuery, mode: 'insensitive' } } ] }, orderBy: { name: 'asc' }, take: 8, include: { owner: { select: { id: true, displayName: true } }, linkedSystemUser: { select: { id: true } } } }) : Promise.resolve([]) ]); const botCandidates: Array<{ id: string; botId: string; displayName: string; username?: string; hasAvatar: boolean; isVerified: boolean; verificationIcon?: string; isBot: boolean; botUsername: string; ownerDisplayName?: string; }> = []; for (const bot of matchedBots) { if (botFatherMatches && bot.username === BOTFATHER_BOT_USERNAME) continue; if (getMyIdMatches && bot.username === GET_MY_ID_BOT_USERNAME) continue; const { userId } = bot.linkedSystemUser ? { userId: bot.linkedSystemUser.id } : await this.botFather.ensureBotSystemUser(bot.id); if (visibleBotUserIds.has(userId)) continue; botCandidates.push({ id: userId, botId: bot.id, displayName: bot.name, username: `@${bot.username}`, hasAvatar: false, isVerified: true, verificationIcon: resolveVerificationIcon('bot'), isBot: true, botUsername: bot.username, ownerDisplayName: bot.owner.displayName }); } const humanCandidates = 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, isBot: false, botUsername: undefined, botId: undefined, ownerDisplayName: undefined })); return { users: [ ...(botFatherMatches ? [ { id: botFatherUserId, displayName: BOTFATHER_DISPLAY_NAME, email: undefined, phone: undefined, username: BOTFATHER_USER_USERNAME, hasAvatar: false, isVerified: true, verificationIcon: resolveVerificationIcon('bot'), isBot: true, botUsername: BOTFATHER_BOT_USERNAME, botId: botFatherBotId, ownerDisplayName: undefined } ] : []), ...(getMyIdMatches ? [ { id: getMyIdUserId, displayName: GET_MY_ID_DISPLAY_NAME, email: undefined, phone: undefined, username: GET_MY_ID_USER_USERNAME, hasAvatar: false, isVerified: true, verificationIcon: resolveVerificationIcon('bot'), isBot: true, botUsername: GET_MY_ID_BOT_USERNAME, botId: getMyIdBotId, ownerDisplayName: undefined } ] : []), ...botCandidates, ...humanCandidates.filter((candidate) => !botCandidates.some((bot) => bot.id === candidate.id)) ].slice(0, 12) }; } 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: this.memberUserInclude() } } }); 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, ...HUMAN_USER_WHERE } }); } const digits = trimmed.replace(/\D/g, ''); if (/^\+?\d/.test(trimmed) && digits.length >= 10) { return this.prisma.user.findFirst({ where: { status: 'ACTIVE', deletedAt: null, ...HUMAN_USER_WHERE, OR: [{ phone: trimmed }, { phone: { endsWith: digits.slice(-10) } }] } }); } return this.prisma.user.findFirst({ where: { status: 'ACTIVE', deletedAt: null, ...HUMAN_USER_WHERE, OR: [ { username: { equals: trimmed, mode: 'insensitive' } }, { displayName: { equals: trimmed, mode: 'insensitive' } } ] } }); } private async 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; linkedBotId?: string | null; linkedBot?: { username: string } | null; }; }>; }, viewerId?: string) { const visibleBotUserIds = viewerId ? await this.getVisibleBotUserIds(group.id, viewerId) : new Set(); const visibleMembers = group.members.filter((member) => !member.user?.linkedBotId || visibleBotUserIds.has(member.userId)); return { id: group.id, ownerId: group.ownerId, name: group.name, hasAvatar: group.hasAvatar, createdAt: group.createdAt.toISOString(), updatedAt: group.updatedAt.toISOString(), members: visibleMembers.map((member) => this.toMember(member)), botFatherAvailable: !visibleMembers.some( (member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME ), getMyIdBotAvailable: !visibleMembers.some( (member) => member.user?.linkedBot?.username === GET_MY_ID_BOT_USERNAME ) }; } private async getVisibleBotUserIds(groupId: string, viewerId: string) { const rooms = await this.prisma.chatRoom.findMany({ where: { groupId, type: 'BOT', members: { some: { userId: viewerId } } }, include: { members: { include: { user: { select: { linkedBotId: true } } } } } }); return new Set( rooms .flatMap((room) => room.members) .filter((member) => Boolean(member.user.linkedBotId)) .map((member) => member.userId) ); } private toMember(member: { id: string; groupId: string; userId: string; role: string; createdAt: Date; user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null; linkedBotId?: string | null; linkedBot?: { username: 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, isBot: Boolean(member.user?.linkedBotId), botUsername: member.user?.linkedBot?.username ?? undefined, createdAt: member.createdAt.toISOString() }; } private memberUserInclude() { return { user: { include: { linkedBot: { select: { username: true } } } } }; } private loadMemberUser(userId: string) { return this.prisma.user.findUnique({ where: { id: userId }, include: { linkedBot: { select: { username: true } } } }); } 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() }; } }