fix document 500 error and fix users tabel for bot

This commit is contained in:
lendry
2026-06-26 10:32:04 +03:00
parent ead3155ad8
commit d5e6b58955
17 changed files with 396 additions and 67 deletions

View File

@@ -30,6 +30,14 @@ const ROLES = [
isSystem: true,
isDefault: true
},
{
slug: 'bot',
name: 'Бот',
description: 'Системная учётная запись Telegram-бота',
permissions: [] as string[],
isSystem: true,
isDefault: false
},
{
slug: 'admin',
name: 'Администратор',
@@ -120,9 +128,13 @@ export class AdminSeedService implements OnModuleInit {
}
}
const users = await this.prisma.user.findMany({ where: { deletedAt: null }, select: { id: true } });
const users = await this.prisma.user.findMany({ where: { deletedAt: null }, select: { id: true, isSystemAccount: true, linkedBotId: true } });
for (const user of users) {
await this.rbac.ensureDefaultUserRole(user.id);
if (user.linkedBotId || user.isSystemAccount) {
await this.rbac.ensureBotUserRole(user.id);
} else {
await this.rbac.ensureDefaultUserRole(user.id);
}
}
}
}

View File

@@ -4,6 +4,32 @@ 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 {
@@ -14,16 +40,19 @@ export class AdminService {
async listUsers(search?: string) {
const users = await this.prisma.user.findMany({
where: search
? {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
{ displayName: { contains: search, mode: 'insensitive' } },
{ username: { contains: search, mode: 'insensitive' } }
]
}
: undefined,
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 } },
@@ -34,14 +63,28 @@ export class AdminService {
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 } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async resetPassword(userId: string, newPassword: string) {
@@ -53,9 +96,9 @@ export class AdminService {
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 } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async suspendUser(userId: string) {
@@ -64,9 +107,9 @@ export class AdminService {
const user = await this.prisma.user.update({
where: { id: userId },
data: { status: UserStatus.SUSPENDED },
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async setSuperAdmin(actorUserId: string, userId: string, isSuperAdmin: boolean) {
@@ -77,6 +120,9 @@ export class AdminService {
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 } });
@@ -88,9 +134,9 @@ export class AdminService {
const user = await this.prisma.user.update({
where: { id: userId },
data: { isSuperAdmin },
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
async setUserVerification(actorUserId: string, userId: string, data: { isVerified: boolean; verificationIcon?: string }) {
@@ -109,9 +155,9 @@ export class AdminService {
verificationIcon: icon,
verifiedAt: new Date()
},
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
const user = await this.prisma.user.update({
@@ -121,9 +167,9 @@ export class AdminService {
verificationIcon: null,
verifiedAt: null
},
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
return this.toAdminUser(user, isProtectedSystemAccount(user));
}
listVerificationIcons() {
@@ -135,27 +181,33 @@ export class AdminService {
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 } } }
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } }, linkedBot: { select: { username: true, isSystemBot: true } } }
});
return this.toAdminUser(user);
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;
userRoles?: { role: { slug: string } }[];
userPermissions?: { permission: { slug: string } }[];
}) {
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,
@@ -170,7 +222,10 @@ export class AdminService {
status: user.status,
createdAt: user.createdAt.toISOString(),
roles: user.userRoles?.map((link) => link.role.slug) ?? [],
directPermissions: user.userPermissions?.map((link) => link.permission.slug) ?? []
directPermissions: user.userPermissions?.map((link) => link.permission.slug) ?? [],
isBot,
linkedBotUsername: user.linkedBot?.username ?? undefined,
isSystemBot: user.linkedBot?.isSystemBot ?? false
};
}

View File

@@ -142,6 +142,12 @@ export class AuthGrpcController {
return { users };
}
@GrpcMethod('AdminService', 'ListBotAccounts')
async listBotAccounts(command: { search?: string }) {
const users = await this.admin.listBotAccounts(command.search);
return { users };
}
@GrpcMethod('AdminService', 'SuspendUser')
suspendUser(command: { userId: string }) {
return this.admin.suspendUser(command.userId);
@@ -461,6 +467,11 @@ export class AuthGrpcController {
return this.totp.disable(command.userId, command.code);
}
@GrpcMethod('SecurityService', 'AdminDisableTotp')
adminDisableTotp(command: { actorUserId: string; userId: string }) {
return this.totp.adminDisable(command.actorUserId, command.userId);
}
@GrpcMethod('SecurityService', 'SetupPin')
setupPin(command: { userId: string; pin: string }) {
return this.pin.setupPin(command.userId, command.pin);

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import { RbacService } from '../rbac.service';
import {
BOTFATHER_BOT_USERNAME,
BOTFATHER_DISPLAY_NAME,
@@ -16,7 +17,10 @@ export class BotFatherSeedService implements OnModuleInit {
private cachedUserId: string | null = null;
private cachedBotId: string | null = null;
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly rbac: RbacService
) {}
async onModuleInit() {
await this.ensureBotFather();
@@ -101,6 +105,7 @@ export class BotFatherSeedService implements OnModuleInit {
await this.upsertSetting(BOTFATHER_SETTING_USER_ID, botFatherUser.id, 'UUID системного пользователя BotFather');
await this.upsertSetting(BOTFATHER_SETTING_BOT_ID, bot.id, 'UUID системного бота BotFather');
await this.rbac.ensureBotUserRole(botFatherUser.id);
this.cachedUserId = botFatherUser.id;
this.cachedBotId = bot.id;

View File

@@ -11,6 +11,7 @@ import { PrismaService } from '../../infra/prisma.service';
import { AccessService } from '../access.service';
import { NotificationsService } from '../notifications.service';
import { SettingsService } from '../settings.service';
import { RbacService } from '../rbac.service';
import { BotRateLimitService } from './bot-rate-limit.service';
import { assertValidBotUsername, generateTelegramStyleBotToken, hashBotToken, normalizeBotUsername } from './bot-token.util';
import { menuButtonToJson, parseMenuButtonInput, resolveComposerMenuButton } from './bot-menu-button.util';
@@ -45,7 +46,8 @@ export class BotFatherService implements OnModuleInit {
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly rateLimit: BotRateLimitService,
private readonly notifications: NotificationsService
private readonly notifications: NotificationsService,
private readonly rbac: RbacService
) {}
async onModuleInit() {
@@ -84,6 +86,8 @@ export class BotFatherService implements OnModuleInit {
select: { id: true }
});
await this.rbac.ensureBotUserRole(systemUser.id);
this.logger.log(`Создан системный пользователь для бота @${bot.username}`);
return { userId: systemUser.id, botId: bot.id };
}

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '../generated/prisma/client';
import { EncryptionService } from '../infra/encryption.service';
import { PrismaService } from '../infra/prisma.service';
import { isProtectedSystemAccount } from './system-account.util';
export const DOCUMENT_TYPES = [
'PASSPORT_RF',
@@ -70,11 +71,29 @@ export class DocumentsService {
}
async list(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { isSystemAccount: true, linkedBotId: true }
});
if (user && isProtectedSystemAccount(user)) {
return { documents: [] };
}
const documents = await this.prisma.userDocument.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' }
});
return { documents: documents.map((document) => this.toResponse(document)) };
const results = [];
for (const document of documents) {
try {
results.push(this.toResponse(document));
} catch {
results.push(this.toFallbackResponse(document));
}
}
return { documents: results };
}
async get(documentId: string, userId: string) {
@@ -124,14 +143,33 @@ export class DocumentsService {
}
private toResponse(document: Awaited<ReturnType<PrismaService['userDocument']['create']>>) {
const response: Record<string, string> = {
id: document.id,
userId: document.userId,
type: document.type,
number: this.encryption.decryptIfNeeded(document.number ?? '—'),
createdAt: this.formatRequiredDate(document.createdAt),
updatedAt: this.formatRequiredDate(document.updatedAt)
};
const issuedAt = this.formatOptionalDate(document.issuedAt);
if (issuedAt) response.issuedAt = issuedAt;
const expiresAt = this.formatOptionalDate(document.expiresAt);
if (expiresAt) response.expiresAt = expiresAt;
const metadataJson = this.resolveMetadataJson(document.metadata);
if (metadataJson) response.metadataJson = metadataJson;
return response;
}
private toFallbackResponse(document: Awaited<ReturnType<PrismaService['userDocument']['create']>>) {
return {
id: document.id,
userId: document.userId,
type: document.type,
number: this.encryption.decryptIfNeeded(document.number),
issuedAt: this.formatOptionalDate(document.issuedAt),
expiresAt: this.formatOptionalDate(document.expiresAt),
metadataJson: this.resolveMetadataJson(document.metadata),
number: '—',
createdAt: this.formatRequiredDate(document.createdAt),
updatedAt: this.formatRequiredDate(document.updatedAt)
};

View File

@@ -1 +1,2 @@
export const DEFAULT_USER_ROLE_SLUG = 'user';
export const BOT_ROLE_SLUG = 'bot';

View File

@@ -4,7 +4,7 @@ import { OAuthClientType } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { AccessService, UserAccessContext } from './access.service';
import { NotificationsService } from './notifications.service';
import { DEFAULT_USER_ROLE_SLUG } from './rbac.constants';
import { DEFAULT_USER_ROLE_SLUG, BOT_ROLE_SLUG } from './rbac.constants';
export interface OAuthClientListItem {
id: string;
@@ -254,6 +254,15 @@ export class RbacService {
}
async ensureDefaultUserRole(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { linkedBotId: true, isSystemAccount: true }
});
if (user?.linkedBotId || user?.isSystemAccount) {
await this.ensureBotUserRole(userId);
return;
}
const role = await this.prisma.role.findUnique({ where: { slug: DEFAULT_USER_ROLE_SLUG } });
if (!role) return;
await this.prisma.userRole.upsert({
@@ -263,6 +272,26 @@ export class RbacService {
});
}
async ensureBotUserRole(userId: string) {
const [botRole, userRole] = await Promise.all([
this.prisma.role.findUnique({ where: { slug: BOT_ROLE_SLUG } }),
this.prisma.role.findUnique({ where: { slug: DEFAULT_USER_ROLE_SLUG } })
]);
if (!botRole) return;
if (userRole) {
await this.prisma.userRole.deleteMany({
where: { userId, roleId: userRole.id }
});
}
await this.prisma.userRole.upsert({
where: { userId_roleId: { userId, roleId: botRole.id } },
create: { userId, roleId: botRole.id },
update: {}
});
}
async assignUserPermission(actorUserId: string, userId: string, permissionSlug: string) {
await this.access.assertRbacManage(actorUserId);
await this.ensureUserExists(userId);

View File

@@ -4,6 +4,7 @@ import { authenticator } from 'otplib';
import { EncryptionService } from '../infra/encryption.service';
import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service';
import { AccessService } from './access.service';
@Injectable()
export class TotpService {
@@ -12,7 +13,8 @@ export class TotpService {
constructor(
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
private readonly settings: SettingsService
private readonly settings: SettingsService,
private readonly access: AccessService
) {}
async getStatus(userId: string) {
@@ -91,6 +93,19 @@ export class TotpService {
return { isEnabled: false };
}
async adminDisable(actorUserId: string, targetUserId: string) {
await this.access.assertSuperAdmin(actorUserId);
await this.ensureUserExists(targetUserId);
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId: targetUserId } });
if (!record?.isEnabled) {
throw new BadRequestException('Двухфакторная аутентификация не включена');
}
await this.prisma.userTotpSecret.delete({ where: { userId: targetUserId } });
return { isEnabled: false };
}
async verifyEnabledCode(userId: string, code: string) {
const record = await this.prisma.userTotpSecret.findUnique({ where: { userId } });
if (!record?.isEnabled) {