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 {