feat(matching): add auto-matching system for recording partners

Implement algorithm to match dancers with recorders based on:
- Heat collision avoidance (division + competitionType + heatNumber)
- Buffer time (1 heat after dancing before can record)
- Location preference (same city > same country > anyone)
- Max 3 recordings per person
- Opt-out support (falls to bottom of queue)

New API endpoints:
- PUT /events/:slug/registration-deadline
- PUT /events/:slug/recorder-opt-out
- POST /events/:slug/run-matching
- GET /events/:slug/match-suggestions
- PUT /events/:slug/match-suggestions/:id/status

Database changes:
- Event: registrationDeadline, matchingRunAt
- EventParticipant: recorderOptOut
- RecordingSuggestion: new model for match suggestions
This commit is contained in:
Radosław Gierwiało
2025-11-23 18:32:14 +01:00
parent edf68f2489
commit c18416ad6f
6 changed files with 1109 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ const express = require('express');
const { prisma } = require('../utils/db');
const { authenticate } = require('../middleware/auth');
const { getIO } = require('../socket');
const matchingService = require('../services/matching');
const router = express.Router();
@@ -907,4 +908,274 @@ router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
}
});
// ============================================
// AUTO-MATCHING ENDPOINTS
// ============================================
// PUT /api/events/:slug/registration-deadline - Set registration deadline
router.put('/:slug/registration-deadline', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
const { registrationDeadline } = req.body;
// Find event
const event = await prisma.event.findUnique({
where: { slug },
select: { id: true },
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// Update registration deadline
const updated = await prisma.event.update({
where: { id: event.id },
data: {
registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null,
},
select: {
id: true,
slug: true,
registrationDeadline: true,
},
});
res.json({
success: true,
data: updated,
});
} catch (error) {
next(error);
}
});
// PUT /api/events/:slug/recorder-opt-out - Set recorder opt-out preference
router.put('/:slug/recorder-opt-out', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
const userId = req.user.id;
const { optOut } = req.body;
if (typeof optOut !== 'boolean') {
return res.status(400).json({
success: false,
error: 'optOut must be a boolean',
});
}
// Find event and participant
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 participant = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: {
userId,
eventId: event.id,
},
},
});
if (!participant) {
return res.status(403).json({
success: false,
error: 'You are not a participant of this event',
});
}
// Update opt-out preference
const updated = await prisma.eventParticipant.update({
where: { id: participant.id },
data: { recorderOptOut: optOut },
select: {
id: true,
recorderOptOut: true,
},
});
res.json({
success: true,
data: {
recorderOptOut: updated.recorderOptOut,
},
});
} catch (error) {
next(error);
}
});
// POST /api/events/:slug/run-matching - Run the auto-matching algorithm
router.post('/:slug/run-matching', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
// Find event
const event = await prisma.event.findUnique({
where: { slug },
select: {
id: true,
registrationDeadline: true,
matchingRunAt: true,
},
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// TODO: In production, add admin check or deadline validation
// For now, allow anyone to run matching for testing
// Run matching algorithm
const suggestions = await matchingService.runMatching(event.id);
// Save results
const count = await matchingService.saveMatchingResults(event.id, suggestions);
// Get statistics
const notFoundCount = suggestions.filter(s => s.status === 'not_found').length;
const matchedCount = suggestions.filter(s => s.status === 'pending').length;
res.json({
success: true,
data: {
totalHeats: suggestions.length,
matched: matchedCount,
notFound: notFoundCount,
runAt: new Date(),
},
});
} catch (error) {
next(error);
}
});
// GET /api/events/:slug/match-suggestions - Get matching suggestions for current user
router.get('/:slug/match-suggestions', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
const userId = req.user.id;
// Find event
const event = await prisma.event.findUnique({
where: { slug },
select: {
id: true,
matchingRunAt: true,
},
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// Get user's suggestions
const suggestions = await matchingService.getUserSuggestions(event.id, userId);
res.json({
success: true,
data: {
matchingRunAt: event.matchingRunAt,
...suggestions,
},
});
} catch (error) {
next(error);
}
});
// PUT /api/events/:slug/match-suggestions/:suggestionId/status - Accept/reject suggestion
router.put('/:slug/match-suggestions/:suggestionId/status', authenticate, async (req, res, next) => {
try {
const { slug, suggestionId } = req.params;
const userId = req.user.id;
const { status } = req.body;
if (!['accepted', 'rejected'].includes(status)) {
return res.status(400).json({
success: false,
error: 'Status must be "accepted" or "rejected"',
});
}
// Find event
const event = await prisma.event.findUnique({
where: { slug },
select: { id: true },
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// Find suggestion
const suggestion = await prisma.recordingSuggestion.findUnique({
where: { id: parseInt(suggestionId) },
include: {
heat: {
select: { userId: true },
},
},
});
if (!suggestion || suggestion.eventId !== event.id) {
return res.status(404).json({
success: false,
error: 'Suggestion not found',
});
}
// Check authorization: only dancer or recorder can update status
const isDancer = suggestion.heat.userId === userId;
const isRecorder = suggestion.recorderId === userId;
if (!isDancer && !isRecorder) {
return res.status(403).json({
success: false,
error: 'You are not authorized to update this suggestion',
});
}
// Update status
const updated = await prisma.recordingSuggestion.update({
where: { id: parseInt(suggestionId) },
data: { status },
select: {
id: true,
status: true,
updatedAt: true,
},
});
res.json({
success: true,
data: updated,
});
} catch (error) {
next(error);
}
});
module.exports = router;