From a9ad25eb38c889962b6183ff8d2d9abef57c1860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sun, 30 Nov 2025 13:37:32 +0100 Subject: [PATCH] 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 --- .../migration.sql | 4 + backend/prisma/schema.prisma | 3 + backend/src/routes/admin.js | 84 +++++++++++++++- backend/src/services/matching.js | 13 ++- backend/src/services/scheduler.js | 2 +- .../components/events/MatchingRunsSection.jsx | 96 +++++++++++++++---- frontend/src/services/api.js | 10 ++ 7 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 backend/prisma/migrations/20251130123000_add_origin_run_status_index/migration.sql diff --git a/backend/prisma/migrations/20251130123000_add_origin_run_status_index/migration.sql b/backend/prisma/migrations/20251130123000_add_origin_run_status_index/migration.sql new file mode 100644 index 0000000..4e63da6 --- /dev/null +++ b/backend/prisma/migrations/20251130123000_add_origin_run_status_index/migration.sql @@ -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"); + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4a198d5..e759e9a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") } diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 62f763a..8731f46 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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); + } +}); diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index 532fc16..c6082c4 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -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 diff --git a/backend/src/services/scheduler.js b/backend/src/services/scheduler.js index 8df4db7..2ec8e37 100644 --- a/backend/src/services/scheduler.js +++ b/backend/src/services/scheduler.js @@ -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; diff --git a/frontend/src/components/events/MatchingRunsSection.jsx b/frontend/src/components/events/MatchingRunsSection.jsx index cbfcd43..df1f213 100644 --- a/frontend/src/components/events/MatchingRunsSection.jsx +++ b/frontend/src/components/events/MatchingRunsSection.jsx @@ -1,12 +1,15 @@ import { useEffect, useState } from 'react'; import { adminAPI } from '../../services/api'; -import { RefreshCcw, Play } from 'lucide-react'; +import { RefreshCcw, Play, ChevronDown, ChevronRight } from 'lucide-react'; export default function MatchingRunsSection({ slug }) { const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [running, setRunning] = useState(false); + const [expandedRunId, setExpandedRunId] = useState(null); + const [pairsByRun, setPairsByRun] = useState({}); + const [pairsLoading, setPairsLoading] = useState(false); const loadRuns = async () => { try { @@ -43,6 +46,26 @@ export default function MatchingRunsSection({ slug }) { const formatDateTime = (dt) => dt ? new Date(dt).toLocaleString() : '-'; + const toggleViewPairs = async (runId) => { + if (expandedRunId === runId) { + setExpandedRunId(null); + return; + } + setExpandedRunId(runId); + if (!pairsByRun[runId]) { + try { + setPairsLoading(true); + const res = await adminAPI.getRunSuggestions(slug, runId, { onlyAssigned: true, includeNotFound: false, limit: 100 }); + setPairsByRun((prev) => ({ ...prev, [runId]: res.suggestions || [] })); + } catch (e) { + console.error('Failed to load run pairs', e); + setError(e.message || 'Failed to load run pairs'); + } finally { + setPairsLoading(false); + } + } + }; + return (
@@ -73,6 +96,7 @@ export default function MatchingRunsSection({ slug }) { + @@ -92,22 +116,59 @@ export default function MatchingRunsSection({ slug }) { ) : ( runs.map((run) => ( - - - - - - - - + <> + + + + + + + + + + {expandedRunId === run.id && ( + + + + )} + )) )} @@ -122,4 +183,3 @@ export default function MatchingRunsSection({ slug }) { ); } - diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 806ab27..6cfbe26 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -455,6 +455,16 @@ export const adminAPI = { const data = await fetchAPI(`/admin/events/${slug}/matching-runs?${params.toString()}`); return data; }, + + async getRunSuggestions(slug, runId, { onlyAssigned = true, includeNotFound = false, limit = 100 } = {}) { + const params = new URLSearchParams({ + onlyAssigned: String(onlyAssigned), + includeNotFound: String(includeNotFound), + limit: String(limit), + }); + const data = await fetchAPI(`/admin/events/${slug}/matching-runs/${runId}/suggestions?${params.toString()}`); + return data; + }, }; export { ApiError };
Started Ended Trigger
{formatDateTime(run.startedAt)}{formatDateTime(run.endedAt)}{run.trigger} - - {run.status} - - {run.matchedCount}{run.notFoundCount}
+ + {formatDateTime(run.startedAt)}{formatDateTime(run.endedAt)}{run.trigger} + + {run.status} + + {run.matchedCount}{run.notFoundCount}
+
Pairs created in this run
+ {pairsLoading && (!pairsByRun[run.id] || pairsByRun[run.id].length === 0) ? ( +
Loading pairs...
+ ) : (pairsByRun[run.id] && pairsByRun[run.id].length > 0 ? ( +
    + {pairsByRun[run.id].map((p) => ( +
  • +
    + {p.heat.label} + {p.dancer?.username} + + {p.recorder?.username} +
    + {p.status} +
  • + ))} +
+ ) : ( +
No pairs created in this run
+ ))} +