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:
@@ -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 keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos'];
|
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
||||||
const out = {};
|
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos'];
|
||||||
for (const k of keys) out[k] = await getSetting(k, '');
|
const result = {};
|
||||||
|
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,70 +1414,72 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditions = [];
|
const payload = await cacheWrap('share:payload:' + req.params.token, SHARE_TTL_MS, async () => {
|
||||||
const params = [];
|
const conditions = [];
|
||||||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
const params = [];
|
||||||
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||||
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||||
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||||||
conditions.push('e.deleted_at IS NULL');
|
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
conditions.push('e.deleted_at IS NULL');
|
||||||
const { rows: entries } = await pool.query(
|
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||||
`SELECT e.*, g.name AS group_name FROM entries e
|
const { rows: entries } = await pool.query(
|
||||||
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`,
|
`SELECT e.*, g.name AS group_name FROM entries e
|
||||||
params
|
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)]
|
|
||||||
);
|
);
|
||||||
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push({ token: f.token, name: f.name }); });
|
let files = {};
|
||||||
}
|
if (entries.length) {
|
||||||
entries.forEach(r => { r.files = files[r.id] || []; });
|
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) {
|
||||||
if (!showStudentNames) {
|
const nameMap = new Map();
|
||||||
const nameMap = new Map();
|
let counter = 1;
|
||||||
let counter = 1;
|
entries.forEach(e => {
|
||||||
entries.forEach(e => {
|
if (!nameMap.has(e.student_name)) {
|
||||||
if (!nameMap.has(e.student_name)) {
|
nameMap.set(e.student_name, `Ученик ${counter++}`);
|
||||||
nameMap.set(e.student_name, `Ученик ${counter++}`);
|
}
|
||||||
}
|
e.student_name = nameMap.get(e.student_name);
|
||||||
e.student_name = nameMap.get(e.student_name);
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let photos = [];
|
let photos = [];
|
||||||
if (l.group_id) {
|
if (l.group_id) {
|
||||||
const pRes = await pool.query(
|
const pRes = await pool.query(
|
||||||
`SELECT id, photo_path, caption, taken_at, created_at FROM group_photos
|
`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`,
|
WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
|
||||||
[l.group_id]
|
[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 }));
|
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,
|
||||||
group_id: l.group_id,
|
group_id: l.group_id,
|
||||||
date_from: l.date_from,
|
date_from: l.date_from,
|
||||||
date_to: l.date_to,
|
date_to: l.date_to,
|
||||||
message: l.message,
|
message: l.message,
|
||||||
link_url: l.link_url,
|
link_url: l.link_url,
|
||||||
created_at: l.created_at,
|
created_at: l.created_at,
|
||||||
expires_at: l.expires_at,
|
expires_at: l.expires_at,
|
||||||
show_student_names: showStudentNames,
|
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_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_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',
|
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,34 +1525,40 @@ 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 bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
const rows = await cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
||||||
const { rows } = await pool.query(
|
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
||||||
`SELECT g.*,
|
const { rows } = await pool.query(
|
||||||
COALESCE(g.cover_path,
|
`SELECT g.*,
|
||||||
(SELECT photo_path FROM group_photos
|
COALESCE(g.cover_path,
|
||||||
WHERE group_id = g.id
|
(SELECT photo_path FROM group_photos
|
||||||
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
|
WHERE group_id = g.id
|
||||||
LIMIT 1)) AS cover_path,
|
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
|
||||||
b.name AS branch_name
|
LIMIT 1)) AS cover_path,
|
||||||
FROM groups g
|
b.name AS branch_name
|
||||||
LEFT JOIN branches b ON b.id = g.branch_id
|
FROM groups g
|
||||||
WHERE 1=1${bw.where}
|
LEFT JOIN branches b ON b.id = g.branch_id
|
||||||
ORDER BY g.id`,
|
WHERE 1=1${bw.where}
|
||||||
bw.params
|
ORDER BY g.id`,
|
||||||
);
|
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 pool.query(`
|
const rows = await cacheWrap('groups:active', PUBLIC_TTL_MS, async () => {
|
||||||
SELECT * FROM groups
|
const { rows } = await pool.query(`
|
||||||
WHERE day_of_week IS NOT NULL
|
SELECT * FROM groups
|
||||||
AND time_start IS NOT NULL
|
WHERE day_of_week IS NOT NULL
|
||||||
AND time_end IS NOT NULL
|
AND time_start IS NOT NULL
|
||||||
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
AND time_end IS NOT NULL
|
||||||
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
|
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
||||||
ORDER BY id
|
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
|
||||||
`);
|
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,19 +1865,24 @@ 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 bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
||||||
const { rows } = await pool.query(
|
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
||||||
`SELECT s.*, g.name AS group_name FROM students s
|
const { rows } = await pool.query(
|
||||||
LEFT JOIN groups g ON g.id = s.group_id
|
`SELECT s.*, g.name AS group_name FROM students s
|
||||||
WHERE 1=1${bw.where} ORDER BY s.name`,
|
LEFT JOIN groups g ON g.id = s.group_id
|
||||||
bw.params
|
WHERE 1=1${bw.where} ORDER BY s.name`,
|
||||||
);
|
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,37 +2272,41 @@ 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 isAdmin = req.user.role === 'admin';
|
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
||||||
const s = branchScope(req.user);
|
const isAdmin = req.user.role === 'admin';
|
||||||
let groupFilter;
|
const s = branchScope(req.user);
|
||||||
if (isAdmin) {
|
let groupFilter;
|
||||||
groupFilter = { where: '', params: [] };
|
if (isAdmin) {
|
||||||
} else if (!s.ids.length) {
|
groupFilter = { where: '', params: [] };
|
||||||
groupFilter = { where: ' AND 1 = 0', params: [] };
|
} else if (!s.ids.length) {
|
||||||
} else {
|
groupFilter = { where: ' AND 1 = 0', params: [] };
|
||||||
const ph = s.ids.map(id => `$${s.ids.indexOf(id) + 1}`).join(',');
|
} else {
|
||||||
groupFilter = { where: ` AND g.branch_id IN (${ph})`, params: s.ids };
|
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 groupsParams = isAdmin ? [] : (s.ids.length ? s.ids : [0]);
|
||||||
const [entries, trash, groups, students, today] = await Promise.all([
|
const groupsWhere = isAdmin ? '' : (s.ids.length ? ` WHERE g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0');
|
||||||
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),
|
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 NOT 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${groupFilter.where}`, groupFilter.params),
|
||||||
pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams),
|
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(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 groups g${groupsWhere}`, groupsParams),
|
||||||
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(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,
|
return {
|
||||||
trash: trash.rows[0].n,
|
entries: entries.rows[0].n,
|
||||||
groups: groups.rows[0].n,
|
trash: trash.rows[0].n,
|
||||||
students: students.rows[0].n,
|
groups: groups.rows[0].n,
|
||||||
today: today.rows[0].n,
|
students: students.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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user