Add in-memory caching layer with TTL and invalidation for settings, groups, students, entries, share payloads and stats; add static asset caching headers

This commit is contained in:
dev
2026-09-11 23:52:58 +03:00
parent f9d310c8ea
commit 9798872f20
+145 -10
View File
@@ -17,6 +17,56 @@ types.setTypeParser(1082, v => v);
const app = express(); const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); 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({ const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 300, max: 300,
@@ -64,8 +114,9 @@ app.use(helmet({
} }
})); }));
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/uploads', express.static(path.join(__dirname, 'uploads'), { maxAge: '365d', immutable: true }));
app.use(express.static(path.join(__dirname, 'public'))); 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; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -294,8 +345,12 @@ const adminUpload = multer({
}); });
async function getSetting(key, def) { 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]); 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'); 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) => { app.get('/api/public-settings', apiLimiter, async (_, res) => {
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 keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos'];
const out = {}; const result = {};
for (const k of keys) out[k] = await getSetting(k, ''); for (const k of keys) result[k] = await getSetting(k, '');
return result;
});
res.json(out); res.json(out);
}); });
@@ -685,6 +743,7 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
} }
await client.query('COMMIT'); await client.query('COMMIT');
await logAudit(req, 'settings.update', { settings }); await logAudit(req, 'settings.update', { settings });
invalidateSettings();
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key'); const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
const out = {}; const out = {};
rows.forEach(r => { out[r.key] = r.value; }); 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 sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
await ensureFirstAdmin().catch(err => console.error('First admin:', err)); await ensureFirstAdmin().catch(err => console.error('First admin:', err));
await logAudit(req, 'backup.restore', {}); await logAudit(req, 'backup.restore', {});
invalidateAll();
res.json({ ok: true }); 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] [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() }); await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
invalidateShare();
res.status(201).json(normDates(rows[0])); 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: 'Не найдено' }); if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
await logAudit(req, 'link.update', { id: req.params.id, name: name.trim() }); await logAudit(req, 'link.update', { id: req.params.id, name: name.trim() });
invalidateShare();
res.json(normDates(rows[0])); 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 pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]);
await logAudit(req, 'link.delete', { id: req.params.id }); await logAudit(req, 'link.delete', { id: req.params.id });
invalidateShare();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -1351,6 +1414,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
} }
} }
const payload = await cacheWrap('share:payload:' + req.params.token, SHARE_TTL_MS, async () => {
const conditions = []; const conditions = [];
const params = []; const params = [];
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); } if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
@@ -1376,7 +1440,6 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
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) { if (!showStudentNames) {
const nameMap = new Map(); const nameMap = new Map();
let counter = 1; let counter = 1;
@@ -1397,7 +1460,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
); );
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 })); 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({ return {
name: l.name, name: l.name,
group_name: l.group_name, group_name: l.group_name,
student_name: l.student_name, student_name: l.student_name,
@@ -1414,7 +1477,9 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false', show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false',
entries, entries,
photos: (l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false') ? photos : [], 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) => { app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res) => {
@@ -1460,6 +1525,7 @@ app.get('/s/:token', (req, res) => {
// --- Groups CRUD --- // --- Groups CRUD ---
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => { app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
const rows = await cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }; const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT g.*, `SELECT g.*,
@@ -1475,10 +1541,13 @@ app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
ORDER BY g.id`, ORDER BY g.id`,
bw.params bw.params
); );
return rows;
});
res.json(rows); res.json(rows);
}); });
app.get('/api/groups/active', apiLimiter, async (_, res) => { app.get('/api/groups/active', apiLimiter, async (_, res) => {
const rows = await cacheWrap('groups:active', PUBLIC_TTL_MS, async () => {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT * FROM groups SELECT * FROM groups
WHERE day_of_week IS NOT NULL WHERE day_of_week IS NOT NULL
@@ -1488,6 +1557,8 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => {
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
ORDER BY id ORDER BY id
`); `);
return rows;
});
res.json(rows); res.json(rows);
}); });
@@ -1516,6 +1587,8 @@ app.put('/api/groups/:id', requireAuth, async (req, res) => {
req.params.id] req.params.id]
); );
await logAudit(req, 'group.update', { id: req.params.id, ...req.body }); await logAudit(req, 'group.update', { id: req.params.id, ...req.body });
invalidateGroups();
invalidateStats();
res.json(rows[0]); res.json(rows[0]);
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' }); 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] [name.trim(), isAdmin ? effectiveBranch : null]
); );
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id }); await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id });
invalidateGroups();
invalidateStats();
res.status(201).json(rows[0]); res.status(201).json(rows[0]);
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); 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)); rows.forEach(r => safeUnlink(r.photo_path));
await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]); await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
await logAudit(req, 'group.delete', { id: req.params.id }); await logAudit(req, 'group.delete', { id: req.params.id });
invalidateGroups();
invalidateStats();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -1591,6 +1668,7 @@ app.post('/api/branches', requireAdmin, async (req, res) => {
[name.trim(), address?.trim() || null, phone?.trim() || null] [name.trim(), address?.trim() || null, phone?.trim() || null]
); );
await logAudit(req, 'branch.create', { id: rows[0].id, name: name.trim() }); await logAudit(req, 'branch.create', { id: rows[0].id, name: name.trim() });
invalidateGroups();
res.status(201).json(rows[0]); res.status(201).json(rows[0]);
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' }); 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: 'Не найдено' }); if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
await logAudit(req, 'branch.update', { id: req.params.id, ...req.body }); await logAudit(req, 'branch.update', { id: req.params.id, ...req.body });
invalidateGroups();
res.json(rows[0]); res.json(rows[0]);
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' }); 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: 'Нельзя удалить филиал: есть привязанные группы' }); if (rows.length) return res.status(400).json({ error: 'Нельзя удалить филиал: есть привязанные группы' });
await pool.query('DELETE FROM branches WHERE id = $1', [req.params.id]); await pool.query('DELETE FROM branches WHERE id = $1', [req.params.id]);
await logAudit(req, 'branch.delete', { id: req.params.id }); await logAudit(req, 'branch.delete', { id: req.params.id });
invalidateGroups();
res.json({ ok: true }); 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] [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 }); 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]); res.status(201).json(rows[0]);
} catch (e) { } catch (e) {
safeUnlink(`uploads/${req.file.filename}`); 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 client.query('COMMIT');
await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids }); await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids });
invalidateShare();
invalidateGroups();
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
await client.query('ROLLBACK'); 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: 'Не найдено' }); 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 }); await logAudit(req, 'group.photo.update', { group_id: req.params.id, photo_id: req.params.photoId });
invalidateShare();
res.json(rows[0]); 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('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 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 }); await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
invalidateShare();
invalidateGroups();
invalidateStats();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -1776,12 +1865,15 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) =
if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); 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 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 }); 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]); const { rows: gRows } = await pool.query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
res.json(gRows[0]); res.json(gRows[0]);
}); });
// --- Students CRUD --- // --- Students CRUD ---
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }; const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT s.*, g.name AS group_name FROM students s `SELECT s.*, g.name AS group_name FROM students s
@@ -1789,6 +1881,8 @@ app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
WHERE 1=1${bw.where} ORDER BY s.name`, WHERE 1=1${bw.where} ORDER BY s.name`,
bw.params bw.params
); );
return rows;
});
res.json(rows); res.json(rows);
}); });
@@ -1817,6 +1911,8 @@ app.post('/api/students', requireAuth, async (req, res) => {
[name.trim(), gid] [name.trim(), gid]
); );
await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() }); await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() });
invalidateStudents();
invalidateStats();
res.status(201).json(rows[0]); res.status(201).json(rows[0]);
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); 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' }); if (!rows.length) return res.status(404).json({ error: 'Not found' });
await logAudit(req, 'student.update', { id: req.params.id, name: name.trim() }); await logAudit(req, 'student.update', { id: req.params.id, name: name.trim() });
invalidateStudents();
res.json(rows[0]); res.json(rows[0]);
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); 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 params
); );
await logAudit(req, 'student.batch-group', { group_id, count: rows.length }); await logAudit(req, 'student.batch-group', { group_id, count: rows.length });
invalidateStudents();
res.json({ ok: true, updated: rows.length }); 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 pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
await logAudit(req, 'student.delete', { id: req.params.id }); await logAudit(req, 'student.delete', { id: req.params.id });
invalidateStudents();
invalidateStats();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -2026,6 +2126,8 @@ app.post('/api/entries/:id/files', requireAuth, (req, res, next) => {
); );
} }
await client.query('COMMIT'); await client.query('COMMIT');
invalidateShare();
invalidateEntries();
res.status(201).json({ ok: true, count: files.length }); res.status(201).json({ ok: true, count: files.length });
} catch (e) { } catch (e) {
await client.query('ROLLBACK').catch(() => {}); await client.query('ROLLBACK').catch(() => {});
@@ -2133,6 +2235,8 @@ app.post('/api/files/:id/detach', requireAuth, async (req, res) => {
[req.params.id] [req.params.id]
); );
if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
invalidateShare();
invalidateEntries();
res.json({ ok: true }); 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: 'Не найдено' }); if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
safeUnlink(rows[0].path); safeUnlink(rows[0].path);
await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]); await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]);
invalidateShare();
invalidateEntries();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -2166,6 +2272,7 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => {
}); });
app.get('/api/stats', requireAuth, async (req, res) => { app.get('/api/stats', requireAuth, async (req, res) => {
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
const isAdmin = req.user.role === 'admin'; const isAdmin = req.user.role === 'admin';
const s = branchScope(req.user); const s = branchScope(req.user);
let groupFilter; let groupFilter;
@@ -2186,17 +2293,20 @@ app.get('/api/stats', requireAuth, async (req, res) => {
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(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), 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({ return {
entries: entries.rows[0].n, entries: entries.rows[0].n,
trash: trash.rows[0].n, trash: trash.rows[0].n,
groups: groups.rows[0].n, groups: groups.rows[0].n,
students: students.rows[0].n, students: students.rows[0].n,
today: today.rows[0].n, today: today.rows[0].n,
};
}); });
res.json(payload);
}); });
app.get('/api/system-info', requireAdmin, async (_, res) => { app.get('/api/system-info', requireAdmin, async (_, res) => {
try { try {
const payload = await cacheWrap('system-info', SYSTEM_TTL_MS, async () => {
const db = await pool.query(` const db = await pool.query(`
SELECT SELECT
pg_size_pretty(pg_database_size(current_database())) AS db_size, 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(); const diskInfo = getDiskInfo();
res.json({ return {
database: { database: {
size: db.rows[0].db_size, size: db.rows[0].db_size,
size_bytes: parseInt(db.rows[0].db_size_bytes, 10), 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, size_bytes: uploadsSize,
}, },
disk: diskInfo, disk: diskInfo,
};
}); });
res.json(payload);
} catch (e) { } catch (e) {
console.error('System info error:', e); console.error('System info error:', e);
res.status(500).json({ error: e.message }); 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) => { app.get('/api/dashboard', requireAuth, async (req, res) => {
const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => {
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const isAdmin = req.user.role === 'admin'; const isAdmin = req.user.role === 'admin';
const s = branchScope(req.user); 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_start: (g.time_start || '').slice(0, 5),
time_end: (g.time_end || '').slice(0, 5), time_end: (g.time_end || '').slice(0, 5),
})); }));
res.json({ return {
stats: stats, stats: stats,
activity: activity.rows, activity: activity.rows,
active_groups: activeGroups, active_groups: activeGroups,
@@ -2374,7 +2487,9 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
photos: photos.rows, photos: photos.rows,
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null, latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
disk: getDiskInfo(), disk: getDiskInfo(),
};
}); });
res.json(payload);
}); });
const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'files', maxCount: 10 }]); 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'); await client.query('COMMIT');
if (entryAutoChecker) entryAutoChecker.notify(); if (entryAutoChecker) entryAutoChecker.notify();
invalidateEntries();
invalidateStats();
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length }); res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
} catch (e) { } catch (e) {
await client.query('ROLLBACK').catch(() => {}); 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 }); await logAudit(req, 'entry.update', { id: req.params.id });
invalidateEntries();
invalidateStats();
res.json(rows[0]); 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 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 }); await logAudit(req, 'entry.photo.delete', { entry_id: req.params.id, photo_id: req.params.photoId });
invalidateEntries();
res.json({ ok: true }); 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: 'Фото не найдено' }); 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 }); await logAudit(req, 'entry.photo.update', { entry_id: req.params.id, photo_id: req.params.photoId });
invalidateEntries();
res.json(rows[0]); 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: 'Фото не найдено' }); 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 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 }); 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 }); 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 pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1', [req.params.id]);
await logAudit(req, 'entry.soft-delete', { id: req.params.id }); await logAudit(req, 'entry.soft-delete', { id: req.params.id });
invalidateEntries();
invalidateStats();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -2618,6 +2742,8 @@ app.put('/api/entries/:id/restore', requireAuth, async (req, res) => {
return res.status(404).json({ error: 'Запись не найдена или уже восстановлена' }); return res.status(404).json({ error: 'Запись не найдена или уже восстановлена' });
} }
await logAudit(req, 'entry.restore', { id: req.params.id }); await logAudit(req, 'entry.restore', { id: req.params.id });
invalidateEntries();
invalidateStats();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -2633,6 +2759,8 @@ app.delete('/api/entries/:id/permanent', requireAuth, async (req, res) => {
await removeEntryFiles(req.params.id); await removeEntryFiles(req.params.id);
await pool.query('DELETE FROM entries WHERE id = $1', [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 }); await logAudit(req, 'entry.permanent-delete', { id: req.params.id });
invalidateEntries();
invalidateStats();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -2748,6 +2876,7 @@ app.post('/api/ai/requeue-failed', requireAdmin, async (req, res) => {
); );
if (entryAutoChecker) entryAutoChecker.notify(); if (entryAutoChecker) entryAutoChecker.notify();
await logAudit(req, 'ai.requeue-failed', { count: rowCount }); await logAudit(req, 'ai.requeue-failed', { count: rowCount });
invalidateEntries();
res.json({ ok: true, count: rowCount }); 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 (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
if (entryAutoChecker) entryAutoChecker.notify(); if (entryAutoChecker) entryAutoChecker.notify();
await logAudit(req, 'entry.ai.recheck', { id: req.params.id }); await logAudit(req, 'entry.ai.recheck', { id: req.params.id });
invalidateEntries();
invalidateStats();
res.json({ ok: true }); 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: 'Оригинал текста недоступен' }); if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' });
await logAudit(req, 'entry.ai.revert', { id: req.params.id }); await logAudit(req, 'entry.ai.revert', { id: req.params.id });
invalidateEntries();
invalidateStats();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -2834,6 +2967,8 @@ app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => {
); );
rows.forEach(r => safeUnlink(r.p)); rows.forEach(r => safeUnlink(r.p));
const d = await pool.query('DELETE FROM entries WHERE deleted_at IS NOT NULL'); const d = await pool.query('DELETE FROM entries WHERE deleted_at IS NOT NULL');
invalidateEntries();
invalidateStats();
res.json({ ok: true, deleted: d.rowCount }); res.json({ ok: true, deleted: d.rowCount });
}); });