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
+1
View File
@@ -490,6 +490,7 @@ function normalizeRestoreData(data) {
body: optText(x.body, 5000),
edited_at: optTs(x.edited_at),
deleted_at: optTs(x.deleted_at),
reply_to_id: optInt(x.reply_to_id, 1, 2147483647),
created_at: optTs(x.created_at),
}));
const chat_attachments = (data.chat_attachments || []).map(x => ({
+2
View File
@@ -364,10 +364,12 @@ CREATE TABLE IF NOT EXISTS chat_messages (
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()
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id);
CREATE INDEX IF NOT EXISTS idx_chat_messages_reply ON chat_messages(reply_to_id);
CREATE TABLE IF NOT EXISTS chat_attachments (
id SERIAL PRIMARY KEY,
+3
View File
@@ -337,13 +337,16 @@ CREATE TABLE IF NOT EXISTS chat_messages (
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()
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id);
CREATE INDEX IF NOT EXISTS idx_chat_messages_reply ON chat_messages(reply_to_id);
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS reply_to_id INT REFERENCES chat_messages(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS chat_attachments (
id SERIAL PRIMARY KEY,
+24 -1
View File
@@ -982,7 +982,18 @@ body.report-card-open .report-fields{display: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)}
.msg-reply{display:flex;flex-direction:column;gap:1px;border-left:2px solid var(--accent);background:var(--bg);border-radius:0 7px 7px 0;padding:4px 8px;margin-bottom:2px;min-width:0;text-align:left;font:inherit;cursor:pointer;position:relative}
.msg-reply:hover{background:var(--card);border-left-color:var(--accent)}
.msg-reply-img{display:block;width:100%;max-width:280px;max-height:150px;object-fit:cover;border-radius:6px;background:var(--card);margin-bottom:4px}
.msg-reply-who{font-size:.68rem;color:var(--accent);font-weight:600}
.msg-reply-text{font-size:.75rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px}
.msg-reply-text[hidden]{display:none}
.msg-reply-file{display:inline-flex;align-items:center;gap:5px;font-size:.75rem;color:var(--muted);max-width:280px;min-width:0}
.msg-reply-file[hidden]{display:none}
.msg-reply-file svg{width:13px;height:13px;flex-shrink:0}
.msg-reply-file span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.msg-flash{animation:msgFlash 1.4s ease-out}
@keyframes msgFlash{0%,100%{box-shadow:none}12%,55%{box-shadow:0 0 0 3px rgba(37,99,235,.35)}}
.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)}
@@ -997,6 +1008,18 @@ body.report-card-open .report-fields{display:flex}
.msg-file-img img{width:100%;height:110px;object-fit:cover;border-radius:6px;display:block;background:var(--bg)}
.msg-file-img .msg-file-name{font-size:.7rem;color:var(--muted)}
.chat-composer{border-top:1px solid var(--border);padding:11px 14px 13px;display:flex;flex-direction:column;gap:8px}
.chat-reply-bar{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--accent);border-radius:9px;padding:6px 9px;font-size:.78rem}
.chat-reply-bar[hidden]{display:none}
.chat-reply-bar svg{width:14px;height:14px;color:var(--accent);flex-shrink:0}
.chat-reply-meta{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
.chat-reply-who{color:var(--accent);font-weight:600;font-size:.7rem}
.chat-reply-text{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chat-reply-text[hidden]{display:none}
.chat-reply-file{display:inline-flex;align-items:center;gap:5px;color:var(--muted);min-width:0}
.chat-reply-file[hidden]{display:none}
.chat-reply-img{display:block;height:44px;width:44px;object-fit:cover;border-radius:6px;background:var(--card);flex-shrink:0}
.chat-reply-file svg{width:13px;height:13px;flex-shrink:0}
.chat-reply-file span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chat-attach-list{display:flex;flex-wrap:wrap;gap:7px}
.chat-attach-list[hidden]{display:none}
.chat-attach-chip{display:inline-flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:5px 9px;font-size:.75rem;max-width:250px}
+9
View File
@@ -49,6 +49,15 @@
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-composer">
<div class="chat-reply-bar" id="chatReplyBar" hidden>
<i data-lucide="corner-up-left"></i>
<span class="chat-reply-meta">
<span class="chat-reply-who" id="chatReplyWho"></span>
<span class="chat-reply-text" id="chatReplyText"></span>
<span class="chat-reply-file" id="chatReplyFile" hidden></span>
</span>
<button type="button" class="chat-attach-del" id="chatReplyCancel" title="Отменить ответ">✕</button>
</div>
<div class="chat-attach-list" id="chatAttachList" hidden></div>
<div class="chat-compose-row">
<button class="chat-attach-btn" id="chatAttachBtn" type="button" title="Прикрепить файл или фото"><i data-lucide="paperclip"></i></button>
+127 -16
View File
@@ -17,6 +17,7 @@ let chatPendingFiles = [];
let chatLoading = false;
let chatSending = false;
let chatEditId = 0;
let chatReplyTo = null;
let chatHiddenMode = false;
let chatHiddenCount = 0;
@@ -125,17 +126,7 @@ 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>`;
}
if (m.deleted_at) return '';
const canDelete = mine || isAdmin();
return `<div class="msg ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
<div class="msg-head">
@@ -144,16 +135,38 @@ function chatMessageHtml(m) {
${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">
<span class="msg-actions">
<button type="button" class="msg-act" data-msg-reply="${m.id}" title="Ответить"><i data-lucide="corner-up-left"></i></button>
${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>` : ''}
${canDelete ? `<button type="button" class="msg-act msg-act-del" data-msg-del="${m.id}" title="Удалить"><i data-lucide="trash-2"></i></button>` : ''}
</span>
</div>
${chatReplyHtml(m)}
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
${chatFilesHtml(m.files)}
</div>`;
}
function chatReplyHtml(m) {
if (!m.reply_to_id) return '';
const who = m.reply_sender_name || 'Тьютор';
const gone = !!m.reply_deleted_at;
const body = String(m.reply_body || '').trim();
const file = m.reply_file_name ? String(m.reply_file_name) : '';
const img = !gone && file && CHAT_IMAGE_EXT.test(file) && m.reply_file_token
? `<img class="msg-reply-img" src="${chatFileThumbUrl({ token: m.reply_file_token })}" alt="" loading="lazy">`
: '';
const fileLine = !gone && file
? `<span class="msg-reply-file"><i data-lucide="${chatFileIcon(file)}"></i><span title="${esc(file)}">${esc(file)}</span></span>`
: '';
const head = `<span class="msg-reply-who">${esc(who)}</span>`;
const inner = gone
? `${head}<span class="msg-reply-text">Сообщение удалено</span>`
: `${head}${body ? `<span class="msg-reply-text">${esc(body)}</span>` : ''}${fileLine}`;
if (gone) return `<div class="msg-reply">${inner}</div>`;
return `<button type="button" class="msg-reply" data-msg-jump="${m.reply_to_id}" title="Перейти к сообщению">${img}${inner}</button>`;
}
function addChatMessage(m) {
if (!m || m.id === undefined || m.id === null) return false;
if (chatMessages.some(x => Number(x.id) === Number(m.id))) return false;
@@ -161,14 +174,19 @@ function addChatMessage(m) {
return true;
}
function chatVisibleMessages() {
return chatMessages.filter(m => !m.deleted_at);
}
function renderChatMessages() {
const box = document.getElementById('chatMessages');
if (!box) return;
if (!chatMessages.length) {
const visible = chatVisibleMessages();
if (!visible.length) {
box.innerHTML = '<div class="chat-msgs-empty">Сообщений пока нет. Напишите первым.</div>';
return;
}
box.innerHTML = chatMessages.map(chatMessageHtml).join('');
box.innerHTML = visible.map(chatMessageHtml).join('');
renderIcons();
}
@@ -177,6 +195,15 @@ function scrollChatDown() {
if (box) box.scrollTop = box.scrollHeight;
}
function scrollChatToMessage(messageId) {
const box = document.getElementById('chatMessages');
const node = box && box.querySelector(`.msg[data-msg="${messageId}"]`);
if (!node) return;
node.scrollIntoView({ block: 'center', behavior: 'smooth' });
node.classList.add('msg-flash');
setTimeout(() => node.classList.remove('msg-flash'), 1400);
}
function chatAtBottom() {
const box = document.getElementById('chatMessages');
if (!box) return true;
@@ -416,8 +443,59 @@ function autoGrowChatInput(el) {
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}
function renderChatReplyBar() {
const bar = document.getElementById('chatReplyBar');
if (!bar) return;
if (!chatReplyTo) {
bar.hidden = true;
return;
}
const who = document.getElementById('chatReplyWho');
const text = document.getElementById('chatReplyText');
const file = document.getElementById('chatReplyFile');
if (who) who.textContent = chatReplyTo.sender_name || 'Тьютор';
if (text) {
const body = String(chatReplyTo.body || '').trim();
text.textContent = body || (chatReplyTo.file_name ? '' : 'Вложение');
text.hidden = !text.textContent;
}
if (file) {
const fn = chatReplyTo.file_name;
file.innerHTML = !fn
? ''
: CHAT_IMAGE_EXT.test(fn) && chatReplyTo.file_token
? `<img class="chat-reply-img" src="${chatFileThumbUrl({ token: chatReplyTo.file_token })}" alt="">`
: `<i data-lucide="${chatFileIcon(fn)}"></i><span title="${esc(fn)}">${esc(fn)}</span>`;
file.hidden = !fn;
}
bar.hidden = false;
renderIcons();
}
function setChatReply(messageId) {
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
if (!msg || msg.deleted_at) return;
const files = msg.files || [];
chatReplyTo = {
id: Number(msg.id),
sender_name: msg.sender_name,
body: msg.body,
file_name: files.length ? files[0].name : '',
file_token: files.length ? files[0].token : '',
};
renderChatReplyBar();
const input = document.getElementById('chatInput');
if (input) input.focus();
}
function clearChatReply() {
chatReplyTo = null;
renderChatReplyBar();
}
function selectChatThread(id) {
chatActiveId = Number(id) || 0;
clearChatReply();
renderChatThreads();
loadChatMessages();
}
@@ -464,6 +542,14 @@ function onChatMessageUpdate(p) {
} else {
chatMessages[idx] = { ...chatMessages[idx], ...m };
}
for (const x of chatMessages) {
if (Number(x.reply_to_id) === Number(m.id)) {
x.reply_body = m.body || '';
x.reply_sender_name = m.sender_name || x.reply_sender_name;
x.reply_file_name = m.files && m.files.length ? m.files[0].name : null;
x.reply_file_token = m.files && m.files.length ? m.files[0].token : null;
}
}
renderChatMessages();
}
@@ -474,6 +560,15 @@ function onChatMessageDelete(p) {
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 };
for (const x of chatMessages) {
if (Number(x.reply_to_id) === Number(m.id)) {
x.reply_body = '';
x.reply_file_name = null;
x.reply_file_token = null;
x.reply_deleted_at = m.deleted_at || new Date().toISOString();
}
}
if (chatReplyTo && Number(chatReplyTo.id) === Number(m.id)) clearChatReply();
renderChatMessages();
if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead();
}
@@ -552,6 +647,7 @@ async function deleteChatMessage(messageId) {
if (idx >= 0) {
chatMessages[idx] = { ...chatMessages[idx], body: '', files: [], edited_at: null, deleted_at: new Date().toISOString() };
}
if (chatReplyTo && Number(chatReplyTo.id) === Number(messageId)) clearChatReply();
renderChatMessages();
loadChatThreads();
} catch (e) {
@@ -590,10 +686,12 @@ async function sendChatMessage() {
try {
const fd = new FormData();
fd.append('body', body);
if (chatReplyTo) fd.append('reply_to_id', String(chatReplyTo.id));
for (const f of files) fd.append('files', f, f.name);
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages`, { method: 'POST', headers: hdr(), body: fd });
chatPendingFiles = [];
renderChatAttach();
clearChatReply();
input.value = '';
autoGrowChatInput();
const stick = chatAtBottom();
@@ -672,6 +770,16 @@ function initChatPage() {
if (box) {
box.addEventListener('click', e => {
const jumpBtn = e.target.closest('[data-msg-jump]');
if (jumpBtn) {
scrollChatToMessage(Number(jumpBtn.dataset.msgJump));
return;
}
const replyBtn = e.target.closest('[data-msg-reply]');
if (replyBtn) {
setChatReply(Number(replyBtn.dataset.msgReply));
return;
}
const editBtn = e.target.closest('[data-msg-edit]');
if (editBtn) {
openChatEditModal(Number(editBtn.dataset.msgEdit));
@@ -681,6 +789,8 @@ function initChatPage() {
if (delBtn) deleteChatMessage(Number(delBtn.dataset.msgDel));
});
}
const replyCancel = document.getElementById('chatReplyCancel');
if (replyCancel) replyCancel.addEventListener('click', clearChatReply);
if (editCancel) editCancel.addEventListener('click', closeChatEditModal);
if (editSave) editSave.addEventListener('click', submitChatEdit);
if (editModal) {
@@ -780,6 +890,7 @@ function initChatPage() {
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (editModal && editModal.classList.contains('open')) { closeChatEditModal(); return; }
if (chatReplyTo) { clearChatReply(); return; }
if (imgModal && imgModal.classList.contains('open')) { imgModal.classList.remove('open'); return; }
if (newModal && newModal.classList.contains('open')) newModal.classList.remove('open');
});
+1
View File
@@ -157,6 +157,7 @@ function openShortsModal(row) {
document.getElementById('shortsDate').value = row ? row.lesson_date : todayIso();
document.getElementById('shortsTopic').value = row ? (row.topic || '') : '';
document.getElementById('shortsResident').value = row ? (row.resident || '') : '';
document.getElementById('shortsResidentField').style.display = row ? '' : 'none';
document.getElementById('shortsUsed').checked = row ? !!row.used : false;
document.getElementById('shortsUsedAt').value = row && row.used_at ? String(row.used_at).slice(0, 10) : '';
const usedDisplay = row ? '' : 'none';
+1 -1
View File
@@ -68,7 +68,7 @@
</label>
<textarea id="shortsLines" class="settings-input" rows="5" placeholder="Иванов И.И. - Отличная работа на занятии&#10;Петров П.П. — Продолжили изучение Photoshop"></textarea>
</div>
<div class="settings-field">
<div class="settings-field" id="shortsResidentField" style="display:none">
<label>Резидент (для добавления без разбора строк)</label>
<input type="text" id="shortsResident" class="settings-input" placeholder="Иванов И.И.">
</div>
+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]);