MOPC-App/docs/unified-architecture-redesign/02-data-model.md

55 KiB

02. Data Model

Overview

This document defines the complete Prisma schema and Zod configuration contracts for the MOPC platform redesign. The data model uses Competition and Round terminology (replacing Pipeline/Stage/Track), implements a 5-layer policy precedence system, and introduces new models for deliberation, mentoring workspace, result locking, and assignment governance.

Key Design Principles:

  • Competition-centric flow (no intermediate Track layer for main competition)
  • Round-type-specific typed configurations (Zod-validated)
  • First-class Jury Group entities with per-member policy overrides
  • Multi-round submission windows with explicit file requirements
  • Deliberation system for final winner confirmation (replaces WinnerProposal)
  • Result locking with audit trail
  • Mentoring workspace with file promotion to official submissions

Entity Relationship Summary

Program (1) ──── (N) Competition
Competition (1) ──── (N) Round
Competition (1) ──── (N) JuryGroup
Competition (1) ──── (N) SubmissionWindow
Competition (1) ──── (N) SpecialAward
Competition (1) ──── (N) DeliberationSession

Round (N) ──── (1) JuryGroup (optional — for EVALUATION, LIVE_FINAL, DELIBERATION)
Round (N) ──── (1) SubmissionWindow (optional — for INTAKE, SUBMISSION)
Round (1) ──── (N) RoundSubmissionVisibility ──── (N) SubmissionWindow

JuryGroup (1) ──── (N) JuryGroupMember ──── (1) User
JuryGroup (1) ──── (N) Assignment

SubmissionWindow (1) ──── (N) SubmissionFileRequirement
SubmissionWindow (1) ──── (N) ProjectFile

Project (1) ──── (N) ProjectRoundState ──── (1) Round
Project (1) ──── (N) Assignment ──── (1) Evaluation
Project (1) ──── (N) ProjectFile
Project (1) ──── (0..1) MentorAssignment ──── (N) MentorFile ──── (N) MentorFileComment
                                       ──── (N) MentorMessage

SpecialAward (N) ──── (1) JuryGroup (optional)
SpecialAward (N) ──── (1) Round (evaluationRound — runs alongside)

DeliberationSession (1) ──── (N) DeliberationVote
DeliberationSession (1) ──── (N) DeliberationResult
DeliberationSession (1) ──── (N) DeliberationParticipant

ResultLock (1) ──── (N) ResultUnlockEvent

Core Models

Competition (replaces Pipeline)

model Competition {
  id           String             @id @default(cuid())
  programId    String
  name         String             // "MOPC 2026 Competition"
  slug         String             @unique // "mopc-2026"
  status       CompetitionStatus  @default(DRAFT)

  // Competition-wide settings (typed, not generic JSON)
  categoryMode             String  @default("SHARED")  // "SHARED" (both categories same flow) | "SPLIT" (separate finalist counts)
  startupFinalistCount     Int     @default(3)
  conceptFinalistCount     Int     @default(3)

  // Notification preferences
  notifyOnRoundAdvance     Boolean @default(true)
  notifyOnDeadlineApproach Boolean @default(true)
  deadlineReminderDays     Int[]   @default([7, 3, 1])  // Days before deadline to send reminders

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  program               Program                @relation(fields: [programId], references: [id], onDelete: Cascade)
  rounds                Round[]
  juryGroups            JuryGroup[]
  submissionWindows     SubmissionWindow[]
  specialAwards         SpecialAward[]
  deliberationSessions  DeliberationSession[]
  resultLocks           ResultLock[]

  @@index([programId])
  @@index([status])
}

enum CompetitionStatus {
  DRAFT
  ACTIVE
  CLOSED
  ARCHIVED
}

Round (replaces Stage)

model Round {
  id              String      @id @default(cuid())
  competitionId   String
  name            String      // "Jury 1 - Semi-finalist Selection"
  slug            String      // "jury-1-semifinalist"
  roundType       RoundType
  status          RoundStatus @default(ROUND_DRAFT)
  sortOrder       Int         @default(0)

  // Time windows
  windowOpenAt    DateTime?
  windowCloseAt   DateTime?

  // Round-type-specific configuration (validated by Zod per RoundType)
  configJson      Json?       @db.JsonB

  // Optional analytics tag — semantic label for grouping/reporting (e.g., "jury1_selection", "semifinal_docs")
  purposeKey      String?     // Free-form, not an enum — allows flexible analytics without schema changes

  // Links to other entities
  juryGroupId         String?      // Which jury evaluates this round (EVALUATION, LIVE_FINAL, DELIBERATION)
  submissionWindowId  String?      // Which submission window this round collects docs for (INTAKE, SUBMISSION)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  competition              Competition                @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  juryGroup                JuryGroup?                 @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
  submissionWindow         SubmissionWindow?          @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)

  projectRoundStates       ProjectRoundState[]
  assignments              Assignment[]
  evaluationForms          EvaluationForm[]
  filteringRules           FilteringRule[]
  filteringResults         FilteringResult[]
  filteringJobs            FilteringJob[]
  evaluationSummaries      EvaluationSummary[]
  evaluationDiscussions    EvaluationDiscussion[]
  gracePeriods             GracePeriod[]
  liveCursor               LiveProgressCursor?
  liveVotingSession        LiveVotingSession?
  advancementRules         AdvancementRule[]
  resultLocks              ResultLock[]

  // Visible submission windows (which doc rounds jury can see)
  visibleSubmissionWindows RoundSubmissionVisibility[]

  @@unique([competitionId, slug])
  @@unique([competitionId, sortOrder])
  @@index([competitionId])
  @@index([roundType])
  @@index([status])
}

enum RoundType {
  INTAKE          // Application window — collect initial submissions
  FILTERING       // AI screening — automated eligibility check
  EVALUATION      // Jury evaluation — scoring, feedback, advancement decision
  SUBMISSION      // New submission window — additional docs from advancing teams
  MENTORING       // Mentor-team collaboration period
  LIVE_FINAL      // Live ceremony — real-time voting, audience participation
  DELIBERATION    // Winner confirmation — jury consensus + admin approval
}

enum RoundStatus {
  ROUND_DRAFT     // Being configured, not visible to participants
  ROUND_ACTIVE    // Open/in progress
  ROUND_CLOSED    // Window closed, results pending or finalized
  ROUND_ARCHIVED  // Historical, read-only
}

ProjectRoundState (replaces ProjectStageState)

model ProjectRoundState {
  id           String                 @id @default(cuid())
  projectId    String
  roundId      String
  state        ProjectRoundStateValue @default(PENDING)
  enteredAt    DateTime               @default(now())
  exitedAt     DateTime?
  metadataJson Json?                  @db.JsonB   // Round-type-specific state data

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  project  Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  round    Round   @relation(fields: [roundId], references: [id], onDelete: Cascade)

  @@unique([projectId, roundId])
  @@index([projectId])
  @@index([roundId])
  @@index([state])
}

enum ProjectRoundStateValue {
  PENDING       // Entered round, awaiting action
  IN_PROGRESS   // Active (submission in progress, evaluation ongoing)
  PASSED        // Cleared this round, eligible to advance
  REJECTED      // Did not pass this round
  COMPLETED     // Round fully complete for this project
  WITHDRAWN     // Project withdrew
}

AdvancementRule (replaces StageTransition)

model AdvancementRule {
  id              String              @id @default(cuid())
  roundId         String              // The round this rule applies to (source round)
  targetRoundId   String?             // Where projects advance to (null = next round by sortOrder)

  ruleType        AdvancementRuleType
  configJson      Json                @db.JsonB   // Rule-type-specific config

  isDefault       Boolean             @default(true)  // Default advancement path
  sortOrder       Int                 @default(0)     // Priority when multiple rules exist

  createdAt DateTime @default(now())

  round       Round  @relation(fields: [roundId], references: [id], onDelete: Cascade)

  @@index([roundId])
}

enum AdvancementRuleType {
  AUTO_ADVANCE      // All PASSED projects advance to next round automatically
  SCORE_THRESHOLD   // Projects above score threshold advance
  TOP_N             // Top N projects per category advance
  ADMIN_SELECTION   // Admin manually selects who advances
  AI_RECOMMENDED    // AI suggests advancement, admin confirms
}

AdvancementRule configJson shapes:

// AUTO_ADVANCE
{ trigger: "on_round_close" | "immediate" }

// SCORE_THRESHOLD
{ minScore: 7.0, metric: "average_global" | "weighted_criteria" }

// TOP_N
{
  perCategory: true,
  counts: { STARTUP: 10, BUSINESS_CONCEPT: 10 },
  tieBreaker: "admin_decides" | "highest_individual" | "revote"
}

// ADMIN_SELECTION
{ requireAIRecommendation: true, showRankings: true }

// AI_RECOMMENDED
{ topN: 10, confidenceThreshold: 0.7, requireAdminApproval: true }

Program

model Program {
  id           String        @id @default(cuid())
  name         String        // e.g., "Monaco Ocean Protection Challenge"
  slug         String?       @unique // URL-friendly identifier
  year         Int           // e.g., 2026
  status       ProgramStatus @default(DRAFT)
  description  String?
  settingsJson Json?         @db.JsonB

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  projects          Project[]
  learningResources LearningResource[]
  partners          Partner[]
  specialAwards     SpecialAward[]
  taggingJobs       TaggingJob[]
  wizardTemplates   WizardTemplate[]
  mentorMilestones  MentorMilestone[]
  competitions      Competition[]

  @@unique([name, year])
  @@index([status])
}

enum ProgramStatus {
  DRAFT
  ACTIVE
  ARCHIVED
}

Jury & Assignment Models

JuryGroup

model JuryGroup {
  id              String @id @default(cuid())
  competitionId   String
  name            String      // "Jury 1", "Jury 2", "Jury 3", "Innovation Award Jury"
  slug            String      // "jury-1", "jury-2"
  description     String?     @db.Text
  sortOrder       Int         @default(0)

  // Default assignment configuration for this jury
  defaultMaxAssignments    Int     @default(20)
  defaultCapMode           CapMode @default(SOFT)
  softCapBuffer            Int     @default(2)    // Extra assignments above cap for load balancing

  // Default category quotas (per juror)
  categoryQuotasEnabled    Boolean @default(false)
  defaultCategoryQuotas    Json?   @db.JsonB   // { "STARTUP": { "min": 2, "max": 15 }, "BUSINESS_CONCEPT": { "min": 2, "max": 15 } }

  // Onboarding: can jurors adjust their own cap/ratio during onboarding?
  allowJurorCapAdjustment     Boolean @default(false)
  allowJurorRatioAdjustment   Boolean @default(false)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  competition Competition       @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  members     JuryGroupMember[]
  rounds      Round[]           // Rounds this jury is assigned to
  assignments Assignment[]      // Assignments made through this jury group
  awards      SpecialAward[]    // Awards using this jury

  @@unique([competitionId, slug])
  @@index([competitionId])
}

enum CapMode {
  HARD    // Absolute maximum — AI/algorithm cannot exceed
  SOFT    // Target maximum — can exceed by softCapBuffer for load balancing
  NONE    // No cap — unlimited assignments
}

JuryGroupMember

model JuryGroupMember {
  id              String              @id @default(cuid())
  juryGroupId     String
  userId          String
  role            JuryGroupMemberRole @default(MEMBER)  // CHAIR (lead), MEMBER, OBSERVER (view-only)
  joinedAt        DateTime            @default(now())

  // Per-juror overrides (null = use group defaults)
  maxAssignmentsOverride   Int?
  capModeOverride          CapMode?
  categoryQuotasOverride   Json?   @db.JsonB  // Same shape as JuryGroup.defaultCategoryQuotas

  // Juror preferences (set during onboarding)
  preferredStartupRatio    Float?  // 0.0 to 1.0 — desired % of startups (e.g., 0.6 = 60% startups)
  availabilityNotes        String? @db.Text

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  juryGroup           JuryGroup            @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
  user                User                 @relation(fields: [userId], references: [id], onDelete: Cascade)
  assignmentIntents   AssignmentIntent[]
  deliberationVotes   DeliberationVote[]
  deliberationParticipations DeliberationParticipant[]

  @@unique([juryGroupId, userId])
  @@index([juryGroupId])
  @@index([userId])
}

Assignment (modified)

model Assignment {
  id        String  @id @default(cuid())
  userId    String
  projectId String
  roundId   String

  // Assignment info
  method      AssignmentMethod @default(MANUAL)
  isRequired  Boolean          @default(true)
  isCompleted Boolean          @default(false)

  // AI assignment metadata
  aiConfidenceScore   Float?
  expertiseMatchScore Float?
  aiReasoning         String? @db.Text

  // Jury group link
  juryGroupId   String?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  user               User                @relation(fields: [userId], references: [id], onDelete: Cascade)
  project            Project             @relation(fields: [projectId], references: [id], onDelete: Cascade)
  round              Round               @relation(fields: [roundId], references: [id], onDelete: Cascade)
  juryGroup          JuryGroup?          @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
  evaluation         Evaluation?
  conflictOfInterest ConflictOfInterest?
  exceptions         AssignmentException[]

  @@unique([userId, projectId, roundId])
  @@index([userId])
  @@index([projectId])
  @@index([roundId])
  @@index([juryGroupId])
  @@index([isCompleted])
}

enum AssignmentMethod {
  MANUAL
  BULK
  AI_SUGGESTED
  AI_AUTO
  ALGORITHM
}

AssignmentIntent

model AssignmentIntent {
  id                String   @id @default(cuid())
  juryGroupMemberId String
  roundId           String
  projectId         String
  source            AssignmentIntentSource   // How this intent was created
  status            AssignmentIntentStatus  @default(PENDING)  // Lifecycle: PENDING → HONORED/OVERRIDDEN/EXPIRED

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  juryGroupMember JuryGroupMember @relation(fields: [juryGroupMemberId], references: [id], onDelete: Cascade)
  round           Round           @relation(fields: [roundId], references: [id], onDelete: Cascade)
  project         Project         @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@unique([juryGroupMemberId, roundId, projectId])
  @@index([roundId])
  @@index([projectId])
  @@index([status])
}

enum AssignmentIntentSource {
  INVITE         // Created during member invite (pre-assignment)
  ADMIN          // Admin-created intent
  SYSTEM         // System-generated (e.g., from algorithm)
}

enum AssignmentIntentStatus {
  PENDING     // Created, awaiting assignment algorithm execution
  HONORED     // Assignment algorithm materialized this intent into an Assignment record
  OVERRIDDEN  // Admin changed the assignment, superseding this intent
  EXPIRED     // Round completed without this intent being honored
  CANCELLED   // Explicitly cancelled by admin or system
}

AssignmentException

model AssignmentException {
  id           String   @id @default(cuid())
  assignmentId String
  reason       String   @db.Text // Why exception was granted
  overCapBy    Int      // How many assignments over cap
  approvedById String
  createdAt    DateTime @default(now())

  // Relations
  assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
  approvedBy User       @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])

  @@index([assignmentId])
  @@index([approvedById])
}

Submission & Document Models

SubmissionWindow

model SubmissionWindow {
  id              String         @id @default(cuid())
  competitionId   String
  name            String         // "Round 1 Application Docs", "Semi-finalist Additional Docs"
  slug            String         // "round-1-docs"
  roundNumber     Int            // 1, 2, 3... (sequential)
  sortOrder       Int            @default(0)

  // Window timing
  windowOpenAt    DateTime?
  windowCloseAt   DateTime?

  // Deadline behavior
  deadlinePolicy  DeadlinePolicy @default(FLAG)
  graceHours      Int?           // Hours after windowCloseAt where late submissions accepted

  // Locking behavior
  lockOnClose     Boolean        @default(true)  // Applicants can't edit after window closes
  isLocked        Boolean        @default(false) // Manual lock toggle — admin can lock/unlock independently of window close

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  competition      Competition                @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  fileRequirements SubmissionFileRequirement[]
  projectFiles     ProjectFile[]
  rounds           Round[]                    // Rounds that collect submissions for this window
  visibility       RoundSubmissionVisibility[] // Which evaluation rounds can see these docs

  @@unique([competitionId, slug])
  @@unique([competitionId, roundNumber])
  @@index([competitionId])
}

enum DeadlinePolicy {
  HARD    // Submissions rejected after close
  FLAG    // Submissions accepted but marked late
  GRACE   // Grace period after close, then hard cutoff
}

SubmissionFileRequirement

model SubmissionFileRequirement {
  id                  String @id @default(cuid())
  submissionWindowId  String
  name                String           // "Executive Summary", "Business Plan", "Video Pitch"
  description         String?          @db.Text
  acceptedMimeTypes   String[]         // ["application/pdf", "video/*"]
  maxSizeMB           Int?             // Size limit
  isRequired          Boolean @default(true)
  sortOrder           Int     @default(0)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade)
  files            ProjectFile[]    // Files uploaded against this requirement

  @@index([submissionWindowId])
}

RoundSubmissionVisibility

model RoundSubmissionVisibility {
  id                  String @id @default(cuid())
  roundId             String
  submissionWindowId  String

  canView             Boolean @default(true)   // Jury can see these docs
  displayLabel        String?                  // "Round 1 Docs", "Round 2 Docs" (shown to jury)

  // Relations
  round            Round            @relation(fields: [roundId], references: [id], onDelete: Cascade)
  submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade)

  @@unique([roundId, submissionWindowId])
  @@index([roundId])
}

ProjectFile (modified)

model ProjectFile {
  id              String @id @default(cuid())
  projectId       String

  // Link to SubmissionWindow instead of legacy roundId
  submissionWindowId  String?
  requirementId       String?          // Links to SubmissionFileRequirement

  fileType        FileType
  fileName        String
  mimeType        String
  size            Int
  bucket          String
  objectKey       String

  isLate          Boolean  @default(false)
  version         Int      @default(1)
  replacedById    String?  @unique

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  project            Project                    @relation(fields: [projectId], references: [id], onDelete: Cascade)
  submissionWindow   SubmissionWindow?          @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
  requirement        SubmissionFileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull)
  replacedBy         ProjectFile?               @relation("FileVersion", fields: [replacedById], references: [id])
  previousVersion    ProjectFile?               @relation("FileVersion")
  promotedFrom       MentorFile?                @relation("PromotedFromMentorFile")

  @@index([projectId])
  @@index([submissionWindowId])
  @@index([requirementId])
}

enum FileType {
  EXEC_SUMMARY
  PRESENTATION
  VIDEO
  OTHER
  BUSINESS_PLAN
  VIDEO_PITCH
  SUPPORTING_DOC
}

MentorFile (NEW)

model MentorFile {
  id                   String @id @default(cuid())
  mentorAssignmentId   String
  uploadedByUserId     String

  fileName             String
  mimeType             String
  size                 Int
  bucket               String
  objectKey            String
  description          String?  @db.Text

  // Promotion to official submission
  isPromoted           Boolean @default(false)
  promotedToFileId     String?  @unique          // Links to the ProjectFile created on promotion
  promotedAt           DateTime?
  promotedByUserId     String?

  createdAt DateTime @default(now())

  // Relations
  mentorAssignment     MentorAssignment         @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
  uploadedBy           User                     @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
  promotedBy           User?                    @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
  promotedFile         ProjectFile?             @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
  comments             MentorFileComment[]
  promotionEvents      SubmissionPromotionEvent[]

  @@index([mentorAssignmentId])
  @@index([uploadedByUserId])
}

MentorFileComment (NEW)

model MentorFileComment {
  id           String @id @default(cuid())
  mentorFileId String
  authorId     String
  content      String @db.Text

  // Threading support
  parentCommentId String?     // null = top-level comment, non-null = reply

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  mentorFile    MentorFile         @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
  author        User               @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
  parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
  replies       MentorFileComment[] @relation("CommentThread")

  @@index([mentorFileId])
  @@index([authorId])
  @@index([parentCommentId])
}

MentorMessage (NEW)

model MentorMessage {
  id          String             @id @default(cuid())
  workspaceId String             // Links to MentorAssignment
  senderId    String
  senderRole  MentorMessageRole  // MENTOR | APPLICANT | ADMIN
  content     String             @db.Text
  createdAt   DateTime           @default(now())

  // Relations
  workspace MentorAssignment @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
  sender    User             @relation("MentorMessageSender", fields: [senderId], references: [id])

  @@index([workspaceId])
  @@index([senderId])
  @@index([createdAt])
}

enum MentorMessageRole {
  MENTOR
  APPLICANT
  ADMIN
}

SubmissionPromotionEvent (NEW)

model SubmissionPromotionEvent {
  id               String   @id @default(cuid())
  projectId        String
  roundId          String
  slotKey          String   // Which requirement slot this promoted file fills
  sourceType       SubmissionPromotionSource
  sourceFileId     String?  // MentorFile ID if promoted from mentoring workspace
  promotedById     String
  createdAt        DateTime @default(now())

  // Relations
  project      Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)
  round        Round        @relation(fields: [roundId], references: [id], onDelete: Cascade)
  sourceFile   MentorFile?  @relation(fields: [sourceFileId], references: [id], onDelete: SetNull)
  promotedBy   User         @relation("SubmissionPromoter", fields: [promotedById], references: [id])

  @@index([projectId])
  @@index([roundId])
  @@index([sourceFileId])
}

enum SubmissionPromotionSource {
  MENTOR_FILE          // Promoted from mentoring workspace
  ADMIN_REPLACEMENT    // Admin directly replaced official submission
}

MentorAssignment (modified)

model MentorAssignment {
  id        String @id @default(cuid())
  projectId String @unique // One mentor per project
  mentorId  String // User with MENTOR role or expertise

  // Assignment tracking
  method     AssignmentMethod @default(MANUAL)
  assignedAt DateTime         @default(now())
  assignedBy String?

  // AI assignment metadata
  aiConfidenceScore   Float?
  expertiseMatchScore Float?
  aiReasoning         String? @db.Text

  // Tracking
  completionStatus String    @default("in_progress") // 'in_progress' | 'completed' | 'paused'
  lastViewedAt     DateTime?

  // Workspace activation
  workspaceEnabled   Boolean   @default(false)   // Activated when MENTORING round opens
  workspaceOpenAt    DateTime?                   // When mentoring files/chat becomes available
  workspaceCloseAt   DateTime?                   // When workspace access ends

  // Relations
  project              Project                     @relation(fields: [projectId], references: [id], onDelete: Cascade)
  mentor               User                        @relation("MentorAssignment", fields: [mentorId], references: [id])
  notes                MentorNote[]
  milestoneCompletions MentorMilestoneCompletion[]
  messages             MentorMessage[]
  files                MentorFile[]

  @@index([mentorId])
  @@index([method])
}

Evaluation & Scoring Models

Evaluation

model Evaluation {
  id              String @id @default(cuid())
  assignmentId    String @unique
  formId          String
  status          EvaluationStatus @default(NOT_STARTED)

  // Scores
  criterionScoresJson Json?    @db.JsonB
  globalScore         Int?
  binaryDecision      Boolean?
  feedbackText        String?  @db.Text

  version     Int       @default(1)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  submittedAt DateTime?

  // Relations
  assignment Assignment     @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
  form       EvaluationForm @relation(fields: [formId], references: [id], onDelete: Cascade)

  @@index([status])
  @@index([submittedAt])
  @@index([formId])
}

enum EvaluationStatus {
  NOT_STARTED
  DRAFT
  SUBMITTED
  LOCKED
}

EvaluationForm

model EvaluationForm {
  id      String  @id @default(cuid())
  roundId String
  version Int     @default(1)

  // Form configuration
  criteriaJson Json    @db.JsonB  // Array of { id, label, description, scale, weight, required }
  scalesJson   Json?   @db.JsonB  // { "1-5": { min, max, labels }, "1-10": { min, max, labels } }
  isActive     Boolean @default(false)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  round       Round        @relation(fields: [roundId], references: [id], onDelete: Cascade)
  evaluations Evaluation[]

  @@unique([roundId, version])
  @@index([roundId, isActive])
}

ConflictOfInterest

model ConflictOfInterest {
  id           String   @id @default(cuid())
  assignmentId String   @unique
  userId       String
  projectId    String
  hasConflict  Boolean  @default(false)
  conflictType String?  // "financial", "personal", "organizational", "other"
  description  String?  @db.Text
  declaredAt   DateTime @default(now())

  // Admin review
  reviewedById String?
  reviewedAt   DateTime?
  reviewAction String?  // "cleared", "reassigned", "noted"

  // Relations
  assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
  user       User       @relation(fields: [userId], references: [id])
  reviewedBy User?      @relation("COIReviewedBy", fields: [reviewedById], references: [id])

  @@index([userId])
  @@index([hasConflict])
}

EvaluationSummary

model EvaluationSummary {
  id            String   @id @default(cuid())
  projectId     String
  roundId       String
  summaryJson   Json     @db.JsonB
  generatedAt   DateTime @default(now())
  generatedById String
  model         String
  tokensUsed    Int

  // Relations
  project     Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  round       Round   @relation(fields: [roundId], references: [id], onDelete: Cascade)
  generatedBy User    @relation("EvaluationSummaryGeneratedBy", fields: [generatedById], references: [id])

  @@unique([projectId, roundId])
  @@index([roundId])
}

Deliberation Models (NEW - replaces WinnerProposal/WinnerApproval)

DeliberationSession

model DeliberationSession {
  id                    String             @id @default(cuid())
  competitionId         String
  roundId               String
  category              ProjectCategory
  mode                  DeliberationMode   // SINGLE_WINNER_VOTE | FULL_RANKING
  showCollectiveRankings Boolean           @default(false)
  showPriorJuryData     Boolean            @default(false)
  status                DeliberationStatus // OPEN | VOTING | TALLYING | RUNOFF | LOCKED
  tieBreakMethod        TieBreakMethod     // RUNOFF | ADMIN_DECIDES | SCORE_FALLBACK
  adminOverrideResult   Json?              @db.JsonB
  createdAt             DateTime           @default(now())
  updatedAt             DateTime           @updatedAt

  // Relations
  competition  Competition               @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  round        Round                     @relation(fields: [roundId], references: [id], onDelete: Cascade)
  votes        DeliberationVote[]
  results      DeliberationResult[]
  participants DeliberationParticipant[]

  @@index([competitionId])
  @@index([roundId])
  @@index([status])
}

enum DeliberationMode {
  SINGLE_WINNER_VOTE  // Each jury member votes for one winner
  FULL_RANKING        // Each jury member ranks all projects
}

enum DeliberationStatus {
  OPEN      // Session created, waiting for votes
  VOTING    // Active voting window
  TALLYING  // Counting votes
  RUNOFF    // Tie detected, runoff voting in progress
  LOCKED    // Results finalized, no more changes
}

enum TieBreakMethod {
  RUNOFF           // Create new voting round for tied projects
  ADMIN_DECIDES    // Admin manually breaks tie
  SCORE_FALLBACK   // Use previous round scores to break tie
}

enum ProjectCategory {
  STARTUP           // Existing companies
  BUSINESS_CONCEPT  // Students/graduates
}

DeliberationVote

model DeliberationVote {
  id            String   @id @default(cuid())
  sessionId     String
  juryMemberId  String
  projectId     String
  rank          Int?     // ordinal rank in FULL_RANKING mode
  isWinnerPick  Boolean  @default(false) // true in SINGLE_WINNER_VOTE mode
  runoffRound   Int      @default(0)     // 0 = initial, 1+ = runoff rounds
  createdAt     DateTime @default(now())

  // Relations
  session      DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
  juryMember   JuryGroupMember     @relation(fields: [juryMemberId], references: [id], onDelete: Cascade)
  project      Project             @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@unique([sessionId, juryMemberId, projectId, runoffRound])
  @@index([sessionId])
  @@index([juryMemberId])
  @@index([projectId])
}

DeliberationResult

model DeliberationResult {
  id                String   @id @default(cuid())
  sessionId         String
  projectId         String
  finalRank         Int
  voteCount         Int      @default(0)
  isAdminOverridden Boolean  @default(false)
  overrideReason    String?  @db.Text

  // Relations
  session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
  project Project             @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@unique([sessionId, projectId])
  @@index([sessionId])
  @@index([projectId])
}

DeliberationParticipant

model DeliberationParticipant {
  id             String                        @id @default(cuid())
  sessionId      String
  userId         String
  status         DeliberationParticipantStatus // REQUIRED | ABSENT_EXCUSED | REPLACED | REPLACEMENT_ACTIVE
  replacedById   String?

  // Relations
  session     DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
  user        JuryGroupMember     @relation(fields: [userId], references: [id], onDelete: Cascade)
  replacedBy  User?               @relation("DeliberationReplacement", fields: [replacedById], references: [id])

  @@unique([sessionId, userId])
  @@index([sessionId])
  @@index([userId])
}

enum DeliberationParticipantStatus {
  REQUIRED            // Expected to participate
  ABSENT_EXCUSED      // Absent with permission
  REPLACED            // Original member replaced
  REPLACEMENT_ACTIVE  // Acting as replacement
}

Result & Audit Models

ResultLock (NEW)

model ResultLock {
  id             String   @id @default(cuid())
  competitionId  String
  roundId        String
  category       ProjectCategory
  lockedById     String
  resultSnapshot Json     @db.JsonB  // Frozen result data
  lockedAt       DateTime @default(now())

  // Relations
  competition   Competition         @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  round         Round               @relation(fields: [roundId], references: [id], onDelete: Cascade)
  lockedBy      User                @relation("ResultLockCreator", fields: [lockedById], references: [id])
  unlockEvents  ResultUnlockEvent[]

  @@index([competitionId])
  @@index([roundId])
  @@index([category])
}

ResultUnlockEvent (NEW)

model ResultUnlockEvent {
  id            String   @id @default(cuid())
  resultLockId  String
  unlockedById  String
  reason        String   @db.Text
  unlockedAt    DateTime @default(now())

  // Relations
  resultLock ResultLock @relation(fields: [resultLockId], references: [id], onDelete: Cascade)
  unlockedBy User       @relation("ResultUnlocker", fields: [unlockedById], references: [id])

  @@index([resultLockId])
  @@index([unlockedById])
}

DecisionAuditLog

model DecisionAuditLog {
  id           String  @id @default(cuid())
  eventType    String  // round.transitioned, routing.executed, filtering.completed, etc.
  entityType   String
  entityId     String
  actorId      String?
  detailsJson  Json?   @db.JsonB
  snapshotJson Json?   @db.JsonB // State at time of decision

  createdAt DateTime @default(now())

  @@index([eventType])
  @@index([entityType, entityId])
  @@index([actorId])
  @@index([createdAt])
}

Audit detailsJson convention: All admin override events MUST include beforeState and afterState fields for diff tracking:

// Example: admin overrides eligibility
{
  action: "override_eligibility",
  beforeState: { eligible: false, aiConfidence: 0.35 },
  afterState: { eligible: true },
  reason: "Project meets criteria despite low AI confidence — manual review confirms eligibility"
}

// Example: admin overrides assignment
{
  action: "override_assignment",
  beforeState: { assignedToUserId: "user-a", method: "ALGORITHM" },
  afterState: { assignedToUserId: "user-b", method: "MANUAL" },
  reason: "Reassigned due to scheduling conflict"
}

This convention enables:

  • Automated diff reports for compliance
  • Undo/revert capability for admin overrides
  • Before/after comparison in audit UI

Special Award Models

SpecialAward (modified)

model SpecialAward {
  id          String      @id @default(cuid())
  competitionId   String   // Links to Competition
  name        String
  description String?     @db.Text
  criteriaText    String? @db.Text          // For AI eligibility

  // Award mode
  eligibilityMode  AwardEligibilityMode @default(STAY_IN_MAIN)

  // Scoring/voting
  scoringMode     AwardScoringMode @default(PICK_WINNER)
  maxRankedPicks  Int?                      // For RANKED mode

  // Decision
  decisionMode    String @default("JURY_VOTE")  // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"

  // Status
  status          AwardStatus @default(DRAFT)

  // Voting window
  votingStartAt   DateTime?
  votingEndAt     DateTime?

  // Runs alongside which evaluation round
  evaluationRoundId String?

  // Jury (can be its own group or share a competition jury group)
  juryGroupId     String?

  // Winner
  winnerProjectId    String?
  winnerOverridden   Boolean @default(false)

  // AI
  useAiEligibility   Boolean @default(false)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  competition      Competition        @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  evaluationRound  Round?             @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
  juryGroup        JuryGroup?         @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
  winnerProject    Project?           @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull)
  eligibilities    AwardEligibility[]
  jurors           AwardJuror[]
  votes            AwardVote[]

  @@index([competitionId])
  @@index([status])
  @@index([evaluationRoundId])
}

enum AwardEligibilityMode {
  SEPARATE_POOL    // Projects pulled out of main flow into award-only track
  STAY_IN_MAIN     // Projects remain in main competition, flagged as award-eligible
}

enum AwardStatus {
  DRAFT
  NOMINATIONS_OPEN
  VOTING_OPEN
  CLOSED
  ARCHIVED
}

enum AwardScoringMode {
  PICK_WINNER
  RANKED
  SCORED
}

AwardEligibility

model AwardEligibility {
  id              String            @id @default(cuid())
  awardId         String
  projectId       String
  method          EligibilityMethod @default(AUTO)
  eligible        Boolean           @default(false)
  aiReasoningJson Json?             @db.JsonB

  // Admin override
  overriddenBy String?
  overriddenAt DateTime?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  award            SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
  project          Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)
  overriddenByUser User?        @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)

  @@unique([awardId, projectId])
  @@index([awardId])
  @@index([projectId])
  @@index([eligible])
}

enum EligibilityMethod {
  AUTO
  MANUAL
}

AwardJuror

model AwardJuror {
  id      String @id @default(cuid())
  awardId String
  userId  String

  createdAt DateTime @default(now())

  // Relations
  award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
  user  User         @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([awardId, userId])
  @@index([awardId])
  @@index([userId])
}

AwardVote

model AwardVote {
  id        String   @id @default(cuid())
  awardId   String
  userId    String
  projectId String
  rank      Int?     // For RANKED mode
  votedAt   DateTime @default(now())

  // Relations
  award   SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
  user    User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  project Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@unique([awardId, userId, projectId])
  @@index([awardId])
  @@index([userId])
  @@index([projectId])
}

Policy & Override Models

5-Layer Policy Precedence

Policy resolution follows this precedence order (highest to lowest):

Layer Description Example Use Case
1. Admin Override Explicit override action with mandatory reason Admin grants exception to assign 25 projects to a jury member with cap of 20
2. Per-Member Override Individual jury member policy settings Jury member sets personal cap to 15 instead of group default 20
3. Jury Group Default JuryGroup-level configuration Jury 1 has defaultMaxAssignments=20, softCapBuffer=2
4. Program Default Program-wide settings in Program.settingsJson All competitions in MOPC 2026 default to 3 finalists per category
5. System Default Platform-wide hardcoded defaults Default CapMode is SOFT if not specified

Resolution Logic:

  • Each policy query returns: { value, source: "admin_override" | "member" | "jury_group" | "program" | "system", explanation: string }
  • UI shows policy source and allows drilling into why a value was chosen
  • All policy evaluations are logged in DecisionAuditLog

Policy Models

Policy configuration is embedded in existing models:

System Defaults → Hardcoded in application config Program DefaultsProgram.settingsJson Jury Group DefaultsJuryGroup.defaultMaxAssignments, JuryGroup.defaultCapMode, etc. Per-Member OverridesJuryGroupMember.maxAssignmentsOverride, JuryGroupMember.capModeOverride, etc. Admin OverridesAssignmentException, FilteringResult.overriddenBy, AwardEligibility.overriddenBy


Enum Definitions

enum CompetitionStatus {
  DRAFT
  ACTIVE
  CLOSED
  ARCHIVED
}

enum RoundType {
  INTAKE
  FILTERING
  EVALUATION
  SUBMISSION
  MENTORING
  LIVE_FINAL
  DELIBERATION
}

enum RoundStatus {
  ROUND_DRAFT
  ROUND_ACTIVE
  ROUND_CLOSED
  ROUND_ARCHIVED
}

enum ProjectRoundStateValue {
  PENDING
  IN_PROGRESS
  PASSED
  REJECTED
  COMPLETED
  WITHDRAWN
}

enum AdvancementRuleType {
  AUTO_ADVANCE
  SCORE_THRESHOLD
  TOP_N
  ADMIN_SELECTION
  AI_RECOMMENDED
}

enum CapMode {
  HARD
  SOFT
  NONE
}

enum AssignmentMethod {
  MANUAL
  BULK
  AI_SUGGESTED
  AI_AUTO
  ALGORITHM
}

enum AssignmentIntentSource {
  INVITE
  ADMIN
  SYSTEM
}

enum AssignmentIntentStatus {
  PENDING
  HONORED
  OVERRIDDEN
  EXPIRED
  CANCELLED
}

enum JuryGroupMemberRole {
  CHAIR       // Jury lead — can manage session, see aggregate data
  MEMBER      // Regular juror — votes, scores, provides feedback
  OBSERVER    // View-only — can see evaluations/deliberations but cannot vote
}

enum DeadlinePolicy {
  HARD
  FLAG
  GRACE
}

enum FileType {
  EXEC_SUMMARY
  PRESENTATION
  VIDEO
  OTHER
  BUSINESS_PLAN
  VIDEO_PITCH
  SUPPORTING_DOC
}

enum MentorMessageRole {
  MENTOR
  APPLICANT
  ADMIN
}

enum SubmissionPromotionSource {
  MENTOR_FILE
  ADMIN_REPLACEMENT
}

enum EvaluationStatus {
  NOT_STARTED
  DRAFT
  SUBMITTED
  LOCKED
}

enum DeliberationMode {
  SINGLE_WINNER_VOTE
  FULL_RANKING
}

enum DeliberationStatus {
  OPEN
  VOTING
  TALLYING
  RUNOFF
  LOCKED
}

enum TieBreakMethod {
  RUNOFF
  ADMIN_DECIDES
  SCORE_FALLBACK
}

enum DeliberationParticipantStatus {
  REQUIRED
  ABSENT_EXCUSED
  REPLACED
  REPLACEMENT_ACTIVE
}

enum ProjectCategory {
  STARTUP
  BUSINESS_CONCEPT
}

enum AwardEligibilityMode {
  SEPARATE_POOL
  STAY_IN_MAIN
}

enum AwardStatus {
  DRAFT
  NOMINATIONS_OPEN
  VOTING_OPEN
  CLOSED
  ARCHIVED
}

enum AwardScoringMode {
  PICK_WINNER
  RANKED
  SCORED
}

enum EligibilityMethod {
  AUTO
  MANUAL
}

Typed Config Schemas

Each RoundType has a specific Zod-validated config schema stored in Round.configJson.

1. IntakeConfig

import { z } from 'zod';

export const IntakeConfigSchema = z.object({
  // Submission behavior
  allowDrafts: z.boolean().default(true),
  draftExpiryDays: z.number().int().positive().default(30),

  // Accepted categories
  acceptedCategories: z.array(z.enum(['STARTUP', 'BUSINESS_CONCEPT'])).default(['STARTUP', 'BUSINESS_CONCEPT']),

  // File constraints
  maxFileSize: z.number().int().positive().default(50), // MB per file
  allowedFileTypes: z.array(z.string()).default(['application/pdf']),
  maxFilesPerSlot: z.number().int().positive().default(1),

  // Late submission notification
  lateSubmissionNotification: z.boolean().default(true), // Notify admin when late submissions arrive

  // File constraints (applied to all slots in this intake window)
  maxFileSizeMB: z.number().int().positive().default(50),
  maxFilesPerSlot: z.number().int().positive().default(1),
  allowedMimeTypes: z.array(z.string()).default(['application/pdf']),

  // Late submission notification
  lateSubmissionNotification: z.boolean().default(true), // Notify admin when late submissions arrive (FLAG policy)

  // Public form settings
  publicFormEnabled: z.boolean().default(false),
  customFields: z.array(z.object({
    id: z.string(),
    label: z.string(),
    type: z.enum(['text', 'textarea', 'select', 'checkbox', 'date']),
    required: z.boolean().default(false),
    options: z.array(z.string()).optional(),
  })).default([]),
});

export type IntakeConfig = z.infer<typeof IntakeConfigSchema>;

2. FilteringConfig

export const FilteringConfigSchema = z.object({
  // Rule engine
  rules: z.array(z.object({
    id: z.string(),
    name: z.string(),
    ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK']),
    conditions: z.any(), // Rule-specific conditions
    action: z.enum(['PASS', 'REJECT', 'FLAG']),
  })).default([]),

  // AI screening
  aiScreeningEnabled: z.boolean().default(true),
  aiCriteriaText: z.string().optional(),
  aiConfidenceThresholds: z.object({
    high: z.number().min(0).max(1).default(0.85),   // Auto-pass
    medium: z.number().min(0).max(1).default(0.6),  // Flag for review
    low: z.number().min(0).max(1).default(0.4),     // Auto-reject
  }).default({
    high: 0.85,
    medium: 0.6,
    low: 0.4,
  }),

  // Manual queue
  manualReviewEnabled: z.boolean().default(true),

  // Auto-advancement
  autoAdvanceEligible: z.boolean().default(false), // Passing projects auto-advance without admin review

  // Duplicate detection
  duplicateDetectionEnabled: z.boolean().default(true), // Flag potential duplicate submissions

  // Auto-advance eligible projects (skip admin review for clear passes)
  autoAdvanceEligible: z.boolean().default(false),

  // Duplicate detection
  duplicateDetectionEnabled: z.boolean().default(true),

  // Batch processing
  batchSize: z.number().int().positive().default(20),
});

export type FilteringConfig = z.infer<typeof FilteringConfigSchema>;

3. EvaluationConfig

export const EvaluationConfigSchema = z.object({
  // Assignment settings
  requiredReviewsPerProject: z.number().int().positive().default(3),

  // Scoring
  scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
  requireFeedback: z.boolean().default(true),
  feedbackMinLength: z.number().int().nonnegative().default(0), // 0 = no minimum
  requireAllCriteriaScored: z.boolean().default(true), // Block submission if any criterion is blank

  // COI
  coiRequired: z.boolean().default(true),

  // Feedback
  feedbackMinLength: z.number().int().nonnegative().default(0), // 0 = no minimum
  requireAllCriteriaScored: z.boolean().default(true),

  // Peer review
  peerReviewEnabled: z.boolean().default(false),
  anonymizationLevel: z.enum(['fully_anonymous', 'show_initials', 'named']).default('fully_anonymous'),

  // AI features
  aiSummaryEnabled: z.boolean().default(false),
  generateAiShortlist: z.boolean().default(true), // AI-ranked shortlist per category at round end
  generateAiShortlist: z.boolean().default(false), // Generate AI-ranked shortlist at round end

  // Advancement
  advancementMode: z.enum(['auto_top_n', 'admin_selection', 'ai_recommended']).default('admin_selection'),
  advancementConfig: z.object({
    perCategory: z.boolean().default(true),
    startupCount: z.number().int().nonnegative().default(10),
    conceptCount: z.number().int().nonnegative().default(10),
    tieBreaker: z.enum(['admin_decides', 'highest_individual', 'revote']).default('admin_decides'),
  }).optional(),
});

export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>;

4. SubmissionConfig

export const SubmissionConfigSchema = z.object({
  // Who can submit
  eligibleStatuses: z.array(z.enum(['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'])).default(['PASSED']),

  // Notification
  notifyEligibleTeams: z.boolean().default(true),

  // Lock previous rounds
  lockPreviousWindows: z.boolean().default(true),
});

export type SubmissionConfig = z.infer<typeof SubmissionConfigSchema>;

5. MentoringConfig

export const MentoringConfigSchema = z.object({
  // Eligibility
  eligibility: z.enum(['all_advancing', 'requested_only']).default('requested_only'),

  // Workspace features
  chatEnabled: z.boolean().default(true),
  fileUploadEnabled: z.boolean().default(true),
  fileCommentsEnabled: z.boolean().default(true),
  filePromotionEnabled: z.boolean().default(true),

  // Promotion target
  promotionTargetWindowId: z.string().optional(),

  // Auto-assignment
  autoAssignMentors: z.boolean().default(false),
});

export type MentoringConfig = z.infer<typeof MentoringConfigSchema>;

6. LiveFinalConfig

export const LiveFinalConfigSchema = z.object({
  // Jury voting
  juryVotingEnabled: z.boolean().default(true),
  votingMode: z.enum(['simple', 'criteria']).default('simple'),

  // Audience voting
  audienceVotingEnabled: z.boolean().default(false),
  audienceVoteWeight: z.number().min(0).max(1).default(0),
  audienceVotingMode: z.enum(['per_project', 'per_category', 'favorites']).default('per_project'),
  audienceMaxFavorites: z.number().int().positive().default(3),
  audienceRequireIdentification: z.boolean().default(false),

  // Deliberation
  deliberationEnabled: z.boolean().default(false),
  deliberationDurationMinutes: z.number().int().positive().default(30),
  showAudienceVotesToJury: z.boolean().default(false),

  // Presentation
  presentationOrderMode: z.enum(['manual', 'random', 'score_based']).default('manual'),
  presentationDurationMinutes: z.number().int().positive().default(15), // Per-project presentation time
  qaDurationMinutes: z.number().int().nonnegative().default(5), // Per-project Q&A time (0 = no Q&A)

  // Results reveal
  revealPolicy: z.enum(['immediate', 'delayed', 'ceremony']).default('ceremony'),
});

export type LiveFinalConfig = z.infer<typeof LiveFinalConfigSchema>;

7. DeliberationConfig

export const DeliberationConfigSchema = z.object({
  // Jury group
  juryGroupId: z.string(),

  // Mode
  mode: z.enum(['SINGLE_WINNER_VOTE', 'FULL_RANKING']).default('SINGLE_WINNER_VOTE'),

  // Display
  showCollectiveRankings: z.boolean().default(false),
  showPriorJuryData: z.boolean().default(false), // Show Jury 1/2 scores/feedback to deliberation jury

  // Tie handling
  tieBreakMethod: z.enum(['RUNOFF', 'ADMIN_DECIDES', 'SCORE_FALLBACK']).default('ADMIN_DECIDES'),

  // Timing
  votingDuration: z.number().int().positive().default(60), // minutes

  // Winner count
  topN: z.number().int().positive().default(3), // How many winners per category (podium size)

  // Admin control
  allowAdminOverride: z.boolean().default(true),
});

export type DeliberationConfig = z.infer<typeof DeliberationConfigSchema>;

Migration Notes

New Models Added

  • Competition — Replaces Pipeline
  • Round — Replaces Stage
  • JuryGroup, JuryGroupMember — New jury management system
  • SubmissionWindow, SubmissionFileRequirement, RoundSubmissionVisibility — Multi-round document submission
  • MentorFile, MentorFileComment, MentorMessage — Mentoring workspace
  • DeliberationSession, DeliberationVote, DeliberationResult, DeliberationParticipant — Winner confirmation
  • ResultLock, ResultUnlockEvent — Result immutability
  • AssignmentIntent, AssignmentException — Assignment governance
  • SubmissionPromotionEvent — Mentoring file promotion tracking
  • AdvancementRule — Replaces StageTransition

Renamed Models

  • PipelineCompetition
  • StageRound
  • StageTypeRoundType
  • StageStatusRoundStatus
  • ProjectStageStateProjectRoundState
  • ProjectStageStateValueProjectRoundStateValue

Removed Models

  • Track — Eliminated (main flow is linear; awards are SpecialAward entities)
  • StageTransition — Replaced by AdvancementRule
  • WinnerProposal, WinnerApproval — Replaced by Deliberation system

New Enums Added

  • JuryGroupMemberRole (CHAIR, MEMBER, OBSERVER) — replaces boolean isLead
  • AssignmentIntentStatus (PENDING, HONORED, OVERRIDDEN, EXPIRED, CANCELLED) — replaces String status

Modified Models

  • Assignment — Added juryGroupId link
  • Round — Added purposeKey (optional analytics tag)
  • JuryGroupMember — Changed isLead: Boolean to role: JuryGroupMemberRole
  • AssignmentIntent — Changed status: String to status: AssignmentIntentStatus (proper enum with lifecycle)
  • SubmissionWindow — Added isLocked: Boolean (manual lock override independent of window close)
  • ProjectFile — Changed roundId to submissionWindowId, added requirementId, added replacedById self-reference
  • SpecialAward — Added competitionId, evaluationRoundId, juryGroupId
  • MentorAssignment — Added workspace fields (workspaceEnabled, workspaceOpenAt, etc.)
  • Project — Added competitionId (direct link to competition)
  • DecisionAuditLog — Documented beforeState/afterState convention in detailsJson

Field Renames

All stageId foreign keys → roundId All trackId references → Removed


Cross-References