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
+259 -124
View File
@@ -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 });
});