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
+454 -4
View File
@@ -419,6 +419,78 @@ async function ensureNotificationsTable() {
}
}
const CHAT_RETENTION_DEFAULT_DAYS = 365;
const CHAT_BODY_MAX = 5000;
const CHAT_ATTACH_MAX = 10;
const CHAT_ALLOWED_EXT = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif', '.jfif',
'.pdf', '.doc', '.docx', '.txt', '.md', '.rtf', '.odt', '.ods',
'.xls', '.xlsx', '.csv', '.ppt', '.pptx',
'.zip', '.rar', '.7z', '.tar', '.gz',
]);
function chatUploadFilter(req, file, cb) {
file.originalname = fixFilename(file.originalname);
const ext = path.extname(file.originalname).toLowerCase();
if (!CHAT_ALLOWED_EXT.has(ext)) return cb(new Error('Not allowed extension'));
cb(null, true);
}
async function ensureChatTables() {
await pool.query(`CREATE TABLE IF NOT EXISTS chat_threads (
id SERIAL PRIMARY KEY,
tutor_id INT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
last_message_at TIMESTAMPTZ DEFAULT now(),
last_message_text VARCHAR(300),
tutor_unread INT NOT NULL DEFAULT 0,
admin_unread INT NOT NULL DEFAULT 0,
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(`CREATE TABLE IF NOT EXISTS chat_messages (
id SERIAL PRIMARY KEY,
thread_id INT NOT NULL REFERENCES chat_threads(id) ON DELETE CASCADE,
sender_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
)`);
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 (
id SERIAL PRIMARY KEY,
message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
path VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
size INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_attachments_message ON chat_attachments(message_id)`);
await pool.query(
`INSERT INTO settings (key, value) VALUES ('chat_retention_days', $1) ON CONFLICT (key) DO NOTHING`,
[String(CHAT_RETENTION_DEFAULT_DAYS)]
);
}
async function purgeOldChat() {
const raw = parseInt(await getSetting('chat_retention_days', String(CHAT_RETENTION_DEFAULT_DAYS)), 10);
const days = Number.isFinite(raw) && raw >= 1 ? Math.min(raw, 3650) : CHAT_RETENTION_DEFAULT_DAYS;
const { rows } = await pool.query(
`SELECT a.path FROM chat_attachments a
JOIN chat_messages m ON m.id = a.message_id
WHERE m.created_at < now() - ($1 || ' days')::interval`,
[String(days)]
);
const msgs = await pool.query(
`DELETE FROM chat_messages WHERE created_at < now() - ($1 || ' days')::interval`,
[String(days)]
);
await pool.query(
`DELETE FROM chat_threads t WHERE NOT EXISTS (SELECT 1 FROM chat_messages m WHERE m.thread_id = t.id)`
);
rows.forEach(r => safeUnlink(r.path));
if (msgs.rowCount) console.log(`Chat pruned: ${msgs.rowCount} messages, ${rows.length} files (older than ${days} days)`);
}
function notificationsScope(user) {
const s = branchScope(user);
if (s.admin) return { cond: '', params: [] };
@@ -1340,7 +1412,7 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() {
const dir = UPLOADS_DIR;
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }, { rows: studentAvatars }, { rows: groupCovers }, { rows: originals }, { rows: jobBefore }] = await Promise.all([
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }, { rows: studentAvatars }, { rows: groupCovers }, { rows: originals }, { rows: jobBefore }, { rows: chatFiles }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
@@ -1353,9 +1425,10 @@ async function sweepOrphanedUploads() {
pool.query('SELECT cover_path AS p FROM groups WHERE cover_path IS NOT NULL'),
pool.query('SELECT photo_original_path AS p FROM entries WHERE photo_original_path IS NOT NULL'),
pool.query('SELECT before_path AS p FROM photo_jobs WHERE before_path IS NOT NULL'),
pool.query('SELECT path AS p FROM chat_attachments'),
]);
const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos, ...studentAvatars, ...groupCovers, ...originals, ...jobBefore].forEach(r => {
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos, ...studentAvatars, ...groupCovers, ...originals, ...jobBefore, ...chatFiles].forEach(r => {
const key = storage.keyFromPath(r.p);
if (key) refs.add(key);
});
@@ -1830,6 +1903,347 @@ app.delete('/api/notifications', requireAdmin, async (req, res) => {
res.json({ ok: true, deleted: rowCount });
});
// --- Chat (тьютор ↔ админ) ---
const CHAT_CHANNEL = 'whatido:chat';
const chatClients = new Set();
function chatVisible(user, tutorId) {
if (!user) return false;
if (user.role === 'admin') return true;
return Number(user.id) === Number(tutorId);
}
function writeChatFrame(client, event, data) {
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
function publishChat(payload) {
cache.publish(CHAT_CHANNEL, payload).catch(err => console.error('Chat publish failed:', err.message));
}
cache.on(CHAT_CHANNEL, message => {
let payload = null;
try { payload = JSON.parse(message); } catch (e) { return; }
if (!payload || !payload.thread_id) return;
for (const client of [...chatClients]) {
if (!chatVisible(client.user, payload.tutor_id)) continue;
try { writeChatFrame(client, 'message', payload); } catch (e) { chatClients.delete(client); }
}
});
const chatLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: cache.rateLimitStore('chat', 60 * 1000),
message: { error: 'Слишком много сообщений. Подождите минуту.' },
});
async function chatThreadFor(user, threadId) {
const id = parseInt(threadId, 10);
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,
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`,
[id]
);
if (!rows.length) return { error: 404, message: 'Диалог не найден' };
if (user.role !== 'admin' && rows[0].tutor_id !== user.id) return { error: 403, message: 'Нет доступа к диалогу' };
return { thread: rows[0] };
}
function chatThreadJson(t, unread) {
return {
id: t.id,
tutor_id: t.tutor_id,
tutor_username: t.tutor_username,
tutor_name: t.tutor_name || t.tutor_username,
tutor_active: t.tutor_active !== false,
last_message_at: t.last_message_at,
last_message_text: t.last_message_text || '',
unread: unread === undefined ? 0 : unread,
created_at: t.created_at,
};
}
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');
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]);
return rows.length ? rows[0].n : 0;
}
app.get('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => {
try {
const isAdmin = req.user.role === 'admin';
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,
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
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,
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) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/chat/threads', requireAuth, chatLimiter, async (req, res) => {
try {
let tutorId = req.user.id;
if (req.user.role === 'admin') {
const wanted = parseInt(req.body?.user_id, 10);
if (!Number.isInteger(wanted) || wanted < 1) return res.status(400).json({ error: 'Некорректный пользователь' });
const { rows } = await pool.query('SELECT id FROM users WHERE id = $1', [wanted]);
if (!rows.length) return res.status(404).json({ error: 'Пользователь не найден' });
tutorId = rows[0].id;
}
const { rows: ins } = await pool.query(
`INSERT INTO chat_threads (tutor_id) VALUES ($1)
ON CONFLICT (tutor_id) DO UPDATE SET tutor_id = EXCLUDED.tutor_id
RETURNING id`,
[tutorId]
);
const acc = await chatThreadFor(req.user, ins[0].id);
if (acc.error) return res.status(acc.error).json({ error: acc.message });
res.json({ thread: chatThreadJson(acc.thread, 0) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req, res) => {
try {
const acc = await chatThreadFor(req.user, req.params.id);
if (acc.error) return res.status(acc.error).json({ error: acc.message });
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 200);
const before = parseInt(req.query.before, 10);
const params = [acc.thread.id];
let extra = '';
if (Number.isInteger(before) && before > 0) {
params.push(before);
extra = ` AND m.id < $${params.length}`;
}
const { rows } = await pool.query(
`SELECT m.id, m.sender_id, m.body, m.created_at, u.username, u.name, u.role
FROM chat_messages m JOIN users u ON u.id = m.sender_id
WHERE m.thread_id = $1${extra}
ORDER BY m.id DESC
LIMIT $${params.length + 1}`,
[...params, limit]
);
const ids = rows.map(m => m.id);
const byMessage = new Map();
if (ids.length) {
const fr = await pool.query(
`SELECT a.id, a.message_id, a.token, a.name, a.size
FROM chat_attachments a
WHERE a.message_id = ANY($1::int[])
ORDER BY a.id`,
[ids]
);
for (const f of fr.rows) {
if (!byMessage.has(f.message_id)) byMessage.set(f.message_id, []);
byMessage.get(f.message_id).push({ id: f.id, token: f.token, name: f.name, size: f.size });
}
}
const items = rows.reverse().map(m => ({
id: m.id,
sender_id: m.sender_id,
mine: m.sender_id === req.user.id,
sender_name: m.name || m.username,
sender_role: m.role,
body: m.body,
created_at: m.created_at,
files: byMessage.get(m.id) || [],
}));
const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread;
res.json({ items, thread: chatThreadJson(acc.thread, unreadCol) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
const chatFilesUpload = multer({
storage: multer.diskStorage({
destination: (_, __, cb) => {
fs.mkdirSync('uploads', { recursive: true });
cb(null, 'uploads');
},
filename: (_, file, cb) => {
const ext = path.extname(fixFilename(file.originalname)) || '.bin';
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
}),
limits: { fileSize: MAX_FILE_UPLOAD_BYTES },
fileFilter: chatUploadFilter,
}).array('files', CHAT_ATTACH_MAX);
app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res, next) => {
chatFilesUpload(req, res, (err) => {
if (!err) return next();
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: FILE_TOO_LARGE_ERROR });
if (err.code === 'LIMIT_FILE_COUNT') return res.status(400).json({ error: `Не больше ${CHAT_ATTACH_MAX} файлов` });
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
return res.status(400).json({ error: 'Недопустимый файл' });
});
}, async (req, res) => {
const acc = await chatThreadFor(req.user, req.params.id);
const files = req.files || [];
if (acc.error) {
files.forEach(removeUpload);
return res.status(acc.error).json({ error: acc.message });
}
const body = String(req.body?.body || '').trim().slice(0, CHAT_BODY_MAX);
if (!body && !files.length) return res.status(400).json({ error: 'Пустое сообщение' });
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
files.forEach(removeUpload);
return res.status(400).json({ error: TOTAL_TOO_LARGE_ERROR });
}
const client = await pool.connect();
let created;
try {
await client.query('BEGIN');
const mr = await client.query(
'INSERT INTO chat_messages (thread_id, sender_id, body) VALUES ($1, $2, $3) RETURNING id, sender_id, body, created_at',
[acc.thread.id, req.user.id, body]
);
created = mr.rows[0];
for (const f of files) {
await convertPhoto(f);
await client.query(
'INSERT INTO chat_attachments (message_id, token, path, name, size) VALUES ($1, $2, $3, $4, $5)',
[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
WHERE id = $1`,
[acc.thread.id, body.slice(0, 300) || `Вложение: ${files[0].originalname}`, byTutor ? 0 : 1, byTutor ? 1 : 0]
);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
files.forEach(removeUpload);
return res.status(500).json({ error: e.message });
} finally {
client.release();
}
const { rows: frows } = await pool.query(
'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id',
[created.id]
);
const message = {
id: created.id,
sender_id: created.sender_id,
mine: true,
sender_name: req.user.name || req.user.username,
sender_role: req.user.role,
body: created.body,
created_at: created.created_at,
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 });
await logAudit(req, 'chat.message.create', { thread_id: acc.thread.id, message_id: created.id, files: frows.length });
res.json({ message });
});
app.post('/api/chat/threads/:id/read', requireAuth, 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 col = req.user.role === 'admin' ? 'admin_unread' : 'tutor_unread';
await pool.query(`UPDATE chat_threads SET ${col} = 0 WHERE id = $1`, [acc.thread.id]);
res.json({ ok: true, unread: await chatUnread(req.user) });
});
app.get('/api/chat/unread', requireAuth, chatLimiter, async (req, res) => {
res.json({ unread: await chatUnread(req.user) });
});
app.get('/api/chat/stream', async (req, res) => {
let user = null;
try {
user = await loadUserByToken(req.headers['x-auth-token'] || req.query.token);
} catch (e) {
return res.status(500).end();
}
if (!user || !user.is_active) return res.status(401).end();
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(':ok\n\n');
const client = { res, user };
chatClients.add(client);
chatUnread(user)
.then(unread => writeChatFrame(client, 'ready', { unread }))
.catch(err => console.error('Chat unread failed:', err.message));
const ping = setInterval(() => {
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); chatClients.delete(client); }
}, 25000);
req.on('close', () => { clearInterval(ping); chatClients.delete(client); });
});
app.get('/api/chat/files/:token', fileLimiter, async (req, res) => {
let user = null;
try {
user = await loadUserByToken(req.headers['x-auth-token'] || req.query.token);
} catch (e) {
return res.status(500).json({ error: 'Internal server error' });
}
if (!user || !user.is_active) return res.status(401).json({ error: 'Требуется вход' });
const { rows } = await pool.query(
`SELECT a.token, a.path, a.name, t.tutor_id
FROM chat_attachments a
JOIN chat_messages m ON m.id = a.message_id
JOIN chat_threads t ON t.id = m.thread_id
WHERE a.token = $1`,
[req.params.token]
);
if (!rows.length) return res.status(404).json({ error: 'Файл не найден' });
const r = rows[0];
if (!chatVisible(user, r.tutor_id)) return res.status(403).json({ error: 'Нет доступа к файлу' });
const key = storage.keyFromPath(r.path);
if (!key) return res.status(404).json({ error: 'Файл не найден' });
if (isImageName(r.name)) {
if (req.query.thumb) return sendImageThumb(res, key);
const okImg = await storage.streamTo(res, key, { cacheControl: 'private, max-age=3600' });
if (!okImg && !res.headersSent) return res.status(404).json({ error: 'Файл не найден' });
return;
}
if (isPlayableVideoName(r.name) && req.query.play) {
const played = await sendPlayableFile(req, res, key, r.name);
if (!played && !res.headersSent) return res.status(404).json({ error: 'Файл не найден' });
return;
}
if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) {
return res.status(404).json({ error: 'Файл не найден' });
}
});
// --- Users (admin only) ---
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
const { rows } = await pool.query(
@@ -2517,7 +2931,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi] = await Promise.all([
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'),
@@ -2538,6 +2952,9 @@ async function buildBackupArchive() {
pool.query('SELECT * FROM notifications ORDER BY id'),
pool.query('SELECT * FROM notification_reads ORDER BY user_id, notification_id'),
pool.query('SELECT * FROM banned_ips ORDER BY ip'),
pool.query('SELECT * FROM chat_threads ORDER BY id'),
pool.query('SELECT * FROM chat_messages ORDER BY id'),
pool.query('SELECT * FROM chat_attachments ORDER BY id'),
]);
const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; });
@@ -2552,8 +2969,9 @@ async function buildBackupArchive() {
share_links: sl.rowCount, photo_jobs: pj.rowCount, lesson_reports: lr.rowCount,
lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount,
notification_reads: nr.rowCount, banned_ips: bi.rowCount,
chat_threads: ct.rowCount, chat_messages: cm.rowCount, chat_attachments: ca.rowCount,
},
groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows,
groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows,
};
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
const files = await storage.downloadAll(path.join(staging, 'uploads'));
@@ -2716,6 +3134,9 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
await client.query('DELETE FROM notification_reads');
await client.query('DELETE FROM notifications');
await client.query('DELETE FROM banned_ips');
await client.query('DELETE FROM chat_attachments');
await client.query('DELETE FROM chat_messages');
await client.query('DELETE FROM chat_threads');
await client.query('DELETE FROM project_files');
await client.query('DELETE FROM lesson_report_versions');
await client.query('DELETE FROM lesson_reports');
@@ -2843,6 +3264,32 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.user_id, x.notification_id, x.read_at]
);
}
for (const x of ndata.chat_threads) {
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]
);
}
for (const x of ndata.chat_messages) {
const ex = await client.query('SELECT 1 FROM chat_threads WHERE id = $1', [x.thread_id]);
if (!ex.rowCount) continue;
const eu = await client.query('SELECT 1 FROM users WHERE id = $1', [x.sender_id]);
if (!eu.rowCount) continue;
await client.query(
'INSERT INTO chat_messages (id, thread_id, sender_id, body, created_at) VALUES ($1,$2,$3,$4,$5)',
[x.id, x.thread_id, x.sender_id, x.body, x.created_at]
);
}
for (const x of ndata.chat_attachments) {
const ex = await client.query('SELECT 1 FROM chat_messages WHERE id = $1', [x.message_id]);
if (!ex.rowCount) continue;
await client.query(
'INSERT INTO chat_attachments (id, message_id, token, path, name, size, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[x.id, x.message_id, x.token, x.path, x.name, x.size || 0, x.created_at]
);
}
for (const x of ndata.audit_log) {
const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
await client.query(
@@ -7677,8 +8124,11 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); }
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); }
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); }
setInterval(() => { purgeOldChat().catch(err => console.error('Chat purge:', err)); }, 24 * 60 * 60 * 1000).unref();
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Camera setting:', err); }
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Trash purge days setting:', err); }
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }