From 1740c341a0e2bfacce83c303a8ca985f7b097f4d Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 5 Oct 2026 18:32:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PUT/DELETE /api/chat/threads/:id/messages/:messageId под requireAuth+chatLimiter, доступ через chatMessageFor (тред + принадлежность сообщения) - править может только автор (админу чужое сообщение 403), удалять — автор или админ - мягкое удаление: body='', вложения каскадом из БД + safeUnlink по path, счётчик непрочитанных уменьшается на 1 у своей стороны (GREATEST(col-1,0)) - refreshChatThreadPreview() пересчитывает превью треда по последнему живому сообщению - SSE: publishChat с type, список типов валидируется через CHAT_EVENTS; новые message_update/message_delete подписаны в connectChatStream (admin.js) - аудит chat.message.update с textDiff и chat.message.delete без текста - колонки edited_at/deleted_at в db/init.sql, db/migration.sql, ensureChatTables() и в normalizeRestoreData + restore-INSERT — удалённые сообщения не воскресают - фронтенд: кнопки правки/удаления по наведению, модалка правки (Ctrl+Enter), метка «изменено», плейсхолдер «Сообщение удалено», обновление по SSE - покрытие в api.smoketest.js (правка/удаление/403/400/404) и backup.selftest.js --- AGENTS.md | 9 +++ api.smoketest.js | 75 ++++++++++++++++++ backup-restore.js | 12 ++- backup.selftest.js | 9 +++ db/init.sql | 2 + db/migration.sql | 5 ++ public/admin.css | 15 ++++ public/admin.js | 10 +++ public/chat.html | 12 +++ public/js/chat.js | 157 +++++++++++++++++++++++++++++++++++++- server.js | 184 ++++++++++++++++++++++++++++++++++++++++++--- 11 files changed, 478 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed7d794..c291d50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,12 @@ 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_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` +- **Аудит**: `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` - **Файлы вложений** — отдельный multer `chatFilesUpload` с allowlist `CHAT_ALLOWED_EXT` (изображения, документы, архивы). Нельзя переиспользовать `adminUpload`: в нём `.html`/`.htm` явно разрешены поверх `BLOCKED_EXT` — для чата это лишняя поверхность. Правило: расширение проверяется по белому списку, а не «не заблокировано» - **Отдача вложений** — `GET /api/chat/files/:token` под `fileLimiter` + `loadUserByToken`, доступ проверяется `chatVisible(user, tutor_id)`: админ или владелец треда, иначе 403. Изображения отдаются inline (`?thumb=1` — WebP-миниатюра), видео — `?play=1` с `Accept-Ranges`, остальное — attachment. Файл **не** должен попадать в `express.static` или в `api/files/:token` (там доступ только по токену, без проверки участника треда) @@ -305,6 +311,9 @@ curl -H "X-Api-Key: wsk_..." http://localhost:3003/api/v1/me curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/threads curl -N "http://localhost:3003/api/chat/stream?token=$TOKEN" # SSE, first frame is `ready` curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/unread +curl -X PUT -H "X-Auth-Token: $TOKEN" -H 'Content-Type: application/json' \ + -d '{"body":"исправленный текст"}' http://localhost:3003/api/chat/threads/1/messages/42 # правка (только автор) +curl -X DELETE -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/threads/1/messages/42 # удаление (автор или админ) ``` `diff.js` builds the audit payload for text changes: word-level segments diff --git a/api.smoketest.js b/api.smoketest.js index 361351a..08558e1 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -345,6 +345,81 @@ 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 tutorLogin = 'smoke_chat_' + Date.now().toString(36); + const tutorPass = 'SmokeChat123!'; + const tutorCreate = await api('/api/users', { token, method: 'POST', body: { username: tutorLogin, password: tutorPass, name: 'Smoke Chat', role: 'tutor' } }); + ok('chat-edit: создан тестовый тьютор', tutorCreate.status === 201 && tutorCreate.data.id > 0, tutorCreate.status); + if (tutorCreate.status === 201) { + const tutorId = tutorCreate.data.id; + const tutorAuth = await api('/api/auth/login', { method: 'POST', body: { username: tutorLogin, password: tutorPass } }); + ok('chat-edit: тьютор вошёл', tutorAuth.status === 200 && !!tutorAuth.data.token, tutorAuth.status); + const tutorToken = tutorAuth.data.token; + const tutorMe = await api('/api/auth/me', { token: tutorToken }); + ok('chat-edit: сессия тьютора жива', tutorMe.status === 200 && tutorMe.data.role === 'tutor', tutorMe.status); + + const tThread = await api('/api/chat/threads', { token: tutorToken, method: 'POST', body: {} }); + ok('chat-edit: тьютор создал свой диалог', tThread.status === 200 && tThread.data.thread && tThread.data.thread.id > 0, tThread.status); + const tid = tThread.data && tThread.data.thread ? tThread.data.thread.id : 0; + const sent = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken, method: 'POST', body: { body: 'Smoke: исходный текст' } }); + ok('chat-edit: тьютор отправил сообщение', sent.status === 200 && sent.data.message && sent.data.message.id > 0, sent.status); + const mid = sent.data && sent.data.message ? sent.data.message.id : 0; + + const foreignEdit = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token, method: 'PUT', body: { body: 'Админ правит чужое' } }); + ok('chat-edit: админ не может править чужое сообщение -> 403', foreignEdit.status === 403, foreignEdit.status); + + const edited = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: 'Smoke: изменённый текст' } }); + ok('chat-edit: автор правит своё сообщение', edited.status === 200 && edited.data.message && edited.data.message.body === 'Smoke: изменённый текст' && !!edited.data.message.edited_at, edited.data); + + const emptyEdit = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: ' ' } }); + ok('chat-edit: пустой текст -> 400', emptyEdit.status === 400, emptyEdit.status); + + const editNoAuth = await api(`/api/chat/threads/${tid}/messages/${mid}`, { method: 'PUT', body: { body: 'без токена' } }); + ok('chat-edit: правка без токена -> 401', editNoAuth.status === 401, editNoAuth.status); + + const editMissing = await api(`/api/chat/threads/${tid}/messages/99999999`, { token: tutorToken, method: 'PUT', body: { body: 'нет такого' } }); + ok('chat-edit: несуществующее сообщение -> 404', editMissing.status === 404, editMissing.status); + + const listEdited = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken }); + const listed = (listEdited.data.items || []).find(m => m.id === mid); + ok('chat-edit: edited_at виден в ленте', !!listed && !!listed.edited_at && listed.body === 'Smoke: изменённый текст', listed); + + const adminReply = await api(`/api/chat/threads/${tid}/messages`, { token, method: 'POST', body: { body: 'Smoke: ответ админа' } }); + ok('chat-edit: админ ответил в диалоге', adminReply.status === 200 && adminReply.data.message, adminReply.status); + const adminMsgId = adminReply.data && adminReply.data.message ? adminReply.data.message.id : 0; + const tutorDelForeign = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { token: tutorToken, method: 'DELETE' }); + ok('chat-del: тьютор не может удалить сообщение админа -> 403', tutorDelForeign.status === 403, tutorDelForeign.status); + + const delOwn = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'DELETE' }); + ok('chat-del: автор удаляет своё сообщение', delOwn.status === 200 && delOwn.data.ok === true, delOwn.data); + + const delAgain = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'DELETE' }); + ok('chat-del: повторное удаление -> 404', delAgain.status === 404, delAgain.status); + + const editDeleted = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: 'воскрешение' } }); + ok('chat-edit: правка удалённого -> 400', editDeleted.status === 400, editDeleted.status); + + const listDeleted = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken }); + const gone = (listDeleted.data.items || []).find(m => m.id === mid); + ok('chat-del: удалённое сообщение без тела и вложений', !!gone && !!gone.deleted_at && !gone.body && Array.isArray(gone.files) && !gone.files.length, gone); + + const threadAfter = await api('/api/chat/threads', { token }); + const previewThread = (threadAfter.data.items || []).find(t => t.id === tid); + ok('chat-del: превью треда пересчитано', !!previewThread && previewThread.last_message_text === 'Smoke: ответ админа', previewThread && previewThread.last_message_text); + + const adminModeration = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { token, method: 'DELETE' }); + ok('chat-del: админ удаляет чужое сообщение (модерация)', adminModeration.status === 200 && adminModeration.data.ok === true, adminModeration.status); + + const emptyThreadPreview = await api('/api/chat/threads', { token }); + const emptyPreview = (emptyThreadPreview.data.items || []).find(t => t.id === tid); + ok('chat-del: превью очищено, когда все сообщения удалены', !!emptyPreview && !emptyPreview.last_message_text, emptyPreview && emptyPreview.last_message_text); + + const noAuthDel = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { method: 'DELETE' }); + ok('chat-del: удаление без токена -> 401', noAuthDel.status === 401, noAuthDel.status); + + await api(`/api/users/${tutorId}`, { token, method: 'DELETE' }); + } + const logout = await api('/api/auth/logout', { token, method: 'POST' }); ok('logout', logout.status === 200, logout.status); const afterLogout = await api('/api/auth/me', { token }); diff --git a/backup-restore.js b/backup-restore.js index dfbded4..c6dbb23 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -62,6 +62,14 @@ function optStr(v, max) { return reqStr(v, max); } +function optText(v, max) { + if (v === null || v === undefined) return ''; + if (typeof v !== 'string') throw new Error('Invalid string'); + const s = v.trim(); + if (s.length > max) throw new Error('Invalid string length'); + return s; +} + function optTs(v) { if (v === null || v === undefined) return null; if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp'); @@ -476,7 +484,9 @@ function normalizeRestoreData(data) { id: reqInt(x.id), thread_id: reqInt(x.thread_id), sender_id: reqInt(x.sender_id), - body: optStr(x.body, 5000) ?? '', + body: optText(x.body, 5000), + edited_at: optTs(x.edited_at), + deleted_at: optTs(x.deleted_at), created_at: optTs(x.created_at), })); const chat_attachments = (data.chat_attachments || []).map(x => ({ diff --git a/backup.selftest.js b/backup.selftest.js index ce670b3..04c4d52 100644 --- a/backup.selftest.js +++ b/backup.selftest.js @@ -79,6 +79,15 @@ const ch = normalizeRestoreData({ ok('chat_threads нормализуются', ch.chat_threads[0].tutor_id === 1 && ch.chat_threads[0].tutor_unread === 2, ch.chat_threads[0]); ok('chat_messages нормализуются', ch.chat_messages[0].thread_id === 3 && ch.chat_messages[0].body === 'привет', ch.chat_messages[0]); ok('chat_attachments нормализуются', ch.chat_attachments[0].name === 'photo.png' && ch.chat_attachments[0].size === 1234, ch.chat_attachments[0]); +const chEdits = normalizeRestoreData({ + ...base, + chat_messages: [ + { id: 10, thread_id: 3, sender_id: 1, body: 'правлено', edited_at: '2026-01-02T10:00:00.000Z' }, + { id: 11, thread_id: 3, sender_id: 1, body: '', deleted_at: '2026-01-03T10:00:00.000Z' }, + ], +}); +ok('edited_at сообщения сохраняется', chEdits.chat_messages[0].edited_at === '2026-01-02T10:00:00.000Z', chEdits.chat_messages[0]); +ok('deleted_at сообщения сохраняется', chEdits.chat_messages[1].deleted_at === '2026-01-03T10:00:00.000Z', chEdits.chat_messages[1]); let chatBad = false; try { normalizeRestoreData({ ...base, chat_messages: [{ id: 1, thread_id: 3, sender_id: 1, body: 'x'.repeat(5001) }] }); diff --git a/db/init.sql b/db/init.sql index ad364fb..bed90eb 100644 --- a/db/init.sql +++ b/db/init.sql @@ -360,6 +360,8 @@ CREATE TABLE IF NOT EXISTS chat_messages ( thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE, sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, body TEXT NOT NULL DEFAULT '', + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); diff --git a/db/migration.sql b/db/migration.sql index c1e4402..9bc6d0e 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -330,11 +330,16 @@ CREATE TABLE IF NOT EXISTS chat_messages ( thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE, sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, body TEXT NOT NULL DEFAULT '', + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id); +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ; +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + CREATE TABLE IF NOT EXISTS chat_attachments ( id SERIAL PRIMARY KEY, message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, diff --git a/public/admin.css b/public/admin.css index 77ad5e7..eb55deb 100644 --- a/public/admin.css +++ b/public/admin.css @@ -956,6 +956,17 @@ body.report-card-open .report-fields{display:flex} .msg-who{font-weight:600;color:var(--text);font-size:.78rem} .msg-role{border:1px solid var(--border);border-radius:5px;padding:0 5px;font-size:.65rem} .msg-time{white-space:nowrap} +.msg-edited{font-style:italic} +.msg-actions{display:inline-flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s} +.msg:hover .msg-actions,.msg-actions:focus-within{opacity:1} +.msg-act{background:none;border:none;padding:2px;border-radius:6px;color:var(--muted);cursor:pointer;display:inline-flex} +.msg-act:hover{color:var(--accent);background:var(--bg)} +.msg-act-del:hover{color:#ef4444} +.msg-act svg{width:13px;height:13px} +.msg-deleted-body{font-style:italic;color:var(--muted)} +.chat-edit-modal{max-width:560px} +.chat-edit-modal textarea{min-height:110px;max-height:40dvh} +.chat-edit-modal textarea:focus{border-color:var(--accent)} .msg-body{font-size:.88rem;line-height:1.45;white-space:pre-wrap;word-break:break-word} .msg-files{display:flex;flex-wrap:wrap;gap:8px} .msg-file{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:9px;padding:7px 10px;font-size:.78rem;color:var(--text);text-decoration:none;max-width:230px;min-width:0;transition:border-color .15s} @@ -987,6 +998,10 @@ body.report-card-open .report-fields{display:flex} .chat-hint{font-size:.75rem;margin:0} .chat-new-modal{max-width:420px} +@media(hover:none){ + .msg-actions{opacity:1} +} + @media(max-width:900px){ .chat-layout{grid-template-columns:1fr} .chat-side{max-height:230px} diff --git a/public/admin.js b/public/admin.js index 2c8edf4..5a5c0fe 100644 --- a/public/admin.js +++ b/public/admin.js @@ -264,6 +264,16 @@ function connectChatStream() { } if (typeof onChatMessage === 'function') onChatMessage(p); }); + chatStream.addEventListener('message_update', ev => { + let p = null; + try { p = JSON.parse(ev.data); } catch { return; } + if (p && typeof onChatMessageUpdate === 'function') onChatMessageUpdate(p); + }); + chatStream.addEventListener('message_delete', ev => { + let p = null; + try { p = JSON.parse(ev.data); } catch { return; } + if (p && typeof onChatMessageDelete === 'function') onChatMessageDelete(p); + }); chatStream.onerror = () => { if (chatStream) { chatStream.close(); chatStream = null; } clearTimeout(chatReconnectTimer); diff --git a/public/chat.html b/public/chat.html index cb36f4b..e1c5415 100644 --- a/public/chat.html +++ b/public/chat.html @@ -76,6 +76,18 @@ + + diff --git a/public/js/chat.js b/public/js/chat.js index ea66618..ea485ef 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -16,6 +16,7 @@ let chatMessages = []; let chatPendingFiles = []; let chatLoading = false; let chatSending = false; +let chatEditId = 0; function chatHumanSize(b) { const n = Number(b) || 0; @@ -110,12 +111,29 @@ function chatMessageHtml(m) { const mine = Number(m.sender_id) === Number(currentUser?.id); const who = mine ? 'Вы' : (m.sender_name || 'Тьютор'); const role = mine ? '' : (m.sender_role === 'admin' ? 'администратор' : 'тьютор'); + if (m.deleted_at) { + return `
+
+ ${esc(who)} + ${role ? `${esc(role)}` : ''} + + ${esc(chatMessageTime(m.created_at))} +
+
Сообщение удалено
+
`; + } + const canDelete = mine || isAdmin(); return `
${esc(who)} ${role ? `${esc(role)}` : ''} + ${m.edited_at ? `изменено` : ''} ${esc(chatMessageTime(m.created_at))} + ${canDelete ? ` + ${mine ? `` : ''} + + ` : ''}
${m.body ? `
${esc(m.body)}
` : ''} ${chatFilesHtml(m.files)} @@ -292,8 +310,8 @@ function removeChatFile(i) { renderChatAttach(); } -function autoGrowChatInput() { - const input = document.getElementById('chatInput'); +function autoGrowChatInput(el) { + const input = el || document.getElementById('chatInput'); if (!input) return; input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; @@ -325,6 +343,109 @@ function onChatMessage(p) { markChatRead(); } +function applyChatThreadPreview(p) { + if (!p || !p.preview) return; + const idx = chatThreads.findIndex(t => t.id === Number(p.thread_id)); + if (idx < 0) return; + chatThreads[idx].last_message_text = p.preview.last_message_text || ''; + chatThreads[idx].last_message_at = p.preview.last_message_at || chatThreads[idx].last_message_at; + renderChatThreads(); +} + +function onChatMessageUpdate(p) { + if (!p || !p.message) return; + applyChatThreadPreview(p); + if (Number(p.thread_id) !== chatActiveId) return; + const m = p.message; + const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id)); + if (idx < 0) { + addChatMessage(m); + } else { + chatMessages[idx] = { ...chatMessages[idx], ...m }; + } + renderChatMessages(); +} + +function onChatMessageDelete(p) { + if (!p || !p.message) return; + applyChatThreadPreview(p); + if (Number(p.thread_id) !== chatActiveId) return; + const m = p.message; + const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id)); + if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...m, body: '', files: [], edited_at: null }; + renderChatMessages(); + if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead(); +} + +function openChatEditModal(messageId) { + const modal = document.getElementById('chatEditModal'); + const input = document.getElementById('chatEditText'); + if (!modal || !input) return; + const msg = chatMessages.find(m => Number(m.id) === Number(messageId)); + if (!msg || msg.deleted_at) return; + chatEditId = Number(messageId); + input.value = String(msg.body || ''); + modal.classList.add('open'); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + autoGrowChatInput(input); +} + +function closeChatEditModal() { + const modal = document.getElementById('chatEditModal'); + if (modal) modal.classList.remove('open'); + chatEditId = 0; +} + +async function submitChatEdit() { + const input = document.getElementById('chatEditText'); + const saveBtn = document.getElementById('chatEditSave'); + if (!input || !chatEditId) return; + const body = String(input.value || '').trim(); + if (!body) { + showToast('Сообщение не может быть пустым', true); + return; + } + if (saveBtn) saveBtn.disabled = true; + try { + const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages/${chatEditId}`, { + method: 'PUT', + headers: hdrJson(), + body: JSON.stringify({ body }), + }); + if (data.message) { + const idx = chatMessages.findIndex(m => Number(m.id) === Number(data.message.id)); + if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...data.message }; + renderChatMessages(); + } + closeChatEditModal(); + } catch (e) { + showToast(e.message || 'Не удалось изменить сообщение', true); + } finally { + if (saveBtn) saveBtn.disabled = false; + } +} + +async function deleteChatMessage(messageId) { + const msg = chatMessages.find(m => Number(m.id) === Number(messageId)); + if (!msg) return; + const mine = Number(msg.sender_id) === Number(currentUser?.id); + if (!confirm(mine + ? 'Удалить сообщение? Вложения будут удалены безвозвратно.' + : 'Удалить сообщение собеседника? Вложения будут удалены безвозвратно.')) return; + try { + await chatApi(`/api/chat/threads/${chatActiveId}/messages/${messageId}`, { method: 'DELETE', headers: hdr() }); + const idx = chatMessages.findIndex(m => Number(m.id) === Number(messageId)); + if (idx >= 0) { + chatMessages[idx] = { ...chatMessages[idx], body: '', files: [], edited_at: null, deleted_at: new Date().toISOString() }; + } + renderChatMessages(); + loadChatThreads(); + } catch (e) { + showToast(e.message || 'Не удалось удалить сообщение', true); + } +} + async function openChatNewModal() { const modal = document.getElementById('chatNewModal'); const select = document.getElementById('chatNewUser'); @@ -429,6 +550,37 @@ function initChatPage() { const newOk = document.getElementById('chatNewOk'); const attachBox = document.getElementById('chatAttachList'); const imgModal = document.getElementById('imgModal'); + const box = document.getElementById('chatMessages'); + const editModal = document.getElementById('chatEditModal'); + const editInput = document.getElementById('chatEditText'); + const editCancel = document.getElementById('chatEditCancel'); + const editSave = document.getElementById('chatEditSave'); + + if (box) { + box.addEventListener('click', e => { + const editBtn = e.target.closest('[data-msg-edit]'); + if (editBtn) { + openChatEditModal(Number(editBtn.dataset.msgEdit)); + return; + } + const delBtn = e.target.closest('[data-msg-del]'); + if (delBtn) deleteChatMessage(Number(delBtn.dataset.msgDel)); + }); + } + if (editCancel) editCancel.addEventListener('click', closeChatEditModal); + if (editSave) editSave.addEventListener('click', submitChatEdit); + if (editModal) { + editModal.addEventListener('click', e => { if (e.target === editModal) closeChatEditModal(); }); + } + if (editInput) { + editInput.addEventListener('input', () => autoGrowChatInput(editInput)); + editInput.addEventListener('keydown', e => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + submitChatEdit(); + } + }); + } if (list) { list.addEventListener('click', e => { @@ -493,6 +645,7 @@ function initChatPage() { document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; + if (editModal && editModal.classList.contains('open')) { closeChatEditModal(); return; } if (imgModal && imgModal.classList.contains('open')) { imgModal.classList.remove('open'); return; } if (newModal && newModal.classList.contains('open')) newModal.classList.remove('open'); }); diff --git a/server.js b/server.js index d1b57ea..7c1a378 100644 --- a/server.js +++ b/server.js @@ -452,8 +452,12 @@ async function ensureChatTables() { thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE, sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, body TEXT NOT NULL DEFAULT '', + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() )`); + await pool.query('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ'); + await pool.query('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ'); await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)`); await pool.query(`CREATE TABLE IF NOT EXISTS chat_attachments ( id SERIAL PRIMARY KEY, @@ -1921,13 +1925,16 @@ 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']); + cache.on(CHAT_CHANNEL, 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'; for (const client of [...chatClients]) { if (!chatVisible(client.user, payload.tutor_id)) continue; - try { writeChatFrame(client, 'message', payload); } catch (e) { chatClients.delete(client); } + try { writeChatFrame(client, event, payload); } catch (e) { chatClients.delete(client); } } }); @@ -1955,6 +1962,45 @@ async function chatThreadFor(user, threadId) { return { thread: rows[0] }; } +async function chatMessageFor(user, threadId, messageId) { + const acc = await chatThreadFor(user, threadId); + if (acc.error) return acc; + const mid = parseInt(messageId, 10); + if (!Number.isInteger(mid) || mid < 1) return { error: 400, message: 'Invalid message' }; + const { rows } = await pool.query( + `SELECT id, thread_id, sender_id, body, edited_at, deleted_at, created_at + FROM chat_messages WHERE id = $1 AND thread_id = $2`, + [mid, acc.thread.id] + ); + if (!rows.length) return { error: 404, message: 'Сообщение не найдено' }; + return { thread: acc.thread, message: rows[0] }; +} + +function chatPreviewText(body, files) { + const text = String(body || '').trim(); + if (text) return text.slice(0, 300); + const first = (files || [])[0]; + return first ? `Вложение: ${String(first.name || '').slice(0, 280)}` : null; +} + +async function refreshChatThreadPreview(threadId) { + const { rows } = await pool.query( + `SELECT m.id, m.body, m.created_at, m.deleted_at, + (SELECT a.name FROM chat_attachments a WHERE a.message_id = m.id ORDER BY a.id LIMIT 1) AS file_name + FROM chat_messages m + WHERE m.thread_id = $1 AND m.deleted_at IS NULL + ORDER BY m.id DESC LIMIT 1`, + [threadId] + ); + const last = rows[0]; + const preview = last ? chatPreviewText(last.body, last.file_name ? [{ name: last.file_name }] : []) : null; + await pool.query( + 'UPDATE chat_threads SET last_message_at = $2, last_message_text = $3 WHERE id = $1', + [threadId, last ? last.created_at : null, preview] + ); + return { last_message_at: last ? last.created_at : null, last_message_text: preview || '' }; +} + function chatThreadJson(t, unread) { return { id: t.id, @@ -2038,14 +2084,14 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req, extra = ` AND m.id < $${params.length}`; } const { rows } = await pool.query( - `SELECT m.id, m.sender_id, m.body, m.created_at, u.username, u.name, u.role + `SELECT m.id, m.sender_id, m.body, m.edited_at, m.deleted_at, m.created_at, u.username, u.name, u.role FROM chat_messages m JOIN users u ON u.id = m.sender_id WHERE m.thread_id = $1${extra} ORDER BY m.id DESC LIMIT $${params.length + 1}`, [...params, limit] ); - const ids = rows.map(m => m.id); + const ids = rows.filter(m => !m.deleted_at).map(m => m.id); const byMessage = new Map(); if (ids.length) { const fr = await pool.query( @@ -2066,9 +2112,11 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req, mine: m.sender_id === req.user.id, sender_name: m.name || m.username, sender_role: m.role, - body: m.body, + body: m.deleted_at ? '' : m.body, + edited_at: m.edited_at, + deleted_at: m.deleted_at, created_at: m.created_at, - files: byMessage.get(m.id) || [], + files: m.deleted_at ? [] : (byMessage.get(m.id) || []), })); const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread; res.json({ items, thread: chatThreadJson(acc.thread, unreadCol) }); @@ -2139,7 +2187,7 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, tutor_unread = tutor_unread + $3, admin_unread = admin_unread + $4 WHERE id = $1`, - [acc.thread.id, body.slice(0, 300) || `Вложение: ${files[0].originalname}`, byTutor ? 0 : 1, byTutor ? 1 : 0] + [acc.thread.id, chatPreviewText(body, files), byTutor ? 0 : 1, byTutor ? 1 : 0] ); await client.query('COMMIT'); } catch (e) { @@ -2161,14 +2209,132 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, sender_name: req.user.name || req.user.username, sender_role: req.user.role, body: created.body, + edited_at: null, + deleted_at: null, created_at: created.created_at, files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })), }; - publishChat({ thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, message }); + publishChat({ type: 'message', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, message }); await logAudit(req, 'chat.message.create', { thread_id: acc.thread.id, message_id: created.id, files: frows.length }); res.json({ message }); }); +app.put('/api/chat/threads/:id/messages/:messageId', requireAuth, chatLimiter, async (req, res) => { + try { + const acc = await chatMessageFor(req.user, req.params.id, req.params.messageId); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const message = acc.message; + if (message.deleted_at) return res.status(400).json({ error: 'Сообщение удалено' }); + if (Number(message.sender_id) !== Number(req.user.id)) return res.status(403).json({ error: 'Можно редактировать только свои сообщения' }); + let body; + try { + body = reqStr(req.body?.body, CHAT_BODY_MAX); + } catch { + return res.status(400).json({ error: 'Пустое или слишком длинное сообщение' }); + } + if (body === message.body) return res.json({ message: null, unchanged: true }); + + const { rows } = await pool.query( + `UPDATE chat_messages SET body = $1, edited_at = now() + WHERE id = $2 AND deleted_at IS NULL + RETURNING id, sender_id, body, edited_at, deleted_at, created_at`, + [body, message.id] + ); + if (!rows.length) return res.status(400).json({ error: 'Сообщение удалено' }); + const updated = rows[0]; + const { rows: frows } = await pool.query( + 'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id', + [message.id] + ); + const preview = await refreshChatThreadPreview(acc.thread.id); + const payload = { + id: updated.id, + sender_id: updated.sender_id, + mine: updated.sender_id === req.user.id, + sender_name: req.user.name || req.user.username, + sender_role: req.user.role, + body: updated.body, + edited_at: updated.edited_at, + deleted_at: updated.deleted_at, + created_at: updated.created_at, + files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })), + }; + publishChat({ + type: 'message_update', + thread_id: acc.thread.id, + tutor_id: acc.thread.tutor_id, + message: payload, + preview, + }); + const d = textDiff(message.body, body); + await logAudit(req, 'chat.message.update', { + thread_id: acc.thread.id, + message_id: message.id, + changed: d.changed, + fields: ['body'], + changes: d.changed + ? [{ field: 'body', label: 'Текст сообщения', stats: d.stats, diff: d.segments, truncated: d.truncated }] + : [], + }); + res.json({ message: payload }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.delete('/api/chat/threads/:id/messages/:messageId', requireAuth, chatLimiter, async (req, res) => { + try { + const acc = await chatMessageFor(req.user, req.params.id, req.params.messageId); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const message = acc.message; + if (message.deleted_at) return res.status(404).json({ error: 'Сообщение уже удалено' }); + const isAuthor = Number(message.sender_id) === Number(req.user.id); + if (!isAuthor && req.user.role !== 'admin') return res.status(403).json({ error: 'Можно удалять только свои сообщения' }); + + const { rows: arows } = await pool.query( + 'SELECT path FROM chat_attachments WHERE message_id = $1', + [message.id] + ); + await pool.query('DELETE FROM chat_attachments WHERE message_id = $1', [message.id]); + const { rows } = await pool.query( + `UPDATE chat_messages + SET body = '', deleted_at = now() + WHERE id = $1 AND deleted_at IS NULL + RETURNING id, sender_id, deleted_at, created_at`, + [message.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Сообщение уже удалено' }); + const unreadCol = Number(rows[0].sender_id) === Number(acc.thread.tutor_id) ? 'admin_unread' : 'tutor_unread'; + await pool.query(`UPDATE chat_threads SET ${unreadCol} = GREATEST(${unreadCol} - 1, 0) WHERE id = $1`, [acc.thread.id]); + const preview = await refreshChatThreadPreview(acc.thread.id); + arows.forEach(r => safeUnlink(r.path)); + publishChat({ + type: 'message_delete', + thread_id: acc.thread.id, + tutor_id: acc.thread.tutor_id, + message: { + id: rows[0].id, + sender_id: rows[0].sender_id, + body: '', + edited_at: null, + deleted_at: rows[0].deleted_at, + created_at: rows[0].created_at, + files: [], + }, + preview, + }); + await logAudit(req, 'chat.message.delete', { + thread_id: acc.thread.id, + message_id: message.id, + by_admin: req.user.role === 'admin', + files: arows.length, + }); + res.json({ ok: true, id: message.id }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + app.post('/api/chat/threads/:id/read', requireAuth, 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 }); @@ -3278,8 +3444,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req const eu = await client.query('SELECT 1 FROM users WHERE id = $1', [x.sender_id]); if (!eu.rowCount) continue; await client.query( - 'INSERT INTO chat_messages (id, thread_id, sender_id, body, created_at) VALUES ($1,$2,$3,$4,$5)', - [x.id, x.thread_id, x.sender_id, x.body, x.created_at] + 'INSERT INTO chat_messages (id, thread_id, sender_id, body, edited_at, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [x.id, x.thread_id, x.sender_id, x.body, x.edited_at, x.deleted_at, x.created_at] ); } for (const x of ndata.chat_attachments) {