feat(matching-runs): attach origin_run_id to new suggestions and expose pairs-per-run API

- Extend saveMatchingResults(eventId, suggestions, runId) and set originRunId
- Scheduler/Admin run-now: always pass runId
- Admin API: GET /api/admin/events/:slug/matching-runs/:runId/suggestions
- Prisma: add compound index on (origin_run_id, status)
- Frontend: add getRunSuggestions, expand row in MatchingRunsSection with 'Pairs created in this run' wording
This commit is contained in:
Radosław Gierwiało
2025-11-30 13:37:32 +01:00
parent 7e2a196f99
commit a9ad25eb38
7 changed files with 188 additions and 24 deletions

View File

@@ -0,0 +1,4 @@
-- Create compound index for efficient filtering by run and status
CREATE INDEX "recording_suggestions_origin_run_id_status_idx"
ON "recording_suggestions"("origin_run_id", "status");

View File

@@ -286,6 +286,7 @@ model RecordingSuggestion {
heatId Int @unique @map("heat_id") // One suggestion per heat
recorderId Int? @map("recorder_id") // NULL if no match found
status String @default("pending") @db.VarChar(20) // 'pending', 'accepted', 'rejected', 'not_found'
originRunId Int? @map("origin_run_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -294,9 +295,11 @@ model RecordingSuggestion {
heat EventUserHeat @relation(fields: [heatId], references: [id], onDelete: Cascade)
recorder User? @relation("RecorderAssignments", fields: [recorderId], references: [id])
match Match? // Link to created match (if suggestion was accepted)
originRun MatchingRun? @relation(fields: [originRunId], references: [id])
@@index([eventId])
@@index([recorderId])
@@index([originRunId])
@@map("recording_suggestions")
}

View File

@@ -32,7 +32,7 @@ router.post('/events/:slug/run-now', authenticate, async (req, res, next) => {
try {
const suggestions = await matchingService.runMatching(event.id);
await matchingService.saveMatchingResults(event.id, suggestions);
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;
@@ -112,3 +112,85 @@ router.get('/events/:slug/matching-runs', authenticate, async (req, res, next) =
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);
}
});

View File

@@ -507,7 +507,7 @@ async function runMatching(eventId) {
/**
* Save matching results to database
*/
async function saveMatchingResults(eventId, suggestions) {
async function saveMatchingResults(eventId, suggestions, runId = null) {
// Delete ONLY non-committed suggestions (preserve accepted/completed)
// Using notIn to be future-proof: any new statuses (expired, cancelled, etc.)
// will be cleaned up automatically. We only preserve committed suggestions.
@@ -533,9 +533,14 @@ async function saveMatchingResults(eventId, suggestions) {
const newSuggestions = suggestions.filter(s => !committedHeatIds.has(s.heatId));
if (newSuggestions.length > 0) {
await prisma.recordingSuggestion.createMany({
data: newSuggestions
});
const data = newSuggestions.map((s) => ({
eventId,
heatId: s.heatId,
recorderId: s.recorderId ?? null,
status: s.status,
originRunId: runId ?? null,
}));
await prisma.recordingSuggestion.createMany({ data });
}
// Update event's matchingRunAt

View File

@@ -66,7 +66,7 @@ async function runForEvent(event) {
});
const suggestions = await matchingService.runMatching(event.id);
await matchingService.saveMatchingResults(event.id, suggestions);
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;