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>
This commit is contained in:
co-authored by
Cursor
parent
f9b0fb0496
commit
dc0ac81081
@@ -12,6 +12,7 @@ const { requirePermission } = require('./middleware/auth');
|
||||
const { logCuratorAction } = require('./audit-log');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const usersRoutes = require('./routes/users');
|
||||
const auditRoutes = require('./routes/audit');
|
||||
const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
||||
const { getVersionInfo } = require('./version-info');
|
||||
const { searchCatalog } = require('./search-service');
|
||||
@@ -46,6 +47,7 @@ app.use(express.json({ limit: '20mb' }));
|
||||
app.use(createSessionMiddleware());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', usersRoutes);
|
||||
app.use('/api/audit', auditRoutes);
|
||||
app.use('/api/translations', translationRoutes);
|
||||
app.use('/api/influences', influenceRoutes);
|
||||
app.use('/api/tours', tourRoutes);
|
||||
|
||||
@@ -79,6 +79,33 @@ function requirePermission(permission) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Active staff with role admin only. */
|
||||
async function requireAdmin(req, res, next) {
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Curator login required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await loadStaffUser(userId);
|
||||
if (!user || !user.is_active) {
|
||||
req.session.destroy(() => {});
|
||||
return res.status(401).json({ error: 'Curator login required' });
|
||||
}
|
||||
|
||||
attachStaff(req, user);
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('Auth middleware error:', err.message);
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
function staffAuthPayload(user) {
|
||||
return {
|
||||
role: user.role,
|
||||
@@ -90,6 +117,7 @@ function staffAuthPayload(user) {
|
||||
module.exports = {
|
||||
requireCurator,
|
||||
requirePermission,
|
||||
requireAdmin,
|
||||
loadStaffUser,
|
||||
staffAuthPayload,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user