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

View File

@@ -32,7 +32,7 @@ router.post('/events/:slug/run-now', authenticate, async (req, res, next) => {
try { try {
const suggestions = await matchingService.runMatching(event.id); 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 notFoundCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND).length;
const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).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; 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 * 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) // Delete ONLY non-committed suggestions (preserve accepted/completed)
// Using notIn to be future-proof: any new statuses (expired, cancelled, etc.) // Using notIn to be future-proof: any new statuses (expired, cancelled, etc.)
// will be cleaned up automatically. We only preserve committed suggestions. // 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)); const newSuggestions = suggestions.filter(s => !committedHeatIds.has(s.heatId));
if (newSuggestions.length > 0) { if (newSuggestions.length > 0) {
await prisma.recordingSuggestion.createMany({ const data = newSuggestions.map((s) => ({
data: newSuggestions eventId,
}); heatId: s.heatId,
recorderId: s.recorderId ?? null,
status: s.status,
originRunId: runId ?? null,
}));
await prisma.recordingSuggestion.createMany({ data });
} }
// Update event's matchingRunAt // Update event's matchingRunAt

View File

@@ -66,7 +66,7 @@ async function runForEvent(event) {
}); });
const suggestions = await matchingService.runMatching(event.id); 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 notFoundCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND).length;
const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).length; const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).length;

View File

@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { adminAPI } from '../../services/api'; import { adminAPI } from '../../services/api';
import { RefreshCcw, Play } from 'lucide-react'; import { RefreshCcw, Play, ChevronDown, ChevronRight } from 'lucide-react';
export default function MatchingRunsSection({ slug }) { export default function MatchingRunsSection({ slug }) {
const [runs, setRuns] = useState([]); const [runs, setRuns] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [expandedRunId, setExpandedRunId] = useState(null);
const [pairsByRun, setPairsByRun] = useState({});
const [pairsLoading, setPairsLoading] = useState(false);
const loadRuns = async () => { const loadRuns = async () => {
try { try {
@@ -43,6 +46,26 @@ export default function MatchingRunsSection({ slug }) {
const formatDateTime = (dt) => dt ? new Date(dt).toLocaleString() : '-'; 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 ( return (
<section className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <section className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -73,6 +96,7 @@ export default function MatchingRunsSection({ slug }) {
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Started</th> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Started</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ended</th> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ended</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trigger</th> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trigger</th>
@@ -92,7 +116,17 @@ export default function MatchingRunsSection({ slug }) {
</tr> </tr>
) : ( ) : (
runs.map((run) => ( runs.map((run) => (
<>
<tr key={run.id}> <tr key={run.id}>
<td className="px-3 py-2 text-sm text-gray-700">
<button
onClick={() => toggleViewPairs(run.id)}
className="inline-flex items-center text-primary-600 hover:text-primary-700"
title="View pairs created in this run"
>
{expandedRunId === run.id ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
</td>
<td className="px-3 py-2 text-sm text-gray-900">{formatDateTime(run.startedAt)}</td> <td className="px-3 py-2 text-sm text-gray-900">{formatDateTime(run.startedAt)}</td>
<td className="px-3 py-2 text-sm text-gray-700">{formatDateTime(run.endedAt)}</td> <td className="px-3 py-2 text-sm text-gray-700">{formatDateTime(run.endedAt)}</td>
<td className="px-3 py-2 text-sm text-gray-700 capitalize">{run.trigger}</td> <td className="px-3 py-2 text-sm text-gray-700 capitalize">{run.trigger}</td>
@@ -108,6 +142,33 @@ export default function MatchingRunsSection({ slug }) {
<td className="px-3 py-2 text-sm text-right text-gray-700">{run.matchedCount}</td> <td className="px-3 py-2 text-sm text-right text-gray-700">{run.matchedCount}</td>
<td className="px-3 py-2 text-sm text-right text-gray-700">{run.notFoundCount}</td> <td className="px-3 py-2 text-sm text-right text-gray-700">{run.notFoundCount}</td>
</tr> </tr>
{expandedRunId === run.id && (
<tr>
<td colSpan="7" className="px-3 py-2 bg-gray-50">
<div className="text-sm text-gray-800 font-medium mb-2">Pairs created in this run</div>
{pairsLoading && (!pairsByRun[run.id] || pairsByRun[run.id].length === 0) ? (
<div className="text-gray-500">Loading pairs...</div>
) : (pairsByRun[run.id] && pairsByRun[run.id].length > 0 ? (
<ul className="space-y-1">
{pairsByRun[run.id].map((p) => (
<li key={p.id} className="flex justify-between items-center p-2 bg-white rounded border border-gray-200">
<div className="flex items-center gap-2 text-gray-900">
<span className="text-xs text-gray-500">{p.heat.label}</span>
<span className="font-semibold">{p.dancer?.username}</span>
<span className="text-gray-500"></span>
<span className="font-semibold">{p.recorder?.username}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">{p.status}</span>
</li>
))}
</ul>
) : (
<div className="text-gray-500">No pairs created in this run</div>
))}
</td>
</tr>
)}
</>
)) ))
)} )}
</tbody> </tbody>
@@ -122,4 +183,3 @@ export default function MatchingRunsSection({ slug }) {
</section> </section>
); );
} }

View File

@@ -455,6 +455,16 @@ export const adminAPI = {
const data = await fetchAPI(`/admin/events/${slug}/matching-runs?${params.toString()}`); const data = await fetchAPI(`/admin/events/${slug}/matching-runs?${params.toString()}`);
return data; 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 }; export { ApiError };