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
+36
View File
@@ -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!';