feat(chat): правка и удаление сообщений
- PUT/DELETE /api/chat/threads/:id/messages/:messageId под requireAuth+chatLimiter, доступ через chatMessageFor (тред + принадлежность сообщения) - править может только автор (админу чужое сообщение 403), удалять — автор или админ - мягкое удаление: body='', вложения каскадом из БД + safeUnlink по path, счётчик непрочитанных уменьшается на 1 у своей стороны (GREATEST(col-1,0)) - refreshChatThreadPreview() пересчитывает превью треда по последнему живому сообщению - SSE: publishChat с type, список типов валидируется через CHAT_EVENTS; новые message_update/message_delete подписаны в connectChatStream (admin.js) - аудит chat.message.update с textDiff и chat.message.delete без текста - колонки edited_at/deleted_at в db/init.sql, db/migration.sql, ensureChatTables() и в normalizeRestoreData + restore-INSERT — удалённые сообщения не воскресают - фронтенд: кнопки правки/удаления по наведению, модалка правки (Ctrl+Enter), метка «изменено», плейсхолдер «Сообщение удалено», обновление по SSE - покрытие в api.smoketest.js (правка/удаление/403/400/404) и backup.selftest.js
This commit is contained in:
@@ -161,6 +161,12 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
### 3g. Чат тьютор ↔ админ (`server.js`, `public/chat.html`, `public/js/chat.js`)
|
### 3g. Чат тьютор ↔ админ (`server.js`, `public/chat.html`, `public/js/chat.js`)
|
||||||
- **Модель — личные диалоги 1:1**, не общая комната: один тред на тьютора (`chat_threads.tutor_id UNIQUE`). Тьютор видит и пишет только свой тред, админ видит все треды и может писать каждому. Тред создаётся лениво: тьютор — первым сообщением или `POST /api/chat/threads` без тела, админ — с `{ user_id }`
|
- **Модель — личные диалоги 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()`
|
- **Таблицы**: `chat_threads` (`tutor_unread`/`admin_unread` — счётчики прочитанного по ролям, `last_message_text` — превью для списка), `chat_messages`, `chat_attachments`. Схема — в `db/init.sql`, `db/migration.sql` и `ensureChatTables()`
|
||||||
|
- **Правка и удаление сообщений**: `chat_messages.edited_at` / `deleted_at`. Маршруты `PUT`/`DELETE /api/chat/threads/:id/messages/:messageId` под `requireAuth, chatLimiter`, доступ через `chatMessageFor()` (сначала `chatThreadFor`, затем проверка, что сообщение принадлежит этому треду). Править может **только автор** (403 для админа на чужом сообщении), удалять — автор **или** админ (модерация). Обе операции работают по `deleted_at IS NULL`: правка удалённого — 400, повторное удаление — 404
|
||||||
|
- **Удаление мягкое**: тело затирается (`body = ''`), вложения удаляются каскадом из БД, файлы — через `safeUnlink` по `chat_attachments.path` (до или после коммита, `safeUnlink` идемпотентен). В ответе `GET /messages` удалённое сообщение отдаётся с `deleted_at`, пустым `body` и `files: []` — фронтенд рисует «Сообщение удалено»
|
||||||
|
- **Превью треда после правки/удаления** пересчитывает `refreshChatThreadPreview()`: берётся последнее неудалённое сообщение, `last_message_text` = текст или `Вложение: <имя>`, при отсутствии сообщений — `NULL`. Счётчик непрочитанных уменьшается на 1 у той стороны, которой принадлежало удалённое сообщение (`GREATEST(col - 1, 0)`)
|
||||||
|
- **SSE-типы**: имя события не зашито — `publishChat({ type: 'message' | 'message_update' | 'message_delete', thread_id, tutor_id, message, preview })`, а список типов валидируется через `CHAT_EVENTS` в обработчике `cache.on(CHAT_CHANNEL)`. Новый тип обязан быть добавлен и в `CHAT_EVENTS`, и в `connectChatStream()` в `public/admin.js`. Для `message_update` / `message_delete` колбэки — `onChatMessageUpdate` / `onChatMessageDelete` из `public/js/chat.js`; новые сообщения по-прежнему идут событием `message`
|
||||||
|
- **Аудит**: `chat.message.create`, `chat.message.update` (с `textDiff` в `changes[].diff`, как у отчётов о занятии) и `chat.message.delete` (без текста удалённого сообщения)
|
||||||
|
- **`normalizeRestoreData` обязан сохранять `edited_at`/`deleted_at`**, и restore-INSERT — тоже: без этого удалённые сообщения после восстановления вернутся как живые со своим текстом
|
||||||
- **Отдельный SSE-канал `whatido:chat`**, эндпоинт `GET /api/chat/stream` — **не** общая лента `notifications`. Причина: `notifications` широковещательная (видимость только через `admin_only` + `branch_id`), личная переписка попала бы туда и стала видна посторонним тьюторам. Живой чат держит свой счётчик `setChatUnread()` и бейдж `#sidebarChatBadge`
|
- **Отдельный 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` — для чата это лишняя поверхность. Правило: расширение проверяется по белому списку, а не «не заблокировано»
|
- **Файлы вложений** — отдельный 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` (там доступ только по токену, без проверки участника треда)
|
- **Отдача вложений** — `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` (там доступ только по токену, без проверки участника треда)
|
||||||
@@ -305,6 +311,9 @@ curl -H "X-Api-Key: wsk_..." http://localhost:3003/api/v1/me
|
|||||||
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/threads
|
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 -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
|
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/unread
|
||||||
|
curl -X PUT -H "X-Auth-Token: $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"body":"исправленный текст"}' http://localhost:3003/api/chat/threads/1/messages/42 # правка (только автор)
|
||||||
|
curl -X DELETE -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/chat/threads/1/messages/42 # удаление (автор или админ)
|
||||||
```
|
```
|
||||||
|
|
||||||
`diff.js` builds the audit payload for text changes: word-level segments
|
`diff.js` builds the audit payload for text changes: word-level segments
|
||||||
|
|||||||
@@ -345,6 +345,81 @@ async function main() {
|
|||||||
const chatBadUser = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: 99999999 } });
|
const chatBadUser = await api('/api/chat/threads', { token, method: 'POST', body: { user_id: 99999999 } });
|
||||||
ok('chat: несуществующий тьютор -> 404', chatBadUser.status === 404, chatBadUser.status);
|
ok('chat: несуществующий тьютор -> 404', chatBadUser.status === 404, chatBadUser.status);
|
||||||
|
|
||||||
|
// --- Chat: правка и удаление сообщений ---
|
||||||
|
const tutorLogin = 'smoke_chat_' + Date.now().toString(36);
|
||||||
|
const tutorPass = 'SmokeChat123!';
|
||||||
|
const tutorCreate = await api('/api/users', { token, method: 'POST', body: { username: tutorLogin, password: tutorPass, name: 'Smoke Chat', role: 'tutor' } });
|
||||||
|
ok('chat-edit: создан тестовый тьютор', tutorCreate.status === 201 && tutorCreate.data.id > 0, tutorCreate.status);
|
||||||
|
if (tutorCreate.status === 201) {
|
||||||
|
const tutorId = tutorCreate.data.id;
|
||||||
|
const tutorAuth = await api('/api/auth/login', { method: 'POST', body: { username: tutorLogin, password: tutorPass } });
|
||||||
|
ok('chat-edit: тьютор вошёл', tutorAuth.status === 200 && !!tutorAuth.data.token, tutorAuth.status);
|
||||||
|
const tutorToken = tutorAuth.data.token;
|
||||||
|
const tutorMe = await api('/api/auth/me', { token: tutorToken });
|
||||||
|
ok('chat-edit: сессия тьютора жива', tutorMe.status === 200 && tutorMe.data.role === 'tutor', tutorMe.status);
|
||||||
|
|
||||||
|
const tThread = await api('/api/chat/threads', { token: tutorToken, method: 'POST', body: {} });
|
||||||
|
ok('chat-edit: тьютор создал свой диалог', tThread.status === 200 && tThread.data.thread && tThread.data.thread.id > 0, tThread.status);
|
||||||
|
const tid = tThread.data && tThread.data.thread ? tThread.data.thread.id : 0;
|
||||||
|
const sent = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken, method: 'POST', body: { body: 'Smoke: исходный текст' } });
|
||||||
|
ok('chat-edit: тьютор отправил сообщение', sent.status === 200 && sent.data.message && sent.data.message.id > 0, sent.status);
|
||||||
|
const mid = sent.data && sent.data.message ? sent.data.message.id : 0;
|
||||||
|
|
||||||
|
const foreignEdit = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token, method: 'PUT', body: { body: 'Админ правит чужое' } });
|
||||||
|
ok('chat-edit: админ не может править чужое сообщение -> 403', foreignEdit.status === 403, foreignEdit.status);
|
||||||
|
|
||||||
|
const edited = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: 'Smoke: изменённый текст' } });
|
||||||
|
ok('chat-edit: автор правит своё сообщение', edited.status === 200 && edited.data.message && edited.data.message.body === 'Smoke: изменённый текст' && !!edited.data.message.edited_at, edited.data);
|
||||||
|
|
||||||
|
const emptyEdit = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: ' ' } });
|
||||||
|
ok('chat-edit: пустой текст -> 400', emptyEdit.status === 400, emptyEdit.status);
|
||||||
|
|
||||||
|
const editNoAuth = await api(`/api/chat/threads/${tid}/messages/${mid}`, { method: 'PUT', body: { body: 'без токена' } });
|
||||||
|
ok('chat-edit: правка без токена -> 401', editNoAuth.status === 401, editNoAuth.status);
|
||||||
|
|
||||||
|
const editMissing = await api(`/api/chat/threads/${tid}/messages/99999999`, { token: tutorToken, method: 'PUT', body: { body: 'нет такого' } });
|
||||||
|
ok('chat-edit: несуществующее сообщение -> 404', editMissing.status === 404, editMissing.status);
|
||||||
|
|
||||||
|
const listEdited = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken });
|
||||||
|
const listed = (listEdited.data.items || []).find(m => m.id === mid);
|
||||||
|
ok('chat-edit: edited_at виден в ленте', !!listed && !!listed.edited_at && listed.body === 'Smoke: изменённый текст', listed);
|
||||||
|
|
||||||
|
const adminReply = await api(`/api/chat/threads/${tid}/messages`, { token, method: 'POST', body: { body: 'Smoke: ответ админа' } });
|
||||||
|
ok('chat-edit: админ ответил в диалоге', adminReply.status === 200 && adminReply.data.message, adminReply.status);
|
||||||
|
const adminMsgId = adminReply.data && adminReply.data.message ? adminReply.data.message.id : 0;
|
||||||
|
const tutorDelForeign = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { token: tutorToken, method: 'DELETE' });
|
||||||
|
ok('chat-del: тьютор не может удалить сообщение админа -> 403', tutorDelForeign.status === 403, tutorDelForeign.status);
|
||||||
|
|
||||||
|
const delOwn = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'DELETE' });
|
||||||
|
ok('chat-del: автор удаляет своё сообщение', delOwn.status === 200 && delOwn.data.ok === true, delOwn.data);
|
||||||
|
|
||||||
|
const delAgain = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'DELETE' });
|
||||||
|
ok('chat-del: повторное удаление -> 404', delAgain.status === 404, delAgain.status);
|
||||||
|
|
||||||
|
const editDeleted = await api(`/api/chat/threads/${tid}/messages/${mid}`, { token: tutorToken, method: 'PUT', body: { body: 'воскрешение' } });
|
||||||
|
ok('chat-edit: правка удалённого -> 400', editDeleted.status === 400, editDeleted.status);
|
||||||
|
|
||||||
|
const listDeleted = await api(`/api/chat/threads/${tid}/messages`, { token: tutorToken });
|
||||||
|
const gone = (listDeleted.data.items || []).find(m => m.id === mid);
|
||||||
|
ok('chat-del: удалённое сообщение без тела и вложений', !!gone && !!gone.deleted_at && !gone.body && Array.isArray(gone.files) && !gone.files.length, gone);
|
||||||
|
|
||||||
|
const threadAfter = await api('/api/chat/threads', { token });
|
||||||
|
const previewThread = (threadAfter.data.items || []).find(t => t.id === tid);
|
||||||
|
ok('chat-del: превью треда пересчитано', !!previewThread && previewThread.last_message_text === 'Smoke: ответ админа', previewThread && previewThread.last_message_text);
|
||||||
|
|
||||||
|
const adminModeration = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { token, method: 'DELETE' });
|
||||||
|
ok('chat-del: админ удаляет чужое сообщение (модерация)', adminModeration.status === 200 && adminModeration.data.ok === true, adminModeration.status);
|
||||||
|
|
||||||
|
const emptyThreadPreview = await api('/api/chat/threads', { token });
|
||||||
|
const emptyPreview = (emptyThreadPreview.data.items || []).find(t => t.id === tid);
|
||||||
|
ok('chat-del: превью очищено, когда все сообщения удалены', !!emptyPreview && !emptyPreview.last_message_text, emptyPreview && emptyPreview.last_message_text);
|
||||||
|
|
||||||
|
const noAuthDel = await api(`/api/chat/threads/${tid}/messages/${adminMsgId}`, { method: 'DELETE' });
|
||||||
|
ok('chat-del: удаление без токена -> 401', noAuthDel.status === 401, noAuthDel.status);
|
||||||
|
|
||||||
|
await api(`/api/users/${tutorId}`, { token, method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
|
|||||||
+11
-1
@@ -62,6 +62,14 @@ function optStr(v, max) {
|
|||||||
return reqStr(v, max);
|
return reqStr(v, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optText(v, max) {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
if (typeof v !== 'string') throw new Error('Invalid string');
|
||||||
|
const s = v.trim();
|
||||||
|
if (s.length > max) throw new Error('Invalid string length');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
function optTs(v) {
|
function optTs(v) {
|
||||||
if (v === null || v === undefined) return null;
|
if (v === null || v === undefined) return null;
|
||||||
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
|
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
|
||||||
@@ -476,7 +484,9 @@ function normalizeRestoreData(data) {
|
|||||||
id: reqInt(x.id),
|
id: reqInt(x.id),
|
||||||
thread_id: reqInt(x.thread_id),
|
thread_id: reqInt(x.thread_id),
|
||||||
sender_id: reqInt(x.sender_id),
|
sender_id: reqInt(x.sender_id),
|
||||||
body: optStr(x.body, 5000) ?? '',
|
body: optText(x.body, 5000),
|
||||||
|
edited_at: optTs(x.edited_at),
|
||||||
|
deleted_at: optTs(x.deleted_at),
|
||||||
created_at: optTs(x.created_at),
|
created_at: optTs(x.created_at),
|
||||||
}));
|
}));
|
||||||
const chat_attachments = (data.chat_attachments || []).map(x => ({
|
const chat_attachments = (data.chat_attachments || []).map(x => ({
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ const ch = normalizeRestoreData({
|
|||||||
ok('chat_threads нормализуются', ch.chat_threads[0].tutor_id === 1 && ch.chat_threads[0].tutor_unread === 2, ch.chat_threads[0]);
|
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_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]);
|
ok('chat_attachments нормализуются', ch.chat_attachments[0].name === 'photo.png' && ch.chat_attachments[0].size === 1234, ch.chat_attachments[0]);
|
||||||
|
const chEdits = normalizeRestoreData({
|
||||||
|
...base,
|
||||||
|
chat_messages: [
|
||||||
|
{ id: 10, thread_id: 3, sender_id: 1, body: 'правлено', edited_at: '2026-01-02T10:00:00.000Z' },
|
||||||
|
{ id: 11, thread_id: 3, sender_id: 1, body: '', deleted_at: '2026-01-03T10:00:00.000Z' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
ok('edited_at сообщения сохраняется', chEdits.chat_messages[0].edited_at === '2026-01-02T10:00:00.000Z', chEdits.chat_messages[0]);
|
||||||
|
ok('deleted_at сообщения сохраняется', chEdits.chat_messages[1].deleted_at === '2026-01-03T10:00:00.000Z', chEdits.chat_messages[1]);
|
||||||
let chatBad = false;
|
let chatBad = false;
|
||||||
try {
|
try {
|
||||||
normalizeRestoreData({ ...base, chat_messages: [{ id: 1, thread_id: 3, sender_id: 1, body: 'x'.repeat(5001) }] });
|
normalizeRestoreData({ ...base, chat_messages: [{ id: 1, thread_id: 3, sender_id: 1, body: 'x'.repeat(5001) }] });
|
||||||
|
|||||||
@@ -360,6 +360,8 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
|||||||
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
|
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
|
||||||
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
edited_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -330,11 +330,16 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
|||||||
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
|
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
|
||||||
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
edited_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id);
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id);
|
||||||
|
|
||||||
|
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_attachments (
|
CREATE TABLE IF NOT EXISTS chat_attachments (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -956,6 +956,17 @@ body.report-card-open .report-fields{display:flex}
|
|||||||
.msg-who{font-weight:600;color:var(--text);font-size:.78rem}
|
.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-role{border:1px solid var(--border);border-radius:5px;padding:0 5px;font-size:.65rem}
|
||||||
.msg-time{white-space:nowrap}
|
.msg-time{white-space:nowrap}
|
||||||
|
.msg-edited{font-style:italic}
|
||||||
|
.msg-actions{display:inline-flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s}
|
||||||
|
.msg:hover .msg-actions,.msg-actions:focus-within{opacity:1}
|
||||||
|
.msg-act{background:none;border:none;padding:2px;border-radius:6px;color:var(--muted);cursor:pointer;display:inline-flex}
|
||||||
|
.msg-act:hover{color:var(--accent);background:var(--bg)}
|
||||||
|
.msg-act-del:hover{color:#ef4444}
|
||||||
|
.msg-act svg{width:13px;height:13px}
|
||||||
|
.msg-deleted-body{font-style:italic;color:var(--muted)}
|
||||||
|
.chat-edit-modal{max-width:560px}
|
||||||
|
.chat-edit-modal textarea{min-height:110px;max-height:40dvh}
|
||||||
|
.chat-edit-modal textarea:focus{border-color:var(--accent)}
|
||||||
.msg-body{font-size:.88rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
|
.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-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{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}
|
||||||
@@ -987,6 +998,10 @@ body.report-card-open .report-fields{display:flex}
|
|||||||
.chat-hint{font-size:.75rem;margin:0}
|
.chat-hint{font-size:.75rem;margin:0}
|
||||||
.chat-new-modal{max-width:420px}
|
.chat-new-modal{max-width:420px}
|
||||||
|
|
||||||
|
@media(hover:none){
|
||||||
|
.msg-actions{opacity:1}
|
||||||
|
}
|
||||||
|
|
||||||
@media(max-width:900px){
|
@media(max-width:900px){
|
||||||
.chat-layout{grid-template-columns:1fr}
|
.chat-layout{grid-template-columns:1fr}
|
||||||
.chat-side{max-height:230px}
|
.chat-side{max-height:230px}
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ function connectChatStream() {
|
|||||||
}
|
}
|
||||||
if (typeof onChatMessage === 'function') onChatMessage(p);
|
if (typeof onChatMessage === 'function') onChatMessage(p);
|
||||||
});
|
});
|
||||||
|
chatStream.addEventListener('message_update', ev => {
|
||||||
|
let p = null;
|
||||||
|
try { p = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (p && typeof onChatMessageUpdate === 'function') onChatMessageUpdate(p);
|
||||||
|
});
|
||||||
|
chatStream.addEventListener('message_delete', ev => {
|
||||||
|
let p = null;
|
||||||
|
try { p = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (p && typeof onChatMessageDelete === 'function') onChatMessageDelete(p);
|
||||||
|
});
|
||||||
chatStream.onerror = () => {
|
chatStream.onerror = () => {
|
||||||
if (chatStream) { chatStream.close(); chatStream = null; }
|
if (chatStream) { chatStream.close(); chatStream = null; }
|
||||||
clearTimeout(chatReconnectTimer);
|
clearTimeout(chatReconnectTimer);
|
||||||
|
|||||||
@@ -76,6 +76,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="chatEditModal">
|
||||||
|
<div class="edit-modal chat-edit-modal">
|
||||||
|
<h3>Редактировать сообщение</h3>
|
||||||
|
<textarea id="chatEditText" class="settings-input" maxlength="5000"></textarea>
|
||||||
|
<div class="hint">Ctrl+Enter — сохранить. Вложения сообщения останутся как есть.</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="chatEditCancel">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary" id="chatEditSave">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+156
-3
@@ -16,6 +16,7 @@ let chatMessages = [];
|
|||||||
let chatPendingFiles = [];
|
let chatPendingFiles = [];
|
||||||
let chatLoading = false;
|
let chatLoading = false;
|
||||||
let chatSending = false;
|
let chatSending = false;
|
||||||
|
let chatEditId = 0;
|
||||||
|
|
||||||
function chatHumanSize(b) {
|
function chatHumanSize(b) {
|
||||||
const n = Number(b) || 0;
|
const n = Number(b) || 0;
|
||||||
@@ -110,13 +111,30 @@ function chatMessageHtml(m) {
|
|||||||
const mine = Number(m.sender_id) === Number(currentUser?.id);
|
const mine = Number(m.sender_id) === Number(currentUser?.id);
|
||||||
const who = mine ? 'Вы' : (m.sender_name || 'Тьютор');
|
const who = mine ? 'Вы' : (m.sender_name || 'Тьютор');
|
||||||
const role = mine ? '' : (m.sender_role === 'admin' ? 'администратор' : 'тьютор');
|
const role = mine ? '' : (m.sender_role === 'admin' ? 'администратор' : 'тьютор');
|
||||||
return `<div class="msg ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
|
if (m.deleted_at) {
|
||||||
|
return `<div class="msg msg-deleted ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
|
||||||
<div class="msg-head">
|
<div class="msg-head">
|
||||||
<span class="msg-who">${esc(who)}</span>
|
<span class="msg-who">${esc(who)}</span>
|
||||||
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
|
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
|
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="msg-body msg-deleted-body">Сообщение удалено</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const canDelete = mine || isAdmin();
|
||||||
|
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>` : ''}
|
||||||
|
${m.edited_at ? `<span class="msg-edited" title="Изменено ${esc(chatMessageTime(m.edited_at))}">изменено</span>` : ''}
|
||||||
|
<span class="grow"></span>
|
||||||
|
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
|
||||||
|
${canDelete ? `<span class="msg-actions">
|
||||||
|
${mine ? `<button type="button" class="msg-act" data-msg-edit="${m.id}" title="Редактировать"><i data-lucide="pencil"></i></button>` : ''}
|
||||||
|
<button type="button" class="msg-act msg-act-del" data-msg-del="${m.id}" title="Удалить"><i data-lucide="trash-2"></i></button>
|
||||||
|
</span>` : ''}
|
||||||
|
</div>
|
||||||
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
|
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
|
||||||
${chatFilesHtml(m.files)}
|
${chatFilesHtml(m.files)}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -292,8 +310,8 @@ function removeChatFile(i) {
|
|||||||
renderChatAttach();
|
renderChatAttach();
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoGrowChatInput() {
|
function autoGrowChatInput(el) {
|
||||||
const input = document.getElementById('chatInput');
|
const input = el || document.getElementById('chatInput');
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
input.style.height = 'auto';
|
input.style.height = 'auto';
|
||||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||||
@@ -325,6 +343,109 @@ function onChatMessage(p) {
|
|||||||
markChatRead();
|
markChatRead();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyChatThreadPreview(p) {
|
||||||
|
if (!p || !p.preview) return;
|
||||||
|
const idx = chatThreads.findIndex(t => t.id === Number(p.thread_id));
|
||||||
|
if (idx < 0) return;
|
||||||
|
chatThreads[idx].last_message_text = p.preview.last_message_text || '';
|
||||||
|
chatThreads[idx].last_message_at = p.preview.last_message_at || chatThreads[idx].last_message_at;
|
||||||
|
renderChatThreads();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChatMessageUpdate(p) {
|
||||||
|
if (!p || !p.message) return;
|
||||||
|
applyChatThreadPreview(p);
|
||||||
|
if (Number(p.thread_id) !== chatActiveId) return;
|
||||||
|
const m = p.message;
|
||||||
|
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
|
||||||
|
if (idx < 0) {
|
||||||
|
addChatMessage(m);
|
||||||
|
} else {
|
||||||
|
chatMessages[idx] = { ...chatMessages[idx], ...m };
|
||||||
|
}
|
||||||
|
renderChatMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChatMessageDelete(p) {
|
||||||
|
if (!p || !p.message) return;
|
||||||
|
applyChatThreadPreview(p);
|
||||||
|
if (Number(p.thread_id) !== chatActiveId) return;
|
||||||
|
const m = p.message;
|
||||||
|
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
|
||||||
|
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...m, body: '', files: [], edited_at: null };
|
||||||
|
renderChatMessages();
|
||||||
|
if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChatEditModal(messageId) {
|
||||||
|
const modal = document.getElementById('chatEditModal');
|
||||||
|
const input = document.getElementById('chatEditText');
|
||||||
|
if (!modal || !input) return;
|
||||||
|
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
|
||||||
|
if (!msg || msg.deleted_at) return;
|
||||||
|
chatEditId = Number(messageId);
|
||||||
|
input.value = String(msg.body || '');
|
||||||
|
modal.classList.add('open');
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(input.value.length, input.value.length);
|
||||||
|
autoGrowChatInput(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChatEditModal() {
|
||||||
|
const modal = document.getElementById('chatEditModal');
|
||||||
|
if (modal) modal.classList.remove('open');
|
||||||
|
chatEditId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChatEdit() {
|
||||||
|
const input = document.getElementById('chatEditText');
|
||||||
|
const saveBtn = document.getElementById('chatEditSave');
|
||||||
|
if (!input || !chatEditId) return;
|
||||||
|
const body = String(input.value || '').trim();
|
||||||
|
if (!body) {
|
||||||
|
showToast('Сообщение не может быть пустым', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (saveBtn) saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages/${chatEditId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
if (data.message) {
|
||||||
|
const idx = chatMessages.findIndex(m => Number(m.id) === Number(data.message.id));
|
||||||
|
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...data.message };
|
||||||
|
renderChatMessages();
|
||||||
|
}
|
||||||
|
closeChatEditModal();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || 'Не удалось изменить сообщение', true);
|
||||||
|
} finally {
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChatMessage(messageId) {
|
||||||
|
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
|
||||||
|
if (!msg) return;
|
||||||
|
const mine = Number(msg.sender_id) === Number(currentUser?.id);
|
||||||
|
if (!confirm(mine
|
||||||
|
? 'Удалить сообщение? Вложения будут удалены безвозвратно.'
|
||||||
|
: 'Удалить сообщение собеседника? Вложения будут удалены безвозвратно.')) return;
|
||||||
|
try {
|
||||||
|
await chatApi(`/api/chat/threads/${chatActiveId}/messages/${messageId}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
const idx = chatMessages.findIndex(m => Number(m.id) === Number(messageId));
|
||||||
|
if (idx >= 0) {
|
||||||
|
chatMessages[idx] = { ...chatMessages[idx], body: '', files: [], edited_at: null, deleted_at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
renderChatMessages();
|
||||||
|
loadChatThreads();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || 'Не удалось удалить сообщение', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openChatNewModal() {
|
async function openChatNewModal() {
|
||||||
const modal = document.getElementById('chatNewModal');
|
const modal = document.getElementById('chatNewModal');
|
||||||
const select = document.getElementById('chatNewUser');
|
const select = document.getElementById('chatNewUser');
|
||||||
@@ -429,6 +550,37 @@ function initChatPage() {
|
|||||||
const newOk = document.getElementById('chatNewOk');
|
const newOk = document.getElementById('chatNewOk');
|
||||||
const attachBox = document.getElementById('chatAttachList');
|
const attachBox = document.getElementById('chatAttachList');
|
||||||
const imgModal = document.getElementById('imgModal');
|
const imgModal = document.getElementById('imgModal');
|
||||||
|
const box = document.getElementById('chatMessages');
|
||||||
|
const editModal = document.getElementById('chatEditModal');
|
||||||
|
const editInput = document.getElementById('chatEditText');
|
||||||
|
const editCancel = document.getElementById('chatEditCancel');
|
||||||
|
const editSave = document.getElementById('chatEditSave');
|
||||||
|
|
||||||
|
if (box) {
|
||||||
|
box.addEventListener('click', e => {
|
||||||
|
const editBtn = e.target.closest('[data-msg-edit]');
|
||||||
|
if (editBtn) {
|
||||||
|
openChatEditModal(Number(editBtn.dataset.msgEdit));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delBtn = e.target.closest('[data-msg-del]');
|
||||||
|
if (delBtn) deleteChatMessage(Number(delBtn.dataset.msgDel));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (editCancel) editCancel.addEventListener('click', closeChatEditModal);
|
||||||
|
if (editSave) editSave.addEventListener('click', submitChatEdit);
|
||||||
|
if (editModal) {
|
||||||
|
editModal.addEventListener('click', e => { if (e.target === editModal) closeChatEditModal(); });
|
||||||
|
}
|
||||||
|
if (editInput) {
|
||||||
|
editInput.addEventListener('input', () => autoGrowChatInput(editInput));
|
||||||
|
editInput.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitChatEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (list) {
|
if (list) {
|
||||||
list.addEventListener('click', e => {
|
list.addEventListener('click', e => {
|
||||||
@@ -493,6 +645,7 @@ function initChatPage() {
|
|||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key !== 'Escape') return;
|
if (e.key !== 'Escape') return;
|
||||||
|
if (editModal && editModal.classList.contains('open')) { closeChatEditModal(); return; }
|
||||||
if (imgModal && imgModal.classList.contains('open')) { imgModal.classList.remove('open'); return; }
|
if (imgModal && imgModal.classList.contains('open')) { imgModal.classList.remove('open'); return; }
|
||||||
if (newModal && newModal.classList.contains('open')) newModal.classList.remove('open');
|
if (newModal && newModal.classList.contains('open')) newModal.classList.remove('open');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -452,8 +452,12 @@ async function ensureChatTables() {
|
|||||||
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
|
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
|
||||||
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
edited_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
)`);
|
)`);
|
||||||
|
await pool.query('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ');
|
||||||
|
await pool.query('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ');
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)`);
|
||||||
await pool.query(`CREATE TABLE IF NOT EXISTS chat_attachments (
|
await pool.query(`CREATE TABLE IF NOT EXISTS chat_attachments (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -1921,13 +1925,16 @@ function publishChat(payload) {
|
|||||||
cache.publish(CHAT_CHANNEL, payload).catch(err => console.error('Chat publish failed:', err.message));
|
cache.publish(CHAT_CHANNEL, payload).catch(err => console.error('Chat publish failed:', err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHAT_EVENTS = new Set(['message', 'message_update', 'message_delete']);
|
||||||
|
|
||||||
cache.on(CHAT_CHANNEL, message => {
|
cache.on(CHAT_CHANNEL, message => {
|
||||||
let payload = null;
|
let payload = null;
|
||||||
try { payload = JSON.parse(message); } catch (e) { return; }
|
try { payload = JSON.parse(message); } catch (e) { return; }
|
||||||
if (!payload || !payload.thread_id) return;
|
if (!payload || !payload.thread_id) return;
|
||||||
|
const event = CHAT_EVENTS.has(payload.type) ? payload.type : 'message';
|
||||||
for (const client of [...chatClients]) {
|
for (const client of [...chatClients]) {
|
||||||
if (!chatVisible(client.user, payload.tutor_id)) continue;
|
if (!chatVisible(client.user, payload.tutor_id)) continue;
|
||||||
try { writeChatFrame(client, 'message', payload); } catch (e) { chatClients.delete(client); }
|
try { writeChatFrame(client, event, payload); } catch (e) { chatClients.delete(client); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1955,6 +1962,45 @@ async function chatThreadFor(user, threadId) {
|
|||||||
return { thread: rows[0] };
|
return { thread: rows[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chatMessageFor(user, threadId, messageId) {
|
||||||
|
const acc = await chatThreadFor(user, threadId);
|
||||||
|
if (acc.error) return acc;
|
||||||
|
const mid = parseInt(messageId, 10);
|
||||||
|
if (!Number.isInteger(mid) || mid < 1) return { error: 400, message: 'Invalid message' };
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, thread_id, sender_id, body, edited_at, deleted_at, created_at
|
||||||
|
FROM chat_messages WHERE id = $1 AND thread_id = $2`,
|
||||||
|
[mid, acc.thread.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) return { error: 404, message: 'Сообщение не найдено' };
|
||||||
|
return { thread: acc.thread, message: rows[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatPreviewText(body, files) {
|
||||||
|
const text = String(body || '').trim();
|
||||||
|
if (text) return text.slice(0, 300);
|
||||||
|
const first = (files || [])[0];
|
||||||
|
return first ? `Вложение: ${String(first.name || '').slice(0, 280)}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshChatThreadPreview(threadId) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT m.id, m.body, m.created_at, m.deleted_at,
|
||||||
|
(SELECT a.name FROM chat_attachments a WHERE a.message_id = m.id ORDER BY a.id LIMIT 1) AS file_name
|
||||||
|
FROM chat_messages m
|
||||||
|
WHERE m.thread_id = $1 AND m.deleted_at IS NULL
|
||||||
|
ORDER BY m.id DESC LIMIT 1`,
|
||||||
|
[threadId]
|
||||||
|
);
|
||||||
|
const last = rows[0];
|
||||||
|
const preview = last ? chatPreviewText(last.body, last.file_name ? [{ name: last.file_name }] : []) : null;
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE chat_threads SET last_message_at = $2, last_message_text = $3 WHERE id = $1',
|
||||||
|
[threadId, last ? last.created_at : null, preview]
|
||||||
|
);
|
||||||
|
return { last_message_at: last ? last.created_at : null, last_message_text: preview || '' };
|
||||||
|
}
|
||||||
|
|
||||||
function chatThreadJson(t, unread) {
|
function chatThreadJson(t, unread) {
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
@@ -2038,14 +2084,14 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req,
|
|||||||
extra = ` AND m.id < $${params.length}`;
|
extra = ` AND m.id < $${params.length}`;
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT m.id, m.sender_id, m.body, m.created_at, u.username, u.name, u.role
|
`SELECT m.id, m.sender_id, m.body, m.edited_at, m.deleted_at, m.created_at, u.username, u.name, u.role
|
||||||
FROM chat_messages m JOIN users u ON u.id = m.sender_id
|
FROM chat_messages m JOIN users u ON u.id = m.sender_id
|
||||||
WHERE m.thread_id = $1${extra}
|
WHERE m.thread_id = $1${extra}
|
||||||
ORDER BY m.id DESC
|
ORDER BY m.id DESC
|
||||||
LIMIT $${params.length + 1}`,
|
LIMIT $${params.length + 1}`,
|
||||||
[...params, limit]
|
[...params, limit]
|
||||||
);
|
);
|
||||||
const ids = rows.map(m => m.id);
|
const ids = rows.filter(m => !m.deleted_at).map(m => m.id);
|
||||||
const byMessage = new Map();
|
const byMessage = new Map();
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
const fr = await pool.query(
|
const fr = await pool.query(
|
||||||
@@ -2066,9 +2112,11 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req,
|
|||||||
mine: m.sender_id === req.user.id,
|
mine: m.sender_id === req.user.id,
|
||||||
sender_name: m.name || m.username,
|
sender_name: m.name || m.username,
|
||||||
sender_role: m.role,
|
sender_role: m.role,
|
||||||
body: m.body,
|
body: m.deleted_at ? '' : m.body,
|
||||||
|
edited_at: m.edited_at,
|
||||||
|
deleted_at: m.deleted_at,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
files: byMessage.get(m.id) || [],
|
files: m.deleted_at ? [] : (byMessage.get(m.id) || []),
|
||||||
}));
|
}));
|
||||||
const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread;
|
const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread;
|
||||||
res.json({ items, thread: chatThreadJson(acc.thread, unreadCol) });
|
res.json({ items, thread: chatThreadJson(acc.thread, unreadCol) });
|
||||||
@@ -2139,7 +2187,7 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
|
|||||||
tutor_unread = tutor_unread + $3,
|
tutor_unread = tutor_unread + $3,
|
||||||
admin_unread = admin_unread + $4
|
admin_unread = admin_unread + $4
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[acc.thread.id, body.slice(0, 300) || `Вложение: ${files[0].originalname}`, byTutor ? 0 : 1, byTutor ? 1 : 0]
|
[acc.thread.id, chatPreviewText(body, files), byTutor ? 0 : 1, byTutor ? 1 : 0]
|
||||||
);
|
);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2161,14 +2209,132 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
|
|||||||
sender_name: req.user.name || req.user.username,
|
sender_name: req.user.name || req.user.username,
|
||||||
sender_role: req.user.role,
|
sender_role: req.user.role,
|
||||||
body: created.body,
|
body: created.body,
|
||||||
|
edited_at: null,
|
||||||
|
deleted_at: null,
|
||||||
created_at: created.created_at,
|
created_at: created.created_at,
|
||||||
files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })),
|
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 });
|
publishChat({ type: 'message', 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 });
|
await logAudit(req, 'chat.message.create', { thread_id: acc.thread.id, message_id: created.id, files: frows.length });
|
||||||
res.json({ message });
|
res.json({ message });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/chat/threads/:id/messages/:messageId', requireAuth, chatLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acc = await chatMessageFor(req.user, req.params.id, req.params.messageId);
|
||||||
|
if (acc.error) return res.status(acc.error).json({ error: acc.message });
|
||||||
|
const message = acc.message;
|
||||||
|
if (message.deleted_at) return res.status(400).json({ error: 'Сообщение удалено' });
|
||||||
|
if (Number(message.sender_id) !== Number(req.user.id)) return res.status(403).json({ error: 'Можно редактировать только свои сообщения' });
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = reqStr(req.body?.body, CHAT_BODY_MAX);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Пустое или слишком длинное сообщение' });
|
||||||
|
}
|
||||||
|
if (body === message.body) return res.json({ message: null, unchanged: true });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE chat_messages SET body = $1, edited_at = now()
|
||||||
|
WHERE id = $2 AND deleted_at IS NULL
|
||||||
|
RETURNING id, sender_id, body, edited_at, deleted_at, created_at`,
|
||||||
|
[body, message.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(400).json({ error: 'Сообщение удалено' });
|
||||||
|
const updated = rows[0];
|
||||||
|
const { rows: frows } = await pool.query(
|
||||||
|
'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id',
|
||||||
|
[message.id]
|
||||||
|
);
|
||||||
|
const preview = await refreshChatThreadPreview(acc.thread.id);
|
||||||
|
const payload = {
|
||||||
|
id: updated.id,
|
||||||
|
sender_id: updated.sender_id,
|
||||||
|
mine: updated.sender_id === req.user.id,
|
||||||
|
sender_name: req.user.name || req.user.username,
|
||||||
|
sender_role: req.user.role,
|
||||||
|
body: updated.body,
|
||||||
|
edited_at: updated.edited_at,
|
||||||
|
deleted_at: updated.deleted_at,
|
||||||
|
created_at: updated.created_at,
|
||||||
|
files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })),
|
||||||
|
};
|
||||||
|
publishChat({
|
||||||
|
type: 'message_update',
|
||||||
|
thread_id: acc.thread.id,
|
||||||
|
tutor_id: acc.thread.tutor_id,
|
||||||
|
message: payload,
|
||||||
|
preview,
|
||||||
|
});
|
||||||
|
const d = textDiff(message.body, body);
|
||||||
|
await logAudit(req, 'chat.message.update', {
|
||||||
|
thread_id: acc.thread.id,
|
||||||
|
message_id: message.id,
|
||||||
|
changed: d.changed,
|
||||||
|
fields: ['body'],
|
||||||
|
changes: d.changed
|
||||||
|
? [{ field: 'body', label: 'Текст сообщения', stats: d.stats, diff: d.segments, truncated: d.truncated }]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
res.json({ message: payload });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/chat/threads/:id/messages/:messageId', requireAuth, chatLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acc = await chatMessageFor(req.user, req.params.id, req.params.messageId);
|
||||||
|
if (acc.error) return res.status(acc.error).json({ error: acc.message });
|
||||||
|
const message = acc.message;
|
||||||
|
if (message.deleted_at) return res.status(404).json({ error: 'Сообщение уже удалено' });
|
||||||
|
const isAuthor = Number(message.sender_id) === Number(req.user.id);
|
||||||
|
if (!isAuthor && req.user.role !== 'admin') return res.status(403).json({ error: 'Можно удалять только свои сообщения' });
|
||||||
|
|
||||||
|
const { rows: arows } = await pool.query(
|
||||||
|
'SELECT path FROM chat_attachments WHERE message_id = $1',
|
||||||
|
[message.id]
|
||||||
|
);
|
||||||
|
await pool.query('DELETE FROM chat_attachments WHERE message_id = $1', [message.id]);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE chat_messages
|
||||||
|
SET body = '', deleted_at = now()
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
RETURNING id, sender_id, deleted_at, created_at`,
|
||||||
|
[message.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Сообщение уже удалено' });
|
||||||
|
const unreadCol = Number(rows[0].sender_id) === Number(acc.thread.tutor_id) ? 'admin_unread' : 'tutor_unread';
|
||||||
|
await pool.query(`UPDATE chat_threads SET ${unreadCol} = GREATEST(${unreadCol} - 1, 0) WHERE id = $1`, [acc.thread.id]);
|
||||||
|
const preview = await refreshChatThreadPreview(acc.thread.id);
|
||||||
|
arows.forEach(r => safeUnlink(r.path));
|
||||||
|
publishChat({
|
||||||
|
type: 'message_delete',
|
||||||
|
thread_id: acc.thread.id,
|
||||||
|
tutor_id: acc.thread.tutor_id,
|
||||||
|
message: {
|
||||||
|
id: rows[0].id,
|
||||||
|
sender_id: rows[0].sender_id,
|
||||||
|
body: '',
|
||||||
|
edited_at: null,
|
||||||
|
deleted_at: rows[0].deleted_at,
|
||||||
|
created_at: rows[0].created_at,
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
});
|
||||||
|
await logAudit(req, 'chat.message.delete', {
|
||||||
|
thread_id: acc.thread.id,
|
||||||
|
message_id: message.id,
|
||||||
|
by_admin: req.user.role === 'admin',
|
||||||
|
files: arows.length,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, id: message.id });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/chat/threads/:id/read', requireAuth, chatLimiter, async (req, res) => {
|
app.post('/api/chat/threads/:id/read', requireAuth, chatLimiter, async (req, res) => {
|
||||||
const acc = await chatThreadFor(req.user, req.params.id);
|
const acc = await chatThreadFor(req.user, req.params.id);
|
||||||
if (acc.error) return res.status(acc.error).json({ error: acc.message });
|
if (acc.error) return res.status(acc.error).json({ error: acc.message });
|
||||||
@@ -3278,8 +3444,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
const eu = await client.query('SELECT 1 FROM users WHERE id = $1', [x.sender_id]);
|
const eu = await client.query('SELECT 1 FROM users WHERE id = $1', [x.sender_id]);
|
||||||
if (!eu.rowCount) continue;
|
if (!eu.rowCount) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO chat_messages (id, thread_id, sender_id, body, created_at) VALUES ($1,$2,$3,$4,$5)',
|
'INSERT INTO chat_messages (id, thread_id, sender_id, body, edited_at, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||||
[x.id, x.thread_id, x.sender_id, x.body, x.created_at]
|
[x.id, x.thread_id, x.sender_id, x.body, x.edited_at, x.deleted_at, x.created_at]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const x of ndata.chat_attachments) {
|
for (const x of ndata.chat_attachments) {
|
||||||
|
|||||||
Reference in New Issue
Block a user