feat(matching): prevent auto suggestions when manual match exists + comprehensive test scenarios

Matching Service:
- Fetch manual matches at start of runMatching() (suggestionId: null)
- Build manualBlockedPairs set with both directions (A:B and B:A)
- Skip recorder candidates if manual match exists between dancer and recorder
- Ensures no duplicate matches on /matches page
- Manual match = user-controlled, algorithm respects user decisions

Documentation (docs/TODO.md):
- Add comprehensive matching system test scenarios (S1-S16)
- Document 4 critical gaps (P0): ratings/stats, admin middleware, participant validation
- Document 3 high priority items (P1): cleanup conflicts, rate limiting, notifications
- Document 6 medium priority items (P2): audit endpoints, zombie cleanup, reminders
- List 11 edge cases for team discussion (E1-E11)
- Clear priority ranking and questions for team

Critical Findings:
- recordingsDone/recordingsReceived fields exist but NEVER updated (fairness broken!)
- Admin endpoints lack security middleware
- Inconsistent event participant validation across endpoints

Test Coverage:
- S1-S7: Implemented (basic flow, collisions, limits, manual vs auto)
- S10: NOT IMPLEMENTED - ratings/stats system (CRITICAL!)
- S11: Partially implemented - audit trail exists, API endpoints missing
- S14: Partially implemented - recorder-only auth works, admin middleware missing
- S15-S16: NOT IMPLEMENTED - security, notifications
This commit is contained in:
Radosław Gierwiało
2025-11-30 15:53:00 +01:00
parent f45cadae7d
commit 25236222de
2 changed files with 469 additions and 0 deletions

View File

@@ -273,6 +273,23 @@ async function runMatching(eventId) {
// Build division-to-slot map from schedule config
const divisionSlotMap = buildDivisionSlotMap(event?.scheduleConfig);
// 0a. Get manual matches - we won't create auto suggestions for these pairs
const manualMatches = await prisma.match.findMany({
where: {
eventId,
suggestionId: null, // Manual matches don't have a suggestionId
status: { in: ['pending', 'accepted', 'completed'] },
},
select: { user1Id: true, user2Id: true },
});
// Build set of blocked pairs (both directions)
const manualBlockedPairs = new Set();
for (const m of manualMatches) {
manualBlockedPairs.add(`${m.user1Id}:${m.user2Id}`);
manualBlockedPairs.add(`${m.user2Id}:${m.user1Id}`); // both directions
}
// 1. Get all participants with their heats and user info
const participants = await prisma.eventParticipant.findMany({
where: { eventId },
@@ -416,6 +433,9 @@ async function runMatching(eventId) {
// Skip self
if (recorder.userId === dancer.userId) continue;
// Skip if manual match exists between dancer and recorder
if (manualBlockedPairs.has(`${dancer.userId}:${recorder.userId}`)) continue;
// Check assignment limit
const currentCount = recorderAssignmentCount.get(recorder.userId) || 0;
if (currentCount >= MAX_RECORDINGS_PER_PERSON) continue;