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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user