feat(chat): скрытие и плановое удаление диалогов

Админ может скрыть диалог тьютора (мягкое удаление), снять скрытие,
отменить плановое удаление или удалить диалог физически через TTL
общей настройки корзины.

- chat_threads: deleted_at, purge_at (init.sql, migration.sql, ensureChatTables)
- маршруты hide / restore / unschedule / permanent под requireAdmin + аудит
- purgeScheduledDeletions() + hardDeleteChatThread() с safeUnlink по вложениям
- скрытые диалоги видны тьютору как обычно; новое сообщение тьютора
  авто-возвращает диалог в список (thread_update по SSE)
- UI: кнопка «Скрытые» с бейджем и действия в карточке треда
- бэкап: deleted_at/purge_at в normalizeRestoreData
This commit is contained in:
dev
2026-10-05 19:32:56 +03:00
parent 1740c341a0
commit 7d282d4f3d
10 changed files with 333 additions and 20 deletions
+129 -3
View File
@@ -17,6 +17,8 @@ let chatPendingFiles = [];
let chatLoading = false;
let chatSending = false;
let chatEditId = 0;
let chatHiddenMode = false;
let chatHiddenCount = 0;
function chatHumanSize(b) {
const n = Number(b) || 0;
@@ -65,18 +67,29 @@ function chatThreadCardHtml(t) {
const active = t.id === chatActiveId ? ' active' : '';
const unread = Number(t.unread) || 0;
const preview = (t.last_message_text || '').trim() || 'Нет сообщений';
return `<button type="button" class="chat-thread${active}${unread ? ' unread' : ''}" data-thread="${t.id}">
const hidden = t.deleted_at ? ' is-hidden' : '';
const acts = isAdmin() ? (t.deleted_at
? `<span class="chat-thread-acts">
<button type="button" class="chat-act" data-thread-restore="${t.id}" title="Вернуть диалог в список"><i data-lucide="archive-restore"></i></button>
<button type="button" class="chat-act chat-act-danger" data-thread-purge="${t.id}" title="Удалить диалог безвозвратно"><i data-lucide="trash-2"></i></button>
</span>`
: `<span class="chat-thread-acts">
<button type="button" class="chat-act" data-thread-hide="${t.id}" title="Скрыть диалог"><i data-lucide="archive"></i></button>
</span>`) : '';
return `<button type="button" class="chat-thread${active}${hidden}${unread ? ' unread' : ''}" data-thread="${t.id}">
<span class="chat-thread-avatar">${esc((t.tutor_name || '?').slice(0, 1).toUpperCase())}</span>
<span class="chat-thread-body">
<span class="chat-thread-top">
<span class="chat-thread-name">${esc(t.tutor_name || t.tutor_username)}</span>
${t.tutor_active === false ? '<span class="chat-off" title="Пользователь отключён">отключён</span>' : ''}
${t.deleted_at ? '<span class="chat-off chat-hidden-tag" title="Диалог скрыт">скрыт</span>' : ''}
<span class="grow"></span>
<span class="chat-thread-time">${esc(chatThreadTime(t.last_message_at || t.created_at))}</span>
</span>
<span class="chat-thread-preview">${esc(preview)}</span>
</span>
${unread ? `<span class="chat-thread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
${acts}
</button>`;
}
@@ -84,10 +97,11 @@ function renderChatThreads() {
const list = document.getElementById('chatThreadList');
if (!list) return;
if (!chatThreads.length) {
list.innerHTML = '<div class="chat-side-empty">Диалогов пока нет</div>';
list.innerHTML = `<div class="chat-side-empty">${chatHiddenMode ? 'Скрытых диалогов нет' : 'Диалогов пока нет'}</div>`;
return;
}
list.innerHTML = chatThreads.map(chatThreadCardHtml).join('');
renderIcons();
}
function chatFilesHtml(files) {
@@ -184,15 +198,100 @@ async function chatApi(path, options) {
async function loadChatThreads() {
try {
const data = await chatApi('/api/chat/threads', { headers: hdr() });
const data = await chatApi(`/api/chat/threads${chatHiddenMode ? '?deleted=1' : ''}`, { headers: hdr() });
chatThreads = data.items || [];
chatHiddenCount = Number(data.hidden) || 0;
setChatUnread(data.unread);
renderChatHiddenBtn();
renderChatThreads();
} catch {
showToast('Не удалось загрузить диалоги', true);
}
}
function renderChatHiddenBtn() {
const btn = document.getElementById('chatHiddenBtn');
const hint = document.getElementById('chatHiddenHint');
const count = document.getElementById('chatHiddenCount');
if (!btn) return;
btn.hidden = !isAdmin();
btn.classList.toggle('on', chatHiddenMode);
if (hint) hint.hidden = !chatHiddenMode;
if (count) {
count.hidden = !chatHiddenMode || !chatHiddenCount;
count.textContent = chatHiddenCount > 99 ? '99+' : String(chatHiddenCount);
}
btn.title = chatHiddenMode ? 'Показать активные диалоги' : 'Показать скрытые диалоги';
}
function resetChatHiddenMode() {
if (!chatHiddenMode) return;
chatHiddenMode = false;
chatActiveId = 0;
renderChatHiddenBtn();
}
function afterChatThreadListChanged() {
const stillThere = chatThreads.some(t => t.id === chatActiveId);
if (!stillThere) chatActiveId = 0;
renderChatThreads();
if (chatActiveId) loadChatMessages();
else {
renderChatHeader();
renderChatMessages();
}
}
async function hideChatThread(id) {
const thread = chatThreads.find(t => t.id === Number(id));
if (!thread) return;
const name = thread.tutor_name || thread.tutor_username || 'диалог';
if (!confirm(`Скрыть диалог с «${name}»? Он уйдёт из списка активных, сообщения сохранятся и будут удалены автоматически через срок из настроек корзины.`)) return;
try {
await chatApi(`/api/chat/threads/${id}`, { method: 'DELETE', headers: hdr() });
chatHiddenCount += 1;
showToast('Диалог скрыт');
if (chatActiveId === Number(id)) chatActiveId = 0;
chatThreads = chatThreads.filter(t => t.id !== Number(id));
renderChatHiddenBtn();
afterChatThreadListChanged();
loadChatUnread();
} catch (e) {
showToast(e.message || 'Не удалось скрыть диалог', true);
}
}
async function restoreChatThread(id) {
try {
await chatApi(`/api/chat/threads/${id}/restore`, { method: 'PUT', headers: hdr() });
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
showToast('Диалог восстановлен');
if (chatActiveId === Number(id)) chatActiveId = 0;
chatThreads = chatThreads.filter(t => t.id !== Number(id));
renderChatHiddenBtn();
afterChatThreadListChanged();
loadChatUnread();
} catch (e) {
showToast(e.message || 'Не удалось восстановить диалог', true);
}
}
async function purgeChatThread(id) {
const thread = chatThreads.find(t => t.id === Number(id));
if (!thread) return;
const name = thread.tutor_name || thread.tutor_username || 'диалог';
if (!confirm(`Удалить диалог с «${name}» безвозвратно? Сообщения и вложения будут удалены после срока из настроек корзины.`)) return;
try {
await chatApi(`/api/chat/threads/${id}/permanent`, { method: 'DELETE', headers: hdr() });
chatThreads = chatThreads.filter(t => t.id !== Number(id));
if (chatActiveId === Number(id)) chatActiveId = 0;
afterChatThreadListChanged();
showToast('Удаление запланировано');
} catch (e) {
showToast(e.message || 'Не удалось запланировать удаление', true);
}
}
function renderChatHeader() {
const thread = chatThreads.find(t => t.id === chatActiveId);
const empty = document.getElementById('chatEmpty');
@@ -330,6 +429,8 @@ function onChatMessage(p) {
const m = p.message;
if (idx < 0) {
loadChatThreads();
} else if (chatHiddenMode) {
renderChatThreads();
} else {
chatThreads[idx].last_message_text = m.body || 'Вложение';
chatThreads[idx].last_message_at = m.created_at;
@@ -377,6 +478,18 @@ function onChatMessageDelete(p) {
if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead();
}
function onChatThreadUpdate(p) {
if (!p || !isAdmin()) return;
if (p.deleted_at) {
if (chatHiddenMode) return;
chatHiddenCount += 1;
} else {
if (!chatHiddenMode) return;
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
}
loadChatThreads();
}
function openChatEditModal(messageId) {
const modal = document.getElementById('chatEditModal');
const input = document.getElementById('chatEditText');
@@ -545,6 +658,7 @@ function initChatPage() {
const sendBtn = document.getElementById('chatSendBtn');
const reloadBtn = document.getElementById('chatReloadBtn');
const newBtn = document.getElementById('chatNewBtn');
const hiddenBtn = document.getElementById('chatHiddenBtn');
const newModal = document.getElementById('chatNewModal');
const newCancel = document.getElementById('chatNewCancel');
const newOk = document.getElementById('chatNewOk');
@@ -584,6 +698,12 @@ function initChatPage() {
if (list) {
list.addEventListener('click', e => {
const hideBtn = e.target.closest('[data-thread-hide]');
if (hideBtn) { e.stopPropagation(); hideChatThread(Number(hideBtn.dataset.threadHide)); return; }
const restoreBtn = e.target.closest('[data-thread-restore]');
if (restoreBtn) { e.stopPropagation(); restoreChatThread(Number(restoreBtn.dataset.threadRestore)); return; }
const purgeBtn = e.target.closest('[data-thread-purge]');
if (purgeBtn) { e.stopPropagation(); purgeChatThread(Number(purgeBtn.dataset.threadPurge)); return; }
const btn = e.target.closest('[data-thread]');
if (!btn) return;
selectChatThread(Number(btn.dataset.thread));
@@ -614,6 +734,12 @@ function initChatPage() {
});
}
if (newBtn) newBtn.addEventListener('click', openChatNewModal);
if (hiddenBtn) hiddenBtn.addEventListener('click', () => {
chatHiddenMode = !chatHiddenMode;
chatActiveId = 0;
renderChatHiddenBtn();
loadChatThreads();
});
if (newCancel) newCancel.addEventListener('click', () => newModal && newModal.classList.remove('open'));
if (newModal) {
newModal.addEventListener('click', e => { if (e.target === newModal) newModal.classList.remove('open'); });