1432 lines
32 KiB
TypeScript
1432 lines
32 KiB
TypeScript
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()
|
||
|
||
};
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|