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;
|
||||
|
||||
Reference in New Issue
Block a user