feat(chat): личный чат тьютор ↔ админ с вложениями

Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat
вместо общей ленты notifications, вложения с белым списком расширений
и вставкой из буфера (Ctrl+V), очистка по chat_retention_days.
Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
dev
2026-10-05 17:30:26 +03:00
parent 3836fc82ef
commit 235fd43af4
12 changed files with 1409 additions and 10 deletions
+529
View File
@@ -0,0 +1,529 @@
const CHAT_PAGE_SIZE = 100;
const CHAT_MAX_FILES = 10;
const CHAT_MAX_FILE_MB = 50;
const CHAT_ALLOWED_EXT = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif', '.jfif',
'.pdf', '.doc', '.docx', '.txt', '.md', '.rtf', '.odt', '.ods',
'.xls', '.xlsx', '.csv', '.ppt', '.pptx',
'.zip', '.rar', '.7z', '.tar', '.gz',
]);
const CHAT_IMAGE_EXT = /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i;
const CHAT_VIDEO_EXT = /\.(?:mp4|m4v|webm|ogv)$/i;
let chatThreads = [];
let chatActiveId = 0;
let chatMessages = [];
let chatPendingFiles = [];
let chatLoading = false;
let chatSending = false;
function chatHumanSize(b) {
const n = Number(b) || 0;
if (n < 1024) return `${n} Б`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} КБ`;
return `${(n / 1048576).toFixed(1)} МБ`;
}
function chatExt(name) {
const m = /\.[^.\\/]+$/.exec(String(name || ''));
return m ? m[0].toLowerCase() : '';
}
function chatFileIcon(name) {
if (CHAT_IMAGE_EXT.test(name || '')) return 'image';
if (CHAT_VIDEO_EXT.test(name || '')) return 'video';
if (/\.(zip|rar|7z)$/i.test(name || '')) return 'archive';
if (/\.pdf$/i.test(name || '')) return 'file-text';
if (/\.(xlsx?|csv)$/i.test(name || '')) return 'sheet';
if (/\.(docx?|rtf|txt|md)$/i.test(name || '')) return 'file-type';
return 'paperclip';
}
function chatFileUrl(f, play) {
const base = `${API}/api/chat/files/${f.token}?token=${encodeURIComponent(token)}`;
if (play && CHAT_VIDEO_EXT.test(f.name || '')) return `${base}&play=1`;
return base;
}
function chatFileThumbUrl(f) {
return `${chatFileUrl(f)}&thumb=1`;
}
function chatThreadTime(ts) {
if (!ts) return '';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
if (diff < 86400000) return fmtTimeOnly(d);
return fmtDateShort(d);
}
function chatThreadCardHtml(t) {
const active = t.id === chatActiveId ? ' active' : '';
const unread = Number(t.unread) || 0;
const preview = (t.last_message_text || '').trim() || 'Нет сообщений';
return `<button type="button" class="chat-thread${active}${unread ? ' unread' : ''}" data-thread="${t.id}">
<span class="chat-thread-avatar">${esc((t.tutor_name || '?').slice(0, 1).toUpperCase())}</span>
<span class="chat-thread-body">
<span class="chat-thread-top">
<span class="chat-thread-name">${esc(t.tutor_name || t.tutor_username)}</span>
${t.tutor_active === false ? '<span class="chat-off" title="Пользователь отключён">отключён</span>' : ''}
<span class="grow"></span>
<span class="chat-thread-time">${esc(chatThreadTime(t.last_message_at || t.created_at))}</span>
</span>
<span class="chat-thread-preview">${esc(preview)}</span>
</span>
${unread ? `<span class="chat-thread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
</button>`;
}
function renderChatThreads() {
const list = document.getElementById('chatThreadList');
if (!list) return;
if (!chatThreads.length) {
list.innerHTML = '<div class="chat-side-empty">Диалогов пока нет</div>';
return;
}
list.innerHTML = chatThreads.map(chatThreadCardHtml).join('');
}
function chatFilesHtml(files) {
if (!files || !files.length) return '';
return `<div class="msg-files">${files.map(f => {
if (CHAT_IMAGE_EXT.test(f.name || '')) {
return `<a class="msg-file msg-file-img" href="${chatFileUrl(f)}" target="_blank" rel="noopener">
<img src="${chatFileThumbUrl(f)}" alt="${esc(f.name)}" loading="lazy">
<span class="msg-file-name">${esc(f.name)}</span>
</a>`;
}
return `<a class="msg-file" href="${chatFileUrl(f, true)}" target="_blank" rel="noopener">
<i data-lucide="${chatFileIcon(f.name)}"></i>
<span class="msg-file-name">${esc(f.name)}</span>
<span class="msg-file-size">${chatHumanSize(f.size)}</span>
</a>`;
}).join('')}</div>`;
}
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' ? 'администратор' : 'тьютор');
return `<div class="msg ${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>
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
${chatFilesHtml(m.files)}
</div>`;
}
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;
chatMessages.push(m);
return true;
}
function renderChatMessages() {
const box = document.getElementById('chatMessages');
if (!box) return;
if (!chatMessages.length) {
box.innerHTML = '<div class="chat-msgs-empty">Сообщений пока нет. Напишите первым.</div>';
return;
}
box.innerHTML = chatMessages.map(chatMessageHtml).join('');
renderIcons();
}
function scrollChatDown() {
const box = document.getElementById('chatMessages');
if (box) box.scrollTop = box.scrollHeight;
}
function chatAtBottom() {
const box = document.getElementById('chatMessages');
if (!box) return true;
return box.scrollHeight - box.scrollTop - box.clientHeight < 120;
}
function chatMessageTime(ts) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
return `${fmtDateShort(d)} ${fmtTimeOnly(d)}`;
}
async function chatApi(path, options) {
const res = await fetch(`${API}${path}`, options);
let data = null;
try { data = await res.json(); } catch {}
if (!res.ok) throw new Error((data && data.error) || `Ошибка ${res.status}`);
return data;
}
async function loadChatThreads() {
try {
const data = await chatApi('/api/chat/threads', { headers: hdr() });
chatThreads = data.items || [];
setChatUnread(data.unread);
renderChatThreads();
} catch {
showToast('Не удалось загрузить диалоги', true);
}
}
function renderChatHeader() {
const thread = chatThreads.find(t => t.id === chatActiveId);
const empty = document.getElementById('chatEmpty');
const main = document.getElementById('chatMain');
if (!thread) {
if (empty) empty.hidden = false;
if (main) main.hidden = true;
return;
}
if (empty) empty.hidden = true;
if (main) main.hidden = false;
const av = document.getElementById('chatAvatar');
const nm = document.getElementById('chatPeerName');
const sub = document.getElementById('chatPeerSub');
if (av) av.textContent = (thread.tutor_name || '?').slice(0, 1).toUpperCase();
if (nm) nm.textContent = isAdmin() ? (thread.tutor_name || thread.tutor_username) : 'Администратор';
if (sub) {
sub.textContent = isAdmin()
? `@${thread.tutor_username}${thread.tutor_active === false ? ' · отключён' : ''}`
: 'Ответ администратора';
}
}
async function loadChatMessages() {
if (!chatActiveId) {
renderChatHeader();
renderChatMessages();
return;
}
if (chatLoading) return;
chatLoading = true;
try {
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages?limit=${CHAT_PAGE_SIZE}`, { headers: hdr() });
chatMessages = data.items || [];
if (data.thread) {
const idx = chatThreads.findIndex(t => t.id === data.thread.id);
if (idx >= 0) chatThreads[idx] = data.thread;
}
renderChatHeader();
renderChatThreads();
renderChatMessages();
scrollChatDown();
await markChatRead();
} catch (e) {
showToast(e.message || 'Не удалось загрузить сообщения', true);
} finally {
chatLoading = false;
}
}
async function markChatRead() {
if (!chatActiveId) return;
try {
const data = await chatApi(`/api/chat/threads/${chatActiveId}/read`, { method: 'POST', headers: hdr() });
setChatUnread(data.unread);
const thread = chatThreads.find(t => t.id === chatActiveId);
if (thread) thread.unread = 0;
renderChatThreads();
} catch {}
}
function chatPasteFileName(file) {
const base = String(file.name || '').trim();
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
const pad = n => String(n).padStart(2, '0');
const d = new Date();
return `Вставка ${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}${chatExt(file.name) || '.png'}`;
}
function renderChatAttach() {
const box = document.getElementById('chatAttachList');
if (!box) return;
if (!chatPendingFiles.length) {
box.hidden = true;
box.innerHTML = '';
return;
}
box.hidden = false;
box.innerHTML = chatPendingFiles.map((f, i) => `<span class="chat-attach-chip">
<i data-lucide="${chatFileIcon(f.name)}"></i>
<span class="chat-attach-name" title="${esc(f.name)}">${esc(f.name)}</span>
<span class="chat-attach-size">${chatHumanSize(f.size)}</span>
<button type="button" class="chat-attach-del" data-remove="${i}" title="Убрать">✕</button>
</span>`).join('');
renderIcons();
}
function addChatFiles(list) {
const skipped = [];
let added = 0;
for (const file of list) {
if (chatPendingFiles.length >= CHAT_MAX_FILES) {
skipped.push(`Не больше ${CHAT_MAX_FILES} файлов`);
break;
}
if (file.size > CHAT_MAX_FILE_MB * 1024 * 1024) {
skipped.push(`«${file.name}» больше ${CHAT_MAX_FILE_MB} МБ`);
continue;
}
if (!CHAT_ALLOWED_EXT.has(chatExt(file.name))) {
skipped.push(`«${file.name}»: недопустимый тип файла`);
continue;
}
chatPendingFiles.push(file);
added++;
}
if (added) renderChatAttach();
if (skipped.length) showToast(skipped[0], true);
return added;
}
function removeChatFile(i) {
chatPendingFiles.splice(i, 1);
renderChatAttach();
}
function autoGrowChatInput() {
const input = document.getElementById('chatInput');
if (!input) return;
input.style.height = 'auto';
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}
function selectChatThread(id) {
chatActiveId = Number(id) || 0;
renderChatThreads();
loadChatMessages();
}
function onChatMessage(p) {
if (!p || !p.message) return;
const threadId = Number(p.thread_id);
const idx = chatThreads.findIndex(t => t.id === threadId);
const m = p.message;
if (idx < 0) {
loadChatThreads();
} else {
chatThreads[idx].last_message_text = m.body || 'Вложение';
chatThreads[idx].last_message_at = m.created_at;
renderChatThreads();
}
if (threadId !== chatActiveId) return;
const stick = chatAtBottom();
addChatMessage(m);
renderChatMessages();
if (stick) scrollChatDown();
markChatRead();
}
async function openChatNewModal() {
const modal = document.getElementById('chatNewModal');
const select = document.getElementById('chatNewUser');
if (!modal || !select) return;
try {
const users = await chatApi('/api/users', { headers: hdr() });
const tutors = (users || []).filter(u => u.role !== 'admin' && u.is_active !== false);
const known = new Set(chatThreads.map(t => t.tutor_id));
const rest = tutors.filter(u => !known.has(u.id));
const pool = rest.length ? rest : tutors;
select.innerHTML = pool.map(u => `<option value="${u.id}">${esc(u.name || u.username)} (@${esc(u.username)})</option>`).join('')
|| '<option value="">Нет доступных тьюторов</option>';
} catch (e) {
showToast(e.message || 'Не удалось загрузить пользователей', true);
return;
}
modal.classList.add('open');
}
async function sendChatMessage() {
if (chatSending || !chatActiveId) return;
const input = document.getElementById('chatInput');
const body = String(input.value || '').trim();
const files = chatPendingFiles.slice();
if (!body && !files.length) return;
chatSending = true;
const sendBtn = document.getElementById('chatSendBtn');
if (sendBtn) sendBtn.disabled = true;
try {
const fd = new FormData();
fd.append('body', body);
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();
input.value = '';
autoGrowChatInput();
const stick = chatAtBottom();
if (data.message) addChatMessage(data.message);
const thread = chatThreads.find(t => t.id === chatActiveId);
if (thread) {
thread.last_message_text = (data.message && data.message.body) || `Вложение: ${files[0] ? files[0].name : ''}`;
thread.last_message_at = data.message ? data.message.created_at : thread.last_message_at;
}
renderChatThreads();
renderChatMessages();
if (stick) scrollChatDown();
} catch (e) {
showToast(e.message || 'Сообщение не отправлено', true);
} finally {
chatSending = false;
if (sendBtn) sendBtn.disabled = false;
input.focus();
}
}
async function submitChatNew() {
const select = document.getElementById('chatNewUser');
const modal = document.getElementById('chatNewModal');
const userId = Number(select.value);
if (!userId) {
showToast('Выберите тьютора', true);
return;
}
try {
const data = await chatApi('/api/chat/threads', { method: 'POST', headers: hdrJson(), body: JSON.stringify({ user_id: userId }) });
if (modal) modal.classList.remove('open');
if (data.thread) {
if (!chatThreads.some(t => t.id === data.thread.id)) chatThreads.unshift(data.thread);
renderChatThreads();
selectChatThread(data.thread.id);
}
} catch (e) {
showToast(e.message || 'Не удалось создать диалог', true);
}
}
async function ensureOwnThread() {
try {
const data = await chatApi('/api/chat/threads', { method: 'POST', headers: hdrJson(), body: '{}' });
if (data.thread) {
if (!chatThreads.some(t => t.id === data.thread.id)) {
chatThreads.unshift(data.thread);
renderChatThreads();
}
return data.thread.id;
}
} catch {}
return 0;
}
function initChatPage() {
const list = document.getElementById('chatThreadList');
const input = document.getElementById('chatInput');
const fileInput = document.getElementById('chatFileInput');
const attachBtn = document.getElementById('chatAttachBtn');
const sendBtn = document.getElementById('chatSendBtn');
const reloadBtn = document.getElementById('chatReloadBtn');
const newBtn = document.getElementById('chatNewBtn');
const newModal = document.getElementById('chatNewModal');
const newCancel = document.getElementById('chatNewCancel');
const newOk = document.getElementById('chatNewOk');
const attachBox = document.getElementById('chatAttachList');
if (list) {
list.addEventListener('click', e => {
const btn = e.target.closest('[data-thread]');
if (!btn) return;
selectChatThread(Number(btn.dataset.thread));
});
}
if (attachBox) {
attachBox.addEventListener('click', e => {
const btn = e.target.closest('[data-remove]');
if (btn) removeChatFile(Number(btn.dataset.remove));
});
}
if (sendBtn) sendBtn.addEventListener('click', sendChatMessage);
if (reloadBtn) reloadBtn.addEventListener('click', () => { loadChatThreads(); loadChatMessages(); });
if (attachBtn) attachBtn.addEventListener('click', () => fileInput && fileInput.click());
if (fileInput) {
fileInput.addEventListener('change', () => {
addChatFiles([...fileInput.files]);
fileInput.value = '';
});
}
if (input) {
input.addEventListener('input', autoGrowChatInput);
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
});
}
if (newBtn) newBtn.addEventListener('click', openChatNewModal);
if (newCancel) newCancel.addEventListener('click', () => newModal && newModal.classList.remove('open'));
if (newModal) {
newModal.addEventListener('click', e => { if (e.target === newModal) newModal.classList.remove('open'); });
}
if (newOk) newOk.addEventListener('click', submitChatNew);
document.addEventListener('paste', e => {
if (!chatActiveId) return;
const active = document.activeElement;
if (active && active !== input && active !== document.body && !(attachBox && attachBox.contains(active))) return;
const clip = e.clipboardData;
if (!clip) return;
const pasted = [];
for (const item of Array.from(clip.items || [])) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) pasted.push(file);
}
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
if (!pasted.length) return;
e.preventDefault();
const normalized = pasted.map(raw => {
const name = chatPasteFileName(raw);
if (raw.name === name) return raw;
return new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
});
if (addChatFiles(normalized)) showToast(normalized.length === 1 ? 'Файл прикреплён' : `Файлов прикреплено: ${normalized.length}`);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && newModal && newModal.classList.contains('open')) newModal.classList.remove('open');
});
if (newBtn) newBtn.hidden = !isAdmin();
const side = document.getElementById('chatSide');
const layout = document.getElementById('chatLayout');
if (!isAdmin() && side) side.hidden = true;
if (!isAdmin() && layout) layout.classList.add('chat-layout-solo');
const emptySub = document.querySelector('#chatEmpty .chat-empty-sub');
if (emptySub && !isAdmin()) emptySub.textContent = 'Напишите администратору — сообщения доставятся мгновенно';
loadChatThreads().then(async () => {
if (!isAdmin()) {
const id = chatThreads[0] ? chatThreads[0].id : await ensureOwnThread();
if (id) selectChatThread(id);
else renderChatHeader();
} else if (chatThreads[0]) {
selectChatThread(chatThreads[0].id);
} else {
renderChatHeader();
renderChatMessages();
}
});
}
(async function () {
if (!(await checkAuth())) return;
buildSidebar(document.body.dataset.page);
const status = document.getElementById('authStatus');
if (status) {
status.textContent = '';
status.classList.add('loaded');
}
initChatPage();
})();