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
+18
View File
@@ -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'` - **Воркер отчётов о занятии не имеет `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` — после восстановления все внешние ключи мертвы, их надо выпустить заново - **`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 ### 4. API Patterns
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **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` и фильтр не добавляется - **Филиалы**: `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 # External API checks
node api-keys.selftest.js # e2e, needs running stack node api-keys.selftest.js # e2e, needs running stack
curl -H "X-Api-Key: wsk_..." http://localhost:3003/api/v1/me 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 `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/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
| `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах | | `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах |
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | | `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
| `public/chat.html` | Чат тьютор↔админ: список диалогов, лента сообщений, вложения (Ctrl+V) |
| `public/js/chat.js` | Логика чата: загрузка треда/сообщений, отправка, вставка файлов из буфера |
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
+40
View File
@@ -305,6 +305,46 @@ async function main() {
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'), (lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
(lessonNotifyMeta.data.types || []).map(t => t.key)); (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' }); const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status); ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token }); const afterLogout = await api('/api/auth/me', { token });
+29 -3
View File
@@ -7,12 +7,13 @@ const BACKUP_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches', 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches',
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', '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 = [ const BACKUP_SEQUENCE_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos',
'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', '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) { function isSupportedBackupVersion(data) {
@@ -462,7 +463,32 @@ function normalizeRestoreData(data) {
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
finished_at: optTs(x.finished_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 = { module.exports = {
+24
View File
@@ -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('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'); 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' }] }); 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]); ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]);
+36
View File
@@ -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 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') INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30')
+36
View File
@@ -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 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_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_retention_days', '30') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING;
+87
View File
@@ -906,3 +906,90 @@ body.report-card-open .report-fields{display:flex}
.student-card__badge{font-size:12px;padding:6px 11px} .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%}
}
+56
View File
@@ -52,6 +52,7 @@ function buildSidebar(active) {
{ page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' }, { page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true }, { page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' }, { page: 'groups', label: 'Группы', icon: 'users' },
{ page: 'photos', label: 'Фото', icon: 'Image' }, { page: 'photos', label: 'Фото', icon: 'Image' },
@@ -74,10 +75,12 @@ function buildSidebar(active) {
const renderItem = n => { const renderItem = n => {
const isAct = n.page === active; const isAct = n.page === active;
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : ''; const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
const chatBadgeHtml = n.chatBadge ? `<span class="sidebar-badge" id="sidebarChatBadge" ${chatUnread > 0 ? '' : 'hidden'}>${chatUnread > 99 ? '99+' : chatUnread}</span>` : '';
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}"> return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
<i data-lucide="${n.icon}"></i> <i data-lucide="${n.icon}"></i>
<span class="nav-text">${n.label}</span> <span class="nav-text">${n.label}</span>
${badgeHtml} ${badgeHtml}
${chatBadgeHtml}
</a>`; </a>`;
}; };
el.innerHTML = ` el.innerHTML = `
@@ -133,6 +136,8 @@ function buildSidebar(active) {
loadVersion(); loadVersion();
loadBrand(); loadBrand();
initNotifications(); initNotifications();
loadChatUnread();
connectChatStream();
} }
async function loadBrand() { 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) { function notifyItemHtml(n, expanded) {
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : ''; const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}"> return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Чаты — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="chat">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Чаты</h2>
<p class="page-sub">Переписка тьюторов с администратором: сообщения, фотографии и документы</p>
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
<button class="head-btn" id="chatReloadBtn" type="button" title="Обновить диалоги"><i data-lucide="rotate-ccw"></i> Обновить</button>
</div>
<div class="chat-layout" id="chatLayout">
<div class="chat-side" id="chatSide">
<div class="chat-side-head">
<span class="chat-side-title">Диалоги</span>
<span class="grow"></span>
<button class="btn-primary ghost chat-new-btn" id="chatNewBtn" type="button" hidden title="Начать диалог с тьютором"><i data-lucide="user-plus"></i> Начать диалог</button>
</div>
<div class="chat-thread-list" id="chatThreadList"></div>
</div>
<div class="chat-pane" id="chatPane">
<div class="chat-empty" id="chatEmpty">
<i data-lucide="messages-square"></i>
<p class="chat-empty-title">Диалог не выбран</p>
<p class="chat-empty-sub">Выберите тьютора слева или начните новый диалог</p>
</div>
<div class="chat-main" id="chatMain" hidden>
<div class="chat-head">
<span class="chat-avatar" id="chatAvatar"></span>
<div class="chat-head-meta">
<div class="chat-head-name" id="chatPeerName"></div>
<div class="chat-head-sub" id="chatPeerSub"></div>
</div>
<span class="grow"></span>
</div>
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-composer">
<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>
<input type="file" id="chatFileInput" multiple hidden>
<textarea class="chat-input" id="chatInput" rows="1" placeholder="Сообщение… (Ctrl+V — вставить фото)" maxlength="5000"></textarea>
<button class="btn-primary" id="chatSendBtn" type="button"><i data-lucide="send"></i> Отправить</button>
</div>
<div class="hint chat-hint" id="chatHint">Можно вставить скриншот или фото прямо из буфера обмена — Ctrl+V. Фото, документы и архивы, до 10 штук.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="chatNewModal">
<div class="edit-modal chat-new-modal">
<h3>Начать диалог</h3>
<div class="settings-field">
<label>Тьютор</label>
<select id="chatNewUser" class="settings-input"></select>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="chatNewCancel">Отмена</button>
<button type="submit" class="btn-primary" id="chatNewOk">Открыть диалог</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/chat.js"></script>
</body>
</html>
+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();
})();
+15 -3
View File
@@ -107,6 +107,7 @@ function createRedis({ url, prefix } = {}) {
const memory = createMemoryBackend(); const memory = createMemoryBackend();
const handlers = new Map(); const handlers = new Map();
const subscribed = new Set(); 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 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); const CONNECT_TIMEOUT_MS = Math.max(500, parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000', 10) || 5000);
let client = null; 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) { 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 { try {
await sub.subscribe(channel, message => deliver(channel, message)); await sub.subscribe(channel, channelListener(channel));
subscribed.add(channel);
} catch (e) { } catch (e) {
subscribed.delete(channel);
noteError('subscribe', e); noteError('subscribe', e);
} }
} }
+454 -4
View File
@@ -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) { function notificationsScope(user) {
const s = branchScope(user); const s = branchScope(user);
if (s.admin) return { cond: '', params: [] }; if (s.admin) return { cond: '', params: [] };
@@ -1340,7 +1412,7 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() { async function sweepOrphanedUploads() {
const dir = UPLOADS_DIR; const dir = UPLOADS_DIR;
try { fs.mkdirSync(dir, { recursive: true }); } catch {} 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 photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'), pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'), 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 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 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 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(); 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); const key = storage.keyFromPath(r.p);
if (key) refs.add(key); if (key) refs.add(key);
}); });
@@ -1830,6 +1903,347 @@ app.delete('/api/notifications', requireAdmin, async (req, res) => {
res.json({ ok: true, deleted: rowCount }); 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) --- // --- Users (admin only) ---
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => { app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -2517,7 +2931,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() { async function buildBackupArchive() {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try { 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 groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries 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 notifications ORDER BY id'),
pool.query('SELECT * FROM notification_reads ORDER BY user_id, notification_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 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 = {}; const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; }); 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, share_links: sl.rowCount, photo_jobs: pj.rowCount, lesson_reports: lr.rowCount,
lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount, lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount,
notification_reads: nr.rowCount, banned_ips: bi.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)); fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
const files = await storage.downloadAll(path.join(staging, 'uploads')); 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 notification_reads');
await client.query('DELETE FROM notifications'); await client.query('DELETE FROM notifications');
await client.query('DELETE FROM banned_ips'); 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 project_files');
await client.query('DELETE FROM lesson_report_versions'); await client.query('DELETE FROM lesson_report_versions');
await client.query('DELETE FROM lesson_reports'); 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] [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) { 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; const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
await client.query( 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 ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports 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 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); } try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref(); 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 ('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 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); } try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }