import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../infra/prisma.service'; import { AdminInsightsQueryOptions, AdminInsightsRetentionService } from './admin-insights-retention.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, private readonly retention: AdminInsightsRetentionService ) {} 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, options: AdminInsightsQueryOptions = {}) { await this.ensureHumanUser(userId); const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo); const take = Math.min(Math.max(options.limit ?? 50, 1), 100); const skip = Math.max(options.offset ?? 0, 0); const searchTerm = options.search?.trim(); const createdAt = this.retention.createdAtFilter(range); const where = { userId, createdAt, ...(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 { ...this.retention.insightsMeta(range), 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, options: AdminInsightsQueryOptions = {}) { await this.ensureHumanUser(userId); const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo); const searchTerm = options.search?.trim().toLowerCase(); const take = Math.min(Math.max(options.limit ?? 50, 1), 100); const createdAt = this.retention.createdAtFilter(range); const [logs, documents, messages, rooms, families, consents] = await Promise.all([ this.prisma.userActivityLog.findMany({ where: { userId, createdAt }, orderBy: { createdAt: 'desc' }, take: 200 }), this.prisma.userDocument.findMany({ where: { userId, createdAt }, orderBy: { createdAt: 'desc' }, take: 100 }), this.prisma.chatMessage.findMany({ where: { senderId: userId, deletedAt: null, createdAt, room: MODERATABLE_ROOM_FILTER }, include: { room: { include: { group: true } } }, orderBy: { createdAt: 'desc' }, take: 100 }), this.prisma.chatRoom.findMany({ where: { createdById: userId, createdAt, ...MODERATABLE_ROOM_FILTER }, include: { group: true }, orderBy: { createdAt: 'desc' }, take: 50 }), this.prisma.familyGroup.findMany({ where: { ownerId: userId, createdAt }, orderBy: { createdAt: 'desc' }, take: 50 }), this.prisma.oAuthConsent.findMany({ where: { userId, createdAt }, 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(options.offset ?? 0, 0); return { ...this.retention.insightsMeta(range), total: deduped.length, items: deduped.slice(skip, skip + take) }; } async listUserChatRooms(userId: string, options: AdminInsightsQueryOptions = {}) { await this.ensureHumanUser(userId); const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo); const take = Math.min(Math.max(options.limit ?? 50, 1), 100); const skip = Math.max(options.offset ?? 0, 0); const searchTerm = options.search?.trim(); const createdAt = this.retention.createdAtFilter(range); const memberships = await this.prisma.chatRoomMember.findMany({ where: { userId, room: { ...MODERATABLE_ROOM_FILTER, messages: { some: { senderId: userId, createdAt } }, ...(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, createdAt }, orderBy: { createdAt: 'desc' }, take: 1, include: { sender: { select: { displayName: true } } } }, _count: { select: { messages: { where: { senderId: userId, createdAt } } } } } } }, 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 { ...this.retention.insightsMeta(range), total: rooms.length, rooms: rooms.slice(skip, skip + take) }; } async listRoomMessages(userId: string, roomId: string, options: AdminInsightsQueryOptions = {}) { await this.ensureHumanUser(userId); const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo); 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(options.limit ?? 50, 1), 100); const searchTerm = options.search?.trim(); const createdAt = this.retention.createdAtFilter(range); let cursorDate: Date | undefined; if (options.beforeMessageId) { const cursor = await this.prisma.chatMessage.findFirst({ where: { id: options.beforeMessageId, roomId } }); if (cursor) cursorDate = cursor.createdAt; } const messages = await this.prisma.chatMessage.findMany({ where: { roomId, createdAt, ...(cursorDate ? { createdAt: { lt: cursorDate, gte: range.from, lte: range.to } } : {}), ...(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 { ...this.retention.insightsMeta(range), room: { id: room.id, name: room.name, type: room.type, groupName: room.group.name }, messages: messages.reverse().map((message) => this.toAdminMessage(message, { roomName: room.name, groupName: room.group.name }) ) }; } async searchUserChatMessages(userId: string, options: AdminInsightsQueryOptions) { await this.ensureHumanUser(userId); const searchTerm = options.search?.trim(); if (!searchTerm) { throw new BadRequestException('Укажите текст для поиска по сообщениям'); } const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo); const take = Math.min(Math.max(options.limit ?? 50, 1), 100); const skip = Math.max(options.offset ?? 0, 0); const createdAt = this.retention.createdAtFilter(range); const where = { room: { members: { some: { userId } }, ...MODERATABLE_ROOM_FILTER }, deletedAt: null, createdAt, 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 { ...this.retention.insightsMeta(range), total, messages: messages.map((message) => ({ ...this.toAdminMessage(message, { 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 === 'VIDEO') return '[Видео]'; if (type === 'VIDEO_NOTE') return '[Кружок]'; if (type === 'LOCATION') 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; roomId: string; senderId: string; type: string; content: string | null; storageKey: string | null; mimeType: string | null; metadata: unknown; isEncrypted: boolean; deletedAt: Date | null; createdAt: Date; editedAt: Date | null; sender: { id: string; displayName: string }; }, context?: { roomName?: string; groupName?: 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(), roomId: message.roomId, roomName: context?.roomName, groupName: context?.groupName, storageKey: message.deletedAt || message.isEncrypted ? undefined : message.storageKey ?? undefined, mimeType: message.deletedAt || message.isEncrypted ? undefined : message.mimeType ?? undefined, metadataJson: message.deletedAt || message.isEncrypted ? undefined : message.metadata ? JSON.stringify(message.metadata) : undefined }; } }