feat(chat): правка и удаление сообщений

- 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
This commit is contained in:
dev
2026-10-05 18:32:58 +03:00
parent 7e4b1a1324
commit 1740c341a0
11 changed files with 478 additions and 12 deletions
+9
View File
@@ -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
+75
View File
@@ -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 });
+11 -1
View File
@@ -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 => ({
+9
View File
@@ -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) }] });
+2
View File
@@ -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()
);
+5
View File
@@ -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,
+15
View File
@@ -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}
+10
View File
@@ -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);
+12
View File
@@ -76,6 +76,18 @@
</div>
</div>
<div class="modal-overlay" id="chatEditModal">
<div class="edit-modal chat-edit-modal">
<h3>Редактировать сообщение</h3>
<textarea id="chatEditText" class="settings-input" maxlength="5000"></textarea>
<div class="hint">Ctrl+Enter — сохранить. Вложения сообщения останутся как есть.</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="chatEditCancel">Отмена</button>
<button type="submit" class="btn-primary" id="chatEditSave">Сохранить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc">
</div>
+155 -2
View File
@@ -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 `<div class="msg msg-deleted ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
<div class="msg-head">
<span class="msg-who">${esc(who)}</span>
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
<span class="grow"></span>
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
</div>
<div class="msg-body msg-deleted-body">Сообщение удалено</div>
</div>`;
}
const canDelete = mine || isAdmin();
return `<div class="msg ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
<div class="msg-head">
<span class="msg-who">${esc(who)}</span>
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
${m.edited_at ? `<span class="msg-edited" title="Изменено ${esc(chatMessageTime(m.edited_at))}">изменено</span>` : ''}
<span class="grow"></span>
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
${canDelete ? `<span class="msg-actions">
${mine ? `<button type="button" class="msg-act" data-msg-edit="${m.id}" title="Редактировать"><i data-lucide="pencil"></i></button>` : ''}
<button type="button" class="msg-act msg-act-del" data-msg-del="${m.id}" title="Удалить"><i data-lucide="trash-2"></i></button>
</span>` : ''}
</div>
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
${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');
});
+175 -9
View File
@@ -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) {