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 `