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")
}