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