From 7e54cec36144d20608d3c7312cddf2f79600a7c3 Mon Sep 17 00:00:00 2001
From: lendry
Date: Wed, 1 Jul 2026 19:19:27 +0300
Subject: [PATCH] fix and update
---
.../src/controllers/admin.controller.ts | 20 +-
apps/api-gateway/src/dto/admin.dto.ts | 10 +
.../admin/admin-insights-date-filter.tsx | 95 ++++++
.../admin/admin-inspector-message-body.tsx | 273 ++++++++++++++++++
.../components/admin/use-admin-chat-media.ts | 113 ++++++++
.../admin/user-inspector-dialog.tsx | 191 ++++++++----
apps/frontend/lib/api.ts | 9 +
apps/sso-core/src/app.module.ts | 2 +
.../admin-insights-retention.service.ts | 138 +++++++++
.../src/domain/admin-user-insights.service.ts | 152 +++++++---
.../src/domain/auth-grpc.controller.ts | 62 +++-
.../domain/maintenance-scheduler.service.ts | 12 +-
apps/sso-core/src/domain/media.service.ts | 23 +-
.../src/domain/system-settings.seed.ts | 7 +-
install.sh | 101 ++++++-
shared/proto/admin.proto | 35 +++
16 files changed, 1093 insertions(+), 150 deletions(-)
create mode 100644 apps/frontend/components/admin/admin-insights-date-filter.tsx
create mode 100644 apps/frontend/components/admin/admin-inspector-message-body.tsx
create mode 100644 apps/frontend/components/admin/use-admin-chat-media.ts
create mode 100644 apps/sso-core/src/domain/admin-insights-retention.service.ts
diff --git a/apps/api-gateway/src/controllers/admin.controller.ts b/apps/api-gateway/src/controllers/admin.controller.ts
index bbc870f..609af95 100644
--- a/apps/api-gateway/src/controllers/admin.controller.ts
+++ b/apps/api-gateway/src/controllers/admin.controller.ts
@@ -136,7 +136,9 @@ export class AdminController {
userId,
search: query.search,
limit: query.limit,
- offset: query.offset
+ offset: query.offset,
+ dateFrom: query.dateFrom,
+ dateTo: query.dateTo
})
);
}
@@ -152,7 +154,9 @@ export class AdminController {
userId,
search: query.search,
limit: query.limit,
- offset: query.offset
+ offset: query.offset,
+ dateFrom: query.dateFrom,
+ dateTo: query.dateTo
})
);
}
@@ -168,7 +172,9 @@ export class AdminController {
userId,
search: query.search,
limit: query.limit,
- offset: query.offset
+ offset: query.offset,
+ dateFrom: query.dateFrom,
+ dateTo: query.dateTo
})
);
}
@@ -187,7 +193,9 @@ export class AdminController {
userId,
search: query.search.trim(),
limit: query.limit,
- offset: query.offset
+ offset: query.offset,
+ dateFrom: query.dateFrom,
+ dateTo: query.dateTo
})
);
}
@@ -209,7 +217,9 @@ export class AdminController {
roomId,
search: query.search,
limit: query.limit,
- beforeMessageId: query.beforeMessageId
+ beforeMessageId: query.beforeMessageId,
+ dateFrom: query.dateFrom,
+ dateTo: query.dateTo
})
);
}
diff --git a/apps/api-gateway/src/dto/admin.dto.ts b/apps/api-gateway/src/dto/admin.dto.ts
index c50dd9c..6fef123 100644
--- a/apps/api-gateway/src/dto/admin.dto.ts
+++ b/apps/api-gateway/src/dto/admin.dto.ts
@@ -88,4 +88,14 @@ export class UserInsightsQueryDto {
@IsOptional()
@IsString({ message: 'beforeMessageId должно быть строкой' })
beforeMessageId?: string;
+
+ @ApiPropertyOptional({ description: 'Начало периода (ISO 8601 или datetime-local)' })
+ @IsOptional()
+ @IsString({ message: 'dateFrom должно быть строкой' })
+ dateFrom?: string;
+
+ @ApiPropertyOptional({ description: 'Конец периода (ISO 8601 или datetime-local)' })
+ @IsOptional()
+ @IsString({ message: 'dateTo должно быть строкой' })
+ dateTo?: string;
}
diff --git a/apps/frontend/components/admin/admin-insights-date-filter.tsx b/apps/frontend/components/admin/admin-insights-date-filter.tsx
new file mode 100644
index 0000000..320b4d9
--- /dev/null
+++ b/apps/frontend/components/admin/admin-insights-date-filter.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { CalendarRange, RotateCcw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+
+export function toDatetimeLocalValue(date: Date) {
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+export function defaultInsightsPeriodLocal(days = 7) {
+ const to = new Date();
+ const from = new Date();
+ from.setDate(from.getDate() - days);
+ from.setSeconds(0, 0);
+ to.setSeconds(0, 0);
+ return {
+ from: toDatetimeLocalValue(from),
+ to: toDatetimeLocalValue(to)
+ };
+}
+
+export function localDatetimeToIso(value: string) {
+ if (!value.trim()) return undefined;
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return undefined;
+ return parsed.toISOString();
+}
+
+function pluralDays(value: number) {
+ const mod10 = value % 10;
+ const mod100 = value % 100;
+ if (mod10 === 1 && mod100 !== 11) return 'день';
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'дня';
+ return 'дней';
+}
+
+export function AdminInsightsDateFilter({
+ from,
+ to,
+ retentionDays,
+ onFromChange,
+ onToChange,
+ onReset
+}: {
+ from: string;
+ to: string;
+ retentionDays: number;
+ onFromChange: (value: string) => void;
+ onToChange: (value: string) => void;
+ onReset: () => void;
+}) {
+ return (
+
+
+
+ Период просмотра
+
+
+
+
+ onFromChange(event.target.value)}
+ />
+
+
+
+ onToChange(event.target.value)}
+ />
+
+
+
+
+
+
+ Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
+
+
+ );
+}
diff --git a/apps/frontend/components/admin/admin-inspector-message-body.tsx b/apps/frontend/components/admin/admin-inspector-message-body.tsx
new file mode 100644
index 0000000..8c090ed
--- /dev/null
+++ b/apps/frontend/components/admin/admin-inspector-message-body.tsx
@@ -0,0 +1,273 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { Loader2, Trash2 } from 'lucide-react';
+import { ChatMediaAlbumGrid } from '@/components/chat/chat-media-album-grid';
+import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
+import { AdminChatMessage, ChatMessage } from '@/lib/api';
+import { groupChatMessagesForDisplay, parseChatMediaMetadata } from '@/lib/chat-media-album';
+import { cn } from '@/lib/utils';
+import { AdminChatMediaEntry, adminMessageToChatMessage } from '@/components/admin/use-admin-chat-media';
+
+function AdminImageBubble({
+ message,
+ media,
+ onOpen
+}: {
+ message: ChatMessage;
+ media?: AdminChatMediaEntry;
+ onOpen: () => void;
+}) {
+ const meta = parseChatMediaMetadata(message.metadataJson);
+ const fileName = meta.fileName || message.content || 'Фото';
+
+ if (media?.status === 'error') {
+ return {media.errorMessage ?? 'Фото недоступно'}
;
+ }
+
+ if (!media?.blobUrl || media.status === 'loading') {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+export function AdminInspectorMessageBody({
+ messages,
+ roomId,
+ mediaUrls,
+ onOpenImage
+}: {
+ messages: AdminChatMessage[];
+ roomId: string;
+ mediaUrls: Record;
+ onOpenImage: (message: ChatMessage) => void;
+}) {
+ const chatMessages = messages.map((message) => adminMessageToChatMessage(message, roomId));
+ const displayItems = groupChatMessagesForDisplay(chatMessages);
+
+ return (
+
+ {displayItems.map((item) => {
+ if (item.kind === 'album') {
+ const captionMessage = item.messages.find((message) => message.content?.trim());
+ return (
+
+
onOpenImage(message)}
+ />
+ {captionMessage?.content ? (
+ {captionMessage.content}
+ ) : null}
+
+ );
+ }
+
+ const message = item.message;
+ const adminMessage = messages.find((entry) => entry.id === message.id);
+ if (!adminMessage) return null;
+
+ if (message.isEncrypted) {
+ return (
+
+ [E2E недоступно для модерации]
+
+ );
+ }
+
+ if (message.storageKey && message.type === 'IMAGE') {
+ return (
+
onOpenImage(message)}
+ />
+ );
+ }
+
+ if (message.type === 'FILE' && message.storageKey) {
+ const meta = parseChatMediaMetadata(message.metadataJson);
+ return (
+
+ 📎 {meta.fileName || message.content || 'Файл'}
+
+ );
+ }
+
+ if (message.type === 'VOICE' || message.type === 'AUDIO') {
+ return (
+
+ 🎤 Голосовое сообщение
+
+ );
+ }
+
+ if (message.type === 'POLL') {
+ return (
+
+ 📊 Опрос
+
+ );
+ }
+
+ const text = adminMessage.content?.trim() || adminMessage.preview;
+ if (!text || text === '[Пустое сообщение]') {
+ return null;
+ }
+
+ return (
+
+ {text}
+
+ );
+ })}
+
+ );
+}
+
+export function AdminInspectorMessageFeed({
+ messages,
+ roomId,
+ mediaUrls,
+ deletingMessageId,
+ onDelete,
+ onOpenImage
+}: {
+ messages: AdminChatMessage[];
+ roomId: string;
+ mediaUrls: Record;
+ deletingMessageId: string | null;
+ onDelete: (messageId: string) => void;
+ onOpenImage: (message: ChatMessage) => void;
+}) {
+ const chatMessages = messages.map((message) => adminMessageToChatMessage(message, roomId));
+ const displayItems = groupChatMessagesForDisplay(chatMessages);
+ const adminById = new Map(messages.map((message) => [message.id, message]));
+
+ return (
+
+ {displayItems.map((item) => {
+ if (item.kind === 'album') {
+ const anchor = adminById.get(item.messages[0]!.id);
+ if (!anchor) return null;
+ return (
+
deletingMessageId === message.id)}
+ onDelete={() => onDelete(anchor.id)}
+ onOpenImage={onOpenImage}
+ bodyOverride={
+ adminById.get(message.id))
+ .filter((entry): entry is AdminChatMessage => Boolean(entry))}
+ roomId={roomId}
+ mediaUrls={mediaUrls}
+ onOpenImage={onOpenImage}
+ />
+ }
+ />
+ );
+ }
+
+ const adminMessage = adminById.get(item.message.id);
+ if (!adminMessage) return null;
+
+ return (
+ onDelete(adminMessage.id)}
+ onOpenImage={onOpenImage}
+ />
+ );
+ })}
+
+ );
+}
+
+export function AdminInspectorMessageRow({
+ message,
+ roomId,
+ mediaUrls,
+ deleting,
+ onDelete,
+ onOpenImage,
+ bodyOverride
+}: {
+ message: AdminChatMessage;
+ roomId: string;
+ mediaUrls: Record;
+ deleting: boolean;
+ onDelete: () => void;
+ onOpenImage: (message: ChatMessage) => void;
+ bodyOverride?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {message.senderName}
+
+ {new Intl.DateTimeFormat('ru-RU', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ }).format(new Date(message.createdAt))}
+
+ {message.isDeleted ? Удалено : null}
+
+
+ {bodyOverride ?? (
+
+ )}
+
+
+ {!message.isDeleted && !message.isEncrypted ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/frontend/components/admin/use-admin-chat-media.ts b/apps/frontend/components/admin/use-admin-chat-media.ts
new file mode 100644
index 0000000..88cb4f0
--- /dev/null
+++ b/apps/frontend/components/admin/use-admin-chat-media.ts
@@ -0,0 +1,113 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { AdminChatMessage, ChatMessage, fetchChatMediaUrl } from '@/lib/api';
+import { createBlobObjectUrl, fetchAuthenticatedMediaBlob, revokeBlobObjectUrl } from '@/lib/authenticated-media';
+import { parseChatMediaMetadata } from '@/lib/chat-media-album';
+
+export type AdminChatMediaEntry = {
+ blobUrl?: string;
+ fileName?: string;
+ mimeType?: string;
+ status?: 'loading' | 'ready' | 'error';
+ errorMessage?: string;
+};
+
+export function adminMessageToChatMessage(message: AdminChatMessage, roomId: string): ChatMessage {
+ return {
+ id: message.id,
+ roomId: message.roomId ?? roomId,
+ senderId: message.senderId,
+ senderName: message.senderName,
+ senderHasAvatar: false,
+ type: message.type,
+ content: message.content ?? undefined,
+ storageKey: message.storageKey,
+ mimeType: message.mimeType,
+ metadataJson: message.metadataJson,
+ createdAt: message.createdAt,
+ editedAt: message.editedAt,
+ isDeleted: message.isDeleted,
+ isEncrypted: message.isEncrypted
+ };
+}
+
+export function useAdminChatMedia(token: string | null, roomId: string | null, messages: AdminChatMessage[]) {
+ const [mediaUrls, setMediaUrls] = useState>({});
+ const mediaUrlsRef = useRef>({});
+ const inFlightRef = useRef(new Set());
+
+ useEffect(() => {
+ mediaUrlsRef.current = mediaUrls;
+ }, [mediaUrls]);
+
+ const loadMedia = useCallback(
+ async (message: AdminChatMessage, force = false) => {
+ if (!token || !roomId || !message.storageKey || message.isEncrypted || message.isDeleted) return;
+ const storageKey = message.storageKey;
+ const existing = mediaUrlsRef.current[storageKey];
+ if (!force && existing?.status === 'ready' && existing.blobUrl) {
+ return;
+ }
+ if (inFlightRef.current.has(storageKey) && !force) {
+ return;
+ }
+
+ inFlightRef.current.add(storageKey);
+ setMediaUrls((current) => ({
+ ...current,
+ [storageKey]: { ...current[storageKey], status: 'loading', errorMessage: undefined }
+ }));
+
+ const meta = parseChatMediaMetadata(message.metadataJson);
+ const fileName = meta.fileName || message.content || undefined;
+
+ try {
+ const access = await fetchChatMediaUrl(roomId, storageKey, token, fileName);
+ const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token, message.mimeType);
+ const blobUrl = createBlobObjectUrl(blob);
+ setMediaUrls((current) => {
+ revokeBlobObjectUrl(current[storageKey]?.blobUrl);
+ return {
+ ...current,
+ [storageKey]: {
+ blobUrl,
+ fileName,
+ mimeType: message.mimeType,
+ status: 'ready'
+ }
+ };
+ });
+ } catch (error) {
+ setMediaUrls((current) => ({
+ ...current,
+ [storageKey]: {
+ ...current[storageKey],
+ status: 'error',
+ errorMessage: error instanceof Error ? error.message : 'Не удалось загрузить файл'
+ }
+ }));
+ } finally {
+ inFlightRef.current.delete(storageKey);
+ }
+ },
+ [roomId, token]
+ );
+
+ useEffect(() => {
+ if (!token || !roomId) return;
+ messages.forEach((message) => {
+ if (message.storageKey && message.type === 'IMAGE') {
+ void loadMedia(message);
+ }
+ });
+ }, [loadMedia, messages, roomId, token]);
+
+ useEffect(() => {
+ return () => {
+ Object.values(mediaUrlsRef.current).forEach((entry) => revokeBlobObjectUrl(entry.blobUrl));
+ };
+ }, []);
+
+ return { mediaUrls, loadMedia };
+}
diff --git a/apps/frontend/components/admin/user-inspector-dialog.tsx b/apps/frontend/components/admin/user-inspector-dialog.tsx
index b9237db..ba40d61 100644
--- a/apps/frontend/components/admin/user-inspector-dialog.tsx
+++ b/apps/frontend/components/admin/user-inspector-dialog.tsx
@@ -2,18 +2,27 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
- Activity,
CheckCircle2,
Loader2,
- MessageSquare,
+ LogIn,
+ MessagesSquare,
+ ScrollText,
Search,
- ShieldAlert,
- Trash2,
XCircle
} from 'lucide-react';
+import { AdminInspectorMessageFeed } from '@/components/admin/admin-inspector-message-body';
+import {
+ AdminInsightsDateFilter,
+ defaultInsightsPeriodLocal,
+ localDatetimeToIso
+} from '@/components/admin/admin-insights-date-filter';
+import {
+ adminMessageToChatMessage,
+ useAdminChatMedia
+} from '@/components/admin/use-admin-chat-media';
+import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
import { VerificationBadge } from '@/components/id/verification-badge';
import { useToast } from '@/components/id/toast-provider';
-import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -22,11 +31,16 @@ import {
AdminChatRoomSummary,
AdminUser,
AdminUserActivityItem,
+ ChatMessage,
SignInEvent,
apiFetch
} from '@/lib/api';
+import { collectChatImageMessages } from '@/lib/chat-media-album';
import { cn } from '@/lib/utils';
+const inspectorTabTriggerClass =
+ 'flex items-center justify-center gap-2 rounded-lg px-2 py-2.5 text-sm font-medium text-[#667085] shadow-none transition data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm';
+
function formatDateTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
@@ -67,6 +81,16 @@ export function UserInspectorDialog({
}) {
const { showToast } = useToast();
const [tab, setTab] = useState('sign-in');
+ const defaultPeriod = useMemo(() => defaultInsightsPeriodLocal(7), []);
+ const [periodFrom, setPeriodFrom] = useState(defaultPeriod.from);
+ const [periodTo, setPeriodTo] = useState(defaultPeriod.to);
+ const [retentionDays, setRetentionDays] = useState(7);
+
+ const resetPeriod = useCallback(() => {
+ const next = defaultInsightsPeriodLocal(retentionDays);
+ setPeriodFrom(next.from);
+ setPeriodTo(next.to);
+ }, [retentionDays]);
const [signInSearch, setSignInSearch] = useState('');
const [activitySearch, setActivitySearch] = useState('');
@@ -79,6 +103,18 @@ export function UserInspectorDialog({
const debouncedChatSearch = useDebouncedValue(chatSearch);
const debouncedMessageSearch = useDebouncedValue(messageSearch);
const debouncedGlobalMessageSearch = useDebouncedValue(globalMessageSearch);
+ const debouncedPeriodFrom = useDebouncedValue(periodFrom, 500);
+ const debouncedPeriodTo = useDebouncedValue(periodTo, 500);
+
+ const appendDateParams = useCallback(
+ (params: URLSearchParams) => {
+ const fromIso = localDatetimeToIso(debouncedPeriodFrom);
+ const toIso = localDatetimeToIso(debouncedPeriodTo);
+ if (fromIso) params.set('dateFrom', fromIso);
+ if (toIso) params.set('dateTo', toIso);
+ },
+ [debouncedPeriodFrom, debouncedPeriodTo]
+ );
const [signInEvents, setSignInEvents] = useState([]);
const [signInTotal, setSignInTotal] = useState(0);
@@ -98,8 +134,17 @@ export function UserInspectorDialog({
const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingSearch, setLoadingSearch] = useState(false);
const [deletingMessageId, setDeletingMessageId] = useState(null);
+ const [lightboxIndex, setLightboxIndex] = useState(null);
const selectedRoom = useMemo(() => rooms.find((room) => room.id === selectedRoomId) ?? null, [rooms, selectedRoomId]);
+ const { mediaUrls } = useAdminChatMedia(token, selectedRoomId, roomMessages);
+ const chatImageMessages = useMemo(
+ () =>
+ selectedRoomId
+ ? collectChatImageMessages(roomMessages.map((message) => adminMessageToChatMessage(message, selectedRoomId)))
+ : [],
+ [roomMessages, selectedRoomId]
+ );
const loadSignIn = useCallback(async () => {
if (!token || !user) return;
@@ -108,19 +153,27 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedSignInSearch.trim()) params.set('search', debouncedSignInSearch.trim());
params.set('limit', '50');
- const response = await apiFetch<{ total: number; events: SignInEvent[] }>(
+ appendDateParams(params);
+ const response = await apiFetch<{
+ total: number;
+ events: SignInEvent[];
+ retentionDays?: number;
+ periodFrom?: string;
+ periodTo?: string;
+ }>(
`/admin/users/${user.id}/sign-in-history?${params.toString()}`,
{},
token
);
setSignInEvents(response.events ?? []);
setSignInTotal(response.total ?? 0);
+ if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить историю входов');
} finally {
setLoadingSignIn(false);
}
- }, [debouncedSignInSearch, showToast, token, user]);
+ }, [appendDateParams, debouncedSignInSearch, showToast, token, user]);
const loadActivity = useCallback(async () => {
if (!token || !user) return;
@@ -129,19 +182,25 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedActivitySearch.trim()) params.set('search', debouncedActivitySearch.trim());
params.set('limit', '50');
- const response = await apiFetch<{ total: number; items: AdminUserActivityItem[] }>(
+ appendDateParams(params);
+ const response = await apiFetch<{
+ total: number;
+ items: AdminUserActivityItem[];
+ retentionDays?: number;
+ }>(
`/admin/users/${user.id}/activity?${params.toString()}`,
{},
token
);
setActivityItems(response.items ?? []);
setActivityTotal(response.total ?? 0);
+ if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить активность');
} finally {
setLoadingActivity(false);
}
- }, [debouncedActivitySearch, showToast, token, user]);
+ }, [appendDateParams, debouncedActivitySearch, showToast, token, user]);
const loadRooms = useCallback(async () => {
if (!token || !user || !canModerateChats) return;
@@ -150,19 +209,21 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedChatSearch.trim()) params.set('search', debouncedChatSearch.trim());
params.set('limit', '50');
- const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[] }>(
+ appendDateParams(params);
+ const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[]; retentionDays?: number }>(
`/admin/users/${user.id}/chats?${params.toString()}`,
{},
token
);
setRooms(response.rooms ?? []);
setRoomsTotal(response.total ?? 0);
+ if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить чаты');
} finally {
setLoadingRooms(false);
}
- }, [canModerateChats, debouncedChatSearch, showToast, token, user]);
+ }, [appendDateParams, canModerateChats, debouncedChatSearch, showToast, token, user]);
const loadRoomMessages = useCallback(async () => {
if (!token || !user || !selectedRoomId) return;
@@ -171,11 +232,14 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
if (debouncedMessageSearch.trim()) params.set('search', debouncedMessageSearch.trim());
params.set('limit', '80');
+ appendDateParams(params);
const response = await apiFetch<{
room?: { id: string; name: string; type: string; groupName: string };
messages: AdminChatMessage[];
+ retentionDays?: number;
}>(`/admin/users/${user.id}/chats/${selectedRoomId}/messages?${params.toString()}`, {}, token);
setRoomMessages(response.messages ?? []);
+ if (response.retentionDays) setRetentionDays(response.retentionDays);
setRoomContext(
response.room
? { name: response.room.name, groupName: response.room.groupName, type: response.room.type }
@@ -186,7 +250,7 @@ export function UserInspectorDialog({
} finally {
setLoadingMessages(false);
}
- }, [debouncedMessageSearch, selectedRoomId, showToast, token, user]);
+ }, [appendDateParams, debouncedMessageSearch, selectedRoomId, showToast, token, user]);
const loadGlobalSearch = useCallback(async () => {
if (!token || !user || !canModerateChats || !debouncedGlobalMessageSearch.trim()) {
@@ -199,19 +263,21 @@ export function UserInspectorDialog({
const params = new URLSearchParams();
params.set('search', debouncedGlobalMessageSearch.trim());
params.set('limit', '50');
- const response = await apiFetch<{ total: number; messages: AdminChatMessage[] }>(
+ appendDateParams(params);
+ const response = await apiFetch<{ total: number; messages: AdminChatMessage[]; retentionDays?: number }>(
`/admin/users/${user.id}/chats/search?${params.toString()}`,
{},
token
);
setSearchResults(response.messages ?? []);
setSearchTotal(response.total ?? 0);
+ if (response.retentionDays) setRetentionDays(response.retentionDays);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выполнить поиск');
} finally {
setLoadingSearch(false);
}
- }, [canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
+ }, [appendDateParams, canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
useEffect(() => {
if (!open || !user) return;
@@ -249,9 +315,19 @@ export function UserInspectorDialog({
setSelectedRoomId(null);
setRoomMessages([]);
setSearchResults([]);
+ setLightboxIndex(null);
+ const next = defaultInsightsPeriodLocal(7);
+ setPeriodFrom(next.from);
+ setPeriodTo(next.to);
+ setRetentionDays(7);
}
}, [open]);
+ function openImageLightbox(message: ChatMessage) {
+ const index = chatImageMessages.findIndex((item) => item.id === message.id);
+ if (index >= 0) setLightboxIndex(index);
+ }
+
async function handleDeleteMessage(messageId: string) {
if (!token || !user || !canModerateChats) return;
const confirmed = window.confirm('Удалить это сообщение? Действие необратимо для пользователей чата.');
@@ -284,19 +360,30 @@ export function UserInspectorDialog({
+
+
-
-
-
-
+
+
+
+
Входы
-
-
+
+
Активность
-
-
+
+
Чаты
@@ -521,48 +608,15 @@ export function UserInspectorDialog({
Загрузка сообщений...
) : roomMessages.length ? (
-
- {roomMessages.map((message) => (
-
-
-
-
- {message.senderName}
- {formatDateTime(message.createdAt)}
- {message.isDeleted ? (
- Удалено
- ) : null}
-
-
- {message.isEncrypted ? '[E2E недоступно для модерации]' : message.preview}
-
-
- {!message.isDeleted && !message.isEncrypted ? (
-
- ) : null}
-
-
- ))}
+
+
void handleDeleteMessage(messageId)}
+ onOpenImage={openImageLightbox}
+ />
) : (
Сообщения не найдены
@@ -574,6 +628,15 @@ export function UserInspectorDialog({
)}
+
+
setLightboxIndex(null)}
+ onIndexChange={setLightboxIndex}
+ />
);
diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts
index 5b6749f..dc1404a 100644
--- a/apps/frontend/lib/api.ts
+++ b/apps/frontend/lib/api.ts
@@ -649,6 +649,12 @@ export interface ActiveDevice {
trusted: boolean;
}
+export interface AdminInsightsPeriodMeta {
+ retentionDays: number;
+ periodFrom: string;
+ periodTo: string;
+}
+
export interface SignInEvent {
id: string;
success: boolean;
@@ -701,6 +707,9 @@ export interface AdminChatMessage {
roomId?: string;
roomName?: string;
groupName?: string;
+ storageKey?: string;
+ mimeType?: string;
+ metadataJson?: string;
}
export interface ActiveSession {
diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts
index 702d25b..6c57a86 100644
--- a/apps/sso-core/src/app.module.ts
+++ b/apps/sso-core/src/app.module.ts
@@ -5,6 +5,7 @@ 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 { AdminInsightsRetentionService } from './domain/admin-insights-retention.service';
import { ActivityLogService } from './domain/activity-log.service';
import { AuthGrpcController } from './domain/auth-grpc.controller';
import { AuthService } from './domain/auth.service';
@@ -73,6 +74,7 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser
AdminSeedService,
AdminService,
AdminUserInsightsService,
+ AdminInsightsRetentionService,
ActivityLogService,
RbacService,
OAuthSslSansService,
diff --git a/apps/sso-core/src/domain/admin-insights-retention.service.ts b/apps/sso-core/src/domain/admin-insights-retention.service.ts
new file mode 100644
index 0000000..3990dea
--- /dev/null
+++ b/apps/sso-core/src/domain/admin-insights-retention.service.ts
@@ -0,0 +1,138 @@
+import { BadRequestException, Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../infra/prisma.service';
+import { MinioService } from '../infra/minio.service';
+import { SettingsService } from './settings.service';
+
+export const ADMIN_INSIGHTS_RETENTION_DAYS_KEY = 'ADMIN_INSIGHTS_RETENTION_DAYS';
+export const DEFAULT_ADMIN_INSIGHTS_RETENTION_DAYS = 7;
+
+const MODERATABLE_ROOM_FILTER = {
+ isE2E: false,
+ type: { notIn: ['E2E', 'BOT'] as string[] }
+};
+
+export interface AdminInsightsDateRange {
+ from: Date;
+ to: Date;
+ retentionDays: number;
+}
+
+export interface AdminInsightsQueryOptions {
+ search?: string;
+ limit?: number;
+ offset?: number;
+ dateFrom?: string;
+ dateTo?: string;
+ beforeMessageId?: string;
+}
+
+@Injectable()
+export class AdminInsightsRetentionService {
+ private readonly logger = new Logger(AdminInsightsRetentionService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly settings: SettingsService,
+ private readonly minio: MinioService
+ ) {}
+
+ async getRetentionDays(): Promise {
+ const days = await this.settings.getNumber(ADMIN_INSIGHTS_RETENTION_DAYS_KEY, DEFAULT_ADMIN_INSIGHTS_RETENTION_DAYS);
+ return Math.min(Math.max(Math.trunc(days), 1), 90);
+ }
+
+ getRetentionCutoff(retentionDays: number): Date {
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - retentionDays);
+ cutoff.setMilliseconds(0);
+ return cutoff;
+ }
+
+ private parseDateInput(value: string | undefined, label: string): Date | undefined {
+ if (!value?.trim()) return undefined;
+ const parsed = new Date(value.trim());
+ if (Number.isNaN(parsed.getTime())) {
+ throw new BadRequestException(`Некорректная дата ${label}`);
+ }
+ return parsed;
+ }
+
+ async resolveDateRange(dateFrom?: string, dateTo?: string): Promise {
+ const retentionDays = await this.getRetentionDays();
+ const cutoff = this.getRetentionCutoff(retentionDays);
+ const now = new Date();
+
+ let from = this.parseDateInput(dateFrom, 'dateFrom') ?? cutoff;
+ let to = this.parseDateInput(dateTo, 'dateTo') ?? now;
+
+ if (from < cutoff) from = cutoff;
+ if (to > now) to = now;
+ if (from > to) {
+ throw new BadRequestException('Дата начала не может быть позже даты окончания');
+ }
+
+ return { from, to, retentionDays };
+ }
+
+ createdAtFilter(range: AdminInsightsDateRange) {
+ return { gte: range.from, lte: range.to };
+ }
+
+ insightsMeta(range: AdminInsightsDateRange) {
+ return {
+ retentionDays: range.retentionDays,
+ periodFrom: range.from.toISOString(),
+ periodTo: range.to.toISOString()
+ };
+ }
+
+ async purgeExpiredInsights(): Promise<{ signInEvents: number; activityLogs: number; chatMessages: number }> {
+ const retentionDays = await this.getRetentionDays();
+ const cutoff = this.getRetentionCutoff(retentionDays);
+
+ const signInResult = await this.prisma.signInEvent.deleteMany({
+ where: { createdAt: { lt: cutoff } }
+ });
+
+ const activityResult = await this.prisma.userActivityLog.deleteMany({
+ where: { createdAt: { lt: cutoff } }
+ });
+
+ let chatMessages = 0;
+ const batchSize = 500;
+ while (true) {
+ const staleMessages = await this.prisma.chatMessage.findMany({
+ where: {
+ createdAt: { lt: cutoff },
+ room: MODERATABLE_ROOM_FILTER
+ },
+ select: { id: true, storageKey: true },
+ take: batchSize
+ });
+ if (!staleMessages.length) break;
+
+ const storageKeys = staleMessages.map((item) => item.storageKey).filter((key): key is string => Boolean(key));
+ if (storageKeys.length) {
+ await this.minio.deleteObjects(storageKeys);
+ }
+
+ const deleted = await this.prisma.chatMessage.deleteMany({
+ where: { id: { in: staleMessages.map((item) => item.id) } }
+ });
+ chatMessages += deleted.count;
+ if (staleMessages.length < batchSize) break;
+ }
+
+ if (signInResult.count || activityResult.count || chatMessages) {
+ this.logger.log(
+ `Очистка журналов (${retentionDays} дн.): входы=${signInResult.count}, активность=${activityResult.count}, чаты=${chatMessages}`
+ );
+ }
+
+ return {
+ signInEvents: signInResult.count,
+ activityLogs: activityResult.count,
+ chatMessages
+ };
+ }
+}
diff --git a/apps/sso-core/src/domain/admin-user-insights.service.ts b/apps/sso-core/src/domain/admin-user-insights.service.ts
index 5999bd4..1a7e03c 100644
--- a/apps/sso-core/src/domain/admin-user-insights.service.ts
+++ b/apps/sso-core/src/domain/admin-user-insights.service.ts
@@ -1,5 +1,9 @@
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,
@@ -23,7 +27,10 @@ const ROOM_TYPE_LABELS: Record = {
@Injectable()
export class AdminUserInsightsService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly retention: AdminInsightsRetentionService
+ ) {}
async ensureHumanUser(userId: string) {
const user = await this.prisma.user.findFirst({
@@ -35,14 +42,17 @@ export class AdminUserInsightsService {
return user;
}
- async getSignInHistory(userId: string, search?: string, limit = 50, offset = 0) {
+ async getSignInHistory(userId: string, options: AdminInsightsQueryOptions = {}) {
await this.ensureHumanUser(userId);
- const take = Math.min(Math.max(limit, 1), 100);
- const skip = Math.max(offset, 0);
- const searchTerm = search?.trim();
+ 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: [
@@ -67,6 +77,7 @@ export class AdminUserInsightsService {
]);
return {
+ ...this.retention.insightsMeta(range),
total,
events: events.map((event) => ({
id: event.id,
@@ -80,19 +91,21 @@ export class AdminUserInsightsService {
};
}
- async getActivityTimeline(userId: string, search?: string, limit = 50, offset = 0) {
+ async getActivityTimeline(userId: string, options: AdminInsightsQueryOptions = {}) {
await this.ensureHumanUser(userId);
- const searchTerm = search?.trim().toLowerCase();
- const take = Math.min(Math.max(limit, 1), 100);
+ 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 },
+ where: { userId, createdAt },
orderBy: { createdAt: 'desc' },
take: 200
}),
this.prisma.userDocument.findMany({
- where: { userId },
+ where: { userId, createdAt },
orderBy: { createdAt: 'desc' },
take: 100
}),
@@ -100,6 +113,7 @@ export class AdminUserInsightsService {
where: {
senderId: userId,
deletedAt: null,
+ createdAt,
room: MODERATABLE_ROOM_FILTER
},
include: { room: { include: { group: true } } },
@@ -107,18 +121,18 @@ export class AdminUserInsightsService {
take: 100
}),
this.prisma.chatRoom.findMany({
- where: { createdById: userId, ...MODERATABLE_ROOM_FILTER },
+ where: { createdById: userId, createdAt, ...MODERATABLE_ROOM_FILTER },
include: { group: true },
orderBy: { createdAt: 'desc' },
take: 50
}),
this.prisma.familyGroup.findMany({
- where: { ownerId: userId },
+ where: { ownerId: userId, createdAt },
orderBy: { createdAt: 'desc' },
take: 50
}),
this.prisma.oAuthConsent.findMany({
- where: { userId },
+ where: { userId, createdAt },
include: { client: true },
orderBy: { createdAt: 'desc' },
take: 50
@@ -200,24 +214,33 @@ export class AdminUserInsightsService {
return haystack.includes(searchTerm);
});
- const skip = Math.max(offset, 0);
+ 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, search?: string, limit = 50, offset = 0) {
+ async listUserChatRooms(userId: string, options: AdminInsightsQueryOptions = {}) {
await this.ensureHumanUser(userId);
- const take = Math.min(Math.max(limit, 1), 100);
- const skip = Math.max(offset, 0);
- const searchTerm = search?.trim();
+ 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: [
@@ -238,12 +261,18 @@ export class AdminUserInsightsService {
}
},
messages: {
- where: { deletedAt: null },
+ where: { deletedAt: null, createdAt },
orderBy: { createdAt: 'desc' },
take: 1,
include: { sender: { select: { displayName: true } } }
},
- _count: { select: { messages: true } }
+ _count: {
+ select: {
+ messages: {
+ where: { senderId: userId, createdAt }
+ }
+ }
+ }
}
}
},
@@ -278,13 +307,15 @@ export class AdminUserInsightsService {
});
return {
+ ...this.retention.insightsMeta(range),
total: rooms.length,
rooms: rooms.slice(skip, skip + take)
};
}
- async listRoomMessages(userId: string, roomId: string, search?: string, limit = 50, beforeMessageId?: string) {
+ 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 } }
@@ -293,19 +324,21 @@ export class AdminUserInsightsService {
throw new ForbiddenException('Пользователь не состоит в этом чате');
}
- const take = Math.min(Math.max(limit, 1), 100);
- const searchTerm = search?.trim();
+ 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 (beforeMessageId) {
- const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } });
+ 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,
- ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
+ createdAt,
+ ...(cursorDate ? { createdAt: { lt: cursorDate, gte: range.from, lte: range.to } } : {}),
...(searchTerm
? {
OR: [
@@ -321,25 +354,30 @@ export class AdminUserInsightsService {
});
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))
+ messages: messages.reverse().map((message) =>
+ this.toAdminMessage(message, { roomName: room.name, groupName: room.group.name })
+ )
};
}
- async searchUserChatMessages(userId: string, search?: string, limit = 50, offset = 0) {
+ async searchUserChatMessages(userId: string, options: AdminInsightsQueryOptions) {
await this.ensureHumanUser(userId);
- const searchTerm = search?.trim();
+ const searchTerm = options.search?.trim();
if (!searchTerm) {
throw new BadRequestException('Укажите текст для поиска по сообщениям');
}
- const take = Math.min(Math.max(limit, 1), 100);
- const skip = Math.max(offset, 0);
+ 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: {
@@ -347,6 +385,7 @@ export class AdminUserInsightsService {
...MODERATABLE_ROOM_FILTER
},
deletedAt: null,
+ createdAt,
OR: [
{ content: { contains: searchTerm, mode: 'insensitive' as const } },
{ sender: { displayName: { contains: searchTerm, mode: 'insensitive' as const } } }
@@ -368,12 +407,13 @@ export class AdminUserInsightsService {
]);
return {
+ ...this.retention.insightsMeta(range),
total,
messages: messages.map((message) => ({
- ...this.toAdminMessage(message),
- roomId: message.roomId,
- roomName: message.room.name,
- groupName: message.room.group.name
+ ...this.toAdminMessage(message, {
+ roomName: message.room.name,
+ groupName: message.room.group.name
+ })
}))
};
}
@@ -447,17 +487,24 @@ export class AdminUserInsightsService {
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 };
- }) {
+ 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,
@@ -468,7 +515,18 @@ export class AdminUserInsightsService {
isEncrypted: message.isEncrypted,
isDeleted: Boolean(message.deletedAt),
createdAt: message.createdAt.toISOString(),
- editedAt: message.editedAt?.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
};
}
}
diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts
index a17d4cf..b44d128 100644
--- a/apps/sso-core/src/domain/auth-grpc.controller.ts
+++ b/apps/sso-core/src/domain/auth-grpc.controller.ts
@@ -187,34 +187,64 @@ export class AuthGrpcController {
}
@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);
+ getUserSignInHistory(command: {
+ userId: string;
+ search?: string;
+ limit?: number;
+ offset?: number;
+ dateFrom?: string;
+ dateTo?: string;
+ }) {
+ return this.adminInsights.getSignInHistory(command.userId, command);
}
@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);
+ getUserActivity(command: {
+ userId: string;
+ search?: string;
+ limit?: number;
+ offset?: number;
+ dateFrom?: string;
+ dateTo?: string;
+ }) {
+ return this.adminInsights.getActivityTimeline(command.userId, command);
}
@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);
+ listUserChatRooms(command: {
+ userId: string;
+ search?: string;
+ limit?: number;
+ offset?: number;
+ dateFrom?: string;
+ dateTo?: string;
+ }) {
+ return this.adminInsights.listUserChatRooms(command.userId, command);
}
@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
- );
+ listUserChatMessages(command: {
+ userId: string;
+ roomId: string;
+ search?: string;
+ limit?: number;
+ beforeMessageId?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ }) {
+ return this.adminInsights.listRoomMessages(command.userId, command.roomId, command);
}
@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);
+ searchUserChatMessages(command: {
+ userId: string;
+ search: string;
+ limit?: number;
+ offset?: number;
+ dateFrom?: string;
+ dateTo?: string;
+ }) {
+ return this.adminInsights.searchUserChatMessages(command.userId, command);
}
@GrpcMethod('AdminService', 'AdminDeleteChatMessage')
diff --git a/apps/sso-core/src/domain/maintenance-scheduler.service.ts b/apps/sso-core/src/domain/maintenance-scheduler.service.ts
index 0c54dc1..ad12e4e 100644
--- a/apps/sso-core/src/domain/maintenance-scheduler.service.ts
+++ b/apps/sso-core/src/domain/maintenance-scheduler.service.ts
@@ -1,4 +1,5 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
+import { AdminInsightsRetentionService } from './admin-insights-retention.service';
import { PinService } from './pin.service';
import { ProfileService } from './profile.service';
@@ -11,7 +12,8 @@ export class MaintenanceSchedulerService implements OnModuleInit, OnModuleDestro
constructor(
private readonly pin: PinService,
- private readonly profile: ProfileService
+ private readonly profile: ProfileService,
+ private readonly adminInsightsRetention: AdminInsightsRetentionService
) {}
onModuleInit() {
@@ -49,5 +51,13 @@ export class MaintenanceSchedulerService implements OnModuleInit, OnModuleDestro
`Ошибка финализации удаления аккаунтов: ${error instanceof Error ? error.message : error}`
);
}
+
+ try {
+ await this.adminInsightsRetention.purgeExpiredInsights();
+ } catch (error) {
+ this.logger.error(
+ `Ошибка очистки журналов админки: ${error instanceof Error ? error.message : error}`
+ );
+ }
}
}
diff --git a/apps/sso-core/src/domain/media.service.ts b/apps/sso-core/src/domain/media.service.ts
index 6f91708..43669d2 100644
--- a/apps/sso-core/src/domain/media.service.ts
+++ b/apps/sso-core/src/domain/media.service.ts
@@ -251,7 +251,7 @@ export class MediaService {
}
async getChatMediaAccessUrl(requesterId: string, roomId: string, storageKey: string, fileName?: string) {
- await this.assertChatMember(roomId, requesterId);
+ await this.assertChatMemberOrModerator(roomId, requesterId);
if (!storageKey.startsWith(`chat/${roomId}/`)) {
throw new BadRequestException('Некорректный ключ медиафайла');
}
@@ -379,6 +379,27 @@ export class MediaService {
}
}
+ private async assertChatMemberOrModerator(roomId: string, userId: string) {
+ const member = await this.prisma.chatRoomMember.findFirst({ where: { roomId, userId } });
+ if (member) {
+ return;
+ }
+
+ const user = await this.prisma.user.findUnique({ where: { id: userId } });
+ if (!user) {
+ throw new ForbiddenException('Нет доступа к чату');
+ }
+ const access = await this.access.getUserAccess(userId, user.isSuperAdmin);
+ if (!access.canModerateChats) {
+ throw new ForbiddenException('Нет доступа к чату');
+ }
+
+ const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId } });
+ if (!room || room.isE2E || room.type === 'E2E' || room.type === 'BOT') {
+ throw new ForbiddenException('Этот чат недоступен для модерации');
+ }
+ }
+
private async publishUserAvatarUpdated(userId: string) {
const memberships = await this.prisma.familyMember.findMany({
where: { userId },
diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts
index ea08545..f35b6dc 100644
--- a/apps/sso-core/src/domain/system-settings.seed.ts
+++ b/apps/sso-core/src/domain/system-settings.seed.ts
@@ -106,7 +106,12 @@ export const DEFAULT_SYSTEM_SETTINGS = [
{ key: 'BOT_MAX_BOTS_PER_USER', value: '5', description: 'Максимальное количество ботов на одного пользователя' },
{ key: 'BOT_API_RATE_LIMIT_PER_SECOND', value: '30', description: 'Лимит запросов Bot API на один токен в секунду' },
{ key: 'BOT_WEBHOOK_TIMEOUT_MS', value: '10000', description: 'Таймаут HTTP-запроса webhook бота (мс)' },
- { key: 'BOT_WEBHOOK_MAX_RETRIES', value: '3', description: 'Число повторов webhook с exponential backoff' }
+ { key: 'BOT_WEBHOOK_MAX_RETRIES', value: '3', description: 'Число повторов webhook с exponential backoff' },
+ {
+ key: 'ADMIN_INSIGHTS_RETENTION_DAYS',
+ value: '7',
+ description: 'Срок хранения журналов входов, активности и переписок для админки (дни, 1–90). Старые записи удаляются автоматически'
+ }
] as const;
const SECRET_SETTING_KEYS = new Set([
diff --git a/install.sh b/install.sh
index f24c618..00d32f7 100644
--- a/install.sh
+++ b/install.sh
@@ -10,7 +10,7 @@
set -euo pipefail
-SCRIPT_VERSION="2.6.1"
+SCRIPT_VERSION="2.6.2"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
@@ -76,6 +76,7 @@ ${BOLD}Lendry ID v${SCRIPT_VERSION}${NC} — установка и управл
./install.sh --renew-ssl Обновить сертификаты (Let's Encrypt / custom / selfsigned)
./install.sh --restart Перезапуск контейнеров
./install.sh --migrate-rebuild Миграции Prisma + пересборка стека (после git pull)
+ ./install.sh --fix-host-nginx Host Nginx: остановить Docker Nginx, поднять system nginx
./install.sh --docker-prune Только очистка Docker (без пересборки)
./install.sh --fix-proxy Исправить 502: host Nginx + порты 3000/3002/3003
./install.sh --fix-all Комплексное исправление: 502, прокси, порты, Nginx
@@ -159,6 +160,7 @@ parse_args() {
--renew-ssl) ACTION="renew-ssl"; shift ;;
--restart) ACTION="restart"; shift ;;
--migrate-rebuild) ACTION="migrate-rebuild"; shift ;;
+ --fix-host-nginx) ACTION="fix-host-nginx"; shift ;;
--docker-prune) ACTION="docker-prune"; shift ;;
--skip-docker-prune) SKIP_DOCKER_PRUNE=1; shift ;;
--docker-prune-aggressive) DOCKER_PRUNE_AGGRESSIVE=1; shift ;;
@@ -374,6 +376,9 @@ ensure_docker_nginx_not_conflicting() {
if [[ "$(env_get NGINX_MODE docker)" == "host" ]] || [[ "$ports_busy" -eq 1 ]]; then
stop_docker_nginx
+ if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
+ ensure_host_nginx_stack || true
+ fi
fi
}
@@ -388,6 +393,55 @@ stop_docker_nginx() {
fi
}
+# NGINX_MODE=host: системный Nginx на 80/443, Docker Nginx не используется.
+ensure_host_nginx_stack() {
+ load_env
+ [[ "$(env_get NGINX_MODE docker)" == "host" ]] || return 0
+ [[ -n "$(env_get DOMAIN_API "")" ]] || return 0
+ [[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
+
+ stop_docker_nginx
+
+ log "Синхронизация системного Nginx (NGINX_MODE=host)..."
+ refresh_host_nginx_configs || true
+ recreate_host_port_services || true
+ ensure_host_upstreams || true
+
+ if ! host_nginx_available; then
+ fail "NGINX_MODE=host, но системный Nginx не установлен. Установите nginx или переключите NGINX_MODE=docker"
+ fi
+
+ if ! system_nginx_is_active; then
+ log "Запуск системного Nginx (освободили порты после остановки lendry-id-nginx)..."
+ run_root systemctl start nginx 2>/dev/null || run_root service nginx start 2>/dev/null || \
+ fail "Не удалось запустить system nginx — проверьте: sudo systemctl status nginx"
+ fi
+
+ if run_root nginx -t >/dev/null 2>&1; then
+ run_root systemctl reload nginx 2>/dev/null || run_root service nginx reload 2>/dev/null || true
+ ok "Системный Nginx активен и перезагружен"
+ else
+ warn "nginx -t не прошёл — проверьте /etc/nginx/sites-enabled/${NGINX_PREFIX}-*.conf"
+ run_root nginx -t 2>&1 | sed 's/^/ /' || true
+ return 1
+ fi
+
+ if docker_nginx_container_running; then
+ fail "Конфликт: lendry-id-nginx всё ещё запущен при NGINX_MODE=host"
+ fi
+}
+
+action_fix_host_nginx() {
+ [[ -f .env ]] || fail "Файл .env не найден"
+ load_env
+ prepare_compose_stack
+ ensure_host_nginx_stack || fail "Не удалось восстановить системный Nginx"
+ recreate_host_port_services || true
+ ensure_host_upstreams || true
+ show_upstream_health
+ ok "Host Nginx восстановлен — проверьте сайт в браузере"
+}
+
reload_docker_nginx() {
docker_nginx_container_running || return 0
log "Перезагрузка конфигурации Docker Nginx..."
@@ -451,7 +505,7 @@ recreate_docker_nginx() {
wait_for_service "nginx" 60
}
-# После пересборки стека (п.11 migrate-rebuild): поднять Nginx, если он не запущен.
+# После пересборки стека (п.11 migrate-rebuild): Nginx согласно NGINX_MODE.
ensure_nginx_after_stack_rebuild() {
load_env
prepare_compose_stack
@@ -460,6 +514,11 @@ ensure_nginx_after_stack_rebuild() {
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && return 0
+ if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
+ ensure_host_nginx_stack
+ return $?
+ fi
+
if [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then
log "Запуск и синхронизация Docker Nginx (lendry-id-nginx)..."
ensure_nginx_gateway_watch_script
@@ -493,14 +552,7 @@ ensure_nginx_after_stack_rebuild() {
return 0
fi
- log "Синхронизация системного Nginx (NGINX_MODE=host)..."
- refresh_host_nginx_configs || true
- recreate_host_port_services || true
- ensure_host_upstreams || true
- if system_nginx_is_active || lendry_host_nginx_configured; then
- run_root nginx -t >/dev/null 2>&1 && run_root systemctl reload nginx 2>/dev/null || true
- fi
- ok "Системный Nginx синхронизирован"
+ warn "NGINX_MODE=$(env_get NGINX_MODE auto) — пропуск настройки Nginx"
}
ensure_nginx_gateway_watch_script() {
@@ -1586,11 +1638,23 @@ action_migrate_rebuild() {
wait_for_service docs 60
log "Шаг 4/4: Nginx, прокси и проверка..."
- ensure_nginx_after_stack_rebuild || warn "Nginx не поднялся — выполните ./install.sh --fix-all"
- post_deploy_proxy_verify 0 || true
+ ensure_nginx_after_stack_rebuild || warn "Nginx не восстановлен — выполните: ./install.sh --fix-host-nginx"
+ if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
+ show_upstream_health
+ check_https_api_with_origin || warn "HTTPS-проверка не прошла — выполните: ./install.sh --fix-host-nginx"
+ else
+ post_deploy_proxy_verify 0 || true
+ fi
diagnose_common_issues || true
- if compose_uses_proxy_profile && ! docker_nginx_container_running; then
+ if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
+ if docker_nginx_container_running; then
+ fail "Конфликт: lendry-id-nginx запущен при NGINX_MODE=host. Выполните: ./install.sh --fix-host-nginx"
+ fi
+ if ! system_nginx_is_active; then
+ fail "Системный Nginx не запущен. Выполните: ./install.sh --fix-host-nginx"
+ fi
+ elif compose_uses_proxy_profile && ! docker_nginx_container_running; then
fail "Сайт недоступен: lendry-id-nginx не запущен. Выполните: ./install.sh --fix-all"
fi
@@ -3773,7 +3837,7 @@ diagnose_common_issues() {
issues=$((issues + 1))
elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
echo -e " ${RED}✘${NC} Конфликт: NGINX_MODE=host, но запущен lendry-id-nginx"
- echo " Решение: ./install.sh --fix-all"
+ echo " Решение: ./install.sh --fix-host-nginx"
issues=$((issues + 1))
elif [[ "$(env_get NGINX_MODE docker)" == "docker" ]] \
&& ! docker_nginx_container_running \
@@ -3956,10 +4020,16 @@ show_status() {
echo ""
if docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null | grep -q .; then
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
- warn "КОНФЛИКТ: контейнер lendry-id-nginx запущен при NGINX_MODE=host — выполните: ./install.sh --fix-all"
+ warn "КОНФЛИКТ: контейнер lendry-id-nginx запущен при NGINX_MODE=host — выполните: ./install.sh --fix-host-nginx"
else
echo " Docker Nginx: запущен (режим docker; curl 127.0.0.1:3002 не нужен)"
fi
+ elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
+ if system_nginx_is_active; then
+ echo " Host Nginx: активен (systemd)"
+ else
+ warn "Host Nginx: не запущен — выполните: ./install.sh --fix-host-nginx"
+ fi
fi
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
docker_cmd port lendry-id-frontend 3000/tcp 2>/dev/null | sed 's/^/ frontend port: /' || \
@@ -4372,6 +4442,7 @@ main() {
renew-ssl) action_renew_ssl ;;
restart) action_restart ;;
migrate-rebuild) action_migrate_rebuild ;;
+ fix-host-nginx) action_fix_host_nginx ;;
docker-prune) action_docker_prune ;;
fix-proxy) action_fix_host_proxy ;;
fix-all) action_fix_all_errors ;;
diff --git a/shared/proto/admin.proto b/shared/proto/admin.proto
index 37678b4..bb1ceef 100644
--- a/shared/proto/admin.proto
+++ b/shared/proto/admin.proto
@@ -95,6 +95,14 @@ message GetUserInsightsRequest {
optional string search = 2;
optional int32 limit = 3;
optional int32 offset = 4;
+ optional string dateFrom = 5;
+ optional string dateTo = 6;
+}
+
+message AdminInsightsPeriodMeta {
+ int32 retentionDays = 1;
+ string periodFrom = 2;
+ string periodTo = 3;
}
message AdminSignInEvent {
@@ -110,6 +118,10 @@ message AdminSignInEvent {
message UserSignInHistoryResponse {
int32 total = 1;
repeated AdminSignInEvent events = 2;
+ optional AdminInsightsPeriodMeta meta = 3;
+ optional int32 retentionDays = 4;
+ optional string periodFrom = 5;
+ optional string periodTo = 6;
}
message UserActivityItem {
@@ -125,6 +137,10 @@ message UserActivityItem {
message UserActivityResponse {
int32 total = 1;
repeated UserActivityItem items = 2;
+ optional AdminInsightsPeriodMeta meta = 3;
+ optional int32 retentionDays = 4;
+ optional string periodFrom = 5;
+ optional string periodTo = 6;
}
message AdminChatRoomMember {
@@ -155,6 +171,10 @@ message AdminChatRoomSummary {
message UserChatRoomsResponse {
int32 total = 1;
repeated AdminChatRoomSummary rooms = 2;
+ optional AdminInsightsPeriodMeta meta = 3;
+ optional int32 retentionDays = 4;
+ optional string periodFrom = 5;
+ optional string periodTo = 6;
}
message ListUserChatMessagesRequest {
@@ -163,6 +183,8 @@ message ListUserChatMessagesRequest {
optional string search = 3;
optional int32 limit = 4;
optional string beforeMessageId = 5;
+ optional string dateFrom = 6;
+ optional string dateTo = 7;
}
message SearchUserChatMessagesRequest {
@@ -170,6 +192,8 @@ message SearchUserChatMessagesRequest {
string search = 2;
optional int32 limit = 3;
optional int32 offset = 4;
+ optional string dateFrom = 5;
+ optional string dateTo = 6;
}
message AdminChatMessage {
@@ -186,6 +210,9 @@ message AdminChatMessage {
optional string roomId = 11;
optional string roomName = 12;
optional string groupName = 13;
+ optional string storageKey = 14;
+ optional string mimeType = 15;
+ optional string metadataJson = 16;
}
message AdminChatRoomContext {
@@ -198,11 +225,19 @@ message AdminChatRoomContext {
message UserChatMessagesResponse {
optional AdminChatRoomContext room = 1;
repeated AdminChatMessage messages = 2;
+ optional AdminInsightsPeriodMeta meta = 3;
+ optional int32 retentionDays = 4;
+ optional string periodFrom = 5;
+ optional string periodTo = 6;
}
message UserChatMessageSearchResponse {
int32 total = 1;
repeated AdminChatMessage messages = 2;
+ optional AdminInsightsPeriodMeta meta = 3;
+ optional int32 retentionDays = 4;
+ optional string periodFrom = 5;
+ optional string periodTo = 6;
}
message AdminDeleteChatMessageRequest {