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