Files
spotlightcam/backend/src/routes/admin.js
Radosław Gierwiało 560ff1edc1 fix(scheduler): implement deadline-based matching with 5-run limit and fix security issues
Security fixes:
- Replace $queryRawUnsafe with parameterized $queryRaw in admin.js to prevent SQL injection
- Use PostgreSQL ANY() operator for safe array parameter handling

Scheduler improvements:
- Add registrationDeadline support - scheduler now waits until deadline before running
- Implement 5-run limit after deadline (runs exactly 5 times with 5-minute intervals)
- Add countScheduledRunsAfterDeadline() to track post-deadline runs
- Add environment variable validation with sensible min/max ranges
- Fix Prisma query syntax (remove invalid endDate null check for non-nullable field)

UI improvements:
- Fix colspan mismatch in MatchingRunsSection (6 → 8 columns)
- Remove duplicate "Uruchom Matching" button, keep only "Run now" with audit tracking
- Simplify MatchingConfigSection to focus on deadline configuration

Logging enhancements:
- Add detailed scheduler logs showing run progress (e.g., "Running post-deadline matching (3/5)")
- Log wait times before deadline and between runs
- Show completion status after 5 runs
2025-11-30 14:42:08 +01:00

220 lines
6.9 KiB
JavaScript

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