Files
spotlightcam/backend/src/routes/admin.js

335 lines
9.8 KiB
JavaScript
Raw Normal View History

const express = require('express');
const { prisma } = require('../utils/db');
const { authenticate } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/admin');
const matchingService = require('../services/matching');
const { SUGGESTION_STATUS } = require('../constants');
const { ACTIONS, log: activityLog, queryLogs, getActionTypes, getStats } = require('../services/activityLog');
const { getClientIP } = require('../utils/request');
const router = express.Router();
// POST /api/admin/events/:slug/run-now - Trigger matching immediately for an event
router.post('/events/:slug/run-now', authenticate, requireAdmin, async (req, res, next) => {
try {
const { slug } = req.params;
const event = await prisma.event.findUnique({
where: { slug },
select: { id: true, slug: true },
});
if (!event) {
return res.status(404).json({ success: false, error: 'Event not found' });
}
const startedAt = new Date();
const runRow = await prisma.matchingRun.create({
data: {
eventId: event.id,
trigger: 'manual',
status: 'running',
startedAt,
},
});
try {
const suggestions = await matchingService.runMatching(event.id);
await matchingService.saveMatchingResults(event.id, suggestions, runRow.id);
const notFoundCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND).length;
const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).length;
await prisma.matchingRun.update({
where: { id: runRow.id },
data: {
status: 'success',
endedAt: new Date(),
matchedCount,
notFoundCount,
},
});
// Log admin matching run activity
activityLog({
userId: req.user.id,
username: req.user.username,
ipAddress: getClientIP(req),
action: ACTIONS.ADMIN_MATCHING_RUN,
resource: `event:${event.id}`,
method: req.method,
path: req.path,
metadata: {
eventSlug: event.slug,
runId: runRow.id,
matchedCount,
notFoundCount,
},
});
return res.json({
success: true,
data: {
eventSlug: event.slug,
startedAt,
endedAt: new Date(),
matched: matchedCount,
notFound: notFoundCount,
},
});
} catch (err) {
await prisma.matchingRun.update({
where: { id: runRow.id },
data: {
status: 'error',
endedAt: new Date(),
error: String(err?.message || err),
},
});
return res.status(500).json({ success: false, error: 'Matching failed', details: String(err?.message || err) });
}
} catch (error) {
next(error);
}
});
// GET /api/admin/events/:slug/matching-runs?limit=20 - List recent runs
router.get('/events/:slug/matching-runs', authenticate, requireAdmin, async (req, res, next) => {
try {
const { slug } = req.params;
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
const event = await prisma.event.findUnique({
where: { slug },
select: { id: true },
});
if (!event) {
return res.status(404).json({ success: false, error: 'Event not found' });
}
const runs = await prisma.matchingRun.findMany({
where: { eventId: event.id },
orderBy: { startedAt: 'desc' },
take: limit,
select: {
id: true,
trigger: true,
status: true,
startedAt: true,
endedAt: true,
matchedCount: true,
notFoundCount: true,
error: true,
},
});
// Aggregate fresh stats per run from recording_suggestions (origin_run_id)
// Cheap and valuable: shows actual created pairs in this run.
if (runs.length > 0) {
const runIds = runs.map(r => r.id);
// Single SQL query for all listed runs (using parameterized query to prevent SQL injection)
const aggRows = await prisma.$queryRaw`
SELECT origin_run_id AS "originRunId",
COUNT(*)::int AS "totalSuggestions",
COUNT(*) FILTER (WHERE recorder_id IS NOT NULL)::int AS "assignedCount",
COUNT(*) FILTER (WHERE status = 'not_found')::int AS "notFoundCount"
FROM recording_suggestions
WHERE event_id = ${event.id} AND origin_run_id = ANY(${runIds})
GROUP BY origin_run_id
`;
const aggByRun = new Map(aggRows.map(r => [r.originRunId, r]));
for (const r of runs) {
const agg = aggByRun.get(r.id) || { totalSuggestions: 0, assignedCount: 0, notFoundCount: 0 };
r.totalSuggestions = agg.totalSuggestions;
r.assignedCount = agg.assignedCount;
r.aggregatedNotFoundCount = agg.notFoundCount; // keep original notFoundCount for backward compat
}
}
res.json({ success: true, count: runs.length, data: runs });
} catch (error) {
next(error);
}
});
module.exports = router;
// GET /api/admin/events/:slug/matching-runs/:runId/suggestions - List suggestions created in this run
router.get('/events/:slug/matching-runs/:runId/suggestions', authenticate, requireAdmin, async (req, res, next) => {
try {
const { slug, runId } = req.params;
const onlyAssigned = String(req.query.onlyAssigned || 'true') === 'true';
const includeNotFound = String(req.query.includeNotFound || 'false') === 'true';
const limit = Math.min(parseInt(req.query.limit || '100', 10), 200);
const event = await prisma.event.findUnique({ where: { slug }, select: { id: true } });
if (!event) {
return res.status(404).json({ success: false, error: 'Event not found' });
}
const run = await prisma.matchingRun.findFirst({
where: { id: Number(runId), eventId: event.id },
select: {
id: true,
trigger: true,
status: true,
startedAt: true,
endedAt: true,
matchedCount: true,
notFoundCount: true,
},
});
if (!run) {
return res.status(404).json({ success: false, error: 'Run not found for event' });
}
// Build where for suggestions in this run
const where = {
eventId: event.id,
originRunId: Number(runId),
};
if (onlyAssigned && !includeNotFound) {
// Only pairs with assigned recorder
where.recorderId = { not: null };
}
const suggestions = await prisma.recordingSuggestion.findMany({
where,
take: limit,
orderBy: { id: 'asc' },
include: {
heat: {
include: {
division: { select: { id: true, name: true, abbreviation: true } },
competitionType: { select: { id: true, name: true, abbreviation: true } },
user: { select: { id: true, username: true, avatar: true, city: true, country: true } },
},
},
recorder: { select: { id: true, username: true, avatar: true, city: true, country: true } },
},
});
const formatted = suggestions
.filter((s) => includeNotFound || s.status !== 'not_found')
.map((s) => {
const div = s.heat.division;
const ct = s.heat.competitionType;
const label = `${div?.name || ''} ${ct?.abbreviation || ct?.name || ''} #${s.heat.heatNumber}`.trim();
return {
id: s.id,
status: s.status,
heat: {
id: s.heat.id,
heatNumber: s.heat.heatNumber,
label,
division: div,
competitionType: ct,
},
dancer: s.heat.user,
recorder: s.recorder || null,
};
});
return res.json({ success: true, run, suggestions: formatted });
} catch (error) {
next(error);
}
});
// ==================================================================
// ACTIVITY LOGS ENDPOINTS
// ==================================================================
// GET /api/admin/activity-logs - Query activity logs with filters
router.get('/activity-logs', authenticate, requireAdmin, async (req, res, next) => {
try {
const {
startDate,
endDate,
action,
category,
username,
userId,
success,
limit = 100,
offset = 0,
} = req.query;
// Parse query parameters
const filters = {
startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null,
action: action || null,
category: category || null,
username: username || null,
userId: userId ? parseInt(userId) : null,
success: success !== undefined ? success === 'true' : null,
limit: parseInt(limit),
offset: parseInt(offset),
};
// Query logs
const result = await queryLogs(filters);
// Log admin viewing activity logs
activityLog({
userId: req.user.id,
username: req.user.username,
ipAddress: getClientIP(req),
action: ACTIONS.ADMIN_VIEW_LOGS,
method: req.method,
path: req.path,
metadata: {
filters: {
startDate,
endDate,
action,
category,
username,
userId,
success,
},
resultCount: result.logs.length,
},
});
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
});
// GET /api/admin/activity-logs/actions - Get unique action types
router.get('/activity-logs/actions', authenticate, requireAdmin, async (req, res, next) => {
try {
const actions = await getActionTypes();
res.json({
success: true,
count: actions.length,
data: actions,
});
} catch (error) {
next(error);
}
});
// GET /api/admin/activity-logs/stats - Get activity log statistics
router.get('/activity-logs/stats', authenticate, requireAdmin, async (req, res, next) => {
try {
const stats = await getStats();
res.json({
success: true,
data: stats,
});
} catch (error) {
next(error);
}
});