Files
IdP/apps/sso-core/src/domain/admin-user-insights.service.ts
lendry a4b4577c55 fix
2026-07-10 10:37:22 +03:00

536 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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,
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
};
}
}