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
+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');
});