feat: schema for finalist confirmation flow + per-category quotas

Adds 4 new models for grand-finale logistics PR 1:
- FinalistSlotQuota: per-category mutable quotas
- WaitlistEntry: ranked per-category waitlist
- FinalistConfirmation: token-gated confirmation lifecycle (PENDING /
  CONFIRMED / DECLINED / EXPIRED / SUPERSEDED) with optional decline reason
- AttendingMember: who from each team is attending, with visa flag

Plus Program.defaultAttendeeCap (default 3) for the per-edition team
attendance cap.

Migration is purely additive: no DROP/ALTER COLUMN/RENAME on existing
schema. All FKs ON DELETE CASCADE only fire on parent deletion.
This commit is contained in:
Matt
2026-04-28 17:49:26 +02:00
parent d0058b46ed
commit dff18b17f7
2 changed files with 219 additions and 0 deletions

View File

@@ -426,6 +426,9 @@ model User {
// AI Ranking
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
// Grand-finale logistics
finalistAttendances AttendingMember[]
@@index([role])
@@index([status])
}
@@ -483,6 +486,9 @@ model Program {
description String?
settingsJson Json? @db.JsonB
// Grand-finale logistics
defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -496,6 +502,10 @@ model Program {
mentorMilestones MentorMilestone[]
competitions Competition[]
// Grand-finale logistics
finalistSlotQuotas FinalistSlotQuota[]
waitlistEntries WaitlistEntry[]
@@unique([name, year])
@@index([status])
}
@@ -637,6 +647,10 @@ model Project {
submissionPromotions SubmissionPromotionEvent[]
notificationLogs NotificationLog[]
// Grand-finale logistics
waitlistEntry WaitlistEntry?
finalistConfirmation FinalistConfirmation?
@@index([programId])
@@index([status])
@@index([tags])
@@ -2626,3 +2640,89 @@ model ResultUnlockEvent {
@@index([resultLockId])
@@index([unlockedById])
}
// ─────────────────────────────────────────────────────────────────────────────
// Grand-finale logistics (PR 1: finalist confirmation flow)
// ─────────────────────────────────────────────────────────────────────────────
enum WaitlistEntryStatus {
WAITING // available for promotion
PROMOTED // moved into a finalist slot
USED // promoted and confirmation flow completed (declined or accepted)
}
enum FinalistConfirmationStatus {
PENDING // sent, awaiting team response
CONFIRMED // team accepted, attendees selected
DECLINED // team explicitly declined
EXPIRED // deadline passed without response
SUPERSEDED // admin manually overrode (e.g. unconfirmed to allow quota decrease)
}
model FinalistSlotQuota {
id String @id @default(cuid())
programId String
category CompetitionCategory
quota Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
@@unique([programId, category])
@@index([programId])
}
model WaitlistEntry {
id String @id @default(cuid())
programId String
projectId String @unique
category CompetitionCategory
rank Int
status WaitlistEntryStatus @default(WAITING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([programId, category, rank])
@@index([programId, category, status])
}
model FinalistConfirmation {
id String @id @default(cuid())
projectId String @unique
category CompetitionCategory
status FinalistConfirmationStatus @default(PENDING)
deadline DateTime
token String @unique
confirmedAt DateTime?
declinedAt DateTime?
declineReason String? // optional free-text on decline
expiredAt DateTime?
promotedFromWaitlistEntryId String? @unique // null for original finalists
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
attendingMembers AttendingMember[]
@@index([status, deadline]) // for cron scan
@@index([category, status])
}
model AttendingMember {
id String @id @default(cuid())
confirmationId String
userId String // must be a TeamMember of the same project (validated at write time)
needsVisa Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([confirmationId, userId])
@@index([userId])
}