diff --git a/AGENTS.md b/AGENTS.md index db41dcf..ed7d794 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,6 +158,17 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **Воркер отчётов о занятии не имеет `wake`-эндпоинта ни во внутреннем API, ни в `apiV1`** — он будится неявно через `wakeLessonAiWorker()` из `POST`/`PUT /lesson-reports`, когда `ai_check === true` (строго boolean; строка `"true"` не срабатывает) и `lesson_ai_enabled` ≠ `'false'` - **`api_keys` НЕ входит в бэкап** (как `sessions`), а `POST /api/restore` делает `DELETE FROM api_keys` — после восстановления все внешние ключи мертвы, их надо выпустить заново +### 3g. Чат тьютор ↔ админ (`server.js`, `public/chat.html`, `public/js/chat.js`) +- **Модель — личные диалоги 1:1**, не общая комната: один тред на тьютора (`chat_threads.tutor_id UNIQUE`). Тьютор видит и пишет только свой тред, админ видит все треды и может писать каждому. Тред создаётся лениво: тьютор — первым сообщением или `POST /api/chat/threads` без тела, админ — с `{ user_id }` +- **Таблицы**: `chat_threads` (`tutor_unread`/`admin_unread` — счётчики прочитанного по ролям, `last_message_text` — превью для списка), `chat_messages`, `chat_attachments`. Схема — в `db/init.sql`, `db/migration.sql` и `ensureChatTables()` +- **Отдельный SSE-канал `whatido:chat`**, эндпоинт `GET /api/chat/stream` — **не** общая лента `notifications`. Причина: `notifications` широковещательная (видимость только через `admin_only` + `branch_id`), личная переписка попала бы туда и стала видна посторонним тьюторам. Живой чат держит свой счётчик `setChatUnread()` и бейдж `#sidebarChatBadge` +- **Файлы вложений** — отдельный multer `chatFilesUpload` с allowlist `CHAT_ALLOWED_EXT` (изображения, документы, архивы). Нельзя переиспользовать `adminUpload`: в нём `.html`/`.htm` явно разрешены поверх `BLOCKED_EXT` — для чата это лишняя поверхность. Правило: расширение проверяется по белому списку, а не «не заблокировано» +- **Отдача вложений** — `GET /api/chat/files/:token` под `fileLimiter` + `loadUserByToken`, доступ проверяется `chatVisible(user, tutor_id)`: админ или владелец треда, иначе 403. Изображения отдаются inline (`?thumb=1` — WebP-миниатюра), видео — `?play=1` с `Accept-Ranges`, остальное — attachment. Файл **не** должен попадать в `express.static` или в `api/files/:token` (там доступ только по токену, без проверки участника треда) +- **Вставка из буфера обмена (Ctrl+V)** — обработчик `paste` в `public/js/chat.js`, образец в `js/index.js`. Файл нормализуется через `chatPasteFileName()` (безымянные скриншоты получают имя по `todayIso()`/`nowHm()`), проверяется тем же allowlist и лимитами, что и сервер +- **Очистка**: `purgeOldChat()` по настройке `chat_retention_days` (по умолчанию 365), при старте и раз в сутки. Файлы вложений удаляются через `safeUnlink`, пустые треды — `DELETE ... WHERE NOT EXISTS` +- **`sweepOrphanedUploads()` обязан включать `chat_attachments.path`** — без этого вложения чата снесутся как «сироты» +- **Бэкап**: `chat_threads`/`chat_messages`/`chat_attachments` входят в `BACKUP_TABLES` и `BACKUP_SEQUENCE_TABLES`, нормализуются в `normalizeRestoreData` и вставляются в restore с проверкой FK + ### 4. API Patterns - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется @@ -289,6 +300,11 @@ node diff.selftest.js # unit, no stack needed # External API checks node api-keys.selftest.js # e2e, needs running stack curl -H "X-Api-Key: wsk_..." http://localhost:3003/api/v1/me + +# Chat checks (live tutor↔admin, attachments, isolation) +curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/threads +curl -N "http://localhost:3003/api/chat/stream?token=$TOKEN" # SSE, first frame is `ready` +curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/unread ``` `diff.js` builds the audit payload for text changes: word-level segments @@ -354,6 +370,8 @@ guards against. | `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) | | `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах | | `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | +| `public/chat.html` | Чат тьютор↔админ: список диалогов, лента сообщений, вложения (Ctrl+V) | +| `public/js/chat.js` | Логика чата: загрузка треда/сообщений, отправка, вставка файлов из буфера | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | diff --git a/api.smoketest.js b/api.smoketest.js index 74a8ce3..361351a 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -305,6 +305,46 @@ async function main() { (lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'), (lessonNotifyMeta.data.types || []).map(t => t.key)); + // --- Chat (тьютор ↔ админ) --- + const chatNoAuth = await api('/api/chat/threads'); + ok('chat: список без токена -> 401', chatNoAuth.status === 401, chatNoAuth.status); + const chatThreads = await api('/api/chat/threads', { token }); + ok('chat: список диалогов -> 200', chatThreads.status === 200 && Array.isArray(chatThreads.data.items) && typeof chatThreads.data.unread === 'number', chatThreads.status); + const chatUnreadGet = await api('/api/chat/unread', { token }); + ok('chat: счётчик непрочитанных', chatUnreadGet.status === 200 && typeof chatUnreadGet.data.unread === 'number', chatUnreadGet.data); + const chatStream = await fetch(BASE + '/api/chat/stream?token=' + encodeURIComponent(token)); + ok('chat: SSE открывается', chatStream.status === 200, chatStream.status); + if (chatStream.status === 200) { + const chatReader = chatStream.body.getReader(); + const chatFirst = await chatReader.read(); + const chatText = new TextDecoder().decode(chatFirst.value || new Uint8Array()); + ok('chat: SSE отдаёт ready-кадр', chatText.includes('event: ready') || chatText.includes(':ok'), JSON.stringify(chatText.slice(0, 60))); + chatReader.cancel().catch(() => {}); + } + const chatBadId = await api('/api/chat/threads/not-a-number/messages', { token }); + ok('chat: некорректный id диалога -> 400', chatBadId.status === 400, chatBadId.status); + const chatMissing = await api('/api/chat/threads/99999999/messages', { token }); + ok('chat: несуществующий диалог -> 404', chatMissing.status === 404, chatMissing.status); + const chatAdminThread = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: me1.data.id } }); + ok('chat: админ создаёт диалог с пользователем', chatAdminThread.status === 200 && chatAdminThread.data.thread && chatAdminThread.data.thread.id > 0, chatAdminThread.data); + if (chatAdminThread.status === 200 && chatAdminThread.data.thread) { + const tid = chatAdminThread.data.thread.id; + const chatMsg = await api(`/api/chat/threads/${tid}/messages`, { token, method: 'POST', body: { body: 'Smoke: чат работает' } }); + ok('chat: отправка сообщения', chatMsg.status === 200 && chatMsg.data.message && chatMsg.data.message.id > 0, chatMsg.data); + const chatMsgList = await api(`/api/chat/threads/${tid}/messages`, { token }); + ok('chat: чтение сообщений', chatMsgList.status === 200 && Array.isArray(chatMsgList.data.items) && chatMsgList.data.items.some(m => m.body === 'Smoke: чат работает'), (chatMsgList.data.items || []).map(m => m.body)); + const chatEmpty = await api(`/api/chat/threads/${tid}/messages`, { token, method: 'POST', body: { body: ' ' } }); + ok('chat: пустое сообщение -> 400', chatEmpty.status === 400, chatEmpty.status); + const chatRead = await api(`/api/chat/threads/${tid}/read`, { token, method: 'POST' }); + ok('chat: отметка прочитанным', chatRead.status === 200 && typeof chatRead.data.unread === 'number', chatRead.data); + const chatFileNoAuth = await fetch(BASE + '/api/chat/files/deadbeef'); + ok('chat: файл без токена -> 401', chatFileNoAuth.status === 401, chatFileNoAuth.status); + const chatFileMissing = await api('/api/chat/files/deadbeef', { token }); + ok('chat: несуществующий файл -> 404', chatFileMissing.status === 404, chatFileMissing.status); + } + const chatBadUser = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: 99999999 } }); + ok('chat: несуществующий тьютор -> 404', chatBadUser.status === 404, chatBadUser.status); + const logout = await api('/api/auth/logout', { token, method: 'POST' }); ok('logout', logout.status === 200, logout.status); const afterLogout = await api('/api/auth/me', { token }); diff --git a/backup-restore.js b/backup-restore.js index 309b4a5..dfbded4 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -7,12 +7,13 @@ const BACKUP_TABLES = [ 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', - 'notification_reads', 'banned_ips', + 'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments', ]; const BACKUP_SEQUENCE_TABLES = [ 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', - 'lesson_report_versions', 'audit_log', 'notifications', + 'lesson_report_versions', 'audit_log', 'notifications', 'chat_threads', 'chat_messages', + 'chat_attachments', ]; function isSupportedBackupVersion(data) { @@ -462,7 +463,32 @@ function normalizeRestoreData(data) { created_at: optTs(x.created_at), finished_at: optTs(x.finished_at), })); - return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips }; + const chat_threads = (data.chat_threads || []).map(x => ({ + id: reqInt(x.id), + tutor_id: reqInt(x.tutor_id), + last_message_at: optTs(x.last_message_at), + last_message_text: optStr(x.last_message_text, 300), + tutor_unread: optInt(x.tutor_unread, 0, 2147483647) ?? 0, + admin_unread: optInt(x.admin_unread, 0, 2147483647) ?? 0, + created_at: optTs(x.created_at), + })); + const chat_messages = (data.chat_messages || []).map(x => ({ + id: reqInt(x.id), + thread_id: reqInt(x.thread_id), + sender_id: reqInt(x.sender_id), + body: optStr(x.body, 5000) ?? '', + created_at: optTs(x.created_at), + })); + const chat_attachments = (data.chat_attachments || []).map(x => ({ + id: reqInt(x.id), + message_id: reqInt(x.message_id), + token: reqToken(x.token), + path: reqUploadPath(x.path, 255), + name: reqStr(x.name, 255), + size: optInt(x.size, 0, 2147483647) ?? 0, + created_at: optTs(x.created_at), + })); + return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips, chat_threads, chat_messages, chat_attachments }; } module.exports = { diff --git a/backup.selftest.js b/backup.selftest.js index 4f54cc5..ce670b3 100644 --- a/backup.selftest.js +++ b/backup.selftest.js @@ -70,6 +70,30 @@ ok('notification_reads нормализуются', nt.notification_reads[0].not ok('audit_log нормализуется', nt.audit_log[0].action === 'backup.download' && nt.audit_log[0].target === '{"size":5}', nt.audit_log[0]); ok('banned_ips нормализуются', nt.banned_ips[0].ip === '203.0.113.7'); +const ch = normalizeRestoreData({ + ...base, + chat_threads: [{ id: 3, tutor_id: 1, last_message_text: 'привет', tutor_unread: 2, admin_unread: 1 }], + chat_messages: [{ id: 9, thread_id: 3, sender_id: 1, body: 'привет' }], + chat_attachments: [{ id: 4, message_id: 9, token: 'a'.repeat(32), path: '/uploads/1700000000000-abc123.png', name: 'photo.png', size: 1234 }], +}); +ok('chat_threads нормализуются', ch.chat_threads[0].tutor_id === 1 && ch.chat_threads[0].tutor_unread === 2, ch.chat_threads[0]); +ok('chat_messages нормализуются', ch.chat_messages[0].thread_id === 3 && ch.chat_messages[0].body === 'привет', ch.chat_messages[0]); +ok('chat_attachments нормализуются', ch.chat_attachments[0].name === 'photo.png' && ch.chat_attachments[0].size === 1234, ch.chat_attachments[0]); +let chatBad = false; +try { + normalizeRestoreData({ ...base, chat_messages: [{ id: 1, thread_id: 3, sender_id: 1, body: 'x'.repeat(5001) }] }); +} catch (e) { + chatBad = true; +} +ok('слишком длинный текст сообщения отклоняется', chatBad); +let chatPathBad = false; +try { + normalizeRestoreData({ ...base, chat_attachments: [{ id: 1, message_id: 1, token: 'b'.repeat(32), path: '/etc/passwd', name: 'x' }] }); +} catch (e) { + chatPathBad = true; +} +ok('путь вложения вне uploads отклоняется', chatPathBad); + const badLevel = normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', level: 'drop-table', title: 'T' }] }); ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]); diff --git a/db/init.sql b/db/init.sql index 5fca227..ad364fb 100644 --- a/db/init.sql +++ b/db/init.sql @@ -343,6 +343,42 @@ CREATE TABLE IF NOT EXISTS notification_reads ( CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id); +CREATE TABLE IF NOT EXISTS chat_threads ( + id SERIAL PRIMARY KEY, + tutor_id INT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + last_message_at TIMESTAMPTZ DEFAULT now(), + last_message_text VARCHAR(300), + tutor_unread INT NOT NULL DEFAULT 0, + admin_unread INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chat_threads_last_message ON chat_threads(last_message_at DESC); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id SERIAL PRIMARY KEY, + thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE, + sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id); + +CREATE TABLE IF NOT EXISTS chat_attachments ( + id SERIAL PRIMARY KEY, + message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, + token VARCHAR(64) UNIQUE NOT NULL, + path VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + size INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chat_attachments_message ON chat_attachments(message_id); + +INSERT INTO settings (key, value) VALUES ('chat_retention_days', '365') ON CONFLICT (key) DO NOTHING; + INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') diff --git a/db/migration.sql b/db/migration.sql index 3c31ffd..c1e4402 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -313,6 +313,42 @@ CREATE TABLE IF NOT EXISTS notification_reads ( CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id); +CREATE TABLE IF NOT EXISTS chat_threads ( + id SERIAL PRIMARY KEY, + tutor_id INT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + last_message_at TIMESTAMPTZ DEFAULT now(), + last_message_text VARCHAR(300), + tutor_unread INT NOT NULL DEFAULT 0, + admin_unread INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chat_threads_last_message ON chat_threads(last_message_at DESC); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id SERIAL PRIMARY KEY, + thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE, + sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id); + +CREATE TABLE IF NOT EXISTS chat_attachments ( + id SERIAL PRIMARY KEY, + message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, + token VARCHAR(64) UNIQUE NOT NULL, + path VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + size INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chat_attachments_message ON chat_attachments(message_id); + +INSERT INTO settings (key, value) VALUES ('chat_retention_days', '365') ON CONFLICT (key) DO NOTHING; + INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING; diff --git a/public/admin.css b/public/admin.css index 6ab4b3b..bddbf53 100644 --- a/public/admin.css +++ b/public/admin.css @@ -906,3 +906,90 @@ body.report-card-open .report-fields{display:flex} .student-card__badge{font-size:12px;padding:6px 11px} } +.chat-layout{display:grid;grid-template-columns:300px 1fr;gap:16px;align-items:start} +.chat-layout.chat-layout-solo{grid-template-columns:1fr} +.chat-side{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;max-height:calc(100dvh - 190px)} +.chat-side-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border)} +.chat-side-title{font-size:.85rem;font-weight:600} +.chat-side-head .grow{flex:1} +.chat-new-btn{padding:7px 12px;font-size:.8rem} +.chat-new-btn svg{width:14px;height:14px} +.chat-thread-list{overflow-y:auto;display:flex;flex-direction:column} +.chat-side-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center} +.chat-thread{display:flex;gap:10px;align-items:flex-start;width:100%;text-align:left;background:transparent;border:none;border-bottom:1px solid var(--border);padding:11px 14px;cursor:pointer;font-family:inherit;transition:background .15s} +.chat-thread:last-child{border-bottom:none} +.chat-thread:hover{background:var(--bg)} +.chat-thread.active{background:rgba(37,99,235,.07)} +.chat-thread.unread .chat-thread-name{font-weight:700} +.chat-thread-avatar{width:34px;height:34px;border-radius:10px;background:rgba(37,99,235,.1);color:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.9rem;flex-shrink:0} +.chat-thread-body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1} +.chat-thread-top{display:flex;align-items:center;gap:6px;min-width:0} +.chat-thread-top .grow{flex:1} +.chat-thread-name{font-size:.86rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chat-thread-time{font-size:.7rem;color:var(--muted);flex-shrink:0} +.chat-thread-preview{font-size:.78rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chat-off{font-size:.65rem;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:1px 5px;flex-shrink:0} +.chat-thread-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center} +.chat-pane{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);min-height:420px;display:flex;flex-direction:column;overflow:hidden} +.chat-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:70px 20px;text-align:center;color:var(--muted)} +.chat-empty svg{width:34px;height:34px;opacity:.4} +.chat-empty-title{font-size:.95rem;font-weight:600;color:var(--text)} +.chat-empty-sub{font-size:.82rem} +.chat-main{display:flex;flex-direction:column;min-height:520px} +.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden]{display:none} +.chat-head{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--border)} +.chat-head .grow{flex:1} +.chat-avatar{width:36px;height:36px;border-radius:10px;background:rgba(37,99,235,.1);color:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0} +.chat-head-meta{display:flex;flex-direction:column;min-width:0} +.chat-head-name{font-size:.9rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chat-head-sub{font-size:.75rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.chat-live{display:inline-flex;align-items:center;gap:6px;font-size:.72rem;color:var(--muted);flex-shrink:0} +.chat-live[hidden]{display:none} +.chat-live-dot{width:7px;height:7px;border-radius:50%;background:#22c55e} +.chat-live-dot{width:7px;height:7px;border-radius:50%;background:#22c55e} +.chat-messages{flex:1;min-height:260px;max-height:calc(100dvh - 400px);overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--bg)} +.chat-msgs-empty{margin:auto;color:var(--muted);font-size:.85rem;text-align:center} +.msg{display:flex;flex-direction:column;gap:5px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:10px 13px;max-width:78%;align-self:flex-start} +.msg-mine{align-self:flex-end;background:rgba(37,99,235,.07);border-color:rgba(37,99,235,.25)} +.msg-head{display:flex;align-items:center;gap:7px;font-size:.72rem;color:var(--muted)} +.msg-head .grow{flex:1} +.msg-who{font-weight:600;color:var(--text);font-size:.78rem} +.msg-role{border:1px solid var(--border);border-radius:5px;padding:0 5px;font-size:.65rem} +.msg-time{white-space:nowrap} +.msg-body{font-size:.88rem;line-height:1.45;white-space:pre-wrap;word-break:break-word} +.msg-files{display:flex;flex-wrap:wrap;gap:8px} +.msg-file{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:9px;padding:7px 10px;font-size:.78rem;color:var(--text);text-decoration:none;max-width:230px;min-width:0;transition:border-color .15s} +.msg-file:hover{border-color:var(--accent)} +.msg-file svg{width:16px;height:16px;flex-shrink:0;color:var(--muted)} +.msg-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1} +.msg-file-size{font-size:.68rem;color:var(--muted);flex-shrink:0} +.msg-file-img{flex-direction:column;align-items:stretch;gap:5px;padding:5px;max-width:180px} +.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-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} +.chat-attach-chip svg{width:13px;height:13px;color:var(--muted);flex-shrink:0} +.chat-attach-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.chat-attach-size{color:var(--muted);flex-shrink:0;font-size:.68rem} +.chat-attach-del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.8rem;line-height:1;padding:0 1px} +.chat-attach-del:hover{color:#ef4444} +.chat-compose-row{display:flex;align-items:flex-end;gap:8px} +.chat-attach-btn{width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--muted);cursor:pointer;flex-shrink:0} +.chat-attach-btn:hover{color:var(--accent);border-color:var(--accent)} +.chat-attach-btn svg{width:17px;height:17px} +.chat-input{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:10px;padding:10px 13px;font-size:.88rem;outline:none;font-family:inherit;resize:none;line-height:1.45;max-height:160px;min-height:40px} +.chat-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.12)} +.chat-composer .btn-primary{padding:11px 17px} +.chat-composer .btn-primary:disabled{opacity:.55;cursor:not-allowed} +.chat-composer .btn-primary svg{width:15px;height:15px} +.chat-hint{font-size:.75rem;margin:0} +.chat-new-modal{max-width:420px} + +@media(max-width:900px){ + .chat-layout{grid-template-columns:1fr} + .chat-side{max-height:230px} + .chat-messages{max-height:none} + .msg{max-width:92%} +} diff --git a/public/admin.js b/public/admin.js index 86b63aa..2c8edf4 100644 --- a/public/admin.js +++ b/public/admin.js @@ -52,6 +52,7 @@ function buildSidebar(active) { { page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' }, { page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true }, + { page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'groups', label: 'Группы', icon: 'users' }, { page: 'photos', label: 'Фото', icon: 'Image' }, @@ -74,10 +75,12 @@ function buildSidebar(active) { const renderItem = n => { const isAct = n.page === active; const badgeHtml = n.badge ? ` 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}` : ''; + const chatBadgeHtml = n.chatBadge ? ` 0 ? '' : 'hidden'}>${chatUnread > 99 ? '99+' : chatUnread}` : ''; return ` ${n.label} ${badgeHtml} + ${chatBadgeHtml} `; }; el.innerHTML = ` @@ -133,6 +136,8 @@ function buildSidebar(active) { loadVersion(); loadBrand(); initNotifications(); + loadChatUnread(); + connectChatStream(); } async function loadBrand() { @@ -218,6 +223,57 @@ function setNotifyUnread(n) { } } +let chatUnread = 0; +let chatStream = null; +let chatReconnectTimer = null; + +function setChatUnread(n) { + chatUnread = Number(n) || 0; + const badge = document.getElementById('sidebarChatBadge'); + if (badge) { + badge.textContent = chatUnread > 99 ? '99+' : String(chatUnread); + badge.hidden = chatUnread <= 0; + } +} + +async function loadChatUnread() { + try { + const res = await fetch(`${API}/api/chat/unread`, { headers: hdr() }); + if (!res.ok) return; + setChatUnread((await res.json()).unread); + } catch {} +} + +function connectChatStream() { + if (chatStream) { chatStream.close(); chatStream = null; } + const connect = () => { + const t = sessionStorage.getItem('authToken'); + if (!t) return; + chatStream = new EventSource(`${API}/api/chat/stream?token=${encodeURIComponent(t)}`); + chatStream.addEventListener('ready', ev => { + try { setChatUnread(JSON.parse(ev.data).unread); } catch {} + }); + chatStream.addEventListener('message', ev => { + let p = null; + try { p = JSON.parse(ev.data); } catch { return; } + if (!p || !p.message) return; + const m = p.message; + if (m.sender_id !== currentUser?.id) { + setChatUnread(chatUnread + 1); + showToast(`Новое сообщение в чате: ${(m.sender_name || '').trim() || 'диалог'}`); + } + if (typeof onChatMessage === 'function') onChatMessage(p); + }); + chatStream.onerror = () => { + if (chatStream) { chatStream.close(); chatStream = null; } + clearTimeout(chatReconnectTimer); + chatReconnectTimer = setTimeout(connect, 5000); + }; + }; + connect(); + window.addEventListener('beforeunload', () => { if (chatStream) chatStream.close(); }); +} + function notifyItemHtml(n, expanded) { const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : ''; return ` diff --git a/public/chat.html b/public/chat.html new file mode 100644 index 0000000..2add329 --- /dev/null +++ b/public/chat.html @@ -0,0 +1,85 @@ + + + + + +Чаты — Админ-панель + + + +
+ +
+
+

Чаты

+

Переписка тьюторов с администратором: сообщения, фотографии и документы

+ Загрузка… + +
+ +
+
+
+ Диалоги + + +
+
+
+ +
+
+ +

Диалог не выбран

+

Выберите тьютора слева или начните новый диалог

+
+ + +
+
+
+
+ + + +
+ + + + + + \ No newline at end of file diff --git a/public/js/chat.js b/public/js/chat.js new file mode 100644 index 0000000..d83a5f4 --- /dev/null +++ b/public/js/chat.js @@ -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 ``; +} + +function renderChatThreads() { + const list = document.getElementById('chatThreadList'); + if (!list) return; + if (!chatThreads.length) { + list.innerHTML = '
Диалогов пока нет
'; + return; + } + list.innerHTML = chatThreads.map(chatThreadCardHtml).join(''); +} + +function chatFilesHtml(files) { + if (!files || !files.length) return ''; + return `
${files.map(f => { + if (CHAT_IMAGE_EXT.test(f.name || '')) { + return ` + ${esc(f.name)} + ${esc(f.name)} + `; + } + return ` + + ${esc(f.name)} + ${chatHumanSize(f.size)} + `; + }).join('')}
`; +} + +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 `
+
+ ${esc(who)} + ${role ? `${esc(role)}` : ''} + + ${esc(chatMessageTime(m.created_at))} +
+ ${m.body ? `
${esc(m.body)}
` : ''} + ${chatFilesHtml(m.files)} +
`; +} + +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 = '
Сообщений пока нет. Напишите первым.
'; + 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) => ` + + ${esc(f.name)} + ${chatHumanSize(f.size)} + + `).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 => ``).join('') + || ''; + } 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(); +})(); + diff --git a/redis.js b/redis.js index 8dd8a29..cbc9b8f 100644 --- a/redis.js +++ b/redis.js @@ -107,6 +107,7 @@ function createRedis({ url, prefix } = {}) { const memory = createMemoryBackend(); const handlers = new Map(); const subscribed = new Set(); + const channelListeners = new Map(); const stats = { hits: 0, misses: 0, writes: 0, drops: 0, errors: 0, publishes: 0, fallbackOps: 0 }; const CONNECT_TIMEOUT_MS = Math.max(500, parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000', 10) || 5000); let client = null; @@ -175,12 +176,23 @@ function createRedis({ url, prefix } = {}) { } } + function channelListener(channel) { + let fn = channelListeners.get(channel); + if (!fn) { + fn = message => deliver(channel, message); + channelListeners.set(channel, fn); + } + return fn; + } + async function subscribeChannel(channel) { - if (!sub || !sub.isOpen || subscribed.has(channel)) return; + if (!sub || !sub.isOpen) return; + if (subscribed.has(channel)) return; + subscribed.add(channel); try { - await sub.subscribe(channel, message => deliver(channel, message)); - subscribed.add(channel); + await sub.subscribe(channel, channelListener(channel)); } catch (e) { + subscribed.delete(channel); noteError('subscribe', e); } } diff --git a/server.js b/server.js index 4dc1289..d1b57ea 100644 --- a/server.js +++ b/server.js @@ -419,6 +419,78 @@ async function ensureNotificationsTable() { } } +const CHAT_RETENTION_DEFAULT_DAYS = 365; +const CHAT_BODY_MAX = 5000; +const CHAT_ATTACH_MAX = 10; +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', +]); + +function chatUploadFilter(req, file, cb) { + file.originalname = fixFilename(file.originalname); + const ext = path.extname(file.originalname).toLowerCase(); + if (!CHAT_ALLOWED_EXT.has(ext)) return cb(new Error('Not allowed extension')); + cb(null, true); +} + +async function ensureChatTables() { + await pool.query(`CREATE TABLE IF NOT EXISTS chat_threads ( + id SERIAL PRIMARY KEY, + tutor_id INT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + last_message_at TIMESTAMPTZ DEFAULT now(), + last_message_text VARCHAR(300), + tutor_unread INT NOT NULL DEFAULT 0, + admin_unread INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() + )`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_threads_last_message ON chat_threads(last_message_at DESC)`); + await pool.query(`CREATE TABLE IF NOT EXISTS chat_messages ( + id SERIAL PRIMARY KEY, + thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE, + sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now() + )`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, 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, + token VARCHAR(64) UNIQUE NOT NULL, + path VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + size INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() + )`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_attachments_message ON chat_attachments(message_id)`); + await pool.query( + `INSERT INTO settings (key, value) VALUES ('chat_retention_days', $1) ON CONFLICT (key) DO NOTHING`, + [String(CHAT_RETENTION_DEFAULT_DAYS)] + ); +} + +async function purgeOldChat() { + const raw = parseInt(await getSetting('chat_retention_days', String(CHAT_RETENTION_DEFAULT_DAYS)), 10); + const days = Number.isFinite(raw) && raw >= 1 ? Math.min(raw, 3650) : CHAT_RETENTION_DEFAULT_DAYS; + const { rows } = await pool.query( + `SELECT a.path FROM chat_attachments a + JOIN chat_messages m ON m.id = a.message_id + WHERE m.created_at < now() - ($1 || ' days')::interval`, + [String(days)] + ); + const msgs = await pool.query( + `DELETE FROM chat_messages WHERE created_at < now() - ($1 || ' days')::interval`, + [String(days)] + ); + await pool.query( + `DELETE FROM chat_threads t WHERE NOT EXISTS (SELECT 1 FROM chat_messages m WHERE m.thread_id = t.id)` + ); + rows.forEach(r => safeUnlink(r.path)); + if (msgs.rowCount) console.log(`Chat pruned: ${msgs.rowCount} messages, ${rows.length} files (older than ${days} days)`); +} + function notificationsScope(user) { const s = branchScope(user); if (s.admin) return { cond: '', params: [] }; @@ -1340,7 +1412,7 @@ async function removeEntryFiles(entryId) { async function sweepOrphanedUploads() { const dir = UPLOADS_DIR; try { fs.mkdirSync(dir, { recursive: true }); } catch {} - const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }, { rows: studentAvatars }, { rows: groupCovers }, { rows: originals }, { rows: jobBefore }] = await Promise.all([ + const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }, { rows: studentAvatars }, { rows: groupCovers }, { rows: originals }, { rows: jobBefore }, { rows: chatFiles }] = await Promise.all([ pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'), pool.query('SELECT path AS p FROM project_files'), pool.query('SELECT photo_path AS p FROM group_photos'), @@ -1353,9 +1425,10 @@ async function sweepOrphanedUploads() { pool.query('SELECT cover_path AS p FROM groups WHERE cover_path IS NOT NULL'), pool.query('SELECT photo_original_path AS p FROM entries WHERE photo_original_path IS NOT NULL'), pool.query('SELECT before_path AS p FROM photo_jobs WHERE before_path IS NOT NULL'), + pool.query('SELECT path AS p FROM chat_attachments'), ]); const refs = new Set(); - [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos, ...studentAvatars, ...groupCovers, ...originals, ...jobBefore].forEach(r => { + [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos, ...studentAvatars, ...groupCovers, ...originals, ...jobBefore, ...chatFiles].forEach(r => { const key = storage.keyFromPath(r.p); if (key) refs.add(key); }); @@ -1830,6 +1903,347 @@ app.delete('/api/notifications', requireAdmin, async (req, res) => { res.json({ ok: true, deleted: rowCount }); }); +// --- Chat (тьютор ↔ админ) --- +const CHAT_CHANNEL = 'whatido:chat'; +const chatClients = new Set(); + +function chatVisible(user, tutorId) { + if (!user) return false; + if (user.role === 'admin') return true; + return Number(user.id) === Number(tutorId); +} + +function writeChatFrame(client, event, data) { + client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +function publishChat(payload) { + cache.publish(CHAT_CHANNEL, payload).catch(err => console.error('Chat publish failed:', err.message)); +} + +cache.on(CHAT_CHANNEL, message => { + let payload = null; + try { payload = JSON.parse(message); } catch (e) { return; } + if (!payload || !payload.thread_id) return; + for (const client of [...chatClients]) { + if (!chatVisible(client.user, payload.tutor_id)) continue; + try { writeChatFrame(client, 'message', payload); } catch (e) { chatClients.delete(client); } + } +}); + +const chatLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + store: cache.rateLimitStore('chat', 60 * 1000), + message: { error: 'Слишком много сообщений. Подождите минуту.' }, +}); + +async function chatThreadFor(user, threadId) { + const id = parseInt(threadId, 10); + if (!Number.isInteger(id) || id < 1) return { error: 400, message: 'Invalid thread' }; + const { rows } = await pool.query( + `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.admin_unread, t.created_at, + u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active + FROM chat_threads t JOIN users u ON u.id = t.tutor_id + WHERE t.id = $1`, + [id] + ); + if (!rows.length) return { error: 404, message: 'Диалог не найден' }; + if (user.role !== 'admin' && rows[0].tutor_id !== user.id) return { error: 403, message: 'Нет доступа к диалогу' }; + return { thread: rows[0] }; +} + +function chatThreadJson(t, unread) { + return { + id: t.id, + tutor_id: t.tutor_id, + tutor_username: t.tutor_username, + tutor_name: t.tutor_name || t.tutor_username, + tutor_active: t.tutor_active !== false, + last_message_at: t.last_message_at, + last_message_text: t.last_message_text || '', + unread: unread === undefined ? 0 : unread, + created_at: t.created_at, + }; +} + +async function chatUnread(user) { + if (user.role === 'admin') { + const { rows } = await pool.query('SELECT COALESCE(SUM(admin_unread), 0)::int AS n FROM chat_threads'); + return rows[0] ? rows[0].n : 0; + } + const { rows } = await pool.query('SELECT tutor_unread AS n FROM chat_threads WHERE tutor_id = $1', [user.id]); + return rows.length ? rows[0].n : 0; +} + +app.get('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => { + try { + const isAdmin = req.user.role === 'admin'; + const { rows } = isAdmin + ? await pool.query( + `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.admin_unread, t.created_at, + u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active + FROM chat_threads t JOIN users u ON u.id = t.tutor_id + ORDER BY COALESCE(t.last_message_at, t.created_at) DESC` + ) + : await pool.query( + `SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.created_at, + u.username AS tutor_username, u.name AS tutor_name, u.is_active AS tutor_active + FROM chat_threads t JOIN users u ON u.id = t.tutor_id + WHERE t.tutor_id = $1`, + [req.user.id] + ); + res.json({ items: rows.map(t => chatThreadJson(t, isAdmin ? t.admin_unread : t.tutor_unread)), unread: await chatUnread(req.user) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.post('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => { + try { + let tutorId = req.user.id; + if (req.user.role === 'admin') { + const wanted = parseInt(req.body?.user_id, 10); + if (!Number.isInteger(wanted) || wanted < 1) return res.status(400).json({ error: 'Некорректный пользователь' }); + const { rows } = await pool.query('SELECT id FROM users WHERE id = $1', [wanted]); + if (!rows.length) return res.status(404).json({ error: 'Пользователь не найден' }); + tutorId = rows[0].id; + } + const { rows: ins } = await pool.query( + `INSERT INTO chat_threads (tutor_id) VALUES ($1) + ON CONFLICT (tutor_id) DO UPDATE SET tutor_id = EXCLUDED.tutor_id + RETURNING id`, + [tutorId] + ); + const acc = await chatThreadFor(req.user, ins[0].id); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + res.json({ thread: chatThreadJson(acc.thread, 0) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req, res) => { + try { + const acc = await chatThreadFor(req.user, req.params.id); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 200); + const before = parseInt(req.query.before, 10); + const params = [acc.thread.id]; + let extra = ''; + if (Number.isInteger(before) && before > 0) { + params.push(before); + extra = ` AND m.id < $${params.length}`; + } + const { rows } = await pool.query( + `SELECT m.id, m.sender_id, m.body, m.created_at, u.username, u.name, u.role + FROM chat_messages m JOIN users u ON u.id = m.sender_id + WHERE m.thread_id = $1${extra} + ORDER BY m.id DESC + LIMIT $${params.length + 1}`, + [...params, limit] + ); + const ids = rows.map(m => m.id); + const byMessage = new Map(); + if (ids.length) { + const fr = await pool.query( + `SELECT a.id, a.message_id, a.token, a.name, a.size + FROM chat_attachments a + WHERE a.message_id = ANY($1::int[]) + ORDER BY a.id`, + [ids] + ); + for (const f of fr.rows) { + if (!byMessage.has(f.message_id)) byMessage.set(f.message_id, []); + byMessage.get(f.message_id).push({ id: f.id, token: f.token, name: f.name, size: f.size }); + } + } + const items = rows.reverse().map(m => ({ + id: m.id, + sender_id: m.sender_id, + mine: m.sender_id === req.user.id, + sender_name: m.name || m.username, + sender_role: m.role, + body: m.body, + created_at: m.created_at, + files: byMessage.get(m.id) || [], + })); + const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread; + res.json({ items, thread: chatThreadJson(acc.thread, unreadCol) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +const chatFilesUpload = multer({ + storage: multer.diskStorage({ + destination: (_, __, cb) => { + fs.mkdirSync('uploads', { recursive: true }); + cb(null, 'uploads'); + }, + filename: (_, file, cb) => { + const ext = path.extname(fixFilename(file.originalname)) || '.bin'; + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); + }, + }), + limits: { fileSize: MAX_FILE_UPLOAD_BYTES }, + fileFilter: chatUploadFilter, +}).array('files', CHAT_ATTACH_MAX); + +app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, next) => { + chatFilesUpload(req, res, (err) => { + if (!err) return next(); + if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR }); + if (err.code === 'LIMIT_FILE_COUNT') return res.status(400).json({ error: `Не больше ${CHAT_ATTACH_MAX} файлов` }); + if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' }); + return res.status(400).json({ error: 'Недопустимый файл' }); + }); +}, async (req, res) => { + const acc = await chatThreadFor(req.user, req.params.id); + const files = req.files || []; + if (acc.error) { + files.forEach(removeUpload); + 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: 'Пустое сообщение' }); + const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0); + if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) { + files.forEach(removeUpload); + return res.status(400).json({ error: TOTAL_TOO_LARGE_ERROR }); + } + + const client = await pool.connect(); + let created; + 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] + ); + created = mr.rows[0]; + for (const f of files) { + await convertPhoto(f); + await client.query( + 'INSERT INTO chat_attachments (message_id, token, path, name, size) VALUES ($1, $2, $3, $4, $5)', + [created.id, crypto.randomBytes(16).toString('hex'), `/uploads/${f.filename}`, f.originalname, f.size || 0] + ); + } + const byTutor = req.user.role !== 'admin'; + await client.query( + `UPDATE chat_threads + SET last_message_at = now(), + last_message_text = $2, + tutor_unread = tutor_unread + $3, + admin_unread = admin_unread + $4 + WHERE id = $1`, + [acc.thread.id, body.slice(0, 300) || `Вложение: ${files[0].originalname}`, byTutor ? 0 : 1, byTutor ? 1 : 0] + ); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + files.forEach(removeUpload); + return res.status(500).json({ error: e.message }); + } finally { + client.release(); + } + + const { rows: frows } = await pool.query( + 'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id', + [created.id] + ); + const message = { + id: created.id, + sender_id: created.sender_id, + mine: true, + sender_name: req.user.name || req.user.username, + sender_role: req.user.role, + body: created.body, + created_at: created.created_at, + files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })), + }; + publishChat({ thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, message }); + await logAudit(req, 'chat.message.create', { thread_id: acc.thread.id, message_id: created.id, files: frows.length }); + res.json({ message }); +}); + +app.post('/api/chat/threads/:id/read', requireAuth, chatLimiter, async (req, res) => { + const acc = await chatThreadFor(req.user, req.params.id); + if (acc.error) return res.status(acc.error).json({ error: acc.message }); + const col = req.user.role === 'admin' ? 'admin_unread' : 'tutor_unread'; + await pool.query(`UPDATE chat_threads SET ${col} = 0 WHERE id = $1`, [acc.thread.id]); + res.json({ ok: true, unread: await chatUnread(req.user) }); +}); + +app.get('/api/chat/unread', requireAuth, chatLimiter, async (req, res) => { + res.json({ unread: await chatUnread(req.user) }); +}); + +app.get('/api/chat/stream', async (req, res) => { + let user = null; + try { + user = await loadUserByToken(req.headers['x-auth-token'] || req.query.token); + } catch (e) { + return res.status(500).end(); + } + if (!user || !user.is_active) return res.status(401).end(); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write(':ok\n\n'); + const client = { res, user }; + chatClients.add(client); + chatUnread(user) + .then(unread => writeChatFrame(client, 'ready', { unread })) + .catch(err => console.error('Chat unread failed:', err.message)); + const ping = setInterval(() => { + try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); chatClients.delete(client); } + }, 25000); + req.on('close', () => { clearInterval(ping); chatClients.delete(client); }); +}); + +app.get('/api/chat/files/:token', fileLimiter, async (req, res) => { + let user = null; + try { + user = await loadUserByToken(req.headers['x-auth-token'] || req.query.token); + } catch (e) { + return res.status(500).json({ error: 'Internal server error' }); + } + if (!user || !user.is_active) return res.status(401).json({ error: 'Требуется вход' }); + const { rows } = await pool.query( + `SELECT a.token, a.path, a.name, t.tutor_id + FROM chat_attachments a + JOIN chat_messages m ON m.id = a.message_id + JOIN chat_threads t ON t.id = m.thread_id + WHERE a.token = $1`, + [req.params.token] + ); + if (!rows.length) return res.status(404).json({ error: 'Файл не найден' }); + const r = rows[0]; + if (!chatVisible(user, r.tutor_id)) return res.status(403).json({ error: 'Нет доступа к файлу' }); + const key = storage.keyFromPath(r.path); + if (!key) return res.status(404).json({ error: 'Файл не найден' }); + if (isImageName(r.name)) { + if (req.query.thumb) return sendImageThumb(res, key); + const okImg = await storage.streamTo(res, key, { cacheControl: 'private, max-age=3600' }); + if (!okImg && !res.headersSent) return res.status(404).json({ error: 'Файл не найден' }); + return; + } + if (isPlayableVideoName(r.name) && req.query.play) { + const played = await sendPlayableFile(req, res, key, r.name); + if (!played && !res.headersSent) return res.status(404).json({ error: 'Файл не найден' }); + return; + } + if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) { + return res.status(404).json({ error: 'Файл не найден' }); + } +}); + // --- Users (admin only) --- app.get('/api/users', requireAuth, requireAdmin, async (_, res) => { const { rows } = await pool.query( @@ -2517,7 +2931,7 @@ function sweepBackupStorage() { async function buildBackupArchive() { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { - const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi] = await Promise.all([ + const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca] = await Promise.all([ pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'), @@ -2538,6 +2952,9 @@ async function buildBackupArchive() { pool.query('SELECT * FROM notifications ORDER BY id'), pool.query('SELECT * FROM notification_reads ORDER BY user_id, notification_id'), pool.query('SELECT * FROM banned_ips ORDER BY ip'), + pool.query('SELECT * FROM chat_threads ORDER BY id'), + pool.query('SELECT * FROM chat_messages ORDER BY id'), + pool.query('SELECT * FROM chat_attachments ORDER BY id'), ]); const settings = {}; st.rows.forEach(r => { settings[r.key] = r.value; }); @@ -2552,8 +2969,9 @@ async function buildBackupArchive() { share_links: sl.rowCount, photo_jobs: pj.rowCount, lesson_reports: lr.rowCount, lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount, notification_reads: nr.rowCount, banned_ips: bi.rowCount, + chat_threads: ct.rowCount, chat_messages: cm.rowCount, chat_attachments: ca.rowCount, }, - groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, + groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows, }; fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); const files = await storage.downloadAll(path.join(staging, 'uploads')); @@ -2716,6 +3134,9 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req await client.query('DELETE FROM notification_reads'); await client.query('DELETE FROM notifications'); await client.query('DELETE FROM banned_ips'); + await client.query('DELETE FROM chat_attachments'); + await client.query('DELETE FROM chat_messages'); + await client.query('DELETE FROM chat_threads'); await client.query('DELETE FROM project_files'); await client.query('DELETE FROM lesson_report_versions'); await client.query('DELETE FROM lesson_reports'); @@ -2843,6 +3264,32 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.user_id, x.notification_id, x.read_at] ); } + for (const x of ndata.chat_threads) { + const ex = await client.query('SELECT 1 FROM users WHERE id = $1', [x.tutor_id]); + if (!ex.rowCount) continue; + await client.query( + 'INSERT INTO chat_threads (id, tutor_id, last_message_at, last_message_text, tutor_unread, admin_unread, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [x.id, x.tutor_id, x.last_message_at, x.last_message_text, x.tutor_unread || 0, x.admin_unread || 0, x.created_at] + ); + } + 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; + await client.query( + 'INSERT INTO chat_messages (id, thread_id, sender_id, body, created_at) VALUES ($1,$2,$3,$4,$5)', + [x.id, x.thread_id, x.sender_id, x.body, x.created_at] + ); + } + for (const x of ndata.chat_attachments) { + const ex = await client.query('SELECT 1 FROM chat_messages WHERE id = $1', [x.message_id]); + if (!ex.rowCount) continue; + await client.query( + 'INSERT INTO chat_attachments (id, message_id, token, path, name, size, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [x.id, x.message_id, x.token, x.path, x.name, x.size || 0, x.created_at] + ); + } for (const x of ndata.audit_log) { const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount; await client.query( @@ -7677,8 +8124,11 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); } try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); } try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); } + try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); } try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); } setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref(); + try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); } + setInterval(() => { purgeOldChat().catch(err => console.error('Chat purge:', err)); }, 24 * 60 * 60 * 1000).unref(); try { await pool.query(`INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Camera setting:', err); } try { await pool.query(`INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Trash purge days setting:', err); } try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }