Files
IdP/apps/sso-core/src/domain/family.service.ts
2026-06-26 23:41:20 +03:00

1432 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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()
};
}
}