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); } });