536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
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
|
||
};
|
||
}
|
||
}
|