MOPC-App/docs/claude-architecture-redesign/03-data-model.md

38 KiB

Data Model Redesign

Overview

This document defines the complete Prisma schema for the MOPC architecture redesign. It covers new models, modified models, eliminated models, and the migration path from the current schema.

Naming Convention Changes

Current Redesigned Rationale
Pipeline Competition Domain-specific — admins think "Competition 2026"
Track (eliminated) Main flow is linear; awards are standalone
Stage Round Domain-specific — "Round 3: Jury 1 Evaluation"
StageType RoundType Follows rename
StageStatus RoundStatus Follows rename
ProjectStageState ProjectRoundState Follows rename, drops trackId
StageTransition (eliminated) Replaced by linear sortOrder + advancement rules

1. Eliminated Models & Enums

Models Removed

Track                  -- Main flow is linear; awards are standalone SpecialAward entities
StageTransition        -- Replaced by linear round ordering + AdvancementRule
CohortProject          -- Merged into round-level project ordering
TrackKind (enum)       -- No tracks
RoutingMode (enum)     -- No tracks
DecisionMode (enum)    -- Moved to SpecialAward.decisionMode as a string field

Why Track Is Eliminated

The Track model served two purposes:

  1. Main competition flow — But this is always linear (Intake -> Filter -> Eval -> ... -> Finals)
  2. Award branches — But awards don't need their own stage pipeline; they need eligibility + voting

Without Track:

  • Round belongs directly to Competition (no intermediate layer)
  • SpecialAward is self-contained (has its own jury, voting, and result)
  • ProjectRoundState drops trackId (project is in a round, period)
  • Admin UI shows a flat list of rounds instead of nested Track > Stage

2. Core Competition Structure

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[]
  winnerProposals    WinnerProposal[]

  @@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

  // Links to other entities
  juryGroupId         String?      // Which jury evaluates this round (EVALUATION, LIVE_FINAL)
  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?
  cohorts              Cohort[]
  advancementRules     AdvancementRule[]

  // 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
  CONFIRMATION    // Winner agreement — jury signatures + admin confirmation
}

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 + guardJson)

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 }

3. Jury System

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

  @@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
  isLead          Boolean @default(false)
  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)

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

4. Multi-Round Submission System

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

  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

Controls which submission windows a jury evaluation round can see.

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

Example usage:

Jury 1 (Round 3) sees only Round 1 docs:

RoundSubmissionVisibility { roundId: round-3, submissionWindowId: sw-1, canView: true, displayLabel: "Application Docs" }

Jury 2 (Round 5) sees Round 1 AND Round 2 docs:

RoundSubmissionVisibility { roundId: round-5, submissionWindowId: sw-1, canView: true, displayLabel: "Round 1 Docs" }
RoundSubmissionVisibility { roundId: round-5, submissionWindowId: sw-2, canView: true, displayLabel: "Round 2 Docs" }

5. Mentoring Workspace

MentorFile

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[]

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

MentorFileComment

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

MentorAssignment Modifications

model MentorAssignment {
  // ... existing fields preserved ...
  id                 String @id @default(cuid())
  projectId          String @unique
  mentorId           String
  method             AssignmentMethod @default(MANUAL)
  assignedAt         DateTime @default(now())
  assignedBy         String?
  aiConfidenceScore  Float?
  expertiseMatchScore Float?
  aiReasoning        String? @db.Text
  completionStatus   String  @default("in_progress")
  lastViewedAt       DateTime?

  // NEW: 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 (existing + new)
  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[]    // NEW: Workspace files
}

6. Winner Confirmation System

WinnerProposal

model WinnerProposal {
  id              String @id @default(cuid())
  competitionId   String
  category        CompetitionCategory   // STARTUP or BUSINESS_CONCEPT

  status          WinnerProposalStatus @default(PENDING)

  // Proposed rankings (ordered list of project IDs)
  rankedProjectIds String[]             // ["proj-1st", "proj-2nd", "proj-3rd"]

  // Selection basis (evidence)
  sourceRoundId   String               // Which round's scores/votes informed this
  selectionBasis  Json    @db.JsonB    // { method, scores, aiRecommendation, reasoning }

  // Proposer
  proposedById    String
  proposedAt      DateTime @default(now())

  // Finalization
  frozenAt        DateTime?
  frozenById      String?

  // Admin override (if used)
  overrideUsed    Boolean @default(false)
  overrideMode    String?              // "FORCE_MAJORITY" | "ADMIN_DECISION"
  overrideReason  String? @db.Text
  overrideById    String?

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

  // Relations
  competition  Competition      @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  sourceRound  Round            @relation("WinnerProposalSource", fields: [sourceRoundId], references: [id])
  proposedBy   User             @relation("WinnerProposer", fields: [proposedById], references: [id])
  frozenBy     User?            @relation("WinnerFreezer", fields: [frozenById], references: [id])
  overrideBy   User?            @relation("WinnerOverrider", fields: [overrideById], references: [id])
  approvals    WinnerApproval[]

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

enum WinnerProposalStatus {
  PENDING           // Waiting for jury approvals
  APPROVED          // All required approvals received
  REJECTED          // At least one rejection
  OVERRIDDEN        // Admin used override
  FROZEN            // Locked — official results
}

WinnerApproval

model WinnerApproval {
  id                 String @id @default(cuid())
  winnerProposalId   String
  userId             String
  role               WinnerApprovalRole

  // Response
  approved           Boolean?         // null = not yet responded
  comments           String?  @db.Text
  respondedAt        DateTime?

  createdAt DateTime @default(now())

  // Relations
  proposal WinnerProposal @relation(fields: [winnerProposalId], references: [id], onDelete: Cascade)
  user     User           @relation("WinnerApprovalUser", fields: [userId], references: [id])

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

enum WinnerApprovalRole {
  JURY_MEMBER       // Must individually confirm
  ADMIN             // Final sign-off (or override)
}

7. Modified Existing Models

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

  // CHANGED: 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")  // NEW

  @@index([projectId])
  @@index([submissionWindowId])
  @@index([requirementId])
}
model Assignment {
  // ... all existing fields preserved ...
  id            String @id @default(cuid())
  userId        String
  projectId     String
  roundId       String           // RENAMED from stageId
  method        AssignmentMethod @default(MANUAL)
  isRequired    Boolean          @default(true)
  isCompleted   Boolean          @default(false)
  aiConfidenceScore    Float?
  expertiseMatchScore  Float?
  aiReasoning          String? @db.Text

  // NEW: Link to jury group
  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?
  coi         ConflictOfInterest?

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

SpecialAward — Enhanced Standalone

model SpecialAward {
  id              String @id @default(cuid())
  competitionId   String                    // CHANGED: Links to Competition, not Track
  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?                 // NEW: Which round this award runs during

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

  // 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("AwardEvaluationRound", fields: [evaluationRoundId], references: [id], onDelete: SetNull)
  juryGroup        JuryGroup?         @relation("AwardJuryGroup", 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
}

Evaluation — Rename stageId to roundId

model Evaluation {
  // All fields preserved, stageId references updated to roundId via Assignment.roundId
  id              String @id @default(cuid())
  assignmentId    String @unique
  status          EvaluationStatus @default(NOT_STARTED)
  criterionScoresJson Json? @db.JsonB
  globalScore     Int?
  binaryDecision  Boolean?
  feedbackText    String? @db.Text
  submittedAt     DateTime?

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

  assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
}
model Project {
  // ... existing fields ...
  id                String @id @default(cuid())
  programId         String
  competitionId     String?                // NEW: Direct link to competition

  // Remove legacy roundId field
  // roundId        String?  -- REMOVED

  status            ProjectStatus @default(SUBMITTED)
  title             String
  teamName          String?
  description       String? @db.Text
  competitionCategory CompetitionCategory?
  // ... all other existing fields preserved ...

  wantsMentorship   Boolean @default(false)  // Preserved — drives mentoring eligibility

  // Relations (updated)
  program           Program           @relation(fields: [programId], references: [id], onDelete: Cascade)
  competition       Competition?      @relation(fields: [competitionId], references: [id], onDelete: SetNull)
  files             ProjectFile[]
  teamMembers       TeamMember[]
  projectRoundStates ProjectRoundState[]   // RENAMED from projectStageStates
  assignments       Assignment[]
  mentorAssignment  MentorAssignment?
  // ... other existing relations preserved ...

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

8. Round-Type Config Shapes (Zod-validated)

Each round type has a specific config shape stored in Round.configJson. These replace the old generic configJson approach with documented, validated structures.

INTAKE Config

type IntakeConfig = {
  // Submission behavior
  allowDrafts: boolean           // Allow saving drafts before submitting
  draftExpiryDays: number        // Auto-delete drafts after N days (default: 30)

  // What categories are accepted
  acceptedCategories: ("STARTUP" | "BUSINESS_CONCEPT")[]

  // Public form settings
  publicFormEnabled: boolean     // Allow anonymous form access via slug
  customFields: CustomFieldDef[] // Additional form fields beyond standard ones
}

FILTERING Config

type FilteringConfig = {
  // Rule engine
  rules: FilterRuleDef[]         // Field-based and document-check rules

  // AI screening
  aiScreeningEnabled: boolean
  aiCriteriaText: string         // Plain-language criteria for AI
  aiConfidenceThresholds: {
    high: number                 // Above this = auto-pass (default: 0.85)
    medium: number               // Above this = flag for review (default: 0.6)
    low: number                  // Below this = auto-reject (default: 0.4)
  }

  // Manual queue
  manualReviewEnabled: boolean   // Flagged projects need admin review

  // Batch processing
  batchSize: number              // Projects per AI batch (default: 20)
}

EVALUATION Config

type EvaluationConfig = {
  // Assignment settings (work with JuryGroup)
  requiredReviewsPerProject: number     // How many jurors review each project (default: 3)

  // Assignment caps are now on JuryGroup and JuryGroupMember
  // (no longer duplicated here)

  // Scoring
  scoringMode: "criteria" | "global" | "binary"  // How jurors score
  requireFeedback: boolean                        // Must submit text feedback

  // COI
  coiRequired: boolean          // Must declare COI before evaluating (default: true)

  // Peer review
  peerReviewEnabled: boolean    // Allow jurors to see anonymized peer evaluations
  anonymizationLevel: "fully_anonymous" | "show_initials" | "named"

  // AI features
  aiSummaryEnabled: boolean     // Generate AI evaluation summaries

  // Advancement (what happens after evaluation ends)
  advancementMode: "auto_top_n" | "admin_selection" | "ai_recommended"
  advancementConfig: {
    perCategory: boolean
    startupCount: number         // How many startups advance
    conceptCount: number         // How many concepts advance
    tieBreaker: "admin_decides" | "highest_individual" | "revote"
  }
}

SUBMISSION Config

type SubmissionConfig = {
  // Who can submit (based on status from previous round)
  eligibleStatuses: ProjectRoundStateValue[]  // Usually ["PASSED"]

  // Notification
  notifyEligibleTeams: boolean   // Email teams when window opens

  // Previous rounds become read-only for applicants
  lockPreviousWindows: boolean   // Default: true
}

MENTORING Config

type MentoringConfig = {
  // Who gets mentoring
  eligibility: "all_advancing" | "requested_only"  // All finalists or only those who requested

  // Workspace features
  chatEnabled: boolean
  fileUploadEnabled: boolean
  fileCommentsEnabled: boolean
  filePromotionEnabled: boolean     // Can promote files to official submissions

  // Target submission window for promotions
  promotionTargetWindowId: string?  // Which SubmissionWindow promoted files go to

  // Auto-assignment
  autoAssignMentors: boolean        // Use AI/algorithm to assign mentors
}

LIVE_FINAL Config

type LiveFinalConfig = {
  // Jury voting
  juryVotingEnabled: boolean
  votingMode: "simple" | "criteria"     // Simple 1-10 or criteria-based

  // Audience voting
  audienceVotingEnabled: boolean
  audienceVoteWeight: number            // 0.0 to 1.0 (weight vs jury vote)
  audienceVotingMode: "per_project" | "per_category" | "favorites"
  audienceMaxFavorites: number?         // For "favorites" mode
  audienceRequireIdentification: boolean

  // Deliberation
  deliberationEnabled: boolean
  deliberationDurationMinutes: number   // Length of deliberation period
  showAudienceVotesToJury: boolean      // Jury sees audience results during deliberation

  // Presentation
  presentationOrderMode: "manual" | "random" | "score_based"

  // Results reveal
  revealPolicy: "immediate" | "delayed" | "ceremony"
}

CONFIRMATION Config

type ConfirmationConfig = {
  // Approval requirements
  requireAllJuryApproval: boolean    // All jury members must individually confirm (default: true)
  juryGroupId: string?               // Which jury group must approve (usually Jury 3 / finals jury)

  // Admin override
  adminOverrideEnabled: boolean      // Admin can force result (default: true)
  overrideModes: ("FORCE_MAJORITY" | "ADMIN_DECISION")[]  // Available override options

  // Freeze behavior
  autoFreezeOnApproval: boolean      // Lock results immediately when all approve (default: true)

  // Per-category confirmation
  perCategory: boolean               // Separate confirmation per STARTUP vs BUSINESS_CONCEPT
}

9. Models Preserved As-Is (rename stageId -> roundId only)

These models are structurally unchanged. The only modification is renaming foreign keys from stageId to roundId and removing any trackId references:

Model Change
EvaluationForm stageId -> roundId
Evaluation No direct roundId (via Assignment)
ConflictOfInterest No change (via Assignment)
GracePeriod stageId -> roundId
EvaluationSummary stageId -> roundId
EvaluationDiscussion stageId -> roundId
DiscussionComment No change
FilteringRule stageId -> roundId
FilteringResult stageId -> roundId
FilteringJob stageId -> roundId
LiveVotingSession stageId -> roundId
LiveVote No change (via Session)
AudienceVoter No change (via Session)
LiveProgressCursor stageId -> roundId
Cohort stageId -> roundId
CohortProject No change
AwardEligibility No change
AwardJuror No change
AwardVote No change
MentorMessage No change
MentorNote No change
MentorMilestone No change
MentorMilestoneCompletion No change
InAppNotification No change
AuditLog No change
DecisionAuditLog No change
User Add juryGroupMemberships relation
Program Add competitions relation (was pipelines)

10. User Model Additions

model User {
  // ... all existing fields preserved ...

  // NEW relations
  juryGroupMemberships  JuryGroupMember[]
  mentorFileUploads     MentorFile[]          @relation("MentorFileUploader")
  mentorFilePromotions  MentorFile[]          @relation("MentorFilePromoter")
  mentorFileComments    MentorFileComment[]   @relation("MentorFileCommentAuthor")
  winnerProposals       WinnerProposal[]      @relation("WinnerProposer")
  winnerFreezes         WinnerProposal[]      @relation("WinnerFreezer")
  winnerOverrides       WinnerProposal[]      @relation("WinnerOverrider")
  winnerApprovals       WinnerApproval[]      @relation("WinnerApprovalUser")
}

11. 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) WinnerProposal

Round (N) ──── (1) JuryGroup (optional — for EVALUATION and LIVE_FINAL rounds)
Round (N) ──── (1) SubmissionWindow (optional — for INTAKE and SUBMISSION rounds)
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

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

WinnerProposal (1) ──── (N) WinnerApproval

12. Migration Strategy (High-Level)

Phase 1: Add new tables (non-breaking)

  1. Create Competition table
  2. Create Round table
  3. Create JuryGroup, JuryGroupMember tables
  4. Create SubmissionWindow, SubmissionFileRequirement, RoundSubmissionVisibility tables
  5. Create MentorFile, MentorFileComment tables
  6. Create WinnerProposal, WinnerApproval tables
  7. Create AdvancementRule table
  8. Create ProjectRoundState table
  9. Add new columns to Assignment (juryGroupId), ProjectFile (submissionWindowId), MentorAssignment (workspace fields), SpecialAward (competitionId, eligibilityMode, juryGroupId, evaluationRoundId), Project (competitionId)

Phase 2: Data migration

  1. For each Pipeline: Create a Competition record
  2. For each Stage in the MAIN Track: Create a Round record (maintaining sortOrder)
  3. For each ProjectStageState: Create a ProjectRoundState record (dropping trackId)
  4. For each AWARD Track: Migrate to SpecialAward (link to Competition + evaluation round)
  5. For existing FileRequirements: Create SubmissionWindow + SubmissionFileRequirement
  6. For existing Assignments with stageId: Update roundId reference
  7. Create default JuryGroups from existing assignments (group by stageId)

Phase 3: Code migration

  1. Update all services (stageId -> roundId, remove trackId references)
  2. Update all routers (rename, new endpoints)
  3. Update all UI (new pages, enhanced existing pages)

Phase 4: Drop old tables (after verification)

  1. Drop Track table
  2. Drop StageTransition table
  3. Drop ProjectStageState table (after verifying ProjectRoundState)
  4. Drop Stage table (after verifying Round)
  5. Drop Pipeline table (after verifying Competition)
  6. Clean up old enums

Detailed migration SQL will be in 21-migration-strategy.md.