1179 lines
26 KiB
TypeScript
1179 lines
26 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);
|
||
|
||
}
|
||
|
||
|
||
|
||
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()
|
||
|
||
};
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|