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>
267 lines
7.5 KiB
JavaScript
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;
|