feat(chat): ответы на сообщения с цитатой и переходом к оригиналу

Кнопка «Ответить» у каждого сообщения: цитата показывается в новом
сообщении и в поле ввода, отмена — ✕ или Escape, переключение треда
сбрасывает ответ. Клик по цитате прокручивает ленту к исходному
сообщению и подсвечивает его.

Цитата повторяет вложение оригинала: имя файла с иконкой, для
картинок — миниатюра WebP через существующий /api/chat/files/:token.

- chat_messages.reply_to_id: FK на саму таблицу с ON DELETE SET NULL,
  миграция и ensureChatTables, индекс
- POST .../messages принимает reply_to_id и проверяет, что родитель
  в этом же треде и не удалён; вложения при ошибке подчищаются
- цитата едет в списке, при отправке и в SSE правки/удаления, так что
  у собеседника она обновляется по живому каналу
- бэкап: reply_to_id в normalizeRestoreData, при restore ссылка
  принимается только если родитель восстановился, иначе NULL
- shorts: скрывать поле «Резидент» при добавлении без разбора строк
This commit is contained in:
dev
2026-10-06 00:33:17 +03:00
parent 85b737c0aa
commit 3ac54f57b4
9 changed files with 258 additions and 26 deletions
+90 -8
View File
@@ -463,11 +463,14 @@ async function ensureChatTables() {
body TEXT NOT NULL DEFAULT '',
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
reply_to_id INT REFERENCES chat_messages(id) ON DELETE SET NULL,
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('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS reply_to_id INT REFERENCES chat_messages(id) ON DELETE SET NULL');
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_reply ON chat_messages(reply_to_id)`);
await pool.query(`CREATE TABLE IF NOT EXISTS chat_attachments (
id SERIAL PRIMARY KEY,
message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
@@ -2029,7 +2032,17 @@ async function chatMessageFor(user, threadId, messageId) {
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
`SELECT id, thread_id, sender_id, body, edited_at, deleted_at, reply_to_id, created_at,
(SELECT rm.body FROM chat_messages rm WHERE rm.id = chat_messages.reply_to_id) AS reply_body,
(SELECT rm.deleted_at FROM chat_messages rm WHERE rm.id = chat_messages.reply_to_id) AS reply_deleted_at,
(SELECT a.name FROM chat_attachments a
WHERE a.message_id = chat_messages.reply_to_id ORDER BY a.id LIMIT 1) AS reply_file_name,
(SELECT a.token FROM chat_attachments a
WHERE a.message_id = chat_messages.reply_to_id ORDER BY a.id LIMIT 1) AS reply_file_token,
(SELECT ru.name FROM chat_messages rm JOIN users ru ON ru.id = rm.sender_id
WHERE rm.id = chat_messages.reply_to_id) AS reply_name,
(SELECT ru.username FROM chat_messages rm JOIN users ru ON ru.id = rm.sender_id
WHERE rm.id = chat_messages.reply_to_id) AS reply_username
FROM chat_messages WHERE id = $1 AND thread_id = $2`,
[mid, acc.thread.id]
);
@@ -2156,8 +2169,15 @@ 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.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
`SELECT m.id, m.sender_id, m.body, m.edited_at, m.deleted_at, m.created_at, u.username, u.name, u.role,
m.reply_to_id, rm.body AS reply_body, rm.deleted_at AS reply_deleted_at,
ru.name AS reply_name, ru.username AS reply_username,
(SELECT a.name FROM chat_attachments a WHERE a.message_id = rm.id ORDER BY a.id LIMIT 1) AS reply_file_name,
(SELECT a.token FROM chat_attachments a WHERE a.message_id = rm.id ORDER BY a.id LIMIT 1) AS reply_file_token
FROM chat_messages m
JOIN users u ON u.id = m.sender_id
LEFT JOIN chat_messages rm ON rm.id = m.reply_to_id
LEFT JOIN users ru ON ru.id = rm.sender_id
WHERE m.thread_id = $1${extra}
ORDER BY m.id DESC
LIMIT $${params.length + 1}`,
@@ -2188,6 +2208,12 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req,
edited_at: m.edited_at,
deleted_at: m.deleted_at,
created_at: m.created_at,
reply_to_id: m.reply_to_id,
reply_body: m.reply_deleted_at ? '' : m.reply_body || '',
reply_deleted_at: m.reply_deleted_at,
reply_sender_name: m.reply_deleted_at ? '' : (m.reply_name || m.reply_username || 'Тьютор'),
reply_file_name: m.reply_deleted_at ? null : (m.reply_file_name || null),
reply_file_token: m.reply_deleted_at ? null : (m.reply_file_token || null),
files: m.deleted_at ? [] : (byMessage.get(m.id) || []),
}));
const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread;
@@ -2284,7 +2310,27 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
return res.status(acc.error).json({ error: acc.message });
}
const body = String(req.body?.body || '').trim().slice(0, CHAT_BODY_MAX);
if (!body && !files.length) return res.status(400).json({ error: 'Пустое сообщение' });
if (!body && !files.length) {
files.forEach(removeUpload);
return res.status(400).json({ error: 'Пустое сообщение' });
}
let replyToId = null;
const rawReply = req.body?.reply_to_id;
if (rawReply !== undefined && rawReply !== null && rawReply !== '') {
replyToId = parseInt(rawReply, 10);
if (!Number.isInteger(replyToId) || replyToId < 1) {
files.forEach(removeUpload);
return res.status(400).json({ error: 'Некорректное сообщение для ответа' });
}
const rr = await pool.query(
'SELECT id FROM chat_messages WHERE id = $1 AND thread_id = $2 AND deleted_at IS NULL',
[replyToId, acc.thread.id]
);
if (!rr.rows.length) {
files.forEach(removeUpload);
return res.status(404).json({ error: 'Сообщение для ответа не найдено' });
}
}
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
files.forEach(removeUpload);
@@ -2297,8 +2343,9 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
try {
await client.query('BEGIN');
const mr = await client.query(
'INSERT INTO chat_messages (thread_id, sender_id, body) VALUES ($1, $2, $3) RETURNING id, sender_id, body, created_at',
[acc.thread.id, req.user.id, body]
`INSERT INTO chat_messages (thread_id, sender_id, body, reply_to_id)
VALUES ($1, $2, $3, $4) RETURNING id, sender_id, body, reply_to_id, created_at`,
[acc.thread.id, req.user.id, body, replyToId]
);
created = mr.rows[0];
for (const f of files) {
@@ -2333,6 +2380,26 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id',
[created.id]
);
let replyBody = '';
let replySenderName = '';
let replyFileName = null;
let replyFileToken = null;
if (replyToId) {
const { rows: rrows } = await pool.query(
`SELECT m.body, m.deleted_at, u.name, u.username,
(SELECT a.name FROM chat_attachments a WHERE a.message_id = m.id ORDER BY a.id LIMIT 1) AS file_name,
(SELECT a.token FROM chat_attachments a WHERE a.message_id = m.id ORDER BY a.id LIMIT 1) AS file_token
FROM chat_messages m JOIN users u ON u.id = m.sender_id
WHERE m.id = $1`,
[replyToId]
);
if (rrows.length && !rrows[0].deleted_at) {
replyBody = rrows[0].body || '';
replySenderName = rrows[0].name || rrows[0].username || 'Тьютор';
replyFileName = rrows[0].file_name || null;
replyFileToken = rrows[0].file_token || null;
}
}
const message = {
id: created.id,
sender_id: created.sender_id,
@@ -2343,6 +2410,12 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
edited_at: null,
deleted_at: null,
created_at: created.created_at,
reply_to_id: created.reply_to_id,
reply_body: replyBody,
reply_deleted_at: null,
reply_sender_name: replySenderName,
reply_file_name: replyFileName,
reply_file_token: replyFileToken,
files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })),
};
publishChat({ type: 'message', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, message });
@@ -2388,6 +2461,12 @@ app.put('/api/chat/threads/:id/messages/:messageId', requireAuth, chatLimiter, a
edited_at: updated.edited_at,
deleted_at: updated.deleted_at,
created_at: updated.created_at,
reply_to_id: updated.reply_to_id,
reply_body: message.reply_body || '',
reply_deleted_at: message.reply_deleted_at || null,
reply_sender_name: message.reply_sender_name || '',
reply_file_name: message.reply_file_name || null,
reply_file_token: message.reply_file_token || null,
files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })),
};
publishChat({
@@ -3812,15 +3891,18 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.tutor_id, x.last_message_at, x.last_message_text, x.tutor_unread || 0, x.admin_unread || 0, x.deleted_at || null, x.purge_at || null, x.created_at]
);
}
const restoredMsgIds = new Set();
for (const x of ndata.chat_messages) {
const ex = await client.query('SELECT 1 FROM chat_threads WHERE id = $1', [x.thread_id]);
if (!ex.rowCount) continue;
const eu = await client.query('SELECT 1 FROM users WHERE id = $1', [x.sender_id]);
if (!eu.rowCount) continue;
const replyTo = x.reply_to_id && restoredMsgIds.has(x.reply_to_id) ? x.reply_to_id : null;
await client.query(
'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]
'INSERT INTO chat_messages (id, thread_id, sender_id, body, edited_at, deleted_at, reply_to_id, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
[x.id, x.thread_id, x.sender_id, x.body, x.edited_at, x.deleted_at, replyTo, x.created_at]
);
restoredMsgIds.add(x.id);
}
for (const x of ndata.chat_attachments) {
const ex = await client.query('SELECT 1 FROM chat_messages WHERE id = $1', [x.message_id]);