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

@@ -0,0 +1,37 @@
-- AlterTable
ALTER TABLE "event_participants" ADD COLUMN "recorder_opt_out" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "events" ADD COLUMN "matching_run_at" TIMESTAMP(3),
ADD COLUMN "registration_deadline" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "recording_suggestions" (
"id" SERIAL NOT NULL,
"event_id" INTEGER NOT NULL,
"heat_id" INTEGER NOT NULL,
"recorder_id" INTEGER,
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "recording_suggestions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "recording_suggestions_heat_id_key" ON "recording_suggestions"("heat_id");
-- CreateIndex
CREATE INDEX "recording_suggestions_event_id_idx" ON "recording_suggestions"("event_id");
-- CreateIndex
CREATE INDEX "recording_suggestions_recorder_id_idx" ON "recording_suggestions"("recorder_id");
-- AddForeignKey
ALTER TABLE "recording_suggestions" ADD CONSTRAINT "recording_suggestions_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recording_suggestions" ADD CONSTRAINT "recording_suggestions_heat_id_fkey" FOREIGN KEY ("heat_id") REFERENCES "event_user_heats"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recording_suggestions" ADD CONSTRAINT "recording_suggestions_recorder_id_fkey" FOREIGN KEY ("recorder_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -59,29 +59,35 @@ model User {
ratingsReceived Rating[] @relation("RatedRatings")
eventParticipants EventParticipant[]
heats EventUserHeat[]
recordingAssignments RecordingSuggestion[] @relation("RecorderAssignments")
@@map("users")
}
// Events table (dance events from worldsdc.com)
model Event {
id Int @id @default(autoincrement())
slug String @unique @default(cuid()) @db.VarChar(50)
name String @db.VarChar(255)
location String @db.VarChar(255)
startDate DateTime @map("start_date") @db.Date
endDate DateTime @map("end_date") @db.Date
worldsdcId String? @unique @map("worldsdc_id") @db.VarChar(100)
participantsCount Int @default(0) @map("participants_count")
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
slug String @unique @default(cuid()) @db.VarChar(50)
name String @db.VarChar(255)
location String @db.VarChar(255)
startDate DateTime @map("start_date") @db.Date
endDate DateTime @map("end_date") @db.Date
worldsdcId String? @unique @map("worldsdc_id") @db.VarChar(100)
participantsCount Int @default(0) @map("participants_count")
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Auto-matching configuration
registrationDeadline DateTime? @map("registration_deadline") // When registration closes
matchingRunAt DateTime? @map("matching_run_at") // When auto-matching was last run
// Relations
chatRooms ChatRoom[]
matches Match[]
participants EventParticipant[]
checkinToken EventCheckinToken?
userHeats EventUserHeat[]
chatRooms ChatRoom[]
matches Match[]
participants EventParticipant[]
checkinToken EventCheckinToken?
userHeats EventUserHeat[]
recordingSuggestions RecordingSuggestion[]
@@map("events")
}
@@ -186,6 +192,7 @@ model EventParticipant {
userId Int @map("user_id")
eventId Int @map("event_id")
competitorNumber Int? @map("competitor_number") // Bib number - one per user per event
recorderOptOut Boolean @default(false) @map("recorder_opt_out") // Opt-out from being a recorder
joinedAt DateTime @default(now()) @map("joined_at")
// Relations
@@ -236,10 +243,11 @@ model EventUserHeat {
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
division Division @relation(fields: [divisionId], references: [id])
competitionType CompetitionType @relation(fields: [competitionTypeId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
division Division @relation(fields: [divisionId], references: [id])
competitionType CompetitionType @relation(fields: [competitionTypeId], references: [id])
recordingSuggestion RecordingSuggestion?
// Constraint: Cannot have same role in same division+competition type
@@unique([userId, eventId, divisionId, competitionTypeId, role])
@@ -247,3 +255,23 @@ model EventUserHeat {
@@index([eventId])
@@map("event_user_heats")
}
// Recording suggestions from auto-matching algorithm
model RecordingSuggestion {
id Int @id @default(autoincrement())
eventId Int @map("event_id")
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'
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
heat EventUserHeat @relation(fields: [heatId], references: [id], onDelete: Cascade)
recorder User? @relation("RecorderAssignments", fields: [recorderId], references: [id])
@@index([eventId])
@@index([recorderId])
@@map("recording_suggestions")
}

View File

@@ -52,6 +52,7 @@ beforeAll(async () => {
}
if (testEventIds.length > 0) {
await prisma.recordingSuggestion.deleteMany({ where: { eventId: { in: testEventIds } } });
await prisma.chatRoom.deleteMany({ where: { eventId: { in: testEventIds } } });
await prisma.eventCheckinToken.deleteMany({ where: { eventId: { in: testEventIds } } });
await prisma.eventParticipant.deleteMany({ where: { eventId: { in: testEventIds } } });
@@ -222,6 +223,7 @@ afterAll(async () => {
}
if (testEventIds.length > 0) {
await prisma.recordingSuggestion.deleteMany({ where: { eventId: { in: testEventIds } } });
await prisma.chatRoom.deleteMany({ where: { eventId: { in: testEventIds } } });
await prisma.eventCheckinToken.deleteMany({ where: { eventId: { in: testEventIds } } });
await prisma.eventParticipant.deleteMany({ where: { eventId: { in: testEventIds } } });
@@ -946,4 +948,213 @@ describe('Events API Tests', () => {
expect(response.body.error).toContain('your own heats');
});
});
// ============================================
// AUTO-MATCHING API TESTS
// ============================================
describe('PUT /api/events/:slug/registration-deadline', () => {
it('should set registration deadline', async () => {
const deadline = new Date('2025-12-31T23:59:59Z');
const response = await request(app)
.put(`/api/events/${testEvent.slug}/registration-deadline`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ registrationDeadline: deadline.toISOString() })
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('registrationDeadline');
});
it('should clear registration deadline when null', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/registration-deadline`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ registrationDeadline: null })
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data.registrationDeadline).toBeNull();
});
it('should return 404 for non-existent event', async () => {
const response = await request(app)
.put('/api/events/non-existent-slug/registration-deadline')
.set('Authorization', `Bearer ${testToken1}`)
.send({ registrationDeadline: new Date().toISOString() })
.expect(404);
expect(response.body).toHaveProperty('success', false);
});
});
describe('PUT /api/events/:slug/recorder-opt-out', () => {
it('should set recorder opt-out to true', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/recorder-opt-out`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ optOut: true })
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('recorderOptOut', true);
});
it('should set recorder opt-out to false', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/recorder-opt-out`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ optOut: false })
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('recorderOptOut', false);
});
it('should reject non-boolean optOut', async () => {
const response = await request(app)
.put(`/api/events/${testEvent.slug}/recorder-opt-out`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ optOut: 'yes' })
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body.error).toContain('boolean');
});
it('should return 403 for non-participant', async () => {
const response = await request(app)
.put(`/api/events/${testEvent2.slug}/recorder-opt-out`)
.set('Authorization', `Bearer ${testToken1}`)
.send({ optOut: true })
.expect(403);
expect(response.body).toHaveProperty('success', false);
});
});
describe('POST /api/events/:slug/run-matching', () => {
beforeAll(async () => {
// Setup: Add heats for multiple users to test matching
// testUser1 and testUser2 need heats, and testUser3 can be a recorder
// Ensure testUser2 is a participant with competitor number (dancer)
await prisma.eventParticipant.upsert({
where: {
userId_eventId: { userId: testUser2.id, eventId: testEvent.id },
},
create: {
userId: testUser2.id,
eventId: testEvent.id,
competitorNumber: 200,
},
update: {
competitorNumber: 200,
},
});
// Add heat for testUser2
await prisma.eventUserHeat.upsert({
where: {
userId_eventId_divisionId_competitionTypeId_role: {
userId: testUser2.id,
eventId: testEvent.id,
divisionId: testDivision.id,
competitionTypeId: testCompetitionType.id,
role: 'Follower',
},
},
create: {
userId: testUser2.id,
eventId: testEvent.id,
divisionId: testDivision.id,
competitionTypeId: testCompetitionType.id,
heatNumber: 3,
role: 'Follower',
},
update: {},
});
// Update testUser1 with competitor number
await prisma.eventParticipant.update({
where: {
userId_eventId: { userId: testUser1.id, eventId: testEvent.id },
},
data: { competitorNumber: 100 },
});
// Add heat for testUser1
await prisma.eventUserHeat.upsert({
where: {
userId_eventId_divisionId_competitionTypeId_role: {
userId: testUser1.id,
eventId: testEvent.id,
divisionId: testDivision.id,
competitionTypeId: testCompetitionType.id,
role: 'Leader',
},
},
create: {
userId: testUser1.id,
eventId: testEvent.id,
divisionId: testDivision.id,
competitionTypeId: testCompetitionType.id,
heatNumber: 1,
role: 'Leader',
},
update: {},
});
});
it('should run matching algorithm', async () => {
const response = await request(app)
.post(`/api/events/${testEvent.slug}/run-matching`)
.set('Authorization', `Bearer ${testToken1}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('totalHeats');
expect(response.body.data).toHaveProperty('matched');
expect(response.body.data).toHaveProperty('notFound');
expect(response.body.data).toHaveProperty('runAt');
});
it('should return 404 for non-existent event', async () => {
const response = await request(app)
.post('/api/events/non-existent-slug/run-matching')
.set('Authorization', `Bearer ${testToken1}`)
.expect(404);
expect(response.body).toHaveProperty('success', false);
});
});
describe('GET /api/events/:slug/match-suggestions', () => {
it('should get match suggestions for user', async () => {
const response = await request(app)
.get(`/api/events/${testEvent.slug}/match-suggestions`)
.set('Authorization', `Bearer ${testToken1}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('toBeRecorded');
expect(response.body.data).toHaveProperty('toRecord');
expect(response.body.data.toBeRecorded).toBeInstanceOf(Array);
expect(response.body.data.toRecord).toBeInstanceOf(Array);
});
it('should return 404 for non-existent event', async () => {
const response = await request(app)
.get('/api/events/non-existent-slug/match-suggestions')
.set('Authorization', `Bearer ${testToken1}`)
.expect(404);
expect(response.body).toHaveProperty('success', false);
});
});
});

View File

@@ -0,0 +1,200 @@
/**
* Unit tests for auto-matching service
*/
const {
getTimeSlot,
getCoverableHeats,
hasCollision,
getLocationScore,
MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER,
} = require('../services/matching');
describe('Matching Service - Unit Tests', () => {
describe('getTimeSlot', () => {
it('should create unique slot identifier', () => {
const heat = { divisionId: 1, competitionTypeId: 2, heatNumber: 3 };
expect(getTimeSlot(heat)).toBe('1-2-3');
});
it('should create different slots for different heats', () => {
const heat1 = { divisionId: 1, competitionTypeId: 1, heatNumber: 1 };
const heat2 = { divisionId: 1, competitionTypeId: 1, heatNumber: 2 };
const heat3 = { divisionId: 2, competitionTypeId: 1, heatNumber: 1 };
expect(getTimeSlot(heat1)).not.toBe(getTimeSlot(heat2));
expect(getTimeSlot(heat1)).not.toBe(getTimeSlot(heat3));
});
});
describe('getCoverableHeats', () => {
it('should return all heats when recorder has no heats', () => {
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
{ id: 2, divisionId: 1, competitionTypeId: 1, heatNumber: 2 },
];
const recorderHeats = [];
const result = getCoverableHeats(dancerHeats, recorderHeats);
expect(result).toHaveLength(2);
expect(result.map(h => h.id)).toEqual([1, 2]);
});
it('should exclude heats where recorder is dancing', () => {
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
{ id: 2, divisionId: 1, competitionTypeId: 1, heatNumber: 2 },
{ id: 3, divisionId: 1, competitionTypeId: 1, heatNumber: 3 },
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 2 }, // Same as dancer's heat 2
];
const result = getCoverableHeats(dancerHeats, recorderHeats);
// Heat 2 blocked (recorder dancing), Heat 3 blocked (buffer after heat 2)
expect(result.map(h => h.id)).toEqual([1]);
});
it('should apply buffer after recorder heats', () => {
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
{ id: 2, divisionId: 1, competitionTypeId: 1, heatNumber: 2 },
{ id: 3, divisionId: 1, competitionTypeId: 1, heatNumber: 3 },
{ id: 4, divisionId: 1, competitionTypeId: 1, heatNumber: 4 },
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
];
const result = getCoverableHeats(dancerHeats, recorderHeats);
// Heat 1 blocked (recorder dancing)
// Heat 2 blocked (buffer - HEAT_BUFFER=1 after heat 1)
// Heats 3, 4 available
expect(result.map(h => h.id)).toEqual([3, 4]);
});
it('should handle different competition types independently', () => {
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // J&J Novice H1
{ id: 2, divisionId: 1, competitionTypeId: 2, heatNumber: 1 }, // Strictly Novice H1
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // J&J Novice H1
];
const result = getCoverableHeats(dancerHeats, recorderHeats);
// Only J&J Novice H1 blocked, Strictly Novice H1 is available
expect(result.map(h => h.id)).toEqual([2]);
});
it('should handle different divisions independently', () => {
const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // Novice J&J H1
{ id: 2, divisionId: 2, competitionTypeId: 1, heatNumber: 1 }, // Intermediate J&J H1
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // Novice J&J H1
];
const result = getCoverableHeats(dancerHeats, recorderHeats);
// Novice H1 blocked, Intermediate H1 available
expect(result.map(h => h.id)).toEqual([2]);
});
});
describe('hasCollision', () => {
it('should return false when no collision', () => {
const dancerHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 5 },
];
expect(hasCollision(dancerHeats, recorderHeats)).toBe(false);
});
it('should return true when same heat', () => {
const dancerHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
];
expect(hasCollision(dancerHeats, recorderHeats)).toBe(true);
});
it('should return true when within buffer', () => {
const dancerHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 2 }, // Dancer needs recording
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // Recorder dances Heat 1
];
// Heat 2 is within buffer of Heat 1 (buffer = 1)
expect(hasCollision(dancerHeats, recorderHeats)).toBe(true);
});
it('should return false when outside buffer', () => {
const dancerHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 3 },
];
const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
];
// Heat 3 is outside buffer of Heat 1 (buffer = 1, so Heat 2 is blocked, Heat 3 is OK)
expect(hasCollision(dancerHeats, recorderHeats)).toBe(false);
});
});
describe('getLocationScore', () => {
it('should return 3 for same city and country', () => {
const dancer = { city: 'Warsaw', country: 'Poland' };
const recorder = { city: 'Warsaw', country: 'Poland' };
expect(getLocationScore(dancer, recorder)).toBe(3);
});
it('should return 3 for same city (case insensitive)', () => {
const dancer = { city: 'WARSAW', country: 'Poland' };
const recorder = { city: 'warsaw', country: 'poland' };
expect(getLocationScore(dancer, recorder)).toBe(3);
});
it('should return 2 for same country different city', () => {
const dancer = { city: 'Warsaw', country: 'Poland' };
const recorder = { city: 'Krakow', country: 'Poland' };
expect(getLocationScore(dancer, recorder)).toBe(2);
});
it('should return 1 for different country', () => {
const dancer = { city: 'Warsaw', country: 'Poland' };
const recorder = { city: 'Berlin', country: 'Germany' };
expect(getLocationScore(dancer, recorder)).toBe(1);
});
it('should return 1 when location data is missing', () => {
const dancer = { city: null, country: null };
const recorder = { city: 'Berlin', country: 'Germany' };
expect(getLocationScore(dancer, recorder)).toBe(1);
});
});
describe('Constants', () => {
it('should have correct max recordings per person', () => {
expect(MAX_RECORDINGS_PER_PERSON).toBe(3);
});
it('should have correct heat buffer', () => {
expect(HEAT_BUFFER).toBe(1);
});
});
});

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;

View File

@@ -0,0 +1,343 @@
/**
* Auto-matching service for recording partners
*
* Matches dancers with recorders based on:
* - Heat collision avoidance (can't record while dancing)
* - Buffer time (1 heat after dancing)
* - Location preference (same city > same country > anyone)
* - Max recordings per person (3)
*/
const { prisma } = require('../utils/db');
// Constants
const MAX_RECORDINGS_PER_PERSON = 3;
const HEAT_BUFFER = 1; // Number of heats after dancing before can record
/**
* Represents a time slot as a unique string
* Format: "divisionId-competitionTypeId-heatNumber"
*/
function getTimeSlot(heat) {
return `${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber}`;
}
/**
* Get adjacent slots (for buffer calculation)
* Returns the next slot within the same division+competitionType
*/
function getBufferSlots(heat) {
const bufferSlots = [];
for (let i = 1; i <= HEAT_BUFFER; i++) {
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber + i}`);
}
return bufferSlots;
}
/**
* Check if two users have any time collision
* User A is dancing, User B wants to record
* Returns true if B cannot record A (collision exists)
*/
function hasCollision(dancerHeats, recorderHeats) {
// Get all slots where dancer is dancing + buffer slots
const blockedSlots = new Set();
for (const heat of dancerHeats) {
blockedSlots.add(getTimeSlot(heat));
}
// Get all slots where recorder is dancing + their buffer slots
const recorderBusySlots = new Set();
for (const heat of recorderHeats) {
recorderBusySlots.add(getTimeSlot(heat));
// Add buffer after their heats
for (const bufferSlot of getBufferSlots(heat)) {
recorderBusySlots.add(bufferSlot);
}
}
// Check if any of dancer's heats fall within recorder's busy slots
for (const heat of dancerHeats) {
const slot = getTimeSlot(heat);
if (recorderBusySlots.has(slot)) {
return true; // Collision: recorder is busy during this heat
}
}
return false;
}
/**
* Check which specific heats a recorder can cover for a dancer
* Returns array of heat IDs that recorder can film
*/
function getCoverableHeats(dancerHeats, recorderHeats) {
const recorderBusySlots = new Set();
for (const heat of recorderHeats) {
recorderBusySlots.add(getTimeSlot(heat));
for (const bufferSlot of getBufferSlots(heat)) {
recorderBusySlots.add(bufferSlot);
}
}
return dancerHeats.filter(heat => {
const slot = getTimeSlot(heat);
return !recorderBusySlots.has(slot);
});
}
/**
* Calculate location score for sorting
* Higher score = better match
*/
function getLocationScore(dancer, recorder) {
if (dancer.city && recorder.city &&
dancer.city.toLowerCase() === recorder.city.toLowerCase() &&
dancer.country && recorder.country &&
dancer.country.toLowerCase() === recorder.country.toLowerCase()) {
return 3; // Same city
}
if (dancer.country && recorder.country &&
dancer.country.toLowerCase() === recorder.country.toLowerCase()) {
return 2; // Same country
}
return 1; // Different location
}
/**
* Main matching algorithm
* Greedy approach: for each heat, find the best available recorder
*/
async function runMatching(eventId) {
// 1. Get all participants with their heats and user info
const participants = await prisma.eventParticipant.findMany({
where: { eventId },
include: {
user: {
select: {
id: true,
username: true,
city: true,
country: true,
}
}
}
});
// 2. Get all heats for this event
const allHeats = await prisma.eventUserHeat.findMany({
where: { eventId },
include: {
division: true,
competitionType: true,
}
});
// Group heats by user
const heatsByUser = new Map();
for (const heat of allHeats) {
if (!heatsByUser.has(heat.userId)) {
heatsByUser.set(heat.userId, []);
}
heatsByUser.get(heat.userId).push(heat);
}
// 3. Identify dancers (have competitor number) vs potential recorders
const dancers = participants.filter(p => p.competitorNumber !== null);
const potentialRecorders = participants.filter(p => !p.recorderOptOut);
// Sort recorders: non-opt-out first, then by whether they're dancing
potentialRecorders.sort((a, b) => {
// Opt-out users go to the end
if (a.recorderOptOut !== b.recorderOptOut) {
return a.recorderOptOut ? 1 : -1;
}
return 0;
});
// 4. Track recorder assignments
const recorderAssignmentCount = new Map(); // recorderId -> count
const suggestions = []; // Final suggestions
// 5. For each dancer, find recorders for their heats
for (const dancer of dancers) {
const dancerHeats = heatsByUser.get(dancer.userId) || [];
if (dancerHeats.length === 0) continue;
const dancerUser = dancer.user;
// Sort heats by time slot for deterministic ordering
dancerHeats.sort((a, b) => {
const slotA = getTimeSlot(a);
const slotB = getTimeSlot(b);
return slotA.localeCompare(slotB);
});
// For each heat, find a recorder
for (const heat of dancerHeats) {
// Find available recorders for this heat
const candidates = [];
for (const recorder of potentialRecorders) {
// Skip self
if (recorder.userId === dancer.userId) continue;
// Check assignment limit
const currentCount = recorderAssignmentCount.get(recorder.userId) || 0;
if (currentCount >= MAX_RECORDINGS_PER_PERSON) continue;
// Check if this recorder can cover this specific heat
const recorderHeats = heatsByUser.get(recorder.userId) || [];
const coverableHeats = getCoverableHeats([heat], recorderHeats);
if (coverableHeats.length > 0) {
candidates.push({
recorder,
locationScore: getLocationScore(dancerUser, recorder.user),
isOptOut: recorder.recorderOptOut,
currentAssignments: currentCount,
});
}
}
if (candidates.length === 0) {
// No recorder found for this heat
suggestions.push({
eventId,
heatId: heat.id,
recorderId: null,
status: 'not_found',
});
continue;
}
// Sort candidates: location score (desc), assignments (asc), opt-out last
candidates.sort((a, b) => {
if (a.isOptOut !== b.isOptOut) return a.isOptOut ? 1 : -1;
if (a.locationScore !== b.locationScore) return b.locationScore - a.locationScore;
return a.currentAssignments - b.currentAssignments;
});
// Pick the best candidate
const best = candidates[0];
suggestions.push({
eventId,
heatId: heat.id,
recorderId: best.recorder.userId,
status: 'pending',
});
// Update assignment count
recorderAssignmentCount.set(
best.recorder.userId,
(recorderAssignmentCount.get(best.recorder.userId) || 0) + 1
);
}
}
return suggestions;
}
/**
* Save matching results to database
*/
async function saveMatchingResults(eventId, suggestions) {
// Delete existing suggestions for this event
await prisma.recordingSuggestion.deleteMany({
where: { eventId }
});
// Create new suggestions
if (suggestions.length > 0) {
await prisma.recordingSuggestion.createMany({
data: suggestions
});
}
// Update event's matchingRunAt
await prisma.event.update({
where: { id: eventId },
data: { matchingRunAt: new Date() }
});
return suggestions.length;
}
/**
* Get matching suggestions for a user in an event
*/
async function getUserSuggestions(eventId, userId) {
// Get heats where user is the dancer
const userHeats = await prisma.eventUserHeat.findMany({
where: { eventId, userId },
select: { id: true }
});
const heatIds = userHeats.map(h => h.id);
// Get suggestions for those heats
const suggestions = await prisma.recordingSuggestion.findMany({
where: {
eventId,
heatId: { in: heatIds }
},
include: {
heat: {
include: {
division: true,
competitionType: true,
}
},
recorder: {
select: {
id: true,
username: true,
avatar: true,
city: true,
country: true,
}
}
}
});
// Get assignments where user is the recorder
const assignments = await prisma.recordingSuggestion.findMany({
where: {
eventId,
recorderId: userId
},
include: {
heat: {
include: {
division: true,
competitionType: true,
user: {
select: {
id: true,
username: true,
avatar: true,
}
}
}
}
}
});
return {
toBeRecorded: suggestions, // Heats where I need someone to record me
toRecord: assignments, // Heats where I need to record someone
};
}
module.exports = {
runMatching,
saveMatchingResults,
getUserSuggestions,
hasCollision,
getCoverableHeats,
getTimeSlot,
getLocationScore,
MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER,
};