Files
Art-gallery/server/routes/audit.js
T
Danila KhodjaefandCursor dc0ac81081 Add admin Activity page for curator audit reports.
Admins can filter and review curator_audit_log (date/time, actor, action, resource, details) via /api/audit against the environment DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:20:09 +03:00

267 lines
7.5 KiB
JavaScript

const express = require('express');
const pool = require('../db');
const { requireAdmin } = require('../middleware/auth');
const router = express.Router();
router.use(requireAdmin);
const MAX_LIMIT = 200;
const DEFAULT_LIMIT = 50;
function parseOptionalInt(raw) {
if (raw == null || raw === '') return null;
const n = parseInt(String(raw), 10);
return Number.isFinite(n) ? n : null;
}
function parseOptionalDate(raw) {
if (raw == null || raw === '') return null;
const d = new Date(String(raw));
return Number.isNaN(d.getTime()) ? null : d;
}
function buildFilters(query) {
const clauses = [];
const params = [];
const userId = parseOptionalInt(query.user_id);
if (userId != null) {
params.push(userId);
clauses.push(`l.user_id = $${params.length}`);
}
const username =
typeof query.username === 'string' && query.username.trim()
? query.username.trim()
: null;
if (username) {
params.push(username);
clauses.push(`u.username = $${params.length}`);
}
const action =
typeof query.action === 'string' && query.action.trim() ? query.action.trim() : null;
if (action) {
params.push(action);
clauses.push(`l.action = $${params.length}`);
}
const resourceType =
typeof query.resource_type === 'string' && query.resource_type.trim()
? query.resource_type.trim()
: null;
if (resourceType) {
params.push(resourceType);
clauses.push(`l.resource_type = $${params.length}`);
}
const resourceId = parseOptionalInt(query.resource_id);
if (resourceId != null) {
params.push(resourceId);
clauses.push(`l.resource_id = $${params.length}`);
}
const from = parseOptionalDate(query.from);
if (from) {
params.push(from.toISOString());
clauses.push(`l.created_at >= $${params.length}::timestamptz`);
}
const to = parseOptionalDate(query.to);
if (to) {
params.push(to.toISOString());
clauses.push(`l.created_at <= $${params.length}::timestamptz`);
}
const q = typeof query.q === 'string' && query.q.trim() ? query.q.trim() : null;
if (q) {
params.push(`%${q}%`);
const idx = params.length;
clauses.push(`(
l.action ILIKE $${idx}
OR l.resource_type ILIKE $${idx}
OR u.username ILIKE $${idx}
OR COALESCE(l.details::text, '') ILIKE $${idx}
OR COALESCE(l.ip_address, '') ILIKE $${idx}
)`);
}
return {
where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
params,
};
}
const RESOURCE_LABEL_SQL = `
CASE l.resource_type
WHEN 'painting' THEN (SELECT title FROM paintings WHERE id = l.resource_id)
WHEN 'artist' THEN (SELECT name FROM artists WHERE id = l.resource_id)
WHEN 'user' THEN (SELECT username FROM users WHERE id = l.resource_id)
WHEN 'tour' THEN (SELECT title FROM tours WHERE id = l.resource_id)
WHEN 'movement' THEN (SELECT name FROM art_movements WHERE id = l.resource_id)
ELSE NULL
END
`;
router.get('/', async (req, res) => {
try {
const { where, params } = buildFilters(req.query);
let limit = parseOptionalInt(req.query.limit) ?? DEFAULT_LIMIT;
let offset = parseOptionalInt(req.query.offset) ?? 0;
limit = Math.min(MAX_LIMIT, Math.max(1, limit));
offset = Math.max(0, offset);
const countResult = await pool.query(
`SELECT COUNT(*)::int AS total
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}`,
params
);
const listParams = [...params, limit, offset];
const limitIdx = params.length + 1;
const offsetIdx = params.length + 2;
const { rows } = await pool.query(
`SELECT
l.id,
l.created_at,
l.user_id,
u.username,
u.role AS user_role,
l.action,
l.resource_type,
l.resource_id,
l.details,
l.ip_address,
(${RESOURCE_LABEL_SQL}) AS resource_label
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
ORDER BY l.created_at DESC, l.id DESC
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
listParams
);
res.json({
database: process.env.DB_NAME || null,
total: countResult.rows[0].total,
limit,
offset,
entries: rows.map((row) => ({
id: row.id,
created_at: row.created_at,
user_id: row.user_id,
username: row.username,
user_role: row.user_role,
action: row.action,
resource_type: row.resource_type,
resource_id: row.resource_id,
resource_label: row.resource_label,
details: row.details,
ip_address: row.ip_address,
})),
});
} catch (err) {
console.error('Audit list error:', err.message);
res.status(500).json({ error: 'Failed to load audit log' });
}
});
router.get('/summary', async (req, res) => {
try {
const { where, params } = buildFilters(req.query);
const [byUser, byAction, byResource, totals] = await Promise.all([
pool.query(
`SELECT u.id AS user_id, u.username, u.role, COUNT(*)::int AS count
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
GROUP BY u.id, u.username, u.role
ORDER BY count DESC, u.username ASC`,
params
),
pool.query(
`SELECT l.action, COUNT(*)::int AS count
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
GROUP BY l.action
ORDER BY count DESC, l.action ASC`,
params
),
pool.query(
`SELECT l.resource_type, COUNT(*)::int AS count
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}
GROUP BY l.resource_type
ORDER BY count DESC, l.resource_type ASC`,
params
),
pool.query(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '24 hours')::int AS last_24h,
COUNT(*) FILTER (WHERE l.created_at >= NOW() - INTERVAL '7 days')::int AS last_7d,
MIN(l.created_at) AS oldest,
MAX(l.created_at) AS newest
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
${where}`,
params
),
]);
const t = totals.rows[0] || {};
res.json({
database: process.env.DB_NAME || null,
total: t.total || 0,
last_24h: t.last_24h || 0,
last_7d: t.last_7d || 0,
oldest: t.oldest || null,
newest: t.newest || null,
by_user: byUser.rows,
by_action: byAction.rows,
by_resource_type: byResource.rows,
});
} catch (err) {
console.error('Audit summary error:', err.message);
res.status(500).json({ error: 'Failed to load audit summary' });
}
});
router.get('/meta', async (_req, res) => {
try {
const [users, actions, resourceTypes] = await Promise.all([
pool.query(
`SELECT DISTINCT u.id, u.username, u.role
FROM curator_audit_log l
JOIN users u ON u.id = l.user_id
ORDER BY u.username ASC`
),
pool.query(
`SELECT DISTINCT action FROM curator_audit_log ORDER BY action ASC`
),
pool.query(
`SELECT DISTINCT resource_type FROM curator_audit_log ORDER BY resource_type ASC`
),
]);
res.json({
database: process.env.DB_NAME || null,
users: users.rows,
actions: actions.rows.map((r) => r.action),
resource_types: resourceTypes.rows.map((r) => r.resource_type),
});
} catch (err) {
console.error('Audit meta error:', err.message);
res.status(500).json({ error: 'Failed to load audit filters' });
}
});
module.exports = router;