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
+15 -3
View File
@@ -107,6 +107,7 @@ function createRedis({ url, prefix } = {}) {
const memory = createMemoryBackend();
const handlers = new Map();
const subscribed = new Set();
const channelListeners = new Map();
const stats = { hits: 0, misses: 0, writes: 0, drops: 0, errors: 0, publishes: 0, fallbackOps: 0 };
const CONNECT_TIMEOUT_MS = Math.max(500, parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000', 10) || 5000);
let client = null;
@@ -175,12 +176,23 @@ function createRedis({ url, prefix } = {}) {
}
}
function channelListener(channel) {
let fn = channelListeners.get(channel);
if (!fn) {
fn = message => deliver(channel, message);
channelListeners.set(channel, fn);
}
return fn;
}
async function subscribeChannel(channel) {
if (!sub || !sub.isOpen || subscribed.has(channel)) return;
if (!sub || !sub.isOpen) return;
if (subscribed.has(channel)) return;
subscribed.add(channel);
try {
await sub.subscribe(channel, message => deliver(channel, message));
subscribed.add(channel);
await sub.subscribe(channel, channelListener(channel));
} catch (e) {
subscribed.delete(channel);
noteError('subscribe', e);
}
}