# 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) ```prisma 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) ```prisma 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) ```prisma 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) ```prisma 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:** ```typescript // 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 ```prisma 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 ```prisma 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 ```prisma 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) ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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) ```prisma 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) ```prisma 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) ```prisma 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) ```prisma 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) ```prisma 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) ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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) ```prisma 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) ```prisma 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 ```prisma 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: ```typescript // 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) ```prisma 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 ```prisma 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 ```prisma 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 ```prisma 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 Defaults** → `Program.settingsJson` **Jury Group Defaults** → `JuryGroup.defaultMaxAssignments`, `JuryGroup.defaultCapMode`, etc. **Per-Member Overrides** → `JuryGroupMember.maxAssignmentsOverride`, `JuryGroupMember.capModeOverride`, etc. **Admin Overrides** → `AssignmentException`, `FilteringResult.overriddenBy`, `AwardEligibility.overriddenBy` --- ## Enum Definitions ```prisma 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 ```typescript 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; ``` ### 2. FilteringConfig ```typescript 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; ``` ### 3. EvaluationConfig ```typescript 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; ``` ### 4. SubmissionConfig ```typescript 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; ``` ### 5. MentoringConfig ```typescript 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; ``` ### 6. LiveFinalConfig ```typescript 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; ``` ### 7. DeliberationConfig ```typescript 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; ``` --- ## 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 - `Pipeline` → `Competition` - `Stage` → `Round` - `StageType` → `RoundType` - `StageStatus` → `RoundStatus` - `ProjectStageState` → `ProjectRoundState` - `ProjectStageStateValue` → `ProjectRoundStateValue` ### 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 - See [03-competition-flow.md](./03-competition-flow.md) for end-to-end competition orchestration - See [04-jury-system.md](./04-jury-system.md) for jury management and assignment logic - See [05-submission-system.md](./05-submission-system.md) for multi-round document handling - See [06-mentoring-workspace.md](./06-mentoring-workspace.md) for mentoring file collaboration - See [07-deliberation-flow.md](./07-deliberation-flow.md) for winner confirmation process - See [08-result-locking.md](./08-result-locking.md) for result immutability and unlock audit