fix and update
This commit is contained in:
474
apps/sso-core/src/domain/admin-user-insights.service.ts
Normal file
474
apps/sso-core/src/domain/admin-user-insights.service.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user