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

221 lines
6.9 KiB
JavaScript
Raw Normal View History

const express = require('express');
const { prisma } = require('../utils/db');
const { authenticate } = require('../middleware/auth');
const matchingService = require('../services/matching');
const { SUGGESTION_STATUS } = require('../constants');
const router = express.Router();
// POST /api/admin/events/:slug/run-now - Trigger matching immediately for an event
router.post('/events/:slug/run-now', authenticate, 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,
},
});
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, 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
const placeholders = runIds.join(',');
const aggRows = await prisma.$queryRawUnsafe(
`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 IN (${placeholders})
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, 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);
}
});