diff --git a/AGENTS.md b/AGENTS.md index c291d50..0a9d7ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,11 +160,21 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo ### 3g. Чат тьютор ↔ админ (`server.js`, `public/chat.html`, `public/js/chat.js`) - **Модель — личные диалоги 1:1**, не общая комната: один тред на тьютора (`chat_threads.tutor_id UNIQUE`). Тьютор видит и пишет только свой тред, админ видит все треды и может писать каждому. Тред создаётся лениво: тьютор — первым сообщением или `POST /api/chat/threads` без тела, админ — с `{ user_id }` -- **Таблицы**: `chat_threads` (`tutor_unread`/`admin_unread` — счётчики прочитанного по ролям, `last_message_text` — превью для списка), `chat_messages`, `chat_attachments`. Схема — в `db/init.sql`, `db/migration.sql` и `ensureChatTables()` +- **Таблицы**: `chat_threads` (`tutor_unread`/`admin_unread` — счётчики прочитанного по ролям, `last_message_text` — превью для списка, `deleted_at` — скрыт админом, `purge_at` — плановое удаление), `chat_messages`, `chat_attachments`. Схема — в `db/init.sql`, `db/migration.sql` и `ensureChatTables()` +- **Скрытие и удаление диалога админом** (маршруты под `requireAuth, requireAdmin, chatLimiter`, аудит `chat.thread.hide` / `chat.thread.restore` / `chat.thread.unschedule` / `chat.thread.schedule`): + - `DELETE /api/chat/threads/:id` — скрывает: `deleted_at = now()`, `purge_at = now() + trash_purge_days` (TTL берётся из общей настройки корзины, `trashPurgeDays()`). Повторное скрытие — 400 + - `PUT /api/chat/threads/:id/restore` — снимает `deleted_at` и `purge_at`; если не скрыт — 400 + - `PUT /api/chat/threads/:id/unschedule` — снимает `purge_at`, оставляя диалог скрытым («корзина без удаления») + - `DELETE /api/chat/threads/:id/permanent` — планирует физическое удаление (`purge_at = now() + trash_purge_days`); выполняет `purgeScheduledDeletions()` через `hardDeleteChatThread()` (каскад `chat_messages` → `chat_attachments` + `safeUnlink` по `path`) + - **Скрытый тред не исчезает у тьютора**: фильтр `deleted_at IS NULL` действует только в админской ветке `GET /api/chat/threads`, тьюторская ветка не фильтруется. Сообщения скрытого треда читаются через `chatThreadFor()` как обычно + - **Авто-восстановление**: сообщение от тьютора снимает `deleted_at`/`purge_at` в том же `UPDATE chat_threads` при отправке (`CASE WHEN $5::boolean THEN NULL ...`, `byTutor` = `req.user.role !== 'admin'`) и публикует `thread_update`. Иначе переписка тьютора осталась бы невидимой для админа + - **Ответ `GET /api/chat/threads`** дополнительно отдаёт `hidden` (число скрытых, только для админа в обычном режиме) и `hidden_mode` (был ли запрошен `?deleted=1`) + - `chatUnread()` для админа суммирует только `deleted_at IS NULL` — скрытые не мозолят бейдж `#sidebarChatBadge` +- **UI скрытия** в `#chatSide`: кнопка `#chatHiddenBtn` (переключает `chatHiddenMode` в `public/js/chat.js`) с бейджем `#chatHiddenCount` и подсказкой `#chatHiddenHint`; в карточке треда кнопки `data-thread-hide` / `data-thread-restore` / `data-thread-purge` (класс `chat-act`, иконки `archive` / `archive-restore` / `trash-2`). Обработчики висят на делегировании в `initChatPage()` и **обязаны** идти раньше ветки `closest('[data-thread]')` с `stopPropagation()` — иначе клик по действию выберет тред - **Правка и удаление сообщений**: `chat_messages.edited_at` / `deleted_at`. Маршруты `PUT`/`DELETE /api/chat/threads/:id/messages/:messageId` под `requireAuth, chatLimiter`, доступ через `chatMessageFor()` (сначала `chatThreadFor`, затем проверка, что сообщение принадлежит этому треду). Править может **только автор** (403 для админа на чужом сообщении), удалять — автор **или** админ (модерация). Обе операции работают по `deleted_at IS NULL`: правка удалённого — 400, повторное удаление — 404 - **Удаление мягкое**: тело затирается (`body = ''`), вложения удаляются каскадом из БД, файлы — через `safeUnlink` по `chat_attachments.path` (до или после коммита, `safeUnlink` идемпотентен). В ответе `GET /messages` удалённое сообщение отдаётся с `deleted_at`, пустым `body` и `files: []` — фронтенд рисует «Сообщение удалено» - **Превью треда после правки/удаления** пересчитывает `refreshChatThreadPreview()`: берётся последнее неудалённое сообщение, `last_message_text` = текст или `Вложение: <имя>`, при отсутствии сообщений — `NULL`. Счётчик непрочитанных уменьшается на 1 у той стороны, которой принадлежало удалённое сообщение (`GREATEST(col - 1, 0)`) -- **SSE-типы**: имя события не зашито — `publishChat({ type: 'message' | 'message_update' | 'message_delete', thread_id, tutor_id, message, preview })`, а список типов валидируется через `CHAT_EVENTS` в обработчике `cache.on(CHAT_CHANNEL)`. Новый тип обязан быть добавлен и в `CHAT_EVENTS`, и в `connectChatStream()` в `public/admin.js`. Для `message_update` / `message_delete` колбэки — `onChatMessageUpdate` / `onChatMessageDelete` из `public/js/chat.js`; новые сообщения по-прежнему идут событием `message` +- **SSE-типы**: имя события не зашито — `publishChat({ type: 'message' | 'message_update' | 'message_delete' | 'thread_update', thread_id, tutor_id, message, preview, deleted_at })`, а список типов валидируется через `CHAT_EVENTS` в обработчике `cache.on(CHAT_CHANNEL)`. Новый тип обязан быть добавлен и в `CHAT_EVENTS`, и в `connectChatStream()` в `public/admin.js`. Для `message_update` / `message_delete` колбэки — `onChatMessageUpdate` / `onChatMessageDelete` из `public/js/chat.js`, для `thread_update` — `onChatThreadUpdate`; новые сообщения по-прежнему идут событием `message`. Если в payload нет `tutor_id` (как у фонового purge), обработчик сам резолвит его через `chatThreadTutor()` — иначе `chatVisible()` не пропустит событие - **Аудит**: `chat.message.create`, `chat.message.update` (с `textDiff` в `changes[].diff`, как у отчётов о занятии) и `chat.message.delete` (без текста удалённого сообщения) - **`normalizeRestoreData` обязан сохранять `edited_at`/`deleted_at`**, и restore-INSERT — тоже: без этого удалённые сообщения после восстановления вернутся как живые со своим текстом - **Отдельный SSE-канал `whatido:chat`**, эндпоинт `GET /api/chat/stream` — **не** общая лента `notifications`. Причина: `notifications` широковещательная (видимость только через `admin_only` + `branch_id`), личная переписка попала бы туда и стала видна посторонним тьюторам. Живой чат держит свой счётчик `setChatUnread()` и бейдж `#sidebarChatBadge` diff --git a/api.smoketest.js b/api.smoketest.js index 08558e1..800a19b 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -345,6 +345,42 @@ async function main() { const chatBadUser = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: 99999999 } }); ok('chat: несуществующий тьютор -> 404', chatBadUser.status === 404, chatBadUser.status); + // --- Chat: скрытие/восстановление диалога (только админ) --- + const chatHideThread = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: me1.data.id } }); + if (chatHideThread.status === 200 && chatHideThread.data.thread) { + const hid = chatHideThread.data.thread.id; + const hideOk = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' }); + ok('chat: админ скрывает диалог', hideOk.status === 200, hideOk.status); + const hideAgain = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' }); + ok('chat: повторное скрытие -> 400', hideAgain.status === 400, hideAgain.status); + const listActive = await api('/api/chat/threads', { token }); + ok('chat: скрытый диалог ушёл из активного списка', + listActive.status === 200 && !(listActive.data.items || []).some(t => t.id === hid) + && listActive.data.hidden >= 1 && listActive.data.hidden_mode === false, + { hidden: listActive.data.hidden, mode: listActive.data.hidden_mode }); + const listHidden = await api('/api/chat/threads?deleted=1', { token }); + ok('chat: скрытый диалог виден в скрытых', + listHidden.status === 200 && (listHidden.data.items || []).some(t => t.id === hid) + && listHidden.data.hidden_mode === true && !!(listHidden.data.items || []).find(t => t.id === hid).deleted_at, + (listHidden.data.items || []).map(t => t.id)); + const messagesHidden = await api(`/api/chat/threads/${hid}/messages`, { token }); + ok('chat: сообщения скрытого диалога читаются', messagesHidden.status === 200 && Array.isArray(messagesHidden.data.items), messagesHidden.status); + const restore = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' }); + ok('chat: восстановление диалога', restore.status === 200, restore.status); + const restoreAgain = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' }); + ok('chat: повторное восстановление -> 400', restoreAgain.status === 400, restoreAgain.status); + const hideSchedule = await api(`/api/chat/threads/${hid}`, { token, method: 'DELETE' }); + ok('chat: скрытие перед планированием удаления', hideSchedule.status === 200, hideSchedule.status); + const purge = await api(`/api/chat/threads/${hid}/permanent`, { token, method: 'DELETE' }); + ok('chat: планирование удаления диалога', purge.status === 200, purge.status); + const purgeAgain = await api(`/api/chat/threads/${hid}/permanent`, { token, method: 'DELETE' }); + ok('chat: повторное планирование продлевает срок', purgeAgain.status === 200, purgeAgain.status); + const restoreScheduled = await api(`/api/chat/threads/${hid}/restore`, { token, method: 'PUT' }); + ok('chat: восстановление снимает планирование', restoreScheduled.status === 200, restoreScheduled.status); + } + const chatHideNoAuth = await api(`/api/chat/threads/${chatHideThread.data?.thread?.id || 1}`, { method: 'DELETE' }); + ok('chat: скрытие без токена -> 401', chatHideNoAuth.status === 401, chatHideNoAuth.status); + // --- Chat: правка и удаление сообщений --- const tutorLogin = 'smoke_chat_' + Date.now().toString(36); const tutorPass = 'SmokeChat123!'; diff --git a/backup-restore.js b/backup-restore.js index c6dbb23..125d9c5 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -478,6 +478,8 @@ function normalizeRestoreData(data) { last_message_text: optStr(x.last_message_text, 300), tutor_unread: optInt(x.tutor_unread, 0, 2147483647) ?? 0, admin_unread: optInt(x.admin_unread, 0, 2147483647) ?? 0, + deleted_at: optTs(x.deleted_at), + purge_at: optTs(x.purge_at), created_at: optTs(x.created_at), })); const chat_messages = (data.chat_messages || []).map(x => ({ diff --git a/db/init.sql b/db/init.sql index bed90eb..e31d462 100644 --- a/db/init.sql +++ b/db/init.sql @@ -350,6 +350,8 @@ CREATE TABLE IF NOT EXISTS chat_threads ( last_message_text VARCHAR(300), tutor_unread INT NOT NULL DEFAULT 0, admin_unread INT NOT NULL DEFAULT 0, + deleted_at TIMESTAMPTZ, + purge_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); diff --git a/db/migration.sql b/db/migration.sql index 9bc6d0e..894753b 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -320,9 +320,14 @@ CREATE TABLE IF NOT EXISTS chat_threads ( last_message_text VARCHAR(300), tutor_unread INT NOT NULL DEFAULT 0, admin_unread INT NOT NULL DEFAULT 0, + deleted_at TIMESTAMPTZ, + purge_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); +ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ; + CREATE INDEX IF NOT EXISTS idx_chat_threads_last_message ON chat_threads(last_message_at DESC); CREATE TABLE IF NOT EXISTS chat_messages ( diff --git a/public/admin.css b/public/admin.css index eb55deb..7f6cfca 100644 --- a/public/admin.css +++ b/public/admin.css @@ -930,13 +930,32 @@ body.report-card-open .report-fields{display:flex} .chat-thread-preview{font-size:.78rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .chat-off{font-size:.65rem;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:1px 5px;flex-shrink:0} .chat-thread-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center} +.chat-side-toggle{padding:7px 12px;font-size:.8rem;display:inline-flex;align-items:center;gap:6px} +.chat-side-toggle svg{width:14px;height:14px} +.chat-side-toggle.on{background:rgba(37,99,235,.1);border-color:var(--accent);color:var(--accent)} +.chat-hidden-count{min-width:16px;height:16px;padding:0 5px;border-radius:999px;background:var(--muted);color:var(--card);font-size:.62rem;font-weight:700;line-height:16px;text-align:center} +.chat-side-toggle.on .chat-hidden-count{background:var(--accent);color:#fff} +.chat-side-hint{padding:9px 14px;border-bottom:1px solid var(--border);background:var(--bg);color:var(--muted);font-size:.72rem;line-height:1.4} +.chat-side-hint[hidden]{display:none} +.chat-thread-acts{display:flex;align-items:center;gap:4px;margin-left:auto;align-self:center;opacity:0;transition:opacity .15s} +.chat-thread:hover .chat-thread-acts,.chat-thread:focus-visible .chat-thread-acts,.chat-thread.active .chat-thread-acts{opacity:1} +.chat-act{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;padding:0;flex-shrink:0} +.chat-act:hover{color:var(--accent);border-color:var(--accent);background:rgba(37,99,235,.07)} +.chat-act:hover svg{stroke:var(--accent)} +.chat-act-danger:hover{color:#ef4444;border-color:#ef4444;background:rgba(239,68,68,.08)} +.chat-act-danger:hover svg{stroke:#ef4444} +.chat-act svg{width:14px;height:14px} +.chat-thread.is-hidden .chat-thread-avatar{opacity:.5} +.chat-thread.is-hidden .chat-thread-name,.chat-thread.is-hidden .chat-thread-preview{opacity:.65} +.chat-hidden-tag{border-color:rgba(239,68,68,.35);color:#ef4444} +@media(hover:none){.chat-thread-acts{opacity:1}} .chat-pane{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);min-height:420px;display:flex;flex-direction:column;overflow:hidden} .chat-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:70px 20px;text-align:center;color:var(--muted)} .chat-empty svg{width:34px;height:34px;opacity:.4} .chat-empty-title{font-size:.95rem;font-weight:600;color:var(--text)} .chat-empty-sub{font-size:.82rem} .chat-main{display:flex;flex-direction:column;min-height:520px} -.chat-side[hidden],.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden]{display:none} +.chat-side[hidden],.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden],.chat-side-toggle[hidden],.chat-hidden-count[hidden]{display:none} .chat-head{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--border)} .chat-head .grow{flex:1} .chat-avatar{width:36px;height:36px;border-radius:10px;background:rgba(37,99,235,.1);color:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0} diff --git a/public/admin.js b/public/admin.js index 5a5c0fe..725cc07 100644 --- a/public/admin.js +++ b/public/admin.js @@ -274,6 +274,11 @@ function connectChatStream() { try { p = JSON.parse(ev.data); } catch { return; } if (p && typeof onChatMessageDelete === 'function') onChatMessageDelete(p); }); + chatStream.addEventListener('thread_update', ev => { + let p = null; + try { p = JSON.parse(ev.data); } catch { return; } + if (p && typeof onChatThreadUpdate === 'function') onChatThreadUpdate(p); + }); chatStream.onerror = () => { if (chatStream) { chatStream.close(); chatStream = null; } clearTimeout(chatReconnectTimer); diff --git a/public/chat.html b/public/chat.html index e1c5415..99ee51c 100644 --- a/public/chat.html +++ b/public/chat.html @@ -22,8 +22,10 @@
Диалоги +
+
diff --git a/public/js/chat.js b/public/js/chat.js index ea485ef..dc021b1 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -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 ` + + ` + : ` + + `) : ''; + return ``; } @@ -84,10 +97,11 @@ function renderChatThreads() { const list = document.getElementById('chatThreadList'); if (!list) return; if (!chatThreads.length) { - list.innerHTML = '
Диалогов пока нет
'; + list.innerHTML = `
${chatHiddenMode ? 'Скрытых диалогов нет' : 'Диалогов пока нет'}
`; 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'); }); diff --git a/server.js b/server.js index 7c1a378..1db72b6 100644 --- a/server.js +++ b/server.js @@ -447,6 +447,8 @@ async function ensureChatTables() { created_at TIMESTAMPTZ DEFAULT now() )`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_threads_last_message ON chat_threads(last_message_at DESC)`); + await pool.query('ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ'); + await pool.query('ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ'); await pool.query(`CREATE TABLE IF NOT EXISTS chat_messages ( id SERIAL PRIMARY KEY, thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE, @@ -1352,12 +1354,20 @@ async function purgeScheduledDeletions() { for (const g of grefs.rows) { if (await hardDeleteGroup(g.id)) gcount++; } - if (erefs.rowCount || gcount) { + const crefs = await pool.query('SELECT id FROM chat_threads WHERE purge_at IS NOT NULL AND purge_at <= now()'); + let ccount = 0; + for (const c of crefs.rows) { + if (await hardDeleteChatThread(c.id)) { + ccount++; + publishChat({ type: 'thread_update', thread_id: c.id, tutor_id: null, deleted: true }); + } + } + if (erefs.rowCount || gcount || ccount) { invalidateEntries(); invalidateGroups(); invalidateStats(); } - return { entries: erefs.rowCount, groups: gcount }; + return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount }; } function safeUnlink(relPath) { @@ -1368,6 +1378,18 @@ function safeUnlink(relPath) { storage.del(key).catch(err => console.error('Upload delete failed:', key, err.message)); } +async function hardDeleteChatThread(threadId) { + const { rows } = await pool.query( + `SELECT a.path FROM chat_attachments a + JOIN chat_messages m ON m.id = a.message_id + WHERE m.thread_id = $1`, + [threadId] + ); + const r = await pool.query('DELETE FROM chat_threads WHERE id = $1', [threadId]); + rows.forEach(x => safeUnlink(x.path)); + return r.rowCount > 0; +} + function removeUpload(file) { safeUnlink(file && file.path); } @@ -1925,19 +1947,32 @@ function publishChat(payload) { cache.publish(CHAT_CHANNEL, payload).catch(err => console.error('Chat publish failed:', err.message)); } -const CHAT_EVENTS = new Set(['message', 'message_update', 'message_delete']); +const CHAT_EVENTS = new Set(['message', 'message_update', 'message_delete', 'thread_update']); -cache.on(CHAT_CHANNEL, message => { +cache.on(CHAT_CHANNEL, async message => { let payload = null; try { payload = JSON.parse(message); } catch (e) { return; } if (!payload || !payload.thread_id) return; const event = CHAT_EVENTS.has(payload.type) ? payload.type : 'message'; + const tutorId = payload.tutor_id === null || payload.tutor_id === undefined + ? (await chatThreadTutor(payload.thread_id)) + : payload.tutor_id; + if (tutorId === null) return; for (const client of [...chatClients]) { - if (!chatVisible(client.user, payload.tutor_id)) continue; + if (!chatVisible(client.user, tutorId)) continue; try { writeChatFrame(client, event, payload); } catch (e) { chatClients.delete(client); } } }); +async function chatThreadTutor(threadId) { + try { + const { rows } = await pool.query('SELECT tutor_id FROM chat_threads WHERE id = $1', [threadId]); + return rows.length ? rows[0].tutor_id : null; + } catch (e) { + return null; + } +} + const chatLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, @@ -1952,6 +1987,7 @@ async function chatThreadFor(user, threadId) { if (!Number.isInteger(id) || id < 1) return { error: 400, message: 'Invalid thread' }; const { rows } = await pool.query( `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.admin_unread, t.created_at, + t.deleted_at, u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active FROM chat_threads t JOIN users u ON u.id = t.tutor_id WHERE t.id = $1`, @@ -2012,12 +2048,13 @@ function chatThreadJson(t, unread) { last_message_text: t.last_message_text || '', unread: unread === undefined ? 0 : unread, created_at: t.created_at, + deleted_at: t.deleted_at || null, }; } async function chatUnread(user) { if (user.role === 'admin') { - const { rows } = await pool.query('SELECT COALESCE(SUM(admin_unread), 0)::int AS n FROM chat_threads'); + const { rows } = await pool.query('SELECT COALESCE(SUM(admin_unread), 0)::int AS n FROM chat_threads WHERE deleted_at IS NULL'); return rows[0] ? rows[0].n : 0; } const { rows } = await pool.query('SELECT tutor_unread AS n FROM chat_threads WHERE tutor_id = $1', [user.id]); @@ -2027,21 +2064,31 @@ async function chatUnread(user) { app.get('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => { try { const isAdmin = req.user.role === 'admin'; + const showDeleted = isAdmin && (req.query.deleted === '1' || req.query.deleted === 'true'); const { rows } = isAdmin ? await pool.query( - `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.admin_unread, t.created_at, + `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.admin_unread, t.created_at, t.deleted_at, u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active FROM chat_threads t JOIN users u ON u.id = t.tutor_id + WHERE t.deleted_at IS ${showDeleted ? 'NOT NULL' : 'NULL'} ORDER BY COALESCE(t.last_message_at, t.created_at) DESC` ) : await pool.query( - `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.created_at, + `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.created_at, t.deleted_at, u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active FROM chat_threads t JOIN users u ON u.id = t.tutor_id WHERE t.tutor_id = $1`, [req.user.id] ); - res.json({ items: rows.map(t => chatThreadJson(t, isAdmin ? t.admin_unread : t.tutor_unread)), unread: await chatUnread(req.user) }); + const hidden = isAdmin && !showDeleted + ? (await pool.query('SELECT COUNT(*)::int AS n FROM chat_threads WHERE deleted_at IS NOT NULL')).rows[0].n + : 0; + res.json({ + items: rows.map(t => chatThreadJson(t, isAdmin ? t.admin_unread : t.tutor_unread)), + unread: await chatUnread(req.user), + hidden, + hidden_mode: !!showDeleted, + }); } catch (e) { res.status(500).json({ error: e.message }); } @@ -2125,6 +2172,62 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req, } }); +app.delete('/api/chat/threads/:id', requireAuth, requireAdmin, chatLimiter, async (req, res) => { + const acc = await chatThreadFor(req.user, req.params.id); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const days = await trashPurgeDays(); + const r = await pool.query( + `UPDATE chat_threads SET deleted_at = now(), purge_at = now() + ($1 || ' days')::interval + WHERE id = $2 AND deleted_at IS NULL`, + [String(days), acc.thread.id] + ); + if (!r.rowCount) return res.status(400).json({ error: 'Диалог уже скрыт' }); + publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: '1' }); + await logAudit(req, 'chat.thread.hide', { id: acc.thread.id, tutor_id: acc.thread.tutor_id, purge_days: days }); + res.json({ ok: true, thread: chatThreadJson({ ...acc.thread, deleted_at: 'now' }, acc.thread.admin_unread) }); +}); + +app.put('/api/chat/threads/:id/restore', requireAuth, requireAdmin, chatLimiter, async (req, res) => { + const acc = await chatThreadFor(req.user, req.params.id); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const r = await pool.query( + 'UPDATE chat_threads SET deleted_at = NULL, purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL', + [acc.thread.id] + ); + if (!r.rowCount) return res.status(400).json({ error: 'Диалог не скрыт' }); + publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: null }); + await logAudit(req, 'chat.thread.restore', { id: acc.thread.id, tutor_id: acc.thread.tutor_id }); + res.json({ ok: true, thread: chatThreadJson({ ...acc.thread, deleted_at: null }, acc.thread.admin_unread) }); +}); + +app.put('/api/chat/threads/:id/unschedule', requireAuth, requireAdmin, chatLimiter, async (req, res) => { + const acc = await chatThreadFor(req.user, req.params.id); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const r = await pool.query( + 'UPDATE chat_threads SET purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL AND purge_at IS NOT NULL', + [acc.thread.id] + ); + if (!r.rowCount) return res.status(400).json({ error: 'Удаление не запланировано' }); + await logAudit(req, 'chat.thread.unschedule', { id: acc.thread.id }); + res.json({ ok: true }); +}); + +app.delete('/api/chat/threads/:id/permanent', requireAuth, requireAdmin, chatLimiter, async (req, res) => { + const acc = await chatThreadFor(req.user, req.params.id); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const r = await pool.query( + `UPDATE chat_threads SET purge_at = now() + ($1 || ' days')::interval + WHERE id = $2 AND deleted_at IS NOT NULL AND purge_at > now()`, + [String(await trashPurgeDays()), acc.thread.id] + ); + if (!r.rowCount) return res.status(400).json({ error: 'Диалог не скрыт или уже запланирован к удалению' }); + publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: '1' }); + await logAudit(req, 'chat.thread.schedule', { id: acc.thread.id, tutor_id: acc.thread.tutor_id }); + res.json({ ok: true }); +}); + + + const chatFilesUpload = multer({ storage: multer.diskStorage({ destination: (_, __, cb) => { @@ -2165,6 +2268,7 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, const client = await pool.connect(); let created; + const byTutor = req.user.role !== 'admin'; try { await client.query('BEGIN'); const mr = await client.query( @@ -2179,15 +2283,16 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, [created.id, crypto.randomBytes(16).toString('hex'), `/uploads/${f.filename}`, f.originalname, f.size || 0] ); } - const byTutor = req.user.role !== 'admin'; await client.query( `UPDATE chat_threads SET last_message_at = now(), last_message_text = $2, tutor_unread = tutor_unread + $3, - admin_unread = admin_unread + $4 + admin_unread = admin_unread + $4, + deleted_at = CASE WHEN $5::boolean THEN NULL ELSE deleted_at END, + purge_at = CASE WHEN $5::boolean THEN NULL ELSE purge_at END WHERE id = $1`, - [acc.thread.id, chatPreviewText(body, files), byTutor ? 0 : 1, byTutor ? 1 : 0] + [acc.thread.id, chatPreviewText(body, files), byTutor ? 0 : 1, byTutor ? 1 : 0, byTutor] ); await client.query('COMMIT'); } catch (e) { @@ -2197,6 +2302,7 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, } finally { client.release(); } + if (byTutor && acc.thread.deleted_at) publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: null }); const { rows: frows } = await pool.query( 'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id', @@ -3434,8 +3540,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req const ex = await client.query('SELECT 1 FROM users WHERE id = $1', [x.tutor_id]); if (!ex.rowCount) continue; await client.query( - 'INSERT INTO chat_threads (id, tutor_id, last_message_at, last_message_text, tutor_unread, admin_unread, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', - [x.id, x.tutor_id, x.last_message_at, x.last_message_text, x.tutor_unread || 0, x.admin_unread || 0, x.created_at] + 'INSERT INTO chat_threads (id, tutor_id, last_message_at, last_message_text, tutor_unread, admin_unread, deleted_at, purge_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', + [x.id, x.tutor_id, x.last_message_at, x.last_message_text, x.tutor_unread || 0, x.admin_unread || 0, x.deleted_at || null, x.purge_at || null, x.created_at] ); } for (const x of ndata.chat_messages) {