From c18416ad6f5c3ee0db80457b9c6a05e7cca63e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sun, 23 Nov 2025 18:32:14 +0100 Subject: [PATCH] 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 --- .../migration.sql | 37 ++ backend/prisma/schema.prisma | 66 +++- backend/src/__tests__/events.test.js | 211 +++++++++++ backend/src/__tests__/matching.test.js | 200 ++++++++++ backend/src/routes/events.js | 271 ++++++++++++++ backend/src/services/matching.js | 343 ++++++++++++++++++ 6 files changed, 1109 insertions(+), 19 deletions(-) create mode 100644 backend/prisma/migrations/20251123172500_add_auto_matching/migration.sql create mode 100644 backend/src/__tests__/matching.test.js create mode 100644 backend/src/services/matching.js diff --git a/backend/prisma/migrations/20251123172500_add_auto_matching/migration.sql b/backend/prisma/migrations/20251123172500_add_auto_matching/migration.sql new file mode 100644 index 0000000..23c5866 --- /dev/null +++ b/backend/prisma/migrations/20251123172500_add_auto_matching/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0d38514..9cbe5dd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/src/__tests__/events.test.js b/backend/src/__tests__/events.test.js index 849b68f..158f727 100644 --- a/backend/src/__tests__/events.test.js +++ b/backend/src/__tests__/events.test.js @@ -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); + }); + }); }); diff --git a/backend/src/__tests__/matching.test.js b/backend/src/__tests__/matching.test.js new file mode 100644 index 0000000..2df1381 --- /dev/null +++ b/backend/src/__tests__/matching.test.js @@ -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); + }); + }); +}); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 3e8f4b4..be932ff 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -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; diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js new file mode 100644 index 0000000..20e762a --- /dev/null +++ b/backend/src/services/matching.js @@ -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, +};