fix and update

This commit is contained in:
lendry
2026-07-01 17:28:53 +03:00
parent 0c3c6d6d82
commit 06f1481787
16 changed files with 1502 additions and 11 deletions

View File

@@ -16,6 +16,7 @@ export interface UserAccessContext {
canViewUserDocuments: boolean;
canVerifyUsers: boolean;
canManageBots: boolean;
canModerateChats: boolean;
}
function hasAnyPermission(permissions: string[], ...slugs: string[]) {
@@ -44,7 +45,8 @@ export class AccessService {
'rbac.manage',
'documents.view_others',
'users.verify',
'bots.manage.all'
'bots.manage.all',
'chat.moderate'
],
canAccessAdmin: true,
canManageRoles: true,
@@ -57,7 +59,8 @@ export class AccessService {
canManageSettings: true,
canViewUserDocuments: true,
canVerifyUsers: true,
canManageBots: true
canManageBots: true,
canModerateChats: true
};
}
@@ -106,7 +109,8 @@ export class AccessService {
canManageSettings: permissions.includes('settings.manage'),
canViewUserDocuments: permissions.includes('documents.view_others'),
canVerifyUsers: permissions.includes('users.verify'),
canManageBots: permissions.includes('bots.manage.all')
canManageBots: permissions.includes('bots.manage.all'),
canModerateChats: permissions.includes('chat.moderate')
};
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../infra/prisma.service';
export interface RecordActivityInput {
userId: string;
action: string;
title: string;
detail?: string;
entityType?: string;
entityId?: string;
ipAddress?: string;
userAgent?: string;
actorId?: string;
}
@Injectable()
export class ActivityLogService {
constructor(private readonly prisma: PrismaService) {}
async record(input: RecordActivityInput) {
return this.prisma.userActivityLog.create({
data: {
userId: input.userId,
action: input.action,
title: input.title,
detail: input.detail,
entityType: input.entityType,
entityId: input.entityId,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
actorId: input.actorId
}
});
}
recordSafe(input: RecordActivityInput) {
void this.record(input).catch(() => undefined);
}
}

View File

@@ -18,6 +18,7 @@ const PERMISSIONS = [
{ slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
{ slug: 'rbac.manage', name: 'Управление ролями и правами', description: 'Назначение ролей, прав пользователям и редактирование ролей' },
{ slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' },
{ slug: 'chat.moderate', name: 'Модерация чатов', description: 'Просмотр обычных переписок пользователей и удаление сообщений' },
{ slug: 'bots.manage.all', name: 'Управление всеми ботами', description: 'Просмотр, блокировка и администрирование всех Telegram-ботов' }
] as const;
@@ -42,7 +43,7 @@ const ROLES = [
slug: 'admin',
name: 'Администратор',
description: 'Полный доступ к админ-панели',
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'users.verify', 'settings.manage', 'rbac.manage', 'bots.manage.all'],
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'users.verify', 'settings.manage', 'rbac.manage', 'bots.manage.all', 'chat.moderate'],
isSystem: false,
isDefault: false
},
@@ -50,7 +51,7 @@ const ROLES = [
slug: 'moderator',
name: 'Модератор',
description: 'Просмотр пользователей и базовый доступ к админке',
permissions: ['admin.access', 'users.view', 'users.view.all'],
permissions: ['admin.access', 'users.view', 'users.view.all', 'chat.moderate'],
isSystem: false,
isDefault: false
},

View File

@@ -0,0 +1,474 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../infra/prisma.service';
const MODERATABLE_ROOM_FILTER = {
isE2E: false,
type: { notIn: ['E2E', 'BOT'] as string[] }
};
const DOCUMENT_TYPE_LABELS: Record<string, string> = {
PASSPORT: 'Паспорт',
DRIVER_LICENSE: 'Водительское удостоверение',
SNILS: 'СНИЛС',
INN: 'ИНН',
BIRTH_CERTIFICATE: 'Свидетельство о рождении',
OTHER: 'Документ'
};
const ROOM_TYPE_LABELS: Record<string, string> = {
GENERAL: 'Общий чат семьи',
DIRECT: 'Личный чат',
GROUP: 'Групповой чат'
};
@Injectable()
export class AdminUserInsightsService {
constructor(private readonly prisma: PrismaService) {}
async ensureHumanUser(userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null, isSystemAccount: false, linkedBotId: null }
});
if (!user) {
throw new NotFoundException('Пользователь не найден');
}
return user;
}
async getSignInHistory(userId: string, search?: string, limit = 50, offset = 0) {
await this.ensureHumanUser(userId);
const take = Math.min(Math.max(limit, 1), 100);
const skip = Math.max(offset, 0);
const searchTerm = search?.trim();
const where = {
userId,
...(searchTerm
? {
OR: [
{ ipAddress: { contains: searchTerm, mode: 'insensitive' as const } },
{ userAgent: { contains: searchTerm, mode: 'insensitive' as const } },
{ reason: { contains: searchTerm, mode: 'insensitive' as const } },
{ device: { name: { contains: searchTerm, mode: 'insensitive' as const } } }
]
}
: {})
};
const [events, total] = await Promise.all([
this.prisma.signInEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
take,
skip,
include: { device: true }
}),
this.prisma.signInEvent.count({ where })
]);
return {
total,
events: events.map((event) => ({
id: event.id,
success: event.success,
reason: event.reason ?? undefined,
ipAddress: event.ipAddress ?? undefined,
userAgent: event.userAgent ?? undefined,
deviceName: event.device?.name ?? undefined,
createdAt: event.createdAt.toISOString()
}))
};
}
async getActivityTimeline(userId: string, search?: string, limit = 50, offset = 0) {
await this.ensureHumanUser(userId);
const searchTerm = search?.trim().toLowerCase();
const take = Math.min(Math.max(limit, 1), 100);
const [logs, documents, messages, rooms, families, consents] = await Promise.all([
this.prisma.userActivityLog.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 200
}),
this.prisma.userDocument.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 100
}),
this.prisma.chatMessage.findMany({
where: {
senderId: userId,
deletedAt: null,
room: MODERATABLE_ROOM_FILTER
},
include: { room: { include: { group: true } } },
orderBy: { createdAt: 'desc' },
take: 100
}),
this.prisma.chatRoom.findMany({
where: { createdById: userId, ...MODERATABLE_ROOM_FILTER },
include: { group: true },
orderBy: { createdAt: 'desc' },
take: 50
}),
this.prisma.familyGroup.findMany({
where: { ownerId: userId },
orderBy: { createdAt: 'desc' },
take: 50
}),
this.prisma.oAuthConsent.findMany({
where: { userId },
include: { client: true },
orderBy: { createdAt: 'desc' },
take: 50
})
]);
type TimelineItem = {
id: string;
action: string;
title: string;
detail?: string;
entityType?: string;
entityId?: string;
createdAt: string;
};
const items: TimelineItem[] = [
...logs.map((item) => ({
id: item.id,
action: item.action,
title: item.title,
detail: item.detail ?? undefined,
entityType: item.entityType ?? undefined,
entityId: item.entityId ?? undefined,
createdAt: item.createdAt.toISOString()
})),
...documents.map((doc) => ({
id: `doc-${doc.id}`,
action: 'DOCUMENT_CREATED',
title: 'Добавлен документ',
detail: `${DOCUMENT_TYPE_LABELS[doc.type] ?? doc.type} · ${doc.number}`,
entityType: 'document',
entityId: doc.id,
createdAt: doc.createdAt.toISOString()
})),
...messages.map((message) => ({
id: `msg-${message.id}`,
action: 'CHAT_MESSAGE_SENT',
title: 'Сообщение в чате',
detail: this.formatMessagePreview(message.type, message.content, message.isEncrypted),
entityType: 'chat_message',
entityId: message.id,
createdAt: message.createdAt.toISOString()
})),
...rooms.map((room) => ({
id: `room-${room.id}`,
action: 'CHAT_ROOM_CREATED',
title: 'Создан чат',
detail: `${ROOM_TYPE_LABELS[room.type] ?? room.type}: ${room.name} (${room.group.name})`,
entityType: 'chat_room',
entityId: room.id,
createdAt: room.createdAt.toISOString()
})),
...families.map((group) => ({
id: `family-${group.id}`,
action: 'FAMILY_CREATED',
title: 'Создана семья',
detail: group.name,
entityType: 'family',
entityId: group.id,
createdAt: group.createdAt.toISOString()
})),
...consents.map((consent) => ({
id: `oauth-${consent.id}`,
action: 'OAUTH_CONSENT',
title: 'Разрешён доступ приложению',
detail: consent.client.name,
entityType: 'oauth_consent',
entityId: consent.id,
createdAt: consent.createdAt.toISOString()
}))
];
const deduped = [...new Map(items.map((item) => [item.id, item])).values()]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.filter((item) => {
if (!searchTerm) return true;
const haystack = `${item.title} ${item.detail ?? ''} ${item.action}`.toLowerCase();
return haystack.includes(searchTerm);
});
const skip = Math.max(offset, 0);
return {
total: deduped.length,
items: deduped.slice(skip, skip + take)
};
}
async listUserChatRooms(userId: string, search?: string, limit = 50, offset = 0) {
await this.ensureHumanUser(userId);
const take = Math.min(Math.max(limit, 1), 100);
const skip = Math.max(offset, 0);
const searchTerm = search?.trim();
const memberships = await this.prisma.chatRoomMember.findMany({
where: {
userId,
room: {
...MODERATABLE_ROOM_FILTER,
...(searchTerm
? {
OR: [
{ name: { contains: searchTerm, mode: 'insensitive' } },
{ group: { name: { contains: searchTerm, mode: 'insensitive' } } }
]
}
: {})
}
},
include: {
room: {
include: {
group: true,
members: {
include: {
user: { select: { id: true, displayName: true } }
}
},
messages: {
where: { deletedAt: null },
orderBy: { createdAt: 'desc' },
take: 1,
include: { sender: { select: { displayName: true } } }
},
_count: { select: { messages: true } }
}
}
},
orderBy: { joinedAt: 'desc' }
});
const rooms = memberships.map((membership) => {
const room = membership.room;
const lastMessage = room.messages[0];
return {
id: room.id,
type: room.type,
name: room.name,
groupId: room.groupId,
groupName: room.group.name,
memberCount: room.members.length,
messageCount: room._count.messages,
members: room.members.map((member) => ({
id: member.user.id,
displayName: member.user.displayName
})),
lastMessage: lastMessage
? {
id: lastMessage.id,
senderName: lastMessage.sender.displayName,
preview: this.formatMessagePreview(lastMessage.type, lastMessage.content, lastMessage.isEncrypted),
createdAt: lastMessage.createdAt.toISOString()
}
: undefined,
joinedAt: membership.joinedAt.toISOString()
};
});
return {
total: rooms.length,
rooms: rooms.slice(skip, skip + take)
};
}
async listRoomMessages(userId: string, roomId: string, search?: string, limit = 50, beforeMessageId?: string) {
await this.ensureHumanUser(userId);
const room = await this.getModeratableRoom(roomId);
const member = await this.prisma.chatRoomMember.findUnique({
where: { roomId_userId: { roomId, userId } }
});
if (!member) {
throw new ForbiddenException('Пользователь не состоит в этом чате');
}
const take = Math.min(Math.max(limit, 1), 100);
const searchTerm = search?.trim();
let cursorDate: Date | undefined;
if (beforeMessageId) {
const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } });
if (cursor) cursorDate = cursor.createdAt;
}
const messages = await this.prisma.chatMessage.findMany({
where: {
roomId,
...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
...(searchTerm
? {
OR: [
{ content: { contains: searchTerm, mode: 'insensitive' } },
{ sender: { displayName: { contains: searchTerm, mode: 'insensitive' } } }
]
}
: {})
},
include: { sender: { select: { id: true, displayName: true } } },
orderBy: { createdAt: 'desc' },
take
});
return {
room: {
id: room.id,
name: room.name,
type: room.type,
groupName: room.group.name
},
messages: messages.reverse().map((message) => this.toAdminMessage(message))
};
}
async searchUserChatMessages(userId: string, search?: string, limit = 50, offset = 0) {
await this.ensureHumanUser(userId);
const searchTerm = search?.trim();
if (!searchTerm) {
throw new BadRequestException('Укажите текст для поиска по сообщениям');
}
const take = Math.min(Math.max(limit, 1), 100);
const skip = Math.max(offset, 0);
const where = {
room: {
members: { some: { userId } },
...MODERATABLE_ROOM_FILTER
},
deletedAt: null,
OR: [
{ content: { contains: searchTerm, mode: 'insensitive' as const } },
{ sender: { displayName: { contains: searchTerm, mode: 'insensitive' as const } } }
]
};
const [messages, total] = await Promise.all([
this.prisma.chatMessage.findMany({
where,
include: {
sender: { select: { id: true, displayName: true } },
room: { include: { group: true } }
},
orderBy: { createdAt: 'desc' },
take,
skip
}),
this.prisma.chatMessage.count({ where })
]);
return {
total,
messages: messages.map((message) => ({
...this.toAdminMessage(message),
roomId: message.roomId,
roomName: message.room.name,
groupName: message.room.group.name
}))
};
}
async adminDeleteMessage(actorId: string, messageId: string) {
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
include: { room: { include: { group: true } }, sender: true }
});
if (!message) {
throw new NotFoundException('Сообщение не найдено');
}
if (message.room.isE2E || message.room.type === 'E2E') {
throw new ForbiddenException('E2E-сообщения недоступны для модерации');
}
if (message.room.type === 'BOT') {
throw new ForbiddenException('Сообщения бота модерируются отдельно');
}
await this.prisma.chatMessage.update({
where: { id: messageId },
data: {
deletedAt: new Date(),
content: null,
storageKey: null,
mimeType: null,
metadata: { moderated: true, moderatedBy: actorId }
}
});
await this.prisma.userActivityLog.create({
data: {
userId: message.senderId,
action: 'ADMIN_MESSAGE_DELETED',
title: 'Сообщение удалено модератором',
detail: `Чат «${message.room.name}» (${message.room.group.name})`,
entityType: 'chat_message',
entityId: message.id,
actorId
}
});
return { success: true };
}
private async getModeratableRoom(roomId: string) {
const room = await this.prisma.chatRoom.findUnique({
where: { id: roomId },
include: { group: true }
});
if (!room) {
throw new NotFoundException('Чат не найден');
}
if (room.isE2E || room.type === 'E2E' || room.type === 'BOT') {
throw new ForbiddenException('Этот чат недоступен для модерации');
}
return room;
}
private formatMessagePreview(type: string, content: string | null, isEncrypted: boolean) {
if (isEncrypted) {
return '[Зашифрованное сообщение]';
}
if (type === 'IMAGE') return '[Изображение]';
if (type === 'AUDIO' || type === 'VOICE') return '[Аудио]';
if (type === 'FILE') return '[Файл]';
if (type === 'POLL') return '[Опрос]';
if (type === 'SYSTEM') return '[Системное сообщение]';
const text = content?.trim();
if (!text) return '[Пустое сообщение]';
return text.length > 160 ? `${text.slice(0, 157)}` : text;
}
private toAdminMessage(message: {
id: string;
senderId: string;
type: string;
content: string | null;
isEncrypted: boolean;
deletedAt: Date | null;
createdAt: Date;
editedAt: Date | null;
sender: { id: string; displayName: string };
}) {
return {
id: message.id,
senderId: message.senderId,
senderName: message.sender.displayName,
type: message.type,
content: message.isEncrypted ? null : message.content,
preview: this.formatMessagePreview(message.type, message.content, message.isEncrypted),
isEncrypted: message.isEncrypted,
isDeleted: Boolean(message.deletedAt),
createdAt: message.createdAt.toISOString(),
editedAt: message.editedAt?.toISOString()
};
}
}

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Controller, UseFilters } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { GrpcExceptionFilter } from '../infra/grpc-exception.filter';
import { AdminService } from './admin.service';
import { AdminUserInsightsService } from './admin-user-insights.service';
import { AuthService } from './auth.service';
import { LoginCommand, RegisterCommand, VerifyPinCommand } from './dto';
import { PinService } from './pin.service';
@@ -36,6 +37,7 @@ export class AuthGrpcController {
private readonly pin: PinService,
private readonly session: SessionService,
private readonly admin: AdminService,
private readonly adminInsights: AdminUserInsightsService,
private readonly rbac: RbacService,
private readonly security: SecurityService,
private readonly settings: SettingsService,
@@ -184,6 +186,42 @@ export class AuthGrpcController {
return this.admin.listVerificationIcons();
}
@GrpcMethod('AdminService', 'GetUserSignInHistory')
getUserSignInHistory(command: { userId: string; search?: string; limit?: number; offset?: number }) {
return this.adminInsights.getSignInHistory(command.userId, command.search, command.limit, command.offset);
}
@GrpcMethod('AdminService', 'GetUserActivity')
getUserActivity(command: { userId: string; search?: string; limit?: number; offset?: number }) {
return this.adminInsights.getActivityTimeline(command.userId, command.search, command.limit, command.offset);
}
@GrpcMethod('AdminService', 'ListUserChatRooms')
listUserChatRooms(command: { userId: string; search?: string; limit?: number; offset?: number }) {
return this.adminInsights.listUserChatRooms(command.userId, command.search, command.limit, command.offset);
}
@GrpcMethod('AdminService', 'ListUserChatMessages')
listUserChatMessages(command: { userId: string; roomId: string; search?: string; limit?: number; beforeMessageId?: string }) {
return this.adminInsights.listRoomMessages(
command.userId,
command.roomId,
command.search,
command.limit,
command.beforeMessageId
);
}
@GrpcMethod('AdminService', 'SearchUserChatMessages')
searchUserChatMessages(command: { userId: string; search: string; limit?: number; offset?: number }) {
return this.adminInsights.searchUserChatMessages(command.userId, command.search, command.limit, command.offset);
}
@GrpcMethod('AdminService', 'AdminDeleteChatMessage')
adminDeleteChatMessage(command: { actorUserId: string; messageId: string }) {
return this.adminInsights.adminDeleteMessage(command.actorUserId, command.messageId);
}
@GrpcMethod('RbacService', 'ListRoles')
async listRoles() {
const roles = await this.rbac.listRoles();

View File

@@ -932,7 +932,8 @@ export class AuthService implements OnModuleInit {
canViewUsers: access.canViewUsers,
canViewUserDocuments: access.canViewUserDocuments,
canVerifyUsers: access.canVerifyUsers,
canManageBots: access.canManageBots
canManageBots: access.canManageBots,
canModerateChats: access.canModerateChats
};
}

View File

@@ -56,6 +56,7 @@ export interface PublicUser {
verificationIcon?: string;
canVerifyUsers?: boolean;
canManageBots?: boolean;
canModerateChats?: boolean;
}
export interface VerifyPinCommand {