diff --git a/backup-restore.js b/backup-restore.js index 65da24a..03d56f6 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -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 => ({ diff --git a/db/init.sql b/db/init.sql index 19839d8..ebdf74f 100644 --- a/db/init.sql +++ b/db/init.sql @@ -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, diff --git a/db/migration.sql b/db/migration.sql index fd83b61..d57b08b 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -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, diff --git a/public/admin.css b/public/admin.css index c7a720b..6c04ce3 100644 --- a/public/admin.css +++ b/public/admin.css @@ -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} diff --git a/public/chat.html b/public/chat.html index 18f7fed..877708d 100644 --- a/public/chat.html +++ b/public/chat.html @@ -49,6 +49,15 @@
+
diff --git a/public/js/chat.js b/public/js/chat.js index 41b8e57..a859fa8 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -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 `
-
- ${esc(who)} - ${role ? `${esc(role)}` : ''} - - ${esc(chatMessageTime(m.created_at))} -
-
Сообщение удалено
-
`; - } + if (m.deleted_at) return ''; const canDelete = mine || isAdmin(); return `
@@ -144,16 +135,38 @@ function chatMessageHtml(m) { ${m.edited_at ? `изменено` : ''} ${esc(chatMessageTime(m.created_at))} - ${canDelete ? ` + + ${mine ? `` : ''} - - ` : ''} + ${canDelete ? `` : ''} +
+ ${chatReplyHtml(m)} ${m.body ? `
${esc(m.body)}
` : ''} ${chatFilesHtml(m.files)}
`; } +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 + ? `` + : ''; + const fileLine = !gone && file + ? `${esc(file)}` + : ''; + const head = `${esc(who)}`; + const inner = gone + ? `${head}Сообщение удалено` + : `${head}${body ? `${esc(body)}` : ''}${fileLine}`; + if (gone) return `
${inner}
`; + return ``; +} + 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 = '
Сообщений пока нет. Напишите первым.
'; 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 + ? `` + : `${esc(fn)}`; + 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'); }); diff --git a/public/js/shorts.js b/public/js/shorts.js index 0b06d7c..77df3fa 100644 --- a/public/js/shorts.js +++ b/public/js/shorts.js @@ -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'; diff --git a/public/shorts.html b/public/shorts.html index cdf2699..53eab90 100644 --- a/public/shorts.html +++ b/public/shorts.html @@ -68,7 +68,7 @@
-
+ diff --git a/server.js b/server.js index 6015f06..3794529 100644 --- a/server.js +++ b/server.js @@ -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]);