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:
@@ -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;
|
||||
@@ -59,6 +59,7 @@ model User {
|
||||
ratingsReceived Rating[] @relation("RatedRatings")
|
||||
eventParticipants EventParticipant[]
|
||||
heats EventUserHeat[]
|
||||
recordingAssignments RecordingSuggestion[] @relation("RecorderAssignments")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -76,12 +77,17 @@ model Event {
|
||||
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[]
|
||||
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
|
||||
@@ -240,6 +247,7 @@ model EventUserHeat {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
200
backend/src/__tests__/matching.test.js
Normal file
200
backend/src/__tests__/matching.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
343
backend/src/services/matching.js
Normal file
343
backend/src/services/matching.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user