diff --git a/prisma/migrations/20260428154820_add_finalist_confirmation_flow/migration.sql b/prisma/migrations/20260428154820_add_finalist_confirmation_flow/migration.sql new file mode 100644 index 0000000..bf92d28 --- /dev/null +++ b/prisma/migrations/20260428154820_add_finalist_confirmation_flow/migration.sql @@ -0,0 +1,119 @@ +-- CreateEnum +CREATE TYPE "WaitlistEntryStatus" AS ENUM ('WAITING', 'PROMOTED', 'USED'); + +-- CreateEnum +CREATE TYPE "FinalistConfirmationStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'EXPIRED', 'SUPERSEDED'); + +-- AlterTable +ALTER TABLE "Program" ADD COLUMN "defaultAttendeeCap" INTEGER NOT NULL DEFAULT 3; + +-- CreateTable +CREATE TABLE "FinalistSlotQuota" ( + "id" TEXT NOT NULL, + "programId" TEXT NOT NULL, + "category" "CompetitionCategory" NOT NULL, + "quota" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FinalistSlotQuota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WaitlistEntry" ( + "id" TEXT NOT NULL, + "programId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "category" "CompetitionCategory" NOT NULL, + "rank" INTEGER NOT NULL, + "status" "WaitlistEntryStatus" NOT NULL DEFAULT 'WAITING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FinalistConfirmation" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "category" "CompetitionCategory" NOT NULL, + "status" "FinalistConfirmationStatus" NOT NULL DEFAULT 'PENDING', + "deadline" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "confirmedAt" TIMESTAMP(3), + "declinedAt" TIMESTAMP(3), + "declineReason" TEXT, + "expiredAt" TIMESTAMP(3), + "promotedFromWaitlistEntryId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FinalistConfirmation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AttendingMember" ( + "id" TEXT NOT NULL, + "confirmationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "needsVisa" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AttendingMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FinalistSlotQuota_programId_idx" ON "FinalistSlotQuota"("programId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FinalistSlotQuota_programId_category_key" ON "FinalistSlotQuota"("programId", "category"); + +-- CreateIndex +CREATE UNIQUE INDEX "WaitlistEntry_projectId_key" ON "WaitlistEntry"("projectId"); + +-- CreateIndex +CREATE INDEX "WaitlistEntry_programId_category_status_idx" ON "WaitlistEntry"("programId", "category", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "WaitlistEntry_programId_category_rank_key" ON "WaitlistEntry"("programId", "category", "rank"); + +-- CreateIndex +CREATE UNIQUE INDEX "FinalistConfirmation_projectId_key" ON "FinalistConfirmation"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FinalistConfirmation_token_key" ON "FinalistConfirmation"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "FinalistConfirmation_promotedFromWaitlistEntryId_key" ON "FinalistConfirmation"("promotedFromWaitlistEntryId"); + +-- CreateIndex +CREATE INDEX "FinalistConfirmation_status_deadline_idx" ON "FinalistConfirmation"("status", "deadline"); + +-- CreateIndex +CREATE INDEX "FinalistConfirmation_category_status_idx" ON "FinalistConfirmation"("category", "status"); + +-- CreateIndex +CREATE INDEX "AttendingMember_userId_idx" ON "AttendingMember"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AttendingMember_confirmationId_userId_key" ON "AttendingMember"("confirmationId", "userId"); + +-- AddForeignKey +ALTER TABLE "FinalistSlotQuota" ADD CONSTRAINT "FinalistSlotQuota_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FinalistConfirmation" ADD CONSTRAINT "FinalistConfirmation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AttendingMember" ADD CONSTRAINT "AttendingMember_confirmationId_fkey" FOREIGN KEY ("confirmationId") REFERENCES "FinalistConfirmation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AttendingMember" ADD CONSTRAINT "AttendingMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5cfe1ec..3ebc63d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +}