fix and update
This commit is contained in:
@@ -136,7 +136,9 @@ export class AdminController {
|
|||||||
userId,
|
userId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
offset: query.offset
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -152,7 +154,9 @@ export class AdminController {
|
|||||||
userId,
|
userId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
offset: query.offset
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,7 +172,9 @@ export class AdminController {
|
|||||||
userId,
|
userId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
offset: query.offset
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -187,7 +193,9 @@ export class AdminController {
|
|||||||
userId,
|
userId,
|
||||||
search: query.search.trim(),
|
search: query.search.trim(),
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
offset: query.offset
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,7 +217,9 @@ export class AdminController {
|
|||||||
roomId,
|
roomId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
beforeMessageId: query.beforeMessageId
|
beforeMessageId: query.beforeMessageId,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,4 +88,14 @@ export class UserInsightsQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: 'beforeMessageId должно быть строкой' })
|
@IsString({ message: 'beforeMessageId должно быть строкой' })
|
||||||
beforeMessageId?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
apps/frontend/components/admin/admin-inspector-message-body.tsx
Normal file
273
apps/frontend/components/admin/admin-inspector-message-body.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/frontend/components/admin/use-admin-chat-media.ts
Normal file
113
apps/frontend/components/admin/use-admin-chat-media.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -2,18 +2,27 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
LogIn,
|
||||||
|
MessagesSquare,
|
||||||
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
ShieldAlert,
|
|
||||||
Trash2,
|
|
||||||
XCircle
|
XCircle
|
||||||
} from 'lucide-react';
|
} 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 { VerificationBadge } from '@/components/id/verification-badge';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -22,11 +31,16 @@ import {
|
|||||||
AdminChatRoomSummary,
|
AdminChatRoomSummary,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
AdminUserActivityItem,
|
AdminUserActivityItem,
|
||||||
|
ChatMessage,
|
||||||
SignInEvent,
|
SignInEvent,
|
||||||
apiFetch
|
apiFetch
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import { collectChatImageMessages } from '@/lib/chat-media-album';
|
||||||
import { cn } from '@/lib/utils';
|
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) {
|
function formatDateTime(value: string) {
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -67,6 +81,16 @@ export function UserInspectorDialog({
|
|||||||
}) {
|
}) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [tab, setTab] = useState('sign-in');
|
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 [signInSearch, setSignInSearch] = useState('');
|
||||||
const [activitySearch, setActivitySearch] = useState('');
|
const [activitySearch, setActivitySearch] = useState('');
|
||||||
@@ -79,6 +103,18 @@ export function UserInspectorDialog({
|
|||||||
const debouncedChatSearch = useDebouncedValue(chatSearch);
|
const debouncedChatSearch = useDebouncedValue(chatSearch);
|
||||||
const debouncedMessageSearch = useDebouncedValue(messageSearch);
|
const debouncedMessageSearch = useDebouncedValue(messageSearch);
|
||||||
const debouncedGlobalMessageSearch = useDebouncedValue(globalMessageSearch);
|
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 [signInEvents, setSignInEvents] = useState<SignInEvent[]>([]);
|
||||||
const [signInTotal, setSignInTotal] = useState(0);
|
const [signInTotal, setSignInTotal] = useState(0);
|
||||||
@@ -98,8 +134,17 @@ export function UserInspectorDialog({
|
|||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const [loadingSearch, setLoadingSearch] = useState(false);
|
const [loadingSearch, setLoadingSearch] = useState(false);
|
||||||
const [deletingMessageId, setDeletingMessageId] = useState<string | null>(null);
|
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 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 () => {
|
const loadSignIn = useCallback(async () => {
|
||||||
if (!token || !user) return;
|
if (!token || !user) return;
|
||||||
@@ -108,19 +153,27 @@ export function UserInspectorDialog({
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (debouncedSignInSearch.trim()) params.set('search', debouncedSignInSearch.trim());
|
if (debouncedSignInSearch.trim()) params.set('search', debouncedSignInSearch.trim());
|
||||||
params.set('limit', '50');
|
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()}`,
|
`/admin/users/${user.id}/sign-in-history?${params.toString()}`,
|
||||||
{},
|
{},
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
setSignInEvents(response.events ?? []);
|
setSignInEvents(response.events ?? []);
|
||||||
setSignInTotal(response.total ?? 0);
|
setSignInTotal(response.total ?? 0);
|
||||||
|
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить историю входов');
|
showToast(error instanceof Error ? error.message : 'Не удалось загрузить историю входов');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSignIn(false);
|
setLoadingSignIn(false);
|
||||||
}
|
}
|
||||||
}, [debouncedSignInSearch, showToast, token, user]);
|
}, [appendDateParams, debouncedSignInSearch, showToast, token, user]);
|
||||||
|
|
||||||
const loadActivity = useCallback(async () => {
|
const loadActivity = useCallback(async () => {
|
||||||
if (!token || !user) return;
|
if (!token || !user) return;
|
||||||
@@ -129,19 +182,25 @@ export function UserInspectorDialog({
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (debouncedActivitySearch.trim()) params.set('search', debouncedActivitySearch.trim());
|
if (debouncedActivitySearch.trim()) params.set('search', debouncedActivitySearch.trim());
|
||||||
params.set('limit', '50');
|
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()}`,
|
`/admin/users/${user.id}/activity?${params.toString()}`,
|
||||||
{},
|
{},
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
setActivityItems(response.items ?? []);
|
setActivityItems(response.items ?? []);
|
||||||
setActivityTotal(response.total ?? 0);
|
setActivityTotal(response.total ?? 0);
|
||||||
|
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить активность');
|
showToast(error instanceof Error ? error.message : 'Не удалось загрузить активность');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingActivity(false);
|
setLoadingActivity(false);
|
||||||
}
|
}
|
||||||
}, [debouncedActivitySearch, showToast, token, user]);
|
}, [appendDateParams, debouncedActivitySearch, showToast, token, user]);
|
||||||
|
|
||||||
const loadRooms = useCallback(async () => {
|
const loadRooms = useCallback(async () => {
|
||||||
if (!token || !user || !canModerateChats) return;
|
if (!token || !user || !canModerateChats) return;
|
||||||
@@ -150,19 +209,21 @@ export function UserInspectorDialog({
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (debouncedChatSearch.trim()) params.set('search', debouncedChatSearch.trim());
|
if (debouncedChatSearch.trim()) params.set('search', debouncedChatSearch.trim());
|
||||||
params.set('limit', '50');
|
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()}`,
|
`/admin/users/${user.id}/chats?${params.toString()}`,
|
||||||
{},
|
{},
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
setRooms(response.rooms ?? []);
|
setRooms(response.rooms ?? []);
|
||||||
setRoomsTotal(response.total ?? 0);
|
setRoomsTotal(response.total ?? 0);
|
||||||
|
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить чаты');
|
showToast(error instanceof Error ? error.message : 'Не удалось загрузить чаты');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRooms(false);
|
setLoadingRooms(false);
|
||||||
}
|
}
|
||||||
}, [canModerateChats, debouncedChatSearch, showToast, token, user]);
|
}, [appendDateParams, canModerateChats, debouncedChatSearch, showToast, token, user]);
|
||||||
|
|
||||||
const loadRoomMessages = useCallback(async () => {
|
const loadRoomMessages = useCallback(async () => {
|
||||||
if (!token || !user || !selectedRoomId) return;
|
if (!token || !user || !selectedRoomId) return;
|
||||||
@@ -171,11 +232,14 @@ export function UserInspectorDialog({
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (debouncedMessageSearch.trim()) params.set('search', debouncedMessageSearch.trim());
|
if (debouncedMessageSearch.trim()) params.set('search', debouncedMessageSearch.trim());
|
||||||
params.set('limit', '80');
|
params.set('limit', '80');
|
||||||
|
appendDateParams(params);
|
||||||
const response = await apiFetch<{
|
const response = await apiFetch<{
|
||||||
room?: { id: string; name: string; type: string; groupName: string };
|
room?: { id: string; name: string; type: string; groupName: string };
|
||||||
messages: AdminChatMessage[];
|
messages: AdminChatMessage[];
|
||||||
|
retentionDays?: number;
|
||||||
}>(`/admin/users/${user.id}/chats/${selectedRoomId}/messages?${params.toString()}`, {}, token);
|
}>(`/admin/users/${user.id}/chats/${selectedRoomId}/messages?${params.toString()}`, {}, token);
|
||||||
setRoomMessages(response.messages ?? []);
|
setRoomMessages(response.messages ?? []);
|
||||||
|
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||||||
setRoomContext(
|
setRoomContext(
|
||||||
response.room
|
response.room
|
||||||
? { name: response.room.name, groupName: response.room.groupName, type: response.room.type }
|
? { name: response.room.name, groupName: response.room.groupName, type: response.room.type }
|
||||||
@@ -186,7 +250,7 @@ export function UserInspectorDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingMessages(false);
|
setLoadingMessages(false);
|
||||||
}
|
}
|
||||||
}, [debouncedMessageSearch, selectedRoomId, showToast, token, user]);
|
}, [appendDateParams, debouncedMessageSearch, selectedRoomId, showToast, token, user]);
|
||||||
|
|
||||||
const loadGlobalSearch = useCallback(async () => {
|
const loadGlobalSearch = useCallback(async () => {
|
||||||
if (!token || !user || !canModerateChats || !debouncedGlobalMessageSearch.trim()) {
|
if (!token || !user || !canModerateChats || !debouncedGlobalMessageSearch.trim()) {
|
||||||
@@ -199,19 +263,21 @@ export function UserInspectorDialog({
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('search', debouncedGlobalMessageSearch.trim());
|
params.set('search', debouncedGlobalMessageSearch.trim());
|
||||||
params.set('limit', '50');
|
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()}`,
|
`/admin/users/${user.id}/chats/search?${params.toString()}`,
|
||||||
{},
|
{},
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
setSearchResults(response.messages ?? []);
|
setSearchResults(response.messages ?? []);
|
||||||
setSearchTotal(response.total ?? 0);
|
setSearchTotal(response.total ?? 0);
|
||||||
|
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось выполнить поиск');
|
showToast(error instanceof Error ? error.message : 'Не удалось выполнить поиск');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSearch(false);
|
setLoadingSearch(false);
|
||||||
}
|
}
|
||||||
}, [canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
|
}, [appendDateParams, canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !user) return;
|
if (!open || !user) return;
|
||||||
@@ -249,9 +315,19 @@ export function UserInspectorDialog({
|
|||||||
setSelectedRoomId(null);
|
setSelectedRoomId(null);
|
||||||
setRoomMessages([]);
|
setRoomMessages([]);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setLightboxIndex(null);
|
||||||
|
const next = defaultInsightsPeriodLocal(7);
|
||||||
|
setPeriodFrom(next.from);
|
||||||
|
setPeriodTo(next.to);
|
||||||
|
setRetentionDays(7);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
function openImageLightbox(message: ChatMessage) {
|
||||||
|
const index = chatImageMessages.findIndex((item) => item.id === message.id);
|
||||||
|
if (index >= 0) setLightboxIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteMessage(messageId: string) {
|
async function handleDeleteMessage(messageId: string) {
|
||||||
if (!token || !user || !canModerateChats) return;
|
if (!token || !user || !canModerateChats) return;
|
||||||
const confirmed = window.confirm('Удалить это сообщение? Действие необратимо для пользователей чата.');
|
const confirmed = window.confirm('Удалить это сообщение? Действие необратимо для пользователей чата.');
|
||||||
@@ -284,19 +360,30 @@ export function UserInspectorDialog({
|
|||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</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">
|
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="border-b border-[#eceef4] px-6 pt-4">
|
<div className="border-b border-[#eceef4] px-6 pt-4 pb-4">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 rounded-xl bg-[#f4f5f8] p-1">
|
||||||
<TabsTrigger value="sign-in" className="gap-2">
|
<TabsTrigger value="sign-in" className={inspectorTabTriggerClass}>
|
||||||
<ShieldAlert className="h-4 w-4" />
|
<LogIn className="h-4 w-4 shrink-0" />
|
||||||
Входы
|
Входы
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="activity" className="gap-2">
|
<TabsTrigger value="activity" className={inspectorTabTriggerClass}>
|
||||||
<Activity className="h-4 w-4" />
|
<ScrollText className="h-4 w-4 shrink-0" />
|
||||||
Активность
|
Активность
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="chats" className="gap-2" disabled={!canModerateChats}>
|
<TabsTrigger value="chats" className={inspectorTabTriggerClass} disabled={!canModerateChats}>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessagesSquare className="h-4 w-4 shrink-0" />
|
||||||
Чаты
|
Чаты
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -521,48 +608,15 @@ export function UserInspectorDialog({
|
|||||||
Загрузка сообщений...
|
Загрузка сообщений...
|
||||||
</div>
|
</div>
|
||||||
) : roomMessages.length ? (
|
) : roomMessages.length ? (
|
||||||
<div className="space-y-2 p-3">
|
<div className="p-3">
|
||||||
{roomMessages.map((message) => (
|
<AdminInspectorMessageFeed
|
||||||
<div
|
messages={roomMessages}
|
||||||
key={message.id}
|
roomId={selectedRoomId}
|
||||||
className={cn(
|
mediaUrls={mediaUrls}
|
||||||
'rounded-2xl border px-3 py-2',
|
deletingMessageId={deletingMessageId}
|
||||||
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
|
onDelete={(messageId) => void handleDeleteMessage(messageId)}
|
||||||
)}
|
onOpenImage={openImageLightbox}
|
||||||
>
|
/>
|
||||||
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="py-16 text-center text-sm text-[#667085]">Сообщения не найдены</p>
|
<p className="py-16 text-center text-sm text-[#667085]">Сообщения не найдены</p>
|
||||||
@@ -574,6 +628,15 @@ export function UserInspectorDialog({
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<ChatImageLightbox
|
||||||
|
open={lightboxIndex !== null}
|
||||||
|
images={chatImageMessages}
|
||||||
|
index={lightboxIndex ?? 0}
|
||||||
|
mediaUrls={mediaUrls}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -649,6 +649,12 @@ export interface ActiveDevice {
|
|||||||
trusted: boolean;
|
trusted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminInsightsPeriodMeta {
|
||||||
|
retentionDays: number;
|
||||||
|
periodFrom: string;
|
||||||
|
periodTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignInEvent {
|
export interface SignInEvent {
|
||||||
id: string;
|
id: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -701,6 +707,9 @@ export interface AdminChatMessage {
|
|||||||
roomId?: string;
|
roomId?: string;
|
||||||
roomName?: string;
|
roomName?: string;
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
|
storageKey?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
metadataJson?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveSession {
|
export interface ActiveSession {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AccessService } from './domain/access.service';
|
|||||||
import { AdminSeedService } from './domain/admin-seed.service';
|
import { AdminSeedService } from './domain/admin-seed.service';
|
||||||
import { AdminService } from './domain/admin.service';
|
import { AdminService } from './domain/admin.service';
|
||||||
import { AdminUserInsightsService } from './domain/admin-user-insights.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 { ActivityLogService } from './domain/activity-log.service';
|
||||||
import { AuthGrpcController } from './domain/auth-grpc.controller';
|
import { AuthGrpcController } from './domain/auth-grpc.controller';
|
||||||
import { AuthService } from './domain/auth.service';
|
import { AuthService } from './domain/auth.service';
|
||||||
@@ -73,6 +74,7 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser
|
|||||||
AdminSeedService,
|
AdminSeedService,
|
||||||
AdminService,
|
AdminService,
|
||||||
AdminUserInsightsService,
|
AdminUserInsightsService,
|
||||||
|
AdminInsightsRetentionService,
|
||||||
ActivityLogService,
|
ActivityLogService,
|
||||||
RbacService,
|
RbacService,
|
||||||
OAuthSslSansService,
|
OAuthSslSansService,
|
||||||
|
|||||||
138
apps/sso-core/src/domain/admin-insights-retention.service.ts
Normal file
138
apps/sso-core/src/domain/admin-insights-retention.service.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
|
import {
|
||||||
|
AdminInsightsQueryOptions,
|
||||||
|
AdminInsightsRetentionService
|
||||||
|
} from './admin-insights-retention.service';
|
||||||
|
|
||||||
const MODERATABLE_ROOM_FILTER = {
|
const MODERATABLE_ROOM_FILTER = {
|
||||||
isE2E: false,
|
isE2E: false,
|
||||||
@@ -23,7 +27,10 @@ const ROOM_TYPE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminUserInsightsService {
|
export class AdminUserInsightsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly retention: AdminInsightsRetentionService
|
||||||
|
) {}
|
||||||
|
|
||||||
async ensureHumanUser(userId: string) {
|
async ensureHumanUser(userId: string) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
@@ -35,14 +42,17 @@ export class AdminUserInsightsService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSignInHistory(userId: string, search?: string, limit = 50, offset = 0) {
|
async getSignInHistory(userId: string, options: AdminInsightsQueryOptions = {}) {
|
||||||
await this.ensureHumanUser(userId);
|
await this.ensureHumanUser(userId);
|
||||||
const take = Math.min(Math.max(limit, 1), 100);
|
const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo);
|
||||||
const skip = Math.max(offset, 0);
|
const take = Math.min(Math.max(options.limit ?? 50, 1), 100);
|
||||||
const searchTerm = search?.trim();
|
const skip = Math.max(options.offset ?? 0, 0);
|
||||||
|
const searchTerm = options.search?.trim();
|
||||||
|
const createdAt = this.retention.createdAtFilter(range);
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
userId,
|
userId,
|
||||||
|
createdAt,
|
||||||
...(searchTerm
|
...(searchTerm
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -67,6 +77,7 @@ export class AdminUserInsightsService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...this.retention.insightsMeta(range),
|
||||||
total,
|
total,
|
||||||
events: events.map((event) => ({
|
events: events.map((event) => ({
|
||||||
id: event.id,
|
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);
|
await this.ensureHumanUser(userId);
|
||||||
const searchTerm = search?.trim().toLowerCase();
|
const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo);
|
||||||
const take = Math.min(Math.max(limit, 1), 100);
|
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([
|
const [logs, documents, messages, rooms, families, consents] = await Promise.all([
|
||||||
this.prisma.userActivityLog.findMany({
|
this.prisma.userActivityLog.findMany({
|
||||||
where: { userId },
|
where: { userId, createdAt },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 200
|
take: 200
|
||||||
}),
|
}),
|
||||||
this.prisma.userDocument.findMany({
|
this.prisma.userDocument.findMany({
|
||||||
where: { userId },
|
where: { userId, createdAt },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100
|
take: 100
|
||||||
}),
|
}),
|
||||||
@@ -100,6 +113,7 @@ export class AdminUserInsightsService {
|
|||||||
where: {
|
where: {
|
||||||
senderId: userId,
|
senderId: userId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
createdAt,
|
||||||
room: MODERATABLE_ROOM_FILTER
|
room: MODERATABLE_ROOM_FILTER
|
||||||
},
|
},
|
||||||
include: { room: { include: { group: true } } },
|
include: { room: { include: { group: true } } },
|
||||||
@@ -107,18 +121,18 @@ export class AdminUserInsightsService {
|
|||||||
take: 100
|
take: 100
|
||||||
}),
|
}),
|
||||||
this.prisma.chatRoom.findMany({
|
this.prisma.chatRoom.findMany({
|
||||||
where: { createdById: userId, ...MODERATABLE_ROOM_FILTER },
|
where: { createdById: userId, createdAt, ...MODERATABLE_ROOM_FILTER },
|
||||||
include: { group: true },
|
include: { group: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 50
|
take: 50
|
||||||
}),
|
}),
|
||||||
this.prisma.familyGroup.findMany({
|
this.prisma.familyGroup.findMany({
|
||||||
where: { ownerId: userId },
|
where: { ownerId: userId, createdAt },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 50
|
take: 50
|
||||||
}),
|
}),
|
||||||
this.prisma.oAuthConsent.findMany({
|
this.prisma.oAuthConsent.findMany({
|
||||||
where: { userId },
|
where: { userId, createdAt },
|
||||||
include: { client: true },
|
include: { client: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 50
|
take: 50
|
||||||
@@ -200,24 +214,33 @@ export class AdminUserInsightsService {
|
|||||||
return haystack.includes(searchTerm);
|
return haystack.includes(searchTerm);
|
||||||
});
|
});
|
||||||
|
|
||||||
const skip = Math.max(offset, 0);
|
const skip = Math.max(options.offset ?? 0, 0);
|
||||||
return {
|
return {
|
||||||
|
...this.retention.insightsMeta(range),
|
||||||
total: deduped.length,
|
total: deduped.length,
|
||||||
items: deduped.slice(skip, skip + take)
|
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);
|
await this.ensureHumanUser(userId);
|
||||||
const take = Math.min(Math.max(limit, 1), 100);
|
const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo);
|
||||||
const skip = Math.max(offset, 0);
|
const take = Math.min(Math.max(options.limit ?? 50, 1), 100);
|
||||||
const searchTerm = search?.trim();
|
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({
|
const memberships = await this.prisma.chatRoomMember.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
room: {
|
room: {
|
||||||
...MODERATABLE_ROOM_FILTER,
|
...MODERATABLE_ROOM_FILTER,
|
||||||
|
messages: {
|
||||||
|
some: {
|
||||||
|
senderId: userId,
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
},
|
||||||
...(searchTerm
|
...(searchTerm
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -238,12 +261,18 @@ export class AdminUserInsightsService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
where: { deletedAt: null },
|
where: { deletedAt: null, createdAt },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 1,
|
take: 1,
|
||||||
include: { sender: { select: { displayName: true } } }
|
include: { sender: { select: { displayName: true } } }
|
||||||
},
|
},
|
||||||
_count: { select: { messages: true } }
|
_count: {
|
||||||
|
select: {
|
||||||
|
messages: {
|
||||||
|
where: { senderId: userId, createdAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -278,13 +307,15 @@ export class AdminUserInsightsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...this.retention.insightsMeta(range),
|
||||||
total: rooms.length,
|
total: rooms.length,
|
||||||
rooms: rooms.slice(skip, skip + take)
|
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);
|
await this.ensureHumanUser(userId);
|
||||||
|
const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo);
|
||||||
const room = await this.getModeratableRoom(roomId);
|
const room = await this.getModeratableRoom(roomId);
|
||||||
const member = await this.prisma.chatRoomMember.findUnique({
|
const member = await this.prisma.chatRoomMember.findUnique({
|
||||||
where: { roomId_userId: { roomId, userId } }
|
where: { roomId_userId: { roomId, userId } }
|
||||||
@@ -293,19 +324,21 @@ export class AdminUserInsightsService {
|
|||||||
throw new ForbiddenException('Пользователь не состоит в этом чате');
|
throw new ForbiddenException('Пользователь не состоит в этом чате');
|
||||||
}
|
}
|
||||||
|
|
||||||
const take = Math.min(Math.max(limit, 1), 100);
|
const take = Math.min(Math.max(options.limit ?? 50, 1), 100);
|
||||||
const searchTerm = search?.trim();
|
const searchTerm = options.search?.trim();
|
||||||
|
const createdAt = this.retention.createdAtFilter(range);
|
||||||
|
|
||||||
let cursorDate: Date | undefined;
|
let cursorDate: Date | undefined;
|
||||||
if (beforeMessageId) {
|
if (options.beforeMessageId) {
|
||||||
const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } });
|
const cursor = await this.prisma.chatMessage.findFirst({ where: { id: options.beforeMessageId, roomId } });
|
||||||
if (cursor) cursorDate = cursor.createdAt;
|
if (cursor) cursorDate = cursor.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = await this.prisma.chatMessage.findMany({
|
const messages = await this.prisma.chatMessage.findMany({
|
||||||
where: {
|
where: {
|
||||||
roomId,
|
roomId,
|
||||||
...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
|
createdAt,
|
||||||
|
...(cursorDate ? { createdAt: { lt: cursorDate, gte: range.from, lte: range.to } } : {}),
|
||||||
...(searchTerm
|
...(searchTerm
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -321,25 +354,30 @@ export class AdminUserInsightsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...this.retention.insightsMeta(range),
|
||||||
room: {
|
room: {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
name: room.name,
|
name: room.name,
|
||||||
type: room.type,
|
type: room.type,
|
||||||
groupName: room.group.name
|
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);
|
await this.ensureHumanUser(userId);
|
||||||
const searchTerm = search?.trim();
|
const searchTerm = options.search?.trim();
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
throw new BadRequestException('Укажите текст для поиска по сообщениям');
|
throw new BadRequestException('Укажите текст для поиска по сообщениям');
|
||||||
}
|
}
|
||||||
|
|
||||||
const take = Math.min(Math.max(limit, 1), 100);
|
const range = await this.retention.resolveDateRange(options.dateFrom, options.dateTo);
|
||||||
const skip = Math.max(offset, 0);
|
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 = {
|
const where = {
|
||||||
room: {
|
room: {
|
||||||
@@ -347,6 +385,7 @@ export class AdminUserInsightsService {
|
|||||||
...MODERATABLE_ROOM_FILTER
|
...MODERATABLE_ROOM_FILTER
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
createdAt,
|
||||||
OR: [
|
OR: [
|
||||||
{ content: { contains: searchTerm, mode: 'insensitive' as const } },
|
{ content: { contains: searchTerm, mode: 'insensitive' as const } },
|
||||||
{ sender: { displayName: { contains: searchTerm, mode: 'insensitive' as const } } }
|
{ sender: { displayName: { contains: searchTerm, mode: 'insensitive' as const } } }
|
||||||
@@ -368,12 +407,13 @@ export class AdminUserInsightsService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...this.retention.insightsMeta(range),
|
||||||
total,
|
total,
|
||||||
messages: messages.map((message) => ({
|
messages: messages.map((message) => ({
|
||||||
...this.toAdminMessage(message),
|
...this.toAdminMessage(message, {
|
||||||
roomId: message.roomId,
|
roomName: message.room.name,
|
||||||
roomName: message.room.name,
|
groupName: message.room.group.name
|
||||||
groupName: message.room.group.name
|
})
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -447,17 +487,24 @@ export class AdminUserInsightsService {
|
|||||||
return text.length > 160 ? `${text.slice(0, 157)}…` : text;
|
return text.length > 160 ? `${text.slice(0, 157)}…` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toAdminMessage(message: {
|
private toAdminMessage(
|
||||||
id: string;
|
message: {
|
||||||
senderId: string;
|
id: string;
|
||||||
type: string;
|
roomId: string;
|
||||||
content: string | null;
|
senderId: string;
|
||||||
isEncrypted: boolean;
|
type: string;
|
||||||
deletedAt: Date | null;
|
content: string | null;
|
||||||
createdAt: Date;
|
storageKey: string | null;
|
||||||
editedAt: Date | null;
|
mimeType: string | null;
|
||||||
sender: { id: string; displayName: string };
|
metadata: unknown;
|
||||||
}) {
|
isEncrypted: boolean;
|
||||||
|
deletedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
editedAt: Date | null;
|
||||||
|
sender: { id: string; displayName: string };
|
||||||
|
},
|
||||||
|
context?: { roomName?: string; groupName?: string }
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
senderId: message.senderId,
|
senderId: message.senderId,
|
||||||
@@ -468,7 +515,18 @@ export class AdminUserInsightsService {
|
|||||||
isEncrypted: message.isEncrypted,
|
isEncrypted: message.isEncrypted,
|
||||||
isDeleted: Boolean(message.deletedAt),
|
isDeleted: Boolean(message.deletedAt),
|
||||||
createdAt: message.createdAt.toISOString(),
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,34 +187,64 @@ export class AuthGrpcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('AdminService', 'GetUserSignInHistory')
|
@GrpcMethod('AdminService', 'GetUserSignInHistory')
|
||||||
getUserSignInHistory(command: { userId: string; search?: string; limit?: number; offset?: number }) {
|
getUserSignInHistory(command: {
|
||||||
return this.adminInsights.getSignInHistory(command.userId, command.search, command.limit, command.offset);
|
userId: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}) {
|
||||||
|
return this.adminInsights.getSignInHistory(command.userId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('AdminService', 'GetUserActivity')
|
@GrpcMethod('AdminService', 'GetUserActivity')
|
||||||
getUserActivity(command: { userId: string; search?: string; limit?: number; offset?: number }) {
|
getUserActivity(command: {
|
||||||
return this.adminInsights.getActivityTimeline(command.userId, command.search, command.limit, command.offset);
|
userId: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}) {
|
||||||
|
return this.adminInsights.getActivityTimeline(command.userId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('AdminService', 'ListUserChatRooms')
|
@GrpcMethod('AdminService', 'ListUserChatRooms')
|
||||||
listUserChatRooms(command: { userId: string; search?: string; limit?: number; offset?: number }) {
|
listUserChatRooms(command: {
|
||||||
return this.adminInsights.listUserChatRooms(command.userId, command.search, command.limit, command.offset);
|
userId: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}) {
|
||||||
|
return this.adminInsights.listUserChatRooms(command.userId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('AdminService', 'ListUserChatMessages')
|
@GrpcMethod('AdminService', 'ListUserChatMessages')
|
||||||
listUserChatMessages(command: { userId: string; roomId: string; search?: string; limit?: number; beforeMessageId?: string }) {
|
listUserChatMessages(command: {
|
||||||
return this.adminInsights.listRoomMessages(
|
userId: string;
|
||||||
command.userId,
|
roomId: string;
|
||||||
command.roomId,
|
search?: string;
|
||||||
command.search,
|
limit?: number;
|
||||||
command.limit,
|
beforeMessageId?: string;
|
||||||
command.beforeMessageId
|
dateFrom?: string;
|
||||||
);
|
dateTo?: string;
|
||||||
|
}) {
|
||||||
|
return this.adminInsights.listRoomMessages(command.userId, command.roomId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('AdminService', 'SearchUserChatMessages')
|
@GrpcMethod('AdminService', 'SearchUserChatMessages')
|
||||||
searchUserChatMessages(command: { userId: string; search: string; limit?: number; offset?: number }) {
|
searchUserChatMessages(command: {
|
||||||
return this.adminInsights.searchUserChatMessages(command.userId, command.search, command.limit, command.offset);
|
userId: string;
|
||||||
|
search: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}) {
|
||||||
|
return this.adminInsights.searchUserChatMessages(command.userId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('AdminService', 'AdminDeleteChatMessage')
|
@GrpcMethod('AdminService', 'AdminDeleteChatMessage')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { AdminInsightsRetentionService } from './admin-insights-retention.service';
|
||||||
import { PinService } from './pin.service';
|
import { PinService } from './pin.service';
|
||||||
import { ProfileService } from './profile.service';
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
@@ -11,7 +12,8 @@ export class MaintenanceSchedulerService implements OnModuleInit, OnModuleDestro
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pin: PinService,
|
private readonly pin: PinService,
|
||||||
private readonly profile: ProfileService
|
private readonly profile: ProfileService,
|
||||||
|
private readonly adminInsightsRetention: AdminInsightsRetentionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -49,5 +51,13 @@ export class MaintenanceSchedulerService implements OnModuleInit, OnModuleDestro
|
|||||||
`Ошибка финализации удаления аккаунтов: ${error instanceof Error ? error.message : error}`
|
`Ошибка финализации удаления аккаунтов: ${error instanceof Error ? error.message : error}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.adminInsightsRetention.purgeExpiredInsights();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Ошибка очистки журналов админки: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChatMediaAccessUrl(requesterId: string, roomId: string, storageKey: string, fileName?: string) {
|
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}/`)) {
|
if (!storageKey.startsWith(`chat/${roomId}/`)) {
|
||||||
throw new BadRequestException('Некорректный ключ медиафайла');
|
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) {
|
private async publishUserAvatarUpdated(userId: string) {
|
||||||
const memberships = await this.prisma.familyMember.findMany({
|
const memberships = await this.prisma.familyMember.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
|||||||
@@ -106,7 +106,12 @@ export const DEFAULT_SYSTEM_SETTINGS = [
|
|||||||
{ key: 'BOT_MAX_BOTS_PER_USER', value: '5', description: 'Максимальное количество ботов на одного пользователя' },
|
{ key: 'BOT_MAX_BOTS_PER_USER', value: '5', description: 'Максимальное количество ботов на одного пользователя' },
|
||||||
{ key: 'BOT_API_RATE_LIMIT_PER_SECOND', value: '30', description: 'Лимит запросов Bot API на один токен в секунду' },
|
{ 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_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;
|
] as const;
|
||||||
|
|
||||||
const SECRET_SETTING_KEYS = new Set([
|
const SECRET_SETTING_KEYS = new Set([
|
||||||
|
|||||||
101
install.sh
101
install.sh
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_VERSION="2.6.1"
|
SCRIPT_VERSION="2.6.2"
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
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 --renew-ssl Обновить сертификаты (Let's Encrypt / custom / selfsigned)
|
||||||
./install.sh --restart Перезапуск контейнеров
|
./install.sh --restart Перезапуск контейнеров
|
||||||
./install.sh --migrate-rebuild Миграции Prisma + пересборка стека (после git pull)
|
./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 --docker-prune Только очистка Docker (без пересборки)
|
||||||
./install.sh --fix-proxy Исправить 502: host Nginx + порты 3000/3002/3003
|
./install.sh --fix-proxy Исправить 502: host Nginx + порты 3000/3002/3003
|
||||||
./install.sh --fix-all Комплексное исправление: 502, прокси, порты, Nginx
|
./install.sh --fix-all Комплексное исправление: 502, прокси, порты, Nginx
|
||||||
@@ -159,6 +160,7 @@ parse_args() {
|
|||||||
--renew-ssl) ACTION="renew-ssl"; shift ;;
|
--renew-ssl) ACTION="renew-ssl"; shift ;;
|
||||||
--restart) ACTION="restart"; shift ;;
|
--restart) ACTION="restart"; shift ;;
|
||||||
--migrate-rebuild) ACTION="migrate-rebuild"; shift ;;
|
--migrate-rebuild) ACTION="migrate-rebuild"; shift ;;
|
||||||
|
--fix-host-nginx) ACTION="fix-host-nginx"; shift ;;
|
||||||
--docker-prune) ACTION="docker-prune"; shift ;;
|
--docker-prune) ACTION="docker-prune"; shift ;;
|
||||||
--skip-docker-prune) SKIP_DOCKER_PRUNE=1; shift ;;
|
--skip-docker-prune) SKIP_DOCKER_PRUNE=1; shift ;;
|
||||||
--docker-prune-aggressive) DOCKER_PRUNE_AGGRESSIVE=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
|
if [[ "$(env_get NGINX_MODE docker)" == "host" ]] || [[ "$ports_busy" -eq 1 ]]; then
|
||||||
stop_docker_nginx
|
stop_docker_nginx
|
||||||
|
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
|
||||||
|
ensure_host_nginx_stack || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +393,55 @@ stop_docker_nginx() {
|
|||||||
fi
|
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() {
|
reload_docker_nginx() {
|
||||||
docker_nginx_container_running || return 0
|
docker_nginx_container_running || return 0
|
||||||
log "Перезагрузка конфигурации Docker Nginx..."
|
log "Перезагрузка конфигурации Docker Nginx..."
|
||||||
@@ -451,7 +505,7 @@ recreate_docker_nginx() {
|
|||||||
wait_for_service "nginx" 60
|
wait_for_service "nginx" 60
|
||||||
}
|
}
|
||||||
|
|
||||||
# После пересборки стека (п.11 migrate-rebuild): поднять Nginx, если он не запущен.
|
# После пересборки стека (п.11 migrate-rebuild): Nginx согласно NGINX_MODE.
|
||||||
ensure_nginx_after_stack_rebuild() {
|
ensure_nginx_after_stack_rebuild() {
|
||||||
load_env
|
load_env
|
||||||
prepare_compose_stack
|
prepare_compose_stack
|
||||||
@@ -460,6 +514,11 @@ ensure_nginx_after_stack_rebuild() {
|
|||||||
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
|
[[ -n "$(env_get DOMAIN_API "")" ]] || return 0
|
||||||
[[ "$(env_get INSTALL_MODE local)" == "local" ]] && 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
|
if [[ "$(env_get NGINX_MODE docker)" == "docker" ]]; then
|
||||||
log "Запуск и синхронизация Docker Nginx (lendry-id-nginx)..."
|
log "Запуск и синхронизация Docker Nginx (lendry-id-nginx)..."
|
||||||
ensure_nginx_gateway_watch_script
|
ensure_nginx_gateway_watch_script
|
||||||
@@ -493,14 +552,7 @@ ensure_nginx_after_stack_rebuild() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Синхронизация системного Nginx (NGINX_MODE=host)..."
|
warn "NGINX_MODE=$(env_get NGINX_MODE auto) — пропуск настройки Nginx"
|
||||||
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 синхронизирован"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_nginx_gateway_watch_script() {
|
ensure_nginx_gateway_watch_script() {
|
||||||
@@ -1586,11 +1638,23 @@ action_migrate_rebuild() {
|
|||||||
wait_for_service docs 60
|
wait_for_service docs 60
|
||||||
|
|
||||||
log "Шаг 4/4: Nginx, прокси и проверка..."
|
log "Шаг 4/4: Nginx, прокси и проверка..."
|
||||||
ensure_nginx_after_stack_rebuild || warn "Nginx не поднялся — выполните ./install.sh --fix-all"
|
ensure_nginx_after_stack_rebuild || warn "Nginx не восстановлен — выполните: ./install.sh --fix-host-nginx"
|
||||||
post_deploy_proxy_verify 0 || true
|
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
|
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"
|
fail "Сайт недоступен: lendry-id-nginx не запущен. Выполните: ./install.sh --fix-all"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -3773,7 +3837,7 @@ diagnose_common_issues() {
|
|||||||
issues=$((issues + 1))
|
issues=$((issues + 1))
|
||||||
elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
|
elif [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
|
||||||
echo -e " ${RED}✘${NC} Конфликт: NGINX_MODE=host, но запущен lendry-id-nginx"
|
echo -e " ${RED}✘${NC} Конфликт: NGINX_MODE=host, но запущен lendry-id-nginx"
|
||||||
echo " Решение: ./install.sh --fix-all"
|
echo " Решение: ./install.sh --fix-host-nginx"
|
||||||
issues=$((issues + 1))
|
issues=$((issues + 1))
|
||||||
elif [[ "$(env_get NGINX_MODE docker)" == "docker" ]] \
|
elif [[ "$(env_get NGINX_MODE docker)" == "docker" ]] \
|
||||||
&& ! docker_nginx_container_running \
|
&& ! docker_nginx_container_running \
|
||||||
@@ -3956,10 +4020,16 @@ show_status() {
|
|||||||
echo ""
|
echo ""
|
||||||
if docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null | grep -q .; then
|
if docker_cmd ps -q -f name=lendry-id-nginx 2>/dev/null | grep -q .; then
|
||||||
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; 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
|
else
|
||||||
echo " Docker Nginx: запущен (режим docker; curl 127.0.0.1:3002 не нужен)"
|
echo " Docker Nginx: запущен (режим docker; curl 127.0.0.1:3002 не нужен)"
|
||||||
fi
|
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
|
fi
|
||||||
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
|
if [[ "$(env_get NGINX_MODE docker)" == "host" ]]; then
|
||||||
docker_cmd port lendry-id-frontend 3000/tcp 2>/dev/null | sed 's/^/ frontend port: /' || \
|
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 ;;
|
renew-ssl) action_renew_ssl ;;
|
||||||
restart) action_restart ;;
|
restart) action_restart ;;
|
||||||
migrate-rebuild) action_migrate_rebuild ;;
|
migrate-rebuild) action_migrate_rebuild ;;
|
||||||
|
fix-host-nginx) action_fix_host_nginx ;;
|
||||||
docker-prune) action_docker_prune ;;
|
docker-prune) action_docker_prune ;;
|
||||||
fix-proxy) action_fix_host_proxy ;;
|
fix-proxy) action_fix_host_proxy ;;
|
||||||
fix-all) action_fix_all_errors ;;
|
fix-all) action_fix_all_errors ;;
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ message GetUserInsightsRequest {
|
|||||||
optional string search = 2;
|
optional string search = 2;
|
||||||
optional int32 limit = 3;
|
optional int32 limit = 3;
|
||||||
optional int32 offset = 4;
|
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 {
|
message AdminSignInEvent {
|
||||||
@@ -110,6 +118,10 @@ message AdminSignInEvent {
|
|||||||
message UserSignInHistoryResponse {
|
message UserSignInHistoryResponse {
|
||||||
int32 total = 1;
|
int32 total = 1;
|
||||||
repeated AdminSignInEvent events = 2;
|
repeated AdminSignInEvent events = 2;
|
||||||
|
optional AdminInsightsPeriodMeta meta = 3;
|
||||||
|
optional int32 retentionDays = 4;
|
||||||
|
optional string periodFrom = 5;
|
||||||
|
optional string periodTo = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UserActivityItem {
|
message UserActivityItem {
|
||||||
@@ -125,6 +137,10 @@ message UserActivityItem {
|
|||||||
message UserActivityResponse {
|
message UserActivityResponse {
|
||||||
int32 total = 1;
|
int32 total = 1;
|
||||||
repeated UserActivityItem items = 2;
|
repeated UserActivityItem items = 2;
|
||||||
|
optional AdminInsightsPeriodMeta meta = 3;
|
||||||
|
optional int32 retentionDays = 4;
|
||||||
|
optional string periodFrom = 5;
|
||||||
|
optional string periodTo = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdminChatRoomMember {
|
message AdminChatRoomMember {
|
||||||
@@ -155,6 +171,10 @@ message AdminChatRoomSummary {
|
|||||||
message UserChatRoomsResponse {
|
message UserChatRoomsResponse {
|
||||||
int32 total = 1;
|
int32 total = 1;
|
||||||
repeated AdminChatRoomSummary rooms = 2;
|
repeated AdminChatRoomSummary rooms = 2;
|
||||||
|
optional AdminInsightsPeriodMeta meta = 3;
|
||||||
|
optional int32 retentionDays = 4;
|
||||||
|
optional string periodFrom = 5;
|
||||||
|
optional string periodTo = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListUserChatMessagesRequest {
|
message ListUserChatMessagesRequest {
|
||||||
@@ -163,6 +183,8 @@ message ListUserChatMessagesRequest {
|
|||||||
optional string search = 3;
|
optional string search = 3;
|
||||||
optional int32 limit = 4;
|
optional int32 limit = 4;
|
||||||
optional string beforeMessageId = 5;
|
optional string beforeMessageId = 5;
|
||||||
|
optional string dateFrom = 6;
|
||||||
|
optional string dateTo = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SearchUserChatMessagesRequest {
|
message SearchUserChatMessagesRequest {
|
||||||
@@ -170,6 +192,8 @@ message SearchUserChatMessagesRequest {
|
|||||||
string search = 2;
|
string search = 2;
|
||||||
optional int32 limit = 3;
|
optional int32 limit = 3;
|
||||||
optional int32 offset = 4;
|
optional int32 offset = 4;
|
||||||
|
optional string dateFrom = 5;
|
||||||
|
optional string dateTo = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdminChatMessage {
|
message AdminChatMessage {
|
||||||
@@ -186,6 +210,9 @@ message AdminChatMessage {
|
|||||||
optional string roomId = 11;
|
optional string roomId = 11;
|
||||||
optional string roomName = 12;
|
optional string roomName = 12;
|
||||||
optional string groupName = 13;
|
optional string groupName = 13;
|
||||||
|
optional string storageKey = 14;
|
||||||
|
optional string mimeType = 15;
|
||||||
|
optional string metadataJson = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdminChatRoomContext {
|
message AdminChatRoomContext {
|
||||||
@@ -198,11 +225,19 @@ message AdminChatRoomContext {
|
|||||||
message UserChatMessagesResponse {
|
message UserChatMessagesResponse {
|
||||||
optional AdminChatRoomContext room = 1;
|
optional AdminChatRoomContext room = 1;
|
||||||
repeated AdminChatMessage messages = 2;
|
repeated AdminChatMessage messages = 2;
|
||||||
|
optional AdminInsightsPeriodMeta meta = 3;
|
||||||
|
optional int32 retentionDays = 4;
|
||||||
|
optional string periodFrom = 5;
|
||||||
|
optional string periodTo = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UserChatMessageSearchResponse {
|
message UserChatMessageSearchResponse {
|
||||||
int32 total = 1;
|
int32 total = 1;
|
||||||
repeated AdminChatMessage messages = 2;
|
repeated AdminChatMessage messages = 2;
|
||||||
|
optional AdminInsightsPeriodMeta meta = 3;
|
||||||
|
optional int32 retentionDays = 4;
|
||||||
|
optional string periodFrom = 5;
|
||||||
|
optional string periodTo = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdminDeleteChatMessageRequest {
|
message AdminDeleteChatMessageRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user