feat(chat): скрытие и плановое удаление диалогов

Админ может скрыть диалог тьютора (мягкое удаление), снять скрытие,
отменить плановое удаление или удалить диалог физически через TTL
общей настройки корзины.

- chat_threads: deleted_at, purge_at (init.sql, migration.sql, ensureChatTables)
- маршруты hide / restore / unschedule / permanent под requireAdmin + аудит
- purgeScheduledDeletions() + hardDeleteChatThread() с safeUnlink по вложениям
- скрытые диалоги видны тьютору как обычно; новое сообщение тьютора
  авто-возвращает диалог в список (thread_update по SSE)
- UI: кнопка «Скрытые» с бейджем и действия в карточке треда
- бэкап: deleted_at/purge_at в normalizeRestoreData
This commit is contained in:
dev
2026-10-05 19:32:56 +03:00
parent 1740c341a0
commit 7d282d4f3d
10 changed files with 333 additions and 20 deletions
+120 -14
View File
@@ -447,6 +447,8 @@ async function ensureChatTables() {
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('ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ');
await pool.query('ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ');
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,
@@ -1352,12 +1354,20 @@ async function purgeScheduledDeletions() {
for (const g of grefs.rows) {
if (await hardDeleteGroup(g.id)) gcount++;
}
if (erefs.rowCount || gcount) {
const crefs = await pool.query('SELECT id FROM chat_threads WHERE purge_at IS NOT NULL AND purge_at <= now()');
let ccount = 0;
for (const c of crefs.rows) {
if (await hardDeleteChatThread(c.id)) {
ccount++;
publishChat({ type: 'thread_update', thread_id: c.id, tutor_id: null, deleted: true });
}
}
if (erefs.rowCount || gcount || ccount) {
invalidateEntries();
invalidateGroups();
invalidateStats();
}
return { entries: erefs.rowCount, groups: gcount };
return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount };
}
function safeUnlink(relPath) {
@@ -1368,6 +1378,18 @@ function safeUnlink(relPath) {
storage.del(key).catch(err => console.error('Upload delete failed:', key, err.message));
}
async function hardDeleteChatThread(threadId) {
const { rows } = await pool.query(
`SELECT a.path FROM chat_attachments a
JOIN chat_messages m ON m.id = a.message_id
WHERE m.thread_id = $1`,
[threadId]
);
const r = await pool.query('DELETE FROM chat_threads WHERE id = $1', [threadId]);
rows.forEach(x => safeUnlink(x.path));
return r.rowCount > 0;
}
function removeUpload(file) {
safeUnlink(file && file.path);
}
@@ -1925,19 +1947,32 @@ function publishChat(payload) {
cache.publish(CHAT_CHANNEL, payload).catch(err => console.error('Chat publish failed:', err.message));
}
const CHAT_EVENTS = new Set(['message', 'message_update', 'message_delete']);
const CHAT_EVENTS = new Set(['message', 'message_update', 'message_delete', 'thread_update']);
cache.on(CHAT_CHANNEL, message => {
cache.on(CHAT_CHANNEL, async message => {
let payload = null;
try { payload = JSON.parse(message); } catch (e) { return; }
if (!payload || !payload.thread_id) return;
const event = CHAT_EVENTS.has(payload.type) ? payload.type : 'message';
const tutorId = payload.tutor_id === null || payload.tutor_id === undefined
? (await chatThreadTutor(payload.thread_id))
: payload.tutor_id;
if (tutorId === null) return;
for (const client of [...chatClients]) {
if (!chatVisible(client.user, payload.tutor_id)) continue;
if (!chatVisible(client.user, tutorId)) continue;
try { writeChatFrame(client, event, payload); } catch (e) { chatClients.delete(client); }
}
});
async function chatThreadTutor(threadId) {
try {
const { rows } = await pool.query('SELECT tutor_id FROM chat_threads WHERE id = $1', [threadId]);
return rows.length ? rows[0].tutor_id : null;
} catch (e) {
return null;
}
}
const chatLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
@@ -1952,6 +1987,7 @@ async function chatThreadFor(user, threadId) {
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,
t.deleted_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`,
@@ -2012,12 +2048,13 @@ function chatThreadJson(t, unread) {
last_message_text: t.last_message_text || '',
unread: unread === undefined ? 0 : unread,
created_at: t.created_at,
deleted_at: t.deleted_at || null,
};
}
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');
const { rows } = await pool.query('SELECT COALESCE(SUM(admin_unread), 0)::int AS n FROM chat_threads WHERE deleted_at IS NULL');
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]);
@@ -2027,21 +2064,31 @@ async function chatUnread(user) {
app.get('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => {
try {
const isAdmin = req.user.role === 'admin';
const showDeleted = isAdmin && (req.query.deleted === '1' || req.query.deleted === 'true');
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,
`SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.admin_unread, t.created_at, t.deleted_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.deleted_at IS ${showDeleted ? 'NOT NULL' : 'NULL'}
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,
`SELECT t.id, t.tutor_id, t.last_message_at, t.last_message_text, t.tutor_unread, t.created_at, t.deleted_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) });
const hidden = isAdmin && !showDeleted
? (await pool.query('SELECT COUNT(*)::int AS n FROM chat_threads WHERE deleted_at IS NOT NULL')).rows[0].n
: 0;
res.json({
items: rows.map(t => chatThreadJson(t, isAdmin ? t.admin_unread : t.tutor_unread)),
unread: await chatUnread(req.user),
hidden,
hidden_mode: !!showDeleted,
});
} catch (e) {
res.status(500).json({ error: e.message });
}
@@ -2125,6 +2172,62 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req,
}
});
app.delete('/api/chat/threads/:id', requireAuth, requireAdmin, 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 days = await trashPurgeDays();
const r = await pool.query(
`UPDATE chat_threads SET deleted_at = now(), purge_at = now() + ($1 || ' days')::interval
WHERE id = $2 AND deleted_at IS NULL`,
[String(days), acc.thread.id]
);
if (!r.rowCount) return res.status(400).json({ error: 'Диалог уже скрыт' });
publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: '1' });
await logAudit(req, 'chat.thread.hide', { id: acc.thread.id, tutor_id: acc.thread.tutor_id, purge_days: days });
res.json({ ok: true, thread: chatThreadJson({ ...acc.thread, deleted_at: 'now' }, acc.thread.admin_unread) });
});
app.put('/api/chat/threads/:id/restore', requireAuth, requireAdmin, 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 r = await pool.query(
'UPDATE chat_threads SET deleted_at = NULL, purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL',
[acc.thread.id]
);
if (!r.rowCount) return res.status(400).json({ error: 'Диалог не скрыт' });
publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: null });
await logAudit(req, 'chat.thread.restore', { id: acc.thread.id, tutor_id: acc.thread.tutor_id });
res.json({ ok: true, thread: chatThreadJson({ ...acc.thread, deleted_at: null }, acc.thread.admin_unread) });
});
app.put('/api/chat/threads/:id/unschedule', requireAuth, requireAdmin, 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 r = await pool.query(
'UPDATE chat_threads SET purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL AND purge_at IS NOT NULL',
[acc.thread.id]
);
if (!r.rowCount) return res.status(400).json({ error: 'Удаление не запланировано' });
await logAudit(req, 'chat.thread.unschedule', { id: acc.thread.id });
res.json({ ok: true });
});
app.delete('/api/chat/threads/:id/permanent', requireAuth, requireAdmin, 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 r = await pool.query(
`UPDATE chat_threads SET purge_at = now() + ($1 || ' days')::interval
WHERE id = $2 AND deleted_at IS NOT NULL AND purge_at > now()`,
[String(await trashPurgeDays()), acc.thread.id]
);
if (!r.rowCount) return res.status(400).json({ error: 'Диалог не скрыт или уже запланирован к удалению' });
publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: '1' });
await logAudit(req, 'chat.thread.schedule', { id: acc.thread.id, tutor_id: acc.thread.tutor_id });
res.json({ ok: true });
});
const chatFilesUpload = multer({
storage: multer.diskStorage({
destination: (_, __, cb) => {
@@ -2165,6 +2268,7 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
const client = await pool.connect();
let created;
const byTutor = req.user.role !== 'admin';
try {
await client.query('BEGIN');
const mr = await client.query(
@@ -2179,15 +2283,16 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
[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
admin_unread = admin_unread + $4,
deleted_at = CASE WHEN $5::boolean THEN NULL ELSE deleted_at END,
purge_at = CASE WHEN $5::boolean THEN NULL ELSE purge_at END
WHERE id = $1`,
[acc.thread.id, chatPreviewText(body, files), byTutor ? 0 : 1, byTutor ? 1 : 0]
[acc.thread.id, chatPreviewText(body, files), byTutor ? 0 : 1, byTutor ? 1 : 0, byTutor]
);
await client.query('COMMIT');
} catch (e) {
@@ -2197,6 +2302,7 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
} finally {
client.release();
}
if (byTutor && acc.thread.deleted_at) publishChat({ type: 'thread_update', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, deleted_at: null });
const { rows: frows } = await pool.query(
'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id',
@@ -3434,8 +3540,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
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]
'INSERT INTO chat_threads (id, tutor_id, last_message_at, last_message_text, tutor_unread, admin_unread, deleted_at, purge_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[x.id, x.tutor_id, x.last_message_at, x.last_message_text, x.tutor_unread || 0, x.admin_unread || 0, x.deleted_at || null, x.purge_at || null, x.created_at]
);
}
for (const x of ndata.chat_messages) {