feat(chat): личный чат тьютор ↔ админ с вложениями
Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat вместо общей ленты notifications, вложения с белым списком расширений и вставкой из буфера (Ctrl+V), очистка по chat_retention_days. Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
@@ -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) |
|
||||||
|
|||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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%}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 || '')}">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user