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
+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) {