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:
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<section className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<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">
|
||||
<thead className="bg-gray-50">
|
||||
<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">Ended</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>
|
||||
) : (
|
||||
runs.map((run) => (
|
||||
<>
|
||||
<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-700">{formatDateTime(run.endedAt)}</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.notFoundCount}</td>
|
||||
</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>
|
||||
@@ -122,4 +183,3 @@ export default function MatchingRunsSection({ slug }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user