Files
IdP/apps/sso-core/src/domain/family.service.ts
2026-06-26 13:01:52 +03:00

1179 lines
26 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);
}
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: 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: this.memberUserInclude() } }
});
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: 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.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 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: 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 isBotInvite = isProtectedSystemAccount(invitee);
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
}
});
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.map((member) => member.userId), requesterId];
const excludedBotUsernames = new Set(
group.members
.map((member) => member.user?.linkedBot?.username)
.filter((username): username is string => Boolean(username))
);
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) &&
!excludedIds.includes(botFatherUserId) &&
!excludedBotUsernames.has(BOTFATHER_BOT_USERNAME);
const getMyIdMatches =
/get\s*my\s*id|getmyid|мой\s*id|айди|chat[_\s-]*id/i.test(trimmed) &&
!excludedIds.includes(getMyIdUserId) &&
!excludedBotUsernames.has(GET_MY_ID_BOT_USERNAME);
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 (excludedBotUsernames.has(bot.username)) continue;
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 (excludedIds.includes(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 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;
};
}>;
}) {
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)),
botFatherAvailable: !group.members.some(
(member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME
),
getMyIdBotAvailable: !group.members.some(
(member) => member.user?.linkedBot?.username === GET_MY_ID_BOT_USERNAME
)
};
}
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()
};
}
}