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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user