diff --git a/apps/api-gateway/src/controllers/admin.controller.ts b/apps/api-gateway/src/controllers/admin.controller.ts index ecc6db2..bbc870f 100644 --- a/apps/api-gateway/src/controllers/admin.controller.ts +++ b/apps/api-gateway/src/controllers/admin.controller.ts @@ -1,10 +1,10 @@ -import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { firstValueFrom } from 'rxjs'; import { map } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { CurrentAdmin } from '../decorators/current-admin.decorator'; -import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, SetUserVerificationDto, UpdateUserDto } from '../dto/admin.dto'; +import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, SetUserVerificationDto, UpdateUserDto, UserInsightsQueryDto } from '../dto/admin.dto'; import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard'; @ApiTags('Администрирование') @@ -124,4 +124,112 @@ export class AdminController { verificationIcon: dto.verificationIcon }); } + + @Get(':userId/sign-in-history') + @ApiOperation({ summary: 'История входов пользователя', description: 'Журнал SignInEvent с поиском по IP, устройству и причине.' }) + getUserSignInHistory(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canViewUsers && !admin.canManageUsers) { + throw new ForbiddenException('Недостаточно прав для просмотра журнала пользователя'); + } + return firstValueFrom( + this.core.admin.GetUserSignInHistory({ + userId, + search: query.search, + limit: query.limit, + offset: query.offset + }) + ); + } + + @Get(':userId/activity') + @ApiOperation({ summary: 'Активность пользователя', description: 'Созданные документы, чаты, семьи, OAuth-согласия и журнал действий.' }) + getUserActivity(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canViewUsers && !admin.canManageUsers) { + throw new ForbiddenException('Недостаточно прав для просмотра активности пользователя'); + } + return firstValueFrom( + this.core.admin.GetUserActivity({ + userId, + search: query.search, + limit: query.limit, + offset: query.offset + }) + ); + } + + @Get(':userId/chats') + @ApiOperation({ summary: 'Чаты пользователя для модерации', description: 'Обычные чаты без E2E и ботов.' }) + listUserChats(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canModerateChats && !admin.isSuperAdmin) { + throw new ForbiddenException('Недостаточно прав для модерации чатов'); + } + return firstValueFrom( + this.core.admin.ListUserChatRooms({ + userId, + search: query.search, + limit: query.limit, + offset: query.offset + }) + ); + } + + @Get(':userId/chats/search') + @ApiOperation({ summary: 'Поиск по сообщениям пользователя', description: 'Поиск по обычным (не E2E) перепискам пользователя.' }) + searchUserChats(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canModerateChats && !admin.isSuperAdmin) { + throw new ForbiddenException('Недостаточно прав для модерации чатов'); + } + if (!query.search?.trim()) { + throw new BadRequestException('Укажите параметр search'); + } + return firstValueFrom( + this.core.admin.SearchUserChatMessages({ + userId, + search: query.search.trim(), + limit: query.limit, + offset: query.offset + }) + ); + } + + @Get(':userId/chats/:roomId/messages') + @ApiOperation({ summary: 'Сообщения чата пользователя', description: 'Просмотр переписки в обычном чате для модерации.' }) + listUserChatMessages( + @Param('userId') userId: string, + @Param('roomId') roomId: string, + @Query() query: UserInsightsQueryDto, + @CurrentAdmin() admin: AdminRequestUser + ) { + if (!admin.canModerateChats && !admin.isSuperAdmin) { + throw new ForbiddenException('Недостаточно прав для модерации чатов'); + } + return firstValueFrom( + this.core.admin.ListUserChatMessages({ + userId, + roomId, + search: query.search, + limit: query.limit, + beforeMessageId: query.beforeMessageId + }) + ); + } + + @Delete(':userId/chat-messages/:messageId') + @ApiOperation({ summary: 'Удалить сообщение (модерация)', description: 'Мягкое удаление сообщения в обычном чате.' }) + deleteUserChatMessage( + @Param('userId') userId: string, + @Param('messageId') messageId: string, + @CurrentAdmin() admin: AdminRequestUser + ) { + if (!admin.canModerateChats && !admin.isSuperAdmin) { + throw new ForbiddenException('Недостаточно прав для модерации чатов'); + } + void userId; + return firstValueFrom( + this.core.admin.AdminDeleteChatMessage({ + actorUserId: admin.id, + messageId + }) + ); + } } diff --git a/apps/api-gateway/src/dto/admin.dto.ts b/apps/api-gateway/src/dto/admin.dto.ts index a35face..c50dd9c 100644 --- a/apps/api-gateway/src/dto/admin.dto.ts +++ b/apps/api-gateway/src/dto/admin.dto.ts @@ -69,3 +69,23 @@ export class SetUserVerificationDto { @IsString({ message: 'Значок должен быть строкой' }) verificationIcon?: string; } + +export class UserInsightsQueryDto { + @ApiPropertyOptional({ description: 'Поиск по событиям, IP, user-agent, тексту активности или чатам' }) + @IsOptional() + @IsString({ message: 'Поисковая строка должна быть текстом' }) + search?: string; + + @ApiPropertyOptional({ description: 'Лимит записей', default: 50 }) + @IsOptional() + limit?: number; + + @ApiPropertyOptional({ description: 'Смещение для пагинации', default: 0 }) + @IsOptional() + offset?: number; + + @ApiPropertyOptional({ description: 'ID сообщения для пагинации истории чата' }) + @IsOptional() + @IsString({ message: 'beforeMessageId должно быть строкой' }) + beforeMessageId?: string; +} diff --git a/apps/api-gateway/src/guards/admin.guard.ts b/apps/api-gateway/src/guards/admin.guard.ts index 66620ee..7d002f1 100644 --- a/apps/api-gateway/src/guards/admin.guard.ts +++ b/apps/api-gateway/src/guards/admin.guard.ts @@ -17,6 +17,7 @@ export interface AdminRequestUser { canViewUsers: boolean; canManageSettings: boolean; canVerifyUsers: boolean; + canModerateChats: boolean; roles: string[]; permissions: string[]; } @@ -60,6 +61,7 @@ export class AdminGuard implements CanActivate { canManageSettings: Boolean(profile.canManageSettings), canViewUsers: Boolean(profile.canViewUsers), canVerifyUsers: Boolean(profile.canVerifyUsers), + canModerateChats: Boolean(profile.canModerateChats), roles: profile.roles ?? [], permissions: profile.permissions ?? [] }; diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index e9c7d11..d632467 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; +import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; +import { UserInspectorDialog } from '@/components/admin/user-inspector-dialog'; import { VerificationBadge } from '@/components/id/verification-badge'; import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog'; import { AdminShell } from '@/components/id/admin-shell'; @@ -47,6 +48,7 @@ export default function AdminUsersPage() { const [actionLoading, setActionLoading] = useState(false); const [dialog, setDialog] = useState<'password' | 'roles' | 'verification' | null>(null); const [documentsUser, setDocumentsUser] = useState(null); + const [inspectorUser, setInspectorUser] = useState(null); const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON); const loadUsers = useCallback(async () => { @@ -344,7 +346,14 @@ export default function AdminUsersPage() { {users.map((user) => ( - + { + if (user.isBot) return; + setInspectorUser(user); + }} + >
{user.displayName} @@ -358,7 +367,17 @@ export default function AdminUsersPage() { {statusLabels[user.status] ?? user.status} -
+
event.stopPropagation()}> + {(currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot ? ( + + ) : null} {currentUser?.canManageUsers ? ( <> + ))} +
+ ) : ( +

Сообщения не найдены

+ )} +
+ ) : null} + +
+
+
+ + setChatSearch(event.target.value)} + placeholder="Поиск чатов..." + className="pl-10" + /> +
+

Чатов: {roomsTotal}. E2E и боты скрыты.

+
+ {loadingRooms ? ( +
+ +
+ ) : rooms.length ? ( +
+ {rooms.map((room) => ( + + ))} +
+ ) : ( +

Чаты не найдены

+ )} +
+
+ +
+
+

{selectedRoom?.name ?? 'Выберите чат'}

+

+ {roomContext + ? `${roomContext.groupName} · ${roomTypeLabels[roomContext.type] ?? roomContext.type}` + : selectedRoom + ? `${selectedRoom.groupName} · ${selectedRoom.memberCount} участников` + : 'Обычные чаты доступны для модерации'} +

+
+
+ + setMessageSearch(event.target.value)} + placeholder="Поиск в выбранном чате..." + className="pl-10" + disabled={!selectedRoomId} + /> +
+
+ {!selectedRoomId ? ( +

Выберите чат слева

+ ) : loadingMessages ? ( +
+ + Загрузка сообщений... +
+ ) : roomMessages.length ? ( +
+ {roomMessages.map((message) => ( +
+
+
+
+ {message.senderName} + {formatDateTime(message.createdAt)} + {message.isDeleted ? ( + Удалено + ) : null} +
+

+ {message.isEncrypted ? '[E2E недоступно для модерации]' : message.preview} +

+
+ {!message.isDeleted && !message.isEncrypted ? ( + + ) : null} +
+
+ ))} +
+ ) : ( +

Сообщения не найдены

+ )} +
+
+
+ + )} + + + + + ); +} diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index f048c6a..5b6749f 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -107,6 +107,7 @@ export interface PublicUser { verificationIcon?: string | null; canVerifyUsers?: boolean; canManageBots?: boolean; + canModerateChats?: boolean; } export interface AdminUser { @@ -658,6 +659,50 @@ export interface SignInEvent { createdAt: string; } +export interface AdminUserActivityItem { + id: string; + action: string; + title: string; + detail?: string; + entityType?: string; + entityId?: string; + createdAt: string; +} + +export interface AdminChatRoomSummary { + id: string; + type: string; + name: string; + groupId: string; + groupName: string; + memberCount: number; + messageCount: number; + members: Array<{ id: string; displayName: string }>; + lastMessage?: { + id: string; + senderName: string; + preview: string; + createdAt: string; + }; + joinedAt: string; +} + +export interface AdminChatMessage { + id: string; + senderId: string; + senderName: string; + type: string; + content?: string | null; + preview: string; + isEncrypted: boolean; + isDeleted: boolean; + createdAt: string; + editedAt?: string; + roomId?: string; + roomName?: string; + groupName?: string; +} + export interface ActiveSession { id: string; userId: string; diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index bddf2c2..729f835 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -64,6 +64,7 @@ model User { sessions Session[] devices Device[] signInEvents SignInEvent[] + activityLogs UserActivityLog[] authCodes AuthCode[] linkedAccounts LinkedAccount[] userRoles UserRole[] @@ -171,6 +172,24 @@ model SignInEvent { @@index([userId, createdAt]) } +model UserActivityLog { + id String @id @default(uuid()) + userId String + action String + title String + detail String? + entityType String? + entityId String? + ipAddress String? + userAgent String? + actorId String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([userId, action]) +} + model LinkedAccount { id String @id @default(uuid()) userId String diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts index 2dbd635..702d25b 100644 --- a/apps/sso-core/src/app.module.ts +++ b/apps/sso-core/src/app.module.ts @@ -4,6 +4,8 @@ import { JwtModule } from '@nestjs/jwt'; import { AccessService } from './domain/access.service'; import { AdminSeedService } from './domain/admin-seed.service'; import { AdminService } from './domain/admin.service'; +import { AdminUserInsightsService } from './domain/admin-user-insights.service'; +import { ActivityLogService } from './domain/activity-log.service'; import { AuthGrpcController } from './domain/auth-grpc.controller'; import { AuthService } from './domain/auth.service'; import { PinService } from './domain/pin.service'; @@ -70,6 +72,8 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser AccessService, AdminSeedService, AdminService, + AdminUserInsightsService, + ActivityLogService, RbacService, OAuthSslSansService, SecurityService, diff --git a/apps/sso-core/src/domain/access.service.ts b/apps/sso-core/src/domain/access.service.ts index e78bb82..74596f1 100644 --- a/apps/sso-core/src/domain/access.service.ts +++ b/apps/sso-core/src/domain/access.service.ts @@ -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') }; } diff --git a/apps/sso-core/src/domain/activity-log.service.ts b/apps/sso-core/src/domain/activity-log.service.ts new file mode 100644 index 0000000..890952c --- /dev/null +++ b/apps/sso-core/src/domain/activity-log.service.ts @@ -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); + } +} diff --git a/apps/sso-core/src/domain/admin-seed.service.ts b/apps/sso-core/src/domain/admin-seed.service.ts index 8b9c80d..f820b91 100644 --- a/apps/sso-core/src/domain/admin-seed.service.ts +++ b/apps/sso-core/src/domain/admin-seed.service.ts @@ -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 }, diff --git a/apps/sso-core/src/domain/admin-user-insights.service.ts b/apps/sso-core/src/domain/admin-user-insights.service.ts new file mode 100644 index 0000000..5999bd4 --- /dev/null +++ b/apps/sso-core/src/domain/admin-user-insights.service.ts @@ -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 = { + PASSPORT: 'Паспорт', + DRIVER_LICENSE: 'Водительское удостоверение', + SNILS: 'СНИЛС', + INN: 'ИНН', + BIRTH_CERTIFICATE: 'Свидетельство о рождении', + OTHER: 'Документ' +}; + +const ROOM_TYPE_LABELS: Record = { + 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() + }; + } +} diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index f2fe9e0..a17d4cf 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -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(); diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index af2d365..5e484d7 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -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 }; } diff --git a/apps/sso-core/src/domain/dto.ts b/apps/sso-core/src/domain/dto.ts index be57463..bf7d249 100644 --- a/apps/sso-core/src/domain/dto.ts +++ b/apps/sso-core/src/domain/dto.ts @@ -56,6 +56,7 @@ export interface PublicUser { verificationIcon?: string; canVerifyUsers?: boolean; canManageBots?: boolean; + canModerateChats?: boolean; } export interface VerifyPinCommand { diff --git a/shared/proto/admin.proto b/shared/proto/admin.proto index 6c2a737..37678b4 100644 --- a/shared/proto/admin.proto +++ b/shared/proto/admin.proto @@ -11,6 +11,12 @@ service AdminService { rpc SetSuperAdmin (SetSuperAdminRequest) returns (AdminUser); rpc SetUserVerification (SetUserVerificationRequest) returns (AdminUser); rpc ListVerificationIcons (Empty) returns (ListVerificationIconsResponse); + rpc GetUserSignInHistory (GetUserInsightsRequest) returns (UserSignInHistoryResponse); + rpc GetUserActivity (GetUserInsightsRequest) returns (UserActivityResponse); + rpc ListUserChatRooms (GetUserInsightsRequest) returns (UserChatRoomsResponse); + rpc ListUserChatMessages (ListUserChatMessagesRequest) returns (UserChatMessagesResponse); + rpc SearchUserChatMessages (SearchUserChatMessagesRequest) returns (UserChatMessageSearchResponse); + rpc AdminDeleteChatMessage (AdminDeleteChatMessageRequest) returns (Empty); } message Empty {} @@ -83,3 +89,123 @@ message AdminUser { message ListUsersResponse { repeated AdminUser users = 1; } + +message GetUserInsightsRequest { + string userId = 1; + optional string search = 2; + optional int32 limit = 3; + optional int32 offset = 4; +} + +message AdminSignInEvent { + string id = 1; + bool success = 2; + optional string reason = 3; + optional string ipAddress = 4; + optional string userAgent = 5; + optional string deviceName = 6; + string createdAt = 7; +} + +message UserSignInHistoryResponse { + int32 total = 1; + repeated AdminSignInEvent events = 2; +} + +message UserActivityItem { + string id = 1; + string action = 2; + string title = 3; + optional string detail = 4; + optional string entityType = 5; + optional string entityId = 6; + string createdAt = 7; +} + +message UserActivityResponse { + int32 total = 1; + repeated UserActivityItem items = 2; +} + +message AdminChatRoomMember { + string id = 1; + string displayName = 2; +} + +message AdminChatLastMessage { + string id = 1; + string senderName = 2; + string preview = 3; + string createdAt = 4; +} + +message AdminChatRoomSummary { + string id = 1; + string type = 2; + string name = 3; + string groupId = 4; + string groupName = 5; + int32 memberCount = 6; + int32 messageCount = 7; + repeated AdminChatRoomMember members = 8; + optional AdminChatLastMessage lastMessage = 9; + string joinedAt = 10; +} + +message UserChatRoomsResponse { + int32 total = 1; + repeated AdminChatRoomSummary rooms = 2; +} + +message ListUserChatMessagesRequest { + string userId = 1; + string roomId = 2; + optional string search = 3; + optional int32 limit = 4; + optional string beforeMessageId = 5; +} + +message SearchUserChatMessagesRequest { + string userId = 1; + string search = 2; + optional int32 limit = 3; + optional int32 offset = 4; +} + +message AdminChatMessage { + string id = 1; + string senderId = 2; + string senderName = 3; + string type = 4; + optional string content = 5; + string preview = 6; + bool isEncrypted = 7; + bool isDeleted = 8; + string createdAt = 9; + optional string editedAt = 10; + optional string roomId = 11; + optional string roomName = 12; + optional string groupName = 13; +} + +message AdminChatRoomContext { + string id = 1; + string name = 2; + string type = 3; + string groupName = 4; +} + +message UserChatMessagesResponse { + optional AdminChatRoomContext room = 1; + repeated AdminChatMessage messages = 2; +} + +message UserChatMessageSearchResponse { + int32 total = 1; + repeated AdminChatMessage messages = 2; +} + +message AdminDeleteChatMessageRequest { + string actorUserId = 1; + string messageId = 2; +}