diff --git a/server.js b/server.js index 4ef970b..3687e77 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,56 @@ types.setTypeParser(1082, v => v); const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const cacheStore = new Map(); +const SETTINGS_TTL_MS = 30 * 1000; +const PUBLIC_TTL_MS = 60 * 1000; +const SHARE_TTL_MS = 60 * 1000; +const STATS_TTL_MS = 15 * 1000; +const SYSTEM_TTL_MS = 30 * 1000; + +function cacheGet(key) { + const entry = cacheStore.get(key); + if (!entry) return undefined; + if (entry.exp && entry.exp <= Date.now()) { + cacheStore.delete(key); + return undefined; + } + return entry.value; +} + +function cacheSet(key, value, ttlMs) { + cacheStore.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 }); +} + +function cacheDrop(prefix) { + for (const key of cacheStore.keys()) { + if (key.startsWith(prefix)) cacheStore.delete(key); + } +} + +async function cacheWrap(key, ttlMs, fn) { + const hit = cacheGet(key); + if (hit !== undefined) return hit; + const value = await fn(); + cacheSet(key, value, ttlMs); + return value; +} + +function scopeKey(user) { + if (!user) return 'anon'; + const s = branchScope(user); + if (s.admin) return 'all'; + return s.ids.length ? s.ids.slice().sort((a, b) => a - b).join('-') : 'none'; +} + +function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:'); cacheDrop('public-settings'); } +function invalidateStudents() { cacheDrop('students:'); } +function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); } +function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); } +function invalidateShare() { cacheDrop('share:payload:'); } +function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); } +function invalidateAll() { cacheStore.clear(); } + const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 300, @@ -64,8 +114,9 @@ app.use(helmet({ } })); app.use(express.json({ limit: '1mb' })); -app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); -app.use(express.static(path.join(__dirname, 'public'))); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'), { maxAge: '365d', immutable: true })); +app.use('/vendor', express.static(path.join(__dirname, 'public', 'vendor'), { maxAge: '30d' })); +app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h' })); const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; @@ -294,8 +345,12 @@ const adminUpload = multer({ }); async function getSetting(key, def) { + const cached = cacheGet('setting:' + key); + if (cached !== undefined) return cached; const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]); - return rows.length ? rows[0].value : def; + const value = rows.length ? rows[0].value : def; + cacheSet('setting:' + key, value, SETTINGS_TTL_MS); + return value; } const UPLOADS_DIR = path.join(__dirname, 'uploads'); @@ -656,9 +711,12 @@ app.get('/api/settings', requireAdmin, async (_, res) => { }); app.get('/api/public-settings', apiLimiter, async (_, res) => { - const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos']; - const out = {}; - for (const k of keys) out[k] = await getSetting(k, ''); + const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => { + const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos']; + const result = {}; + for (const k of keys) result[k] = await getSetting(k, ''); + return result; + }); res.json(out); }); @@ -685,6 +743,7 @@ app.put('/api/settings', requireAdmin, async (req, res) => { } await client.query('COMMIT'); await logAudit(req, 'settings.update', { settings }); + invalidateSettings(); const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key'); const out = {}; rows.forEach(r => { out[r.key] = r.value; }); @@ -1163,6 +1222,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err)); await ensureFirstAdmin().catch(err => console.error('First admin:', err)); await logAudit(req, 'backup.restore', {}); + invalidateAll(); res.json({ ok: true }); }); @@ -1246,6 +1306,7 @@ app.post('/api/links', requireAuth, async (req, res) => { [token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, expiresAt, passwordHash, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos] ); await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() }); + invalidateShare(); res.status(201).json(normDates(rows[0])); }); @@ -1308,6 +1369,7 @@ app.put('/api/links/:id', requireAuth, async (req, res) => { ); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await logAudit(req, 'link.update', { id: req.params.id, name: name.trim() }); + invalidateShare(); res.json(normDates(rows[0])); }); @@ -1321,6 +1383,7 @@ app.delete('/api/links/:id', requireAuth, async (req, res) => { } await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]); await logAudit(req, 'link.delete', { id: req.params.id }); + invalidateShare(); res.json({ ok: true }); }); @@ -1351,70 +1414,72 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => { } } - const conditions = []; - const params = []; - if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); } - if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); } - if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); } - if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); } - conditions.push('e.deleted_at IS NULL'); - const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; - const { rows: entries } = await pool.query( - `SELECT e.*, g.name AS group_name FROM entries e - JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`, - params - ); - let files = {}; - if (entries.length) { - const fRes = await pool.query( - 'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', - [entries.map(r => r.id)] + const payload = await cacheWrap('share:payload:' + req.params.token, SHARE_TTL_MS, async () => { + const conditions = []; + const params = []; + if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); } + if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); } + if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); } + if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); } + conditions.push('e.deleted_at IS NULL'); + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; + const { rows: entries } = await pool.query( + `SELECT e.*, g.name AS group_name FROM entries e + JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`, + params ); - fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push({ token: f.token, name: f.name }); }); - } - entries.forEach(r => { r.files = files[r.id] || []; }); + let files = {}; + if (entries.length) { + const fRes = await pool.query( + 'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', + [entries.map(r => r.id)] + ); + fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push({ token: f.token, name: f.name }); }); + } + entries.forEach(r => { r.files = files[r.id] || []; }); - const showStudentNames = l.show_student_names != null ? l.show_student_names : (await getSetting('share_show_student_names', 'true')) !== 'false'; + const showStudentNames = l.show_student_names != null ? l.show_student_names : (await getSetting('share_show_student_names', 'true')) !== 'false'; - // Anonymize names if disabled - if (!showStudentNames) { - const nameMap = new Map(); - let counter = 1; - entries.forEach(e => { - if (!nameMap.has(e.student_name)) { - nameMap.set(e.student_name, `Ученик ${counter++}`); - } - e.student_name = nameMap.get(e.student_name); - }); - } + if (!showStudentNames) { + const nameMap = new Map(); + let counter = 1; + entries.forEach(e => { + if (!nameMap.has(e.student_name)) { + nameMap.set(e.student_name, `Ученик ${counter++}`); + } + e.student_name = nameMap.get(e.student_name); + }); + } - let photos = []; - if (l.group_id) { - const pRes = await pool.query( - `SELECT id, photo_path, caption, taken_at, created_at FROM group_photos - WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`, - [l.group_id] - ); - photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at })); - } - res.json({ - name: l.name, - group_name: l.group_name, - student_name: l.student_name, - group_id: l.group_id, - date_from: l.date_from, - date_to: l.date_to, - message: l.message, - link_url: l.link_url, - created_at: l.created_at, - expires_at: l.expires_at, - show_student_names: showStudentNames, - show_student_message: l.show_student_message != null ? l.show_student_message : (await getSetting('share_show_student_message', 'false')) === 'true', - show_entry_date: l.show_entry_date != null ? l.show_entry_date : (await getSetting('share_show_entry_date', 'false')) === 'true', - show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false', - entries, - photos: (l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false') ? photos : [], + let photos = []; + if (l.group_id) { + const pRes = await pool.query( + `SELECT id, photo_path, caption, taken_at, created_at FROM group_photos + WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`, + [l.group_id] + ); + photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at })); + } + return { + name: l.name, + group_name: l.group_name, + student_name: l.student_name, + group_id: l.group_id, + date_from: l.date_from, + date_to: l.date_to, + message: l.message, + link_url: l.link_url, + created_at: l.created_at, + expires_at: l.expires_at, + show_student_names: showStudentNames, + show_student_message: l.show_student_message != null ? l.show_student_message : (await getSetting('share_show_student_message', 'false')) === 'true', + show_entry_date: l.show_entry_date != null ? l.show_entry_date : (await getSetting('share_show_entry_date', 'false')) === 'true', + show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false', + entries, + photos: (l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false') ? photos : [], + }; }); + res.json(payload); }); app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res) => { @@ -1460,34 +1525,40 @@ app.get('/s/:token', (req, res) => { // --- Groups CRUD --- app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => { - const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }; - const { rows } = await pool.query( - `SELECT g.*, - COALESCE(g.cover_path, - (SELECT photo_path FROM group_photos - WHERE group_id = g.id - ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC - LIMIT 1)) AS cover_path, - b.name AS branch_name - FROM groups g - LEFT JOIN branches b ON b.id = g.branch_id - WHERE 1=1${bw.where} - ORDER BY g.id`, - bw.params - ); + const rows = await cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => { + const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }; + const { rows } = await pool.query( + `SELECT g.*, + COALESCE(g.cover_path, + (SELECT photo_path FROM group_photos + WHERE group_id = g.id + ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC + LIMIT 1)) AS cover_path, + b.name AS branch_name + FROM groups g + LEFT JOIN branches b ON b.id = g.branch_id + WHERE 1=1${bw.where} + ORDER BY g.id`, + bw.params + ); + return rows; + }); res.json(rows); }); app.get('/api/groups/active', apiLimiter, async (_, res) => { - const { rows } = await pool.query(` - SELECT * FROM groups - WHERE day_of_week IS NOT NULL - AND time_start IS NOT NULL - AND time_end IS NOT NULL - AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int - AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end - ORDER BY id - `); + const rows = await cacheWrap('groups:active', PUBLIC_TTL_MS, async () => { + const { rows } = await pool.query(` + SELECT * FROM groups + WHERE day_of_week IS NOT NULL + AND time_start IS NOT NULL + AND time_end IS NOT NULL + AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int + AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end + ORDER BY id + `); + return rows; + }); res.json(rows); }); @@ -1516,6 +1587,8 @@ app.put('/api/groups/:id', requireAuth, async (req, res) => { req.params.id] ); await logAudit(req, 'group.update', { id: req.params.id, ...req.body }); + invalidateGroups(); + invalidateStats(); res.json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' }); @@ -1537,6 +1610,8 @@ app.post('/api/groups', requireAuth, async (req, res) => { [name.trim(), isAdmin ? effectiveBranch : null] ); await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id }); + invalidateGroups(); + invalidateStats(); res.status(201).json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); @@ -1555,6 +1630,8 @@ app.delete('/api/groups/:id', requireAuth, async (req, res) => { rows.forEach(r => safeUnlink(r.photo_path)); await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]); await logAudit(req, 'group.delete', { id: req.params.id }); + invalidateGroups(); + invalidateStats(); res.json({ ok: true }); }); @@ -1591,6 +1668,7 @@ app.post('/api/branches', requireAdmin, async (req, res) => { [name.trim(), address?.trim() || null, phone?.trim() || null] ); await logAudit(req, 'branch.create', { id: rows[0].id, name: name.trim() }); + invalidateGroups(); res.status(201).json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' }); @@ -1612,6 +1690,7 @@ app.put('/api/branches/:id', requireAdmin, async (req, res) => { ); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await logAudit(req, 'branch.update', { id: req.params.id, ...req.body }); + invalidateGroups(); res.json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' }); @@ -1624,6 +1703,7 @@ app.delete('/api/branches/:id', requireAdmin, async (req, res) => { if (rows.length) return res.status(400).json({ error: 'Нельзя удалить филиал: есть привязанные группы' }); await pool.query('DELETE FROM branches WHERE id = $1', [req.params.id]); await logAudit(req, 'branch.delete', { id: req.params.id }); + invalidateGroups(); res.json({ ok: true }); }); @@ -1681,6 +1761,9 @@ app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => { [req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null] ); await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path }); + invalidateShare(); + invalidateGroups(); + invalidateStats(); res.status(201).json(rows[0]); } catch (e) { safeUnlink(`uploads/${req.file.filename}`); @@ -1722,6 +1805,8 @@ app.put('/api/groups/:id/photos/reorder', requireAuth, async (req, res) => { } await client.query('COMMIT'); await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids }); + invalidateShare(); + invalidateGroups(); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); @@ -1746,6 +1831,7 @@ app.put('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => { ); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await logAudit(req, 'group.photo.update', { group_id: req.params.id, photo_id: req.params.photoId }); + invalidateShare(); res.json(rows[0]); }); @@ -1762,6 +1848,9 @@ app.delete('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => { await pool.query('UPDATE groups SET cover_path = NULL WHERE id = $1 AND cover_path = $2', [req.params.id, rows[0].photo_path]); await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]); await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId }); + invalidateShare(); + invalidateGroups(); + invalidateStats(); res.json({ ok: true }); }); @@ -1776,19 +1865,24 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) = if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await pool.query('UPDATE groups SET cover_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]); await logAudit(req, 'group.photo.set_cover', { group_id: req.params.id, photo_id: req.params.photoId }); + invalidateShare(); + invalidateGroups(); const { rows: gRows } = await pool.query('SELECT * FROM groups WHERE id = $1', [req.params.id]); res.json(gRows[0]); }); // --- Students CRUD --- app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { - const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }; - const { rows } = await pool.query( - `SELECT s.*, g.name AS group_name FROM students s - LEFT JOIN groups g ON g.id = s.group_id - WHERE 1=1${bw.where} ORDER BY s.name`, - bw.params - ); + const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => { + const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }; + const { rows } = await pool.query( + `SELECT s.*, g.name AS group_name FROM students s + LEFT JOIN groups g ON g.id = s.group_id + WHERE 1=1${bw.where} ORDER BY s.name`, + bw.params + ); + return rows; + }); res.json(rows); }); @@ -1817,6 +1911,8 @@ app.post('/api/students', requireAuth, async (req, res) => { [name.trim(), gid] ); await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() }); + invalidateStudents(); + invalidateStats(); res.status(201).json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); @@ -1852,6 +1948,7 @@ app.put('/api/students/:id', requireAuth, async (req, res) => { ); if (!rows.length) return res.status(404).json({ error: 'Not found' }); await logAudit(req, 'student.update', { id: req.params.id, name: name.trim() }); + invalidateStudents(); res.json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); @@ -1876,6 +1973,7 @@ app.post('/api/students/batch-group', requireAuth, async (req, res) => { params ); await logAudit(req, 'student.batch-group', { group_id, count: rows.length }); + invalidateStudents(); res.json({ ok: true, updated: rows.length }); }); @@ -1892,6 +1990,8 @@ app.delete('/api/students/:id', requireAuth, async (req, res) => { } await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]); await logAudit(req, 'student.delete', { id: req.params.id }); + invalidateStudents(); + invalidateStats(); res.json({ ok: true }); }); @@ -2026,6 +2126,8 @@ app.post('/api/entries/:id/files', requireAuth, (req, res, next) => { ); } await client.query('COMMIT'); + invalidateShare(); + invalidateEntries(); res.status(201).json({ ok: true, count: files.length }); } catch (e) { await client.query('ROLLBACK').catch(() => {}); @@ -2133,6 +2235,8 @@ app.post('/api/files/:id/detach', requireAuth, async (req, res) => { [req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); + invalidateShare(); + invalidateEntries(); res.json({ ok: true }); }); @@ -2152,6 +2256,8 @@ app.delete('/api/files/:id', requireAuth, async (req, res) => { if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); safeUnlink(rows[0].path); await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]); + invalidateShare(); + invalidateEntries(); res.json({ ok: true }); }); @@ -2166,37 +2272,41 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => { }); app.get('/api/stats', requireAuth, async (req, res) => { - const isAdmin = req.user.role === 'admin'; - const s = branchScope(req.user); - let groupFilter; - if (isAdmin) { - groupFilter = { where: '', params: [] }; - } else if (!s.ids.length) { - groupFilter = { where: ' AND 1 = 0', params: [] }; - } else { - const ph = s.ids.map(id => `$${s.ids.indexOf(id) + 1}`).join(','); - groupFilter = { where: ` AND g.branch_id IN (${ph})`, params: s.ids }; - } - const groupsParams = isAdmin ? [] : (s.ids.length ? s.ids : [0]); - const groupsWhere = isAdmin ? '' : (s.ids.length ? ` WHERE g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0'); - const [entries, trash, groups, students, today] = await Promise.all([ - pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params), - pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${groupFilter.where}`, groupFilter.params), - pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams), - pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params), - pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${groupFilter.where}`, groupFilter.params), - ]); - res.json({ - entries: entries.rows[0].n, - trash: trash.rows[0].n, - groups: groups.rows[0].n, - students: students.rows[0].n, - today: today.rows[0].n, + const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => { + const isAdmin = req.user.role === 'admin'; + const s = branchScope(req.user); + let groupFilter; + if (isAdmin) { + groupFilter = { where: '', params: [] }; + } else if (!s.ids.length) { + groupFilter = { where: ' AND 1 = 0', params: [] }; + } else { + const ph = s.ids.map(id => `$${s.ids.indexOf(id) + 1}`).join(','); + groupFilter = { where: ` AND g.branch_id IN (${ph})`, params: s.ids }; + } + const groupsParams = isAdmin ? [] : (s.ids.length ? s.ids : [0]); + const groupsWhere = isAdmin ? '' : (s.ids.length ? ` WHERE g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0'); + const [entries, trash, groups, students, today] = await Promise.all([ + pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params), + pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${groupFilter.where}`, groupFilter.params), + pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams), + pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params), + pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${groupFilter.where}`, groupFilter.params), + ]); + return { + entries: entries.rows[0].n, + trash: trash.rows[0].n, + groups: groups.rows[0].n, + students: students.rows[0].n, + today: today.rows[0].n, + }; }); + res.json(payload); }); app.get('/api/system-info', requireAdmin, async (_, res) => { try { + const payload = await cacheWrap('system-info', SYSTEM_TTL_MS, async () => { const db = await pool.query(` SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size, @@ -2264,7 +2374,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => { const diskInfo = getDiskInfo(); - res.json({ + return { database: { size: db.rows[0].db_size, size_bytes: parseInt(db.rows[0].db_size_bytes, 10), @@ -2290,7 +2400,9 @@ app.get('/api/system-info', requireAdmin, async (_, res) => { size_bytes: uploadsSize, }, disk: diskInfo, + }; }); + res.json(payload); } catch (e) { console.error('System info error:', e); res.status(500).json({ error: e.message }); @@ -2298,6 +2410,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => { }); app.get('/api/dashboard', requireAuth, async (req, res) => { + const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => { const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; const isAdmin = req.user.role === 'admin'; const s = branchScope(req.user); @@ -2365,7 +2478,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { time_start: (g.time_start || '').slice(0, 5), time_end: (g.time_end || '').slice(0, 5), })); - res.json({ + return { stats: stats, activity: activity.rows, active_groups: activeGroups, @@ -2374,7 +2487,9 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { photos: photos.rows, latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null, disk: getDiskInfo(), + }; }); + res.json(payload); }); const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'files', maxCount: 10 }]); @@ -2462,6 +2577,8 @@ app.post('/api/entries', entryLimiter, (req, res, next) => { } await client.query('COMMIT'); if (entryAutoChecker) entryAutoChecker.notify(); + invalidateEntries(); + invalidateStats(); res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length }); } catch (e) { await client.query('ROLLBACK').catch(() => {}); @@ -2526,6 +2643,8 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req, } await logAudit(req, 'entry.update', { id: req.params.id }); + invalidateEntries(); + invalidateStats(); res.json(rows[0]); }); @@ -2560,6 +2679,7 @@ app.delete('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [nextRows.length ? nextRows[0].photo_path : null, req.params.id]); } await logAudit(req, 'entry.photo.delete', { entry_id: req.params.id, photo_id: req.params.photoId }); + invalidateEntries(); res.json({ ok: true }); }); @@ -2576,6 +2696,7 @@ app.put('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => { ); if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' }); await logAudit(req, 'entry.photo.update', { entry_id: req.params.id, photo_id: req.params.photoId }); + invalidateEntries(); res.json(rows[0]); }); @@ -2589,6 +2710,7 @@ app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) = if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' }); await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]); await logAudit(req, 'entry.photo.set_main', { entry_id: req.params.id, photo_id: req.params.photoId }); + invalidateEntries(); res.json({ ok: true, photo_path: rows[0].photo_path }); }); @@ -2600,6 +2722,8 @@ app.delete('/api/entries/:id', requireAuth, async (req, res) => { } await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1', [req.params.id]); await logAudit(req, 'entry.soft-delete', { id: req.params.id }); + invalidateEntries(); + invalidateStats(); res.json({ ok: true }); }); @@ -2618,6 +2742,8 @@ app.put('/api/entries/:id/restore', requireAuth, async (req, res) => { return res.status(404).json({ error: 'Запись не найдена или уже восстановлена' }); } await logAudit(req, 'entry.restore', { id: req.params.id }); + invalidateEntries(); + invalidateStats(); res.json({ ok: true }); }); @@ -2633,6 +2759,8 @@ app.delete('/api/entries/:id/permanent', requireAuth, async (req, res) => { await removeEntryFiles(req.params.id); await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]); await logAudit(req, 'entry.permanent-delete', { id: req.params.id }); + invalidateEntries(); + invalidateStats(); res.json({ ok: true }); }); @@ -2748,6 +2876,7 @@ app.post('/api/ai/requeue-failed', requireAdmin, async (req, res) => { ); if (entryAutoChecker) entryAutoChecker.notify(); await logAudit(req, 'ai.requeue-failed', { count: rowCount }); + invalidateEntries(); res.json({ ok: true, count: rowCount }); }); @@ -2764,6 +2893,8 @@ app.post('/api/entries/:id/ai/recheck', requireAuth, async (req, res) => { if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' }); if (entryAutoChecker) entryAutoChecker.notify(); await logAudit(req, 'entry.ai.recheck', { id: req.params.id }); + invalidateEntries(); + invalidateStats(); res.json({ ok: true }); }); @@ -2781,6 +2912,8 @@ app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => { ); if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' }); await logAudit(req, 'entry.ai.revert', { id: req.params.id }); + invalidateEntries(); + invalidateStats(); res.json({ ok: true }); }); @@ -2834,6 +2967,8 @@ app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => { ); rows.forEach(r => safeUnlink(r.p)); const d = await pool.query('DELETE FROM entries WHERE deleted_at IS NOT NULL'); + invalidateEntries(); + invalidateStats(); res.json({ ok: true, deleted: d.rowCount }); });