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:
@@ -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 => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
@@ -68,7 +68,7 @@
|
||||
</label>
|
||||
<textarea id="shortsLines" class="settings-input" rows="5" placeholder="Иванов И.И. - Отличная работа на занятии Петров П.П. — Продолжили изучение 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>
|
||||
|
||||
@@ -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