239 lines
9.2 KiB
TypeScript
239 lines
9.2 KiB
TypeScript
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||
import * as bcrypt from 'bcryptjs';
|
||
import { UserStatus } from '../generated/prisma/client';
|
||
import { PrismaService } from '../infra/prisma.service';
|
||
import { AccessService } from './access.service';
|
||
import { DEFAULT_VERIFICATION_ICON, isAllowedVerificationIcon, VERIFICATION_ICONS } from './verification.constants';
|
||
import { isProtectedSystemAccount } from './system-account.util';
|
||
|
||
const HUMAN_USERS_WHERE = {
|
||
isSystemAccount: false,
|
||
linkedBotId: null
|
||
};
|
||
|
||
function buildBotAccountsWhere(search?: string) {
|
||
const botFilter = {
|
||
OR: [{ linkedBotId: { not: null } }, { isSystemAccount: true }]
|
||
};
|
||
if (!search) return botFilter;
|
||
return {
|
||
AND: [
|
||
botFilter,
|
||
{
|
||
OR: [
|
||
{ displayName: { contains: search, mode: 'insensitive' as const } },
|
||
{ username: { contains: search, mode: 'insensitive' as const } },
|
||
{ linkedBot: { username: { contains: search, mode: 'insensitive' as const } } },
|
||
{ linkedBot: { name: { contains: search, mode: 'insensitive' as const } } }
|
||
]
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
@Injectable()
|
||
export class AdminService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly access: AccessService
|
||
) {}
|
||
|
||
async listUsers(search?: string) {
|
||
const users = await this.prisma.user.findMany({
|
||
where: {
|
||
...HUMAN_USERS_WHERE,
|
||
...(search
|
||
? {
|
||
OR: [
|
||
{ email: { contains: search, mode: 'insensitive' } },
|
||
{ phone: { contains: search, mode: 'insensitive' } },
|
||
{ displayName: { contains: search, mode: 'insensitive' } },
|
||
{ username: { contains: search, mode: 'insensitive' } }
|
||
]
|
||
}
|
||
: {})
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
include: {
|
||
userRoles: { include: { role: true } },
|
||
userPermissions: { include: { permission: true } }
|
||
}
|
||
});
|
||
|
||
return users.map((user) => this.toAdminUser(user));
|
||
}
|
||
|
||
async listBotAccounts(search?: string) {
|
||
const users = await this.prisma.user.findMany({
|
||
where: buildBotAccountsWhere(search),
|
||
orderBy: { createdAt: 'desc' },
|
||
include: {
|
||
userRoles: { include: { role: true } },
|
||
userPermissions: { include: { permission: true } },
|
||
linkedBot: { select: { username: true, isSystemBot: true } }
|
||
}
|
||
});
|
||
|
||
return users.map((user) => this.toAdminUser(user, true));
|
||
}
|
||
|
||
async updateUserProfile(userId: string, data: { displayName?: string; email?: string; phone?: string; backupEmail?: string; backupPhone?: string; status?: UserStatus }) {
|
||
await this.ensureUserExists(userId);
|
||
const user = await this.prisma.user.update({
|
||
where: { id: userId },
|
||
data,
|
||
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
|
||
});
|
||
return this.toAdminUser(user, isProtectedSystemAccount(user));
|
||
}
|
||
|
||
async resetPassword(userId: string, newPassword: string) {
|
||
if (newPassword.length < 8) {
|
||
throw new BadRequestException('Пароль должен содержать минимум 8 символов');
|
||
}
|
||
|
||
await this.ensureUserExists(userId);
|
||
const user = await this.prisma.user.update({
|
||
where: { id: userId },
|
||
data: { passwordHash: await bcrypt.hash(newPassword, 12) },
|
||
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
|
||
});
|
||
return this.toAdminUser(user, isProtectedSystemAccount(user));
|
||
}
|
||
|
||
async suspendUser(userId: string) {
|
||
await this.ensureUserExists(userId);
|
||
await this.prisma.session.updateMany({ where: { userId }, data: { status: 'REVOKED' } });
|
||
const user = await this.prisma.user.update({
|
||
where: { id: userId },
|
||
data: { status: UserStatus.SUSPENDED },
|
||
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
|
||
});
|
||
return this.toAdminUser(user, isProtectedSystemAccount(user));
|
||
}
|
||
|
||
async setSuperAdmin(actorUserId: string, userId: string, isSuperAdmin: boolean) {
|
||
await this.access.assertSuperAdmin(actorUserId);
|
||
await this.ensureUserExists(userId);
|
||
|
||
const target = await this.prisma.user.findUnique({ where: { id: userId } });
|
||
if (!target) {
|
||
throw new NotFoundException('Пользователь не найден');
|
||
}
|
||
if (isProtectedSystemAccount(target)) {
|
||
throw new ForbiddenException('Нельзя назначать системные учётные записи ботов супер-администраторами');
|
||
}
|
||
|
||
if (target.isSuperAdmin && !isSuperAdmin) {
|
||
const superAdmins = await this.prisma.user.count({ where: { isSuperAdmin: true, status: UserStatus.ACTIVE } });
|
||
if (superAdmins <= 1) {
|
||
throw new BadRequestException('Нельзя снять права у единственного супер-администратора');
|
||
}
|
||
}
|
||
|
||
const user = await this.prisma.user.update({
|
||
where: { id: userId },
|
||
data: { isSuperAdmin },
|
||
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
|
||
});
|
||
return this.toAdminUser(user, isProtectedSystemAccount(user));
|
||
}
|
||
|
||
async setUserVerification(actorUserId: string, userId: string, data: { isVerified: boolean; verificationIcon?: string }) {
|
||
await this.access.assertCanVerifyUsers(actorUserId);
|
||
await this.ensureUserExists(userId);
|
||
|
||
if (data.isVerified) {
|
||
const icon = data.verificationIcon ?? DEFAULT_VERIFICATION_ICON;
|
||
if (!isAllowedVerificationIcon(icon)) {
|
||
throw new BadRequestException('Выберите значок верификации из списка');
|
||
}
|
||
const user = await this.prisma.user.update({
|
||
where: { id: userId },
|
||
data: {
|
||
isVerified: true,
|
||
verificationIcon: icon,
|
||
verifiedAt: new Date()
|
||
},
|
||
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
|
||
});
|
||
return this.toAdminUser(user, isProtectedSystemAccount(user));
|
||
}
|
||
|
||
const user = await this.prisma.user.update({
|
||
where: { id: userId },
|
||
data: {
|
||
isVerified: false,
|
||
verificationIcon: null,
|
||
verifiedAt: null
|
||
},
|
||
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
|
||
});
|
||
return this.toAdminUser(user, isProtectedSystemAccount(user));
|
||
}
|
||
|
||
listVerificationIcons() {
|
||
return { icons: VERIFICATION_ICONS.map((icon) => ({ slug: icon.slug, name: icon.name })) };
|
||
}
|
||
|
||
async deleteUser(userId: string) {
|
||
await this.ensureUserExists(userId);
|
||
const user = await this.prisma.user.update({
|
||
where: { id: userId },
|
||
data: { status: UserStatus.DELETED, deletedAt: new Date() },
|
||
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
|
||
});
|
||
return this.toAdminUser(user, isProtectedSystemAccount(user));
|
||
}
|
||
|
||
private toAdminUser(
|
||
user: {
|
||
id: string;
|
||
email: string | null;
|
||
phone: string | null;
|
||
backupEmail: string | null;
|
||
backupPhone: string | null;
|
||
displayName: string;
|
||
username: string | null;
|
||
isSuperAdmin: boolean;
|
||
isVerified: boolean;
|
||
verificationIcon: string | null;
|
||
status: UserStatus;
|
||
createdAt: Date;
|
||
isSystemAccount?: boolean;
|
||
linkedBotId?: string | null;
|
||
userRoles?: { role: { slug: string } }[];
|
||
userPermissions?: { permission: { slug: string } }[];
|
||
linkedBot?: { username: string; isSystemBot: boolean } | null;
|
||
},
|
||
isBot = isProtectedSystemAccount(user)
|
||
) {
|
||
return {
|
||
id: user.id,
|
||
email: user.email ?? undefined,
|
||
phone: user.phone ?? undefined,
|
||
backupEmail: user.backupEmail ?? undefined,
|
||
backupPhone: user.backupPhone ?? undefined,
|
||
displayName: user.displayName,
|
||
username: user.username ?? undefined,
|
||
isSuperAdmin: user.isSuperAdmin,
|
||
isVerified: user.isVerified,
|
||
verificationIcon: user.isVerified ? (user.verificationIcon ?? DEFAULT_VERIFICATION_ICON) : undefined,
|
||
status: user.status,
|
||
createdAt: user.createdAt.toISOString(),
|
||
roles: user.userRoles?.map((link) => link.role.slug) ?? [],
|
||
directPermissions: user.userPermissions?.map((link) => link.permission.slug) ?? [],
|
||
isBot,
|
||
linkedBotUsername: user.linkedBot?.username ?? undefined,
|
||
isSystemBot: user.linkedBot?.isSystemBot ?? false
|
||
};
|
||
}
|
||
|
||
private async ensureUserExists(userId: string) {
|
||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||
if (!user) {
|
||
throw new NotFoundException('Пользователь не найден');
|
||
}
|
||
}
|
||
}
|