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

Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat
вместо общей ленты notifications, вложения с белым списком расширений
и вставкой из буфера (Ctrl+V), очистка по chat_retention_days.
Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
dev
2026-10-05 17:30:26 +03:00
parent 3836fc82ef
commit 235fd43af4
12 changed files with 1409 additions and 10 deletions
+24
View File
@@ -70,6 +70,30 @@ ok('notification_reads нормализуются', nt.notification_reads[0].not
ok('audit_log нормализуется', nt.audit_log[0].action === 'backup.download' && nt.audit_log[0].target === '{"size":5}', nt.audit_log[0]);
ok('banned_ips нормализуются', nt.banned_ips[0].ip === '203.0.113.7');
const ch = normalizeRestoreData({
...base,
chat_threads: [{ id: 3, tutor_id: 1, last_message_text: 'привет', tutor_unread: 2, admin_unread: 1 }],
chat_messages: [{ id: 9, thread_id: 3, sender_id: 1, body: 'привет' }],
chat_attachments: [{ id: 4, message_id: 9, token: 'a'.repeat(32), path: '/uploads/1700000000000-abc123.png', name: 'photo.png', size: 1234 }],
});
ok('chat_threads нормализуются', ch.chat_threads[0].tutor_id === 1 && ch.chat_threads[0].tutor_unread === 2, ch.chat_threads[0]);
ok('chat_messages нормализуются', ch.chat_messages[0].thread_id === 3 && ch.chat_messages[0].body === 'привет', ch.chat_messages[0]);
ok('chat_attachments нормализуются', ch.chat_attachments[0].name === 'photo.png' && ch.chat_attachments[0].size === 1234, ch.chat_attachments[0]);
let chatBad = false;
try {
normalizeRestoreData({ ...base, chat_messages: [{ id: 1, thread_id: 3, sender_id: 1, body: 'x'.repeat(5001) }] });
} catch (e) {
chatBad = true;
}
ok('слишком длинный текст сообщения отклоняется', chatBad);
let chatPathBad = false;
try {
normalizeRestoreData({ ...base, chat_attachments: [{ id: 1, message_id: 1, token: 'b'.repeat(32), path: '/etc/passwd', name: 'x' }] });
} catch (e) {
chatPathBad = true;
}
ok('путь вложения вне uploads отклоняется', chatPathBad);
const badLevel = normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', level: 'drop-table', title: 'T' }] });
ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]);