2025-11-30 13:14:02 +01:00
|
|
|
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);
|
2025-11-30 13:37:32 +01:00
|
|
|
await matchingService.saveMatchingResults(event.id, suggestions, runRow.id);
|
2025-11-30 13:14:02 +01:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-30 13:43:05 +01:00
|
|
|
// 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);
|
2025-11-30 14:42:08 +01:00
|
|
|
// 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
|
|
|
|
|
`;
|
2025-11-30 13:43:05 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 13:14:02 +01:00
|
|
|
res.json({ success: true, count: runs.length, data: runs });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
module.exports = router;
|
|
|
|
|
|
2025-11-30 13:37:32 +01:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
});
|