fix and update

This commit is contained in:
lendry
2026-07-01 19:19:27 +03:00
parent f423f512f8
commit 7e54cec361
16 changed files with 1093 additions and 150 deletions

View File

@@ -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
})
);
}

View File

@@ -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;
}

View File

@@ -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 (
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#667085]">
<CalendarRange className="h-4 w-4" />
Период просмотра
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div className="space-y-1">
<label className="text-xs text-[#667085]" htmlFor="insights-from">
С даты и времени
</label>
<Input
id="insights-from"
type="datetime-local"
value={from}
onChange={(event) => onFromChange(event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-[#667085]" htmlFor="insights-to">
По дату и время
</label>
<Input
id="insights-to"
type="datetime-local"
value={to}
onChange={(event) => onToChange(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
<RotateCcw className="h-4 w-4" />
Сбросить
</Button>
</div>
</div>
<p className="mt-2 text-xs text-[#667085]">
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
</p>
</div>
);
}

View File

@@ -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 <p className="text-sm text-[#667085]">{media.errorMessage ?? 'Фото недоступно'}</p>;
}
if (!media?.blobUrl || media.status === 'loading') {
return <MediaImageSkeleton className="max-h-56 w-full max-w-[240px]" rounded="xl" />;
}
return (
<button type="button" className="block overflow-hidden rounded-xl" onClick={onOpen}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={media.blobUrl} alt={fileName} className="max-h-56 max-w-full rounded-xl object-cover" />
</button>
);
}
export function AdminInspectorMessageBody({
messages,
roomId,
mediaUrls,
onOpenImage
}: {
messages: AdminChatMessage[];
roomId: string;
mediaUrls: Record<string, AdminChatMediaEntry>;
onOpenImage: (message: ChatMessage) => void;
}) {
const chatMessages = messages.map((message) => adminMessageToChatMessage(message, roomId));
const displayItems = groupChatMessagesForDisplay(chatMessages);
return (
<div className="space-y-2">
{displayItems.map((item) => {
if (item.kind === 'album') {
const captionMessage = item.messages.find((message) => message.content?.trim());
return (
<div key={item.albumId} className="space-y-2">
<ChatMediaAlbumGrid
messages={item.messages}
mediaUrls={mediaUrls}
onImageClick={(message) => onOpenImage(message)}
/>
{captionMessage?.content ? (
<p className="whitespace-pre-wrap break-words text-sm text-[#344054]">{captionMessage.content}</p>
) : null}
</div>
);
}
const message = item.message;
const adminMessage = messages.find((entry) => entry.id === message.id);
if (!adminMessage) return null;
if (message.isEncrypted) {
return (
<p key={message.id} className="text-sm text-[#667085]">
[E2E недоступно для модерации]
</p>
);
}
if (message.storageKey && message.type === 'IMAGE') {
return (
<AdminImageBubble
key={message.id}
message={message}
media={message.storageKey ? mediaUrls[message.storageKey] : undefined}
onOpen={() => onOpenImage(message)}
/>
);
}
if (message.type === 'FILE' && message.storageKey) {
const meta = parseChatMediaMetadata(message.metadataJson);
return (
<p key={message.id} className="text-sm text-[#667085]">
📎 {meta.fileName || message.content || 'Файл'}
</p>
);
}
if (message.type === 'VOICE' || message.type === 'AUDIO') {
return (
<p key={message.id} className="text-sm text-[#667085]">
🎤 Голосовое сообщение
</p>
);
}
if (message.type === 'POLL') {
return (
<p key={message.id} className="text-sm text-[#667085]">
📊 Опрос
</p>
);
}
const text = adminMessage.content?.trim() || adminMessage.preview;
if (!text || text === '[Пустое сообщение]') {
return null;
}
return (
<p
key={message.id}
className={cn(
'whitespace-pre-wrap break-words text-sm text-[#344054]',
message.type === 'SYSTEM' && 'text-[#667085] italic'
)}
>
{text}
</p>
);
})}
</div>
);
}
export function AdminInspectorMessageFeed({
messages,
roomId,
mediaUrls,
deletingMessageId,
onDelete,
onOpenImage
}: {
messages: AdminChatMessage[];
roomId: string;
mediaUrls: Record<string, AdminChatMediaEntry>;
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 (
<div className="space-y-2">
{displayItems.map((item) => {
if (item.kind === 'album') {
const anchor = adminById.get(item.messages[0]!.id);
if (!anchor) return null;
return (
<AdminInspectorMessageRow
key={item.albumId}
message={anchor}
roomId={roomId}
mediaUrls={mediaUrls}
deleting={item.messages.some((message) => deletingMessageId === message.id)}
onDelete={() => onDelete(anchor.id)}
onOpenImage={onOpenImage}
bodyOverride={
<AdminInspectorMessageBody
messages={item.messages
.map((message) => 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 (
<AdminInspectorMessageRow
key={item.message.id}
message={adminMessage}
roomId={roomId}
mediaUrls={mediaUrls}
deleting={deletingMessageId === adminMessage.id}
onDelete={() => onDelete(adminMessage.id)}
onOpenImage={onOpenImage}
/>
);
})}
</div>
);
}
export function AdminInspectorMessageRow({
message,
roomId,
mediaUrls,
deleting,
onDelete,
onOpenImage,
bodyOverride
}: {
message: AdminChatMessage;
roomId: string;
mediaUrls: Record<string, AdminChatMediaEntry>;
deleting: boolean;
onDelete: () => void;
onOpenImage: (message: ChatMessage) => void;
bodyOverride?: ReactNode;
}) {
return (
<div
className={cn(
'rounded-2xl border px-3 py-2',
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{message.senderName}</span>
<span className="text-xs text-[#667085]">
{new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(message.createdAt))}
</span>
{message.isDeleted ? <span className="text-xs font-medium text-red-600">Удалено</span> : null}
</div>
<div className="mt-1">
{bodyOverride ?? (
<AdminInspectorMessageBody
messages={[message]}
roomId={roomId}
mediaUrls={mediaUrls}
onOpenImage={onOpenImage}
/>
)}
</div>
</div>
{!message.isDeleted && !message.isEncrypted ? (
<button
type="button"
className="shrink-0 rounded-lg p-2 text-red-600 transition hover:bg-red-50 disabled:opacity-60"
disabled={deleting}
aria-label="Удалить сообщение"
onClick={onDelete}
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</button>
) : null}
</div>
</div>
);
}

View File

@@ -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<Record<string, AdminChatMediaEntry>>({});
const mediaUrlsRef = useRef<Record<string, AdminChatMediaEntry>>({});
const inFlightRef = useRef(new Set<string>());
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 };
}

View File

@@ -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<SignInEvent[]>([]);
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<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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({
</p>
</DialogHeader>
<div className="border-b border-[#eceef4] px-6 py-4">
<AdminInsightsDateFilter
from={periodFrom}
to={periodTo}
retentionDays={retentionDays}
onFromChange={setPeriodFrom}
onToChange={setPeriodTo}
onReset={resetPeriod}
/>
</div>
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-[#eceef4] px-6 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sign-in" className="gap-2">
<ShieldAlert className="h-4 w-4" />
<div className="border-b border-[#eceef4] px-6 pt-4 pb-4">
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 rounded-xl bg-[#f4f5f8] p-1">
<TabsTrigger value="sign-in" className={inspectorTabTriggerClass}>
<LogIn className="h-4 w-4 shrink-0" />
Входы
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2">
<Activity className="h-4 w-4" />
<TabsTrigger value="activity" className={inspectorTabTriggerClass}>
<ScrollText className="h-4 w-4 shrink-0" />
Активность
</TabsTrigger>
<TabsTrigger value="chats" className="gap-2" disabled={!canModerateChats}>
<MessageSquare className="h-4 w-4" />
<TabsTrigger value="chats" className={inspectorTabTriggerClass} disabled={!canModerateChats}>
<MessagesSquare className="h-4 w-4 shrink-0" />
Чаты
</TabsTrigger>
</TabsList>
@@ -521,48 +608,15 @@ export function UserInspectorDialog({
Загрузка сообщений...
</div>
) : roomMessages.length ? (
<div className="space-y-2 p-3">
{roomMessages.map((message) => (
<div
key={message.id}
className={cn(
'rounded-2xl border px-3 py-2',
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{message.senderName}</span>
<span className="text-xs text-[#667085]">{formatDateTime(message.createdAt)}</span>
{message.isDeleted ? (
<span className="text-xs font-medium text-red-600">Удалено</span>
) : null}
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-[#344054]">
{message.isEncrypted ? '[E2E недоступно для модерации]' : message.preview}
</p>
</div>
{!message.isDeleted && !message.isEncrypted ? (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={deletingMessageId === message.id}
aria-label="Удалить сообщение"
onClick={() => void handleDeleteMessage(message.id)}
>
{deletingMessageId === message.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
) : null}
</div>
</div>
))}
<div className="p-3">
<AdminInspectorMessageFeed
messages={roomMessages}
roomId={selectedRoomId}
mediaUrls={mediaUrls}
deletingMessageId={deletingMessageId}
onDelete={(messageId) => void handleDeleteMessage(messageId)}
onOpenImage={openImageLightbox}
/>
</div>
) : (
<p className="py-16 text-center text-sm text-[#667085]">Сообщения не найдены</p>
@@ -574,6 +628,15 @@ export function UserInspectorDialog({
)}
</TabsContent>
</Tabs>
<ChatImageLightbox
open={lightboxIndex !== null}
images={chatImageMessages}
index={lightboxIndex ?? 0}
mediaUrls={mediaUrls}
onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex}
/>
</DialogContent>
</Dialog>
);

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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<number> {
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<AdminInsightsDateRange> {
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
};
}
}

View File

@@ -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<string, string> = {
@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
};
}
}

View File

@@ -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')

View File

@@ -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}`
);
}
}
}

View File

@@ -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 },

View File

@@ -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: 'Срок хранения журналов входов, активности и переписок для админки (дни, 190). Старые записи удаляются автоматически'
}
] as const;
const SECRET_SETTING_KEYS = new Set([