MOPC-App/docs/claude-architecture-redesign/21-migration-strategy.md

96 KiB

Migration Strategy: Pipeline→Track→Stage to Competition→Round

Document Version: 1.0 Date: 2026-02-15 Status: Complete Purpose: Comprehensive migration plan from current Pipeline/Track/Stage architecture to redesigned Competition/Round architecture with zero data loss and full rollback capability.


Table of Contents

  1. Overview
  2. Migration Principles
  3. Phase 1: Schema Additions (Non-Breaking)
  4. Phase 2: Data Migration
  5. Phase 3: Code Migration
  6. Phase 4: Cleanup (Breaking)
  7. Rollback Plan
  8. Complete Data Mapping Table
  9. Risk Assessment
  10. Testing Strategy
  11. Timeline

1. Overview

Why This Migration

The current Pipeline→Track→Stage model introduces unnecessary abstraction for a fundamentally linear competition flow. This migration:

  1. Eliminates the Track layer — Main flow is linear; awards become standalone entities
  2. Renames for domain clarity — Pipeline→Competition, Stage→Round
  3. Adds missing features — Multi-round submissions, mentoring workspace, winner confirmation, explicit jury groups
  4. Improves type safety — Replace generic configJson with typed round-specific configs

Architecture Before & After

BEFORE:

Program
 └── Pipeline (generic container)
      ├── Track: "Main Competition" (MAIN)
      │    ├── Stage: "Intake" (INTAKE, configJson: {...})
      │    ├── Stage: "Filtering" (FILTER)
      │    └── Stage: "Evaluation" (EVALUATION)
      └── Track: "Award 1" (AWARD)
           └── Stage: "Evaluation" (EVALUATION)

AFTER:

Program
 └── Competition (purpose-built)
      ├── Rounds (linear sequence):
      │    ├── Round 1: "Application Window" (INTAKE)
      │    ├── Round 2: "AI Screening" (FILTERING)
      │    ├── Round 3: "Jury 1" (EVALUATION) → linked to JuryGroup 1
      │    └── Round 4: "Semi-finalist Docs" (SUBMISSION) → linked to SubmissionWindow 2
      ├── Jury Groups (explicit):
      │    └── "Jury 1" → members: [judge-a, judge-b]
      ├── Submission Windows:
      │    ├── Window 1: "Round 1 Docs" → requirements: [Exec Summary]
      │    └── Window 2: "Round 2 Docs" → requirements: [Updated Plan]
      └── Special Awards (standalone):
           └── "Innovation Award" → juryGroup, eligibilityMode

Four-Phase Approach

Phase Type Description Reversible?
Phase 1 Schema Addition Add new tables without touching existing ones Yes (drop new tables)
Phase 2 Data Migration Copy data from old to new tables Yes (delete new data)
Phase 3 Code Migration Update services/routers/UI to use new tables Yes (revert commits)
Phase 4 Cleanup Drop old tables and enums No (permanent)

2. Migration Principles

Zero Data Loss

  • All existing data must be preserved during migration
  • No data deleted until Phase 4 (after full validation)
  • Old tables remain read-only during Phase 2-3

Reversibility at Each Phase

  • Phase 1: Can drop new tables without impact
  • Phase 2: Can delete migrated data, old tables untouched
  • Phase 3: Can revert code changes, both schemas functional
  • Phase 4: Point of no return — full testing before this phase

Incremental Execution

  • Each phase can be deployed independently
  • Not all-or-nothing — can pause between phases
  • Feature flags allow running old + new code in parallel

Tests Pass at Every Step

  • Full test suite runs after each phase
  • Integration tests cover both old and new data models
  • Performance benchmarks ensure no regression

API Continuity During Transition

  • Existing tRPC endpoints continue working
  • New endpoints added alongside old ones
  • Deprecation warnings before removal

3. Phase 1: Schema Additions (Non-Breaking)

3.1 Overview

Add all new tables and columns without modifying existing schema. This phase is completely non-breaking — existing code continues to work unchanged.

3.2 New Enums

-- =============================================================================
-- PHASE 1: NEW ENUMS
-- =============================================================================

-- Competition enum (replaces Pipeline status string)
CREATE TYPE "CompetitionStatus" AS ENUM (
  'DRAFT',
  'ACTIVE',
  'CLOSED',
  'ARCHIVED'
);

-- Round type (expands StageType)
CREATE TYPE "RoundType" AS ENUM (
  'INTAKE',
  'FILTERING',        -- Renamed from FILTER
  'EVALUATION',
  'SUBMISSION',       -- NEW: Multi-round document collection
  'MENTORING',        -- NEW: Mentor-team workspace activation
  'LIVE_FINAL',
  'CONFIRMATION'      -- NEW: Winner confirmation, replaces RESULTS
);

-- Round status (replaces StageStatus)
CREATE TYPE "RoundStatus" AS ENUM (
  'ROUND_DRAFT',
  'ROUND_ACTIVE',
  'ROUND_CLOSED',
  'ROUND_ARCHIVED'
);

-- Project round state (replaces ProjectStageStateValue)
CREATE TYPE "ProjectRoundStateValue" AS ENUM (
  'PENDING',
  'IN_PROGRESS',
  'PASSED',
  'REJECTED',
  'COMPLETED',
  'WITHDRAWN'
);

-- Advancement rule types
CREATE TYPE "AdvancementRuleType" AS ENUM (
  'AUTO_ADVANCE',
  'SCORE_THRESHOLD',
  'TOP_N',
  'ADMIN_SELECTION',
  'AI_RECOMMENDED'
);

-- Jury cap modes
CREATE TYPE "CapMode" AS ENUM (
  'HARD',    -- Absolute maximum
  'SOFT',    -- Target with buffer
  'NONE'     -- Unlimited
);

-- Deadline policies
CREATE TYPE "DeadlinePolicy" AS ENUM (
  'HARD',    -- Reject after close
  'FLAG',    -- Accept but mark late
  'GRACE'    -- Grace period then hard cutoff
);

-- Winner proposal statuses
CREATE TYPE "WinnerProposalStatus" AS ENUM (
  'PENDING',
  'APPROVED',
  'REJECTED',
  'OVERRIDDEN',
  'FROZEN'
);

-- Winner approval roles
CREATE TYPE "WinnerApprovalRole" AS ENUM (
  'JURY_MEMBER',
  'ADMIN'
);

-- Award eligibility modes
CREATE TYPE "AwardEligibilityMode" AS ENUM (
  'SEPARATE_POOL',    -- Remove from main flow
  'STAY_IN_MAIN'      -- Keep in main, flag as eligible
);

3.3 Core Competition Tables

-- =============================================================================
-- PHASE 1: CORE COMPETITION STRUCTURE
-- =============================================================================

-- Competition (replaces Pipeline)
CREATE TABLE "Competition" (
  "id" TEXT NOT NULL,
  "programId" TEXT NOT NULL,
  "name" TEXT NOT NULL,
  "slug" TEXT NOT NULL UNIQUE,
  "status" "CompetitionStatus" NOT NULL DEFAULT 'DRAFT',

  -- Competition-wide settings (typed, not generic JSON)
  "categoryMode" TEXT NOT NULL DEFAULT 'SHARED',  -- 'SHARED' | 'SPLIT'
  "startupFinalistCount" INTEGER NOT NULL DEFAULT 3,
  "conceptFinalistCount" INTEGER NOT NULL DEFAULT 3,

  -- Notification preferences
  "notifyOnRoundAdvance" BOOLEAN NOT NULL DEFAULT true,
  "notifyOnDeadlineApproach" BOOLEAN NOT NULL DEFAULT true,
  "deadlineReminderDays" INTEGER[] NOT NULL DEFAULT ARRAY[7, 3, 1],

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "Competition_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "Competition_programId_fkey"
    FOREIGN KEY ("programId") REFERENCES "Program"("id")
    ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE INDEX "Competition_programId_idx" ON "Competition"("programId");
CREATE INDEX "Competition_status_idx" ON "Competition"("status");

-- Round (replaces Stage)
CREATE TABLE "Round" (
  "id" TEXT NOT NULL,
  "competitionId" TEXT NOT NULL,
  "name" TEXT NOT NULL,
  "slug" TEXT NOT NULL,
  "roundType" "RoundType" NOT NULL,
  "status" "RoundStatus" NOT NULL DEFAULT 'ROUND_DRAFT',
  "sortOrder" INTEGER NOT NULL DEFAULT 0,

  -- Time windows
  "windowOpenAt" TIMESTAMP(3),
  "windowCloseAt" TIMESTAMP(3),

  -- Round-type-specific configuration (validated by Zod per RoundType)
  "configJson" JSONB,

  -- Links to other entities
  "juryGroupId" TEXT,           -- Which jury evaluates this round
  "submissionWindowId" TEXT,    -- Which submission window this collects docs for

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "Round_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "Round_competitionId_fkey"
    FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "Round_competitionId_slug_key" UNIQUE ("competitionId", "slug"),
  CONSTRAINT "Round_competitionId_sortOrder_key" UNIQUE ("competitionId", "sortOrder")
);

CREATE INDEX "Round_competitionId_idx" ON "Round"("competitionId");
CREATE INDEX "Round_roundType_idx" ON "Round"("roundType");
CREATE INDEX "Round_status_idx" ON "Round"("status");

-- ProjectRoundState (replaces ProjectStageState, drops trackId)
CREATE TABLE "ProjectRoundState" (
  "id" TEXT NOT NULL,
  "projectId" TEXT NOT NULL,
  "roundId" TEXT NOT NULL,
  "state" "ProjectRoundStateValue" NOT NULL DEFAULT 'PENDING',
  "enteredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "exitedAt" TIMESTAMP(3),
  "metadataJson" JSONB,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "ProjectRoundState_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "ProjectRoundState_projectId_fkey"
    FOREIGN KEY ("projectId") REFERENCES "Project"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "ProjectRoundState_roundId_fkey"
    FOREIGN KEY ("roundId") REFERENCES "Round"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "ProjectRoundState_projectId_roundId_key" UNIQUE ("projectId", "roundId")
);

CREATE INDEX "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
CREATE INDEX "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
CREATE INDEX "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");

-- AdvancementRule (replaces StageTransition + guardJson)
CREATE TABLE "AdvancementRule" (
  "id" TEXT NOT NULL,
  "roundId" TEXT NOT NULL,
  "targetRoundId" TEXT,             -- null = next round by sortOrder
  "ruleType" "AdvancementRuleType" NOT NULL,
  "configJson" JSONB NOT NULL,
  "isDefault" BOOLEAN NOT NULL DEFAULT true,
  "sortOrder" INTEGER NOT NULL DEFAULT 0,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT "AdvancementRule_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "AdvancementRule_roundId_fkey"
    FOREIGN KEY ("roundId") REFERENCES "Round"("id")
    ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE INDEX "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");

3.4 Jury System Tables

-- =============================================================================
-- PHASE 1: JURY SYSTEM
-- =============================================================================

-- JuryGroup (new first-class entity)
CREATE TABLE "JuryGroup" (
  "id" TEXT NOT NULL,
  "competitionId" TEXT NOT NULL,
  "name" TEXT NOT NULL,
  "slug" TEXT NOT NULL,
  "description" TEXT,
  "sortOrder" INTEGER NOT NULL DEFAULT 0,

  -- Default assignment configuration
  "defaultMaxAssignments" INTEGER NOT NULL DEFAULT 20,
  "defaultCapMode" "CapMode" NOT NULL DEFAULT 'SOFT',
  "softCapBuffer" INTEGER NOT NULL DEFAULT 2,

  -- Category quotas
  "categoryQuotasEnabled" BOOLEAN NOT NULL DEFAULT false,
  "defaultCategoryQuotas" JSONB,

  -- Onboarding settings
  "allowJurorCapAdjustment" BOOLEAN NOT NULL DEFAULT false,
  "allowJurorRatioAdjustment" BOOLEAN NOT NULL DEFAULT false,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "JuryGroup_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "JuryGroup_competitionId_fkey"
    FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "JuryGroup_competitionId_slug_key" UNIQUE ("competitionId", "slug")
);

CREATE INDEX "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");

-- JuryGroupMember
CREATE TABLE "JuryGroupMember" (
  "id" TEXT NOT NULL,
  "juryGroupId" TEXT NOT NULL,
  "userId" TEXT NOT NULL,
  "isLead" BOOLEAN NOT NULL DEFAULT false,
  "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  -- Per-juror overrides
  "maxAssignmentsOverride" INTEGER,
  "capModeOverride" "CapMode",
  "categoryQuotasOverride" JSONB,

  -- Juror preferences
  "preferredStartupRatio" DOUBLE PRECISION,
  "availabilityNotes" TEXT,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "JuryGroupMember_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "JuryGroupMember_juryGroupId_fkey"
    FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "JuryGroupMember_userId_fkey"
    FOREIGN KEY ("userId") REFERENCES "User"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "JuryGroupMember_juryGroupId_userId_key" UNIQUE ("juryGroupId", "userId")
);

CREATE INDEX "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
CREATE INDEX "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");

3.5 Multi-Round Submission Tables

-- =============================================================================
-- PHASE 1: MULTI-ROUND SUBMISSION SYSTEM
-- =============================================================================

-- SubmissionWindow
CREATE TABLE "SubmissionWindow" (
  "id" TEXT NOT NULL,
  "competitionId" TEXT NOT NULL,
  "name" TEXT NOT NULL,
  "slug" TEXT NOT NULL,
  "roundNumber" INTEGER NOT NULL,
  "sortOrder" INTEGER NOT NULL DEFAULT 0,

  -- Window timing
  "windowOpenAt" TIMESTAMP(3),
  "windowCloseAt" TIMESTAMP(3),

  -- Deadline behavior
  "deadlinePolicy" "DeadlinePolicy" NOT NULL DEFAULT 'FLAG',
  "graceHours" INTEGER,

  -- Locking
  "lockOnClose" BOOLEAN NOT NULL DEFAULT true,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "SubmissionWindow_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "SubmissionWindow_competitionId_fkey"
    FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "SubmissionWindow_competitionId_slug_key" UNIQUE ("competitionId", "slug"),
  CONSTRAINT "SubmissionWindow_competitionId_roundNumber_key" UNIQUE ("competitionId", "roundNumber")
);

CREATE INDEX "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");

-- SubmissionFileRequirement (replaces FileRequirement)
CREATE TABLE "SubmissionFileRequirement" (
  "id" TEXT NOT NULL,
  "submissionWindowId" TEXT NOT NULL,
  "name" TEXT NOT NULL,
  "description" TEXT,
  "acceptedMimeTypes" TEXT[] NOT NULL,
  "maxSizeMB" INTEGER,
  "isRequired" BOOLEAN NOT NULL DEFAULT true,
  "sortOrder" INTEGER NOT NULL DEFAULT 0,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "SubmissionFileRequirement_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "SubmissionFileRequirement_submissionWindowId_fkey"
    FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
    ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE INDEX "SubmissionFileRequirement_submissionWindowId_idx"
  ON "SubmissionFileRequirement"("submissionWindowId");

-- RoundSubmissionVisibility (controls which docs jury sees)
CREATE TABLE "RoundSubmissionVisibility" (
  "id" TEXT NOT NULL,
  "roundId" TEXT NOT NULL,
  "submissionWindowId" TEXT NOT NULL,
  "canView" BOOLEAN NOT NULL DEFAULT true,
  "displayLabel" TEXT,

  CONSTRAINT "RoundSubmissionVisibility_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "RoundSubmissionVisibility_roundId_fkey"
    FOREIGN KEY ("roundId") REFERENCES "Round"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "RoundSubmissionVisibility_submissionWindowId_fkey"
    FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "RoundSubmissionVisibility_roundId_submissionWindowId_key"
    UNIQUE ("roundId", "submissionWindowId")
);

CREATE INDEX "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");

3.6 Mentoring Workspace Tables

-- =============================================================================
-- PHASE 1: MENTORING WORKSPACE
-- =============================================================================

-- MentorFile (new workspace file storage)
CREATE TABLE "MentorFile" (
  "id" TEXT NOT NULL,
  "mentorAssignmentId" TEXT NOT NULL,
  "uploadedByUserId" TEXT NOT NULL,

  "fileName" TEXT NOT NULL,
  "mimeType" TEXT NOT NULL,
  "size" INTEGER NOT NULL,
  "bucket" TEXT NOT NULL,
  "objectKey" TEXT NOT NULL,
  "description" TEXT,

  -- Promotion to official submission
  "isPromoted" BOOLEAN NOT NULL DEFAULT false,
  "promotedToFileId" TEXT UNIQUE,
  "promotedAt" TIMESTAMP(3),
  "promotedByUserId" TEXT,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT "MentorFile_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
    FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "MentorFile_uploadedByUserId_fkey"
    FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id")
    ON DELETE RESTRICT ON UPDATE CASCADE,
  CONSTRAINT "MentorFile_promotedByUserId_fkey"
    FOREIGN KEY ("promotedByUserId") REFERENCES "User"("id")
    ON DELETE SET NULL ON UPDATE CASCADE
);

CREATE INDEX "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
CREATE INDEX "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");

-- MentorFileComment (threaded comments on mentor files)
CREATE TABLE "MentorFileComment" (
  "id" TEXT NOT NULL,
  "mentorFileId" TEXT NOT NULL,
  "authorId" TEXT NOT NULL,
  "content" TEXT NOT NULL,
  "parentCommentId" TEXT,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "MentorFileComment_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "MentorFileComment_mentorFileId_fkey"
    FOREIGN KEY ("mentorFileId") REFERENCES "MentorFile"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "MentorFileComment_authorId_fkey"
    FOREIGN KEY ("authorId") REFERENCES "User"("id")
    ON DELETE RESTRICT ON UPDATE CASCADE,
  CONSTRAINT "MentorFileComment_parentCommentId_fkey"
    FOREIGN KEY ("parentCommentId") REFERENCES "MentorFileComment"("id")
    ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE INDEX "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
CREATE INDEX "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
CREATE INDEX "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");

3.7 Winner Confirmation Tables

-- =============================================================================
-- PHASE 1: WINNER CONFIRMATION SYSTEM
-- =============================================================================

-- WinnerProposal
CREATE TABLE "WinnerProposal" (
  "id" TEXT NOT NULL,
  "competitionId" TEXT NOT NULL,
  "category" "CompetitionCategory" NOT NULL,
  "status" "WinnerProposalStatus" NOT NULL DEFAULT 'PENDING',

  -- Proposed rankings
  "rankedProjectIds" TEXT[] NOT NULL,

  -- Selection basis
  "sourceRoundId" TEXT NOT NULL,
  "selectionBasis" JSONB NOT NULL,

  -- Proposer
  "proposedById" TEXT NOT NULL,
  "proposedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  -- Finalization
  "frozenAt" TIMESTAMP(3),
  "frozenById" TEXT,

  -- Admin override
  "overrideUsed" BOOLEAN NOT NULL DEFAULT false,
  "overrideMode" TEXT,
  "overrideReason" TEXT,
  "overrideById" TEXT,

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" TIMESTAMP(3) NOT NULL,

  CONSTRAINT "WinnerProposal_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "WinnerProposal_competitionId_fkey"
    FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "WinnerProposal_sourceRoundId_fkey"
    FOREIGN KEY ("sourceRoundId") REFERENCES "Round"("id")
    ON DELETE RESTRICT ON UPDATE CASCADE,
  CONSTRAINT "WinnerProposal_proposedById_fkey"
    FOREIGN KEY ("proposedById") REFERENCES "User"("id")
    ON DELETE RESTRICT ON UPDATE CASCADE,
  CONSTRAINT "WinnerProposal_frozenById_fkey"
    FOREIGN KEY ("frozenById") REFERENCES "User"("id")
    ON DELETE SET NULL ON UPDATE CASCADE,
  CONSTRAINT "WinnerProposal_overrideById_fkey"
    FOREIGN KEY ("overrideById") REFERENCES "User"("id")
    ON DELETE SET NULL ON UPDATE CASCADE
);

CREATE INDEX "WinnerProposal_competitionId_idx" ON "WinnerProposal"("competitionId");
CREATE INDEX "WinnerProposal_status_idx" ON "WinnerProposal"("status");

-- WinnerApproval
CREATE TABLE "WinnerApproval" (
  "id" TEXT NOT NULL,
  "winnerProposalId" TEXT NOT NULL,
  "userId" TEXT NOT NULL,
  "role" "WinnerApprovalRole" NOT NULL,

  -- Response
  "approved" BOOLEAN,
  "comments" TEXT,
  "respondedAt" TIMESTAMP(3),

  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT "WinnerApproval_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "WinnerApproval_winnerProposalId_fkey"
    FOREIGN KEY ("winnerProposalId") REFERENCES "WinnerProposal"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "WinnerApproval_userId_fkey"
    FOREIGN KEY ("userId") REFERENCES "User"("id")
    ON DELETE RESTRICT ON UPDATE CASCADE,
  CONSTRAINT "WinnerApproval_winnerProposalId_userId_key" UNIQUE ("winnerProposalId", "userId")
);

CREATE INDEX "WinnerApproval_winnerProposalId_idx" ON "WinnerApproval"("winnerProposalId");
CREATE INDEX "WinnerApproval_userId_idx" ON "WinnerApproval"("userId");

3.8 Modified Existing Tables

-- =============================================================================
-- PHASE 1: MODIFICATIONS TO EXISTING TABLES
-- =============================================================================

-- Add competitionId to Project (nullable for now)
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "competitionId" TEXT;
ALTER TABLE "Project" ADD CONSTRAINT "Project_competitionId_fkey"
  FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
  ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "Project_competitionId_idx" ON "Project"("competitionId");

-- Add submissionWindowId to ProjectFile
ALTER TABLE "ProjectFile" ADD COLUMN IF NOT EXISTS "submissionWindowId" TEXT;
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionWindowId_fkey"
  FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
  ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");

-- Add juryGroupId to Assignment
ALTER TABLE "Assignment" ADD COLUMN IF NOT EXISTS "juryGroupId" TEXT;
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_juryGroupId_fkey"
  FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
  ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");

-- Add workspace fields to MentorAssignment
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "workspaceEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "workspaceOpenAt" TIMESTAMP(3);
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "workspaceCloseAt" TIMESTAMP(3);

-- Enhance SpecialAward (link to Competition + jury groups)
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "competitionId" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityMode" "AwardEligibilityMode" DEFAULT 'STAY_IN_MAIN';
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "evaluationRoundId" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "juryGroupId" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "decisionMode" TEXT DEFAULT 'JURY_VOTE';

ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_competitionId_fkey"
  FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
  ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_evaluationRoundId_fkey"
  FOREIGN KEY ("evaluationRoundId") REFERENCES "Round"("id")
  ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_juryGroupId_fkey"
  FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
  ON DELETE SET NULL ON UPDATE CASCADE;

CREATE INDEX IF NOT EXISTS "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
CREATE INDEX IF NOT EXISTS "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");

3.9 Foreign Key Constraint for Round Relations

-- =============================================================================
-- PHASE 1: ROUND FOREIGN KEY CONSTRAINTS (deferred until Round exists)
-- =============================================================================

ALTER TABLE "Round" ADD CONSTRAINT "Round_juryGroupId_fkey"
  FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
  ON DELETE SET NULL ON UPDATE CASCADE;

ALTER TABLE "Round" ADD CONSTRAINT "Round_submissionWindowId_fkey"
  FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
  ON DELETE SET NULL ON UPDATE CASCADE;

3.10 Phase 1 Validation Queries

-- =============================================================================
-- PHASE 1: VALIDATION QUERIES
-- =============================================================================

-- Verify all new tables created
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
  AND table_name IN (
    'Competition', 'Round', 'ProjectRoundState', 'AdvancementRule',
    'JuryGroup', 'JuryGroupMember', 'SubmissionWindow',
    'SubmissionFileRequirement', 'RoundSubmissionVisibility',
    'MentorFile', 'MentorFileComment', 'WinnerProposal', 'WinnerApproval'
  )
ORDER BY table_name;

-- Verify all new enums created
SELECT enumtypid::regtype AS enum_type, enumlabel
FROM pg_enum
WHERE enumtypid::regtype::text IN (
  'CompetitionStatus', 'RoundType', 'RoundStatus', 'ProjectRoundStateValue',
  'AdvancementRuleType', 'CapMode', 'DeadlinePolicy', 'WinnerProposalStatus',
  'WinnerApprovalRole', 'AwardEligibilityMode'
)
ORDER BY enum_type, enumsortorder;

-- Verify new columns added
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Project' AND column_name = 'competitionId'
UNION ALL
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'ProjectFile' AND column_name = 'submissionWindowId'
UNION ALL
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Assignment' AND column_name = 'juryGroupId'
UNION ALL
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'MentorAssignment' AND column_name = 'workspaceEnabled';

-- Verify no data in new tables (should all return 0)
SELECT 'Competition' AS table_name, COUNT(*) FROM "Competition"
UNION ALL
SELECT 'Round', COUNT(*) FROM "Round"
UNION ALL
SELECT 'ProjectRoundState', COUNT(*) FROM "ProjectRoundState"
UNION ALL
SELECT 'JuryGroup', COUNT(*) FROM "JuryGroup"
UNION ALL
SELECT 'SubmissionWindow', COUNT(*) FROM "SubmissionWindow";

3.11 Phase 1 Rollback Script

-- =============================================================================
-- PHASE 1 ROLLBACK: Drop all new tables and columns
-- =============================================================================

-- Drop new tables (in dependency order)
DROP TABLE IF EXISTS "WinnerApproval" CASCADE;
DROP TABLE IF EXISTS "WinnerProposal" CASCADE;
DROP TABLE IF EXISTS "MentorFileComment" CASCADE;
DROP TABLE IF EXISTS "MentorFile" CASCADE;
DROP TABLE IF EXISTS "RoundSubmissionVisibility" CASCADE;
DROP TABLE IF EXISTS "SubmissionFileRequirement" CASCADE;
DROP TABLE IF EXISTS "SubmissionWindow" CASCADE;
DROP TABLE IF EXISTS "JuryGroupMember" CASCADE;
DROP TABLE IF EXISTS "JuryGroup" CASCADE;
DROP TABLE IF EXISTS "AdvancementRule" CASCADE;
DROP TABLE IF EXISTS "ProjectRoundState" CASCADE;
DROP TABLE IF EXISTS "Round" CASCADE;
DROP TABLE IF EXISTS "Competition" CASCADE;

-- Remove new columns from existing tables
ALTER TABLE "Project" DROP COLUMN IF EXISTS "competitionId";
ALTER TABLE "ProjectFile" DROP COLUMN IF EXISTS "submissionWindowId";
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "juryGroupId";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "workspaceEnabled";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "workspaceOpenAt";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "workspaceCloseAt";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "competitionId";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "eligibilityMode";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "evaluationRoundId";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "juryGroupId";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "decisionMode";

-- Drop new enums
DROP TYPE IF EXISTS "AwardEligibilityMode" CASCADE;
DROP TYPE IF EXISTS "WinnerApprovalRole" CASCADE;
DROP TYPE IF EXISTS "WinnerProposalStatus" CASCADE;
DROP TYPE IF EXISTS "DeadlinePolicy" CASCADE;
DROP TYPE IF EXISTS "CapMode" CASCADE;
DROP TYPE IF EXISTS "AdvancementRuleType" CASCADE;
DROP TYPE IF EXISTS "ProjectRoundStateValue" CASCADE;
DROP TYPE IF EXISTS "RoundStatus" CASCADE;
DROP TYPE IF EXISTS "RoundType" CASCADE;
DROP TYPE IF EXISTS "CompetitionStatus" CASCADE;

4. Phase 2: Data Migration

4.1 Overview

Copy data from old Pipeline/Track/Stage tables to new Competition/Round tables. Old tables remain unchanged as backup. All migrations are wrapped in transactions with validation checks.

4.2 Migration 1: Pipeline → Competition

-- =============================================================================
-- PHASE 2.1: MIGRATE PIPELINE → COMPETITION
-- =============================================================================

BEGIN;

-- Create Competition for each Pipeline
INSERT INTO "Competition" (
  "id",
  "programId",
  "name",
  "slug",
  "status",
  "categoryMode",
  "startupFinalistCount",
  "conceptFinalistCount",
  "notifyOnRoundAdvance",
  "notifyOnDeadlineApproach",
  "deadlineReminderDays",
  "createdAt",
  "updatedAt"
)
SELECT
  p.id,                                        -- Keep same ID
  p."programId",
  p.name,
  p.slug,
  -- Map status string to enum
  CASE
    WHEN p.status = 'DRAFT' THEN 'DRAFT'::text::"CompetitionStatus"
    WHEN p.status = 'ACTIVE' THEN 'ACTIVE'::text::"CompetitionStatus"
    WHEN p.status = 'ARCHIVED' THEN 'ARCHIVED'::text::"CompetitionStatus"
    ELSE 'DRAFT'::text::"CompetitionStatus"
  END,
  -- Extract from settingsJson or use defaults
  COALESCE((p."settingsJson"->>'categoryMode')::text, 'SHARED'),
  COALESCE((p."settingsJson"->>'startupFinalistCount')::int, 3),
  COALESCE((p."settingsJson"->>'conceptFinalistCount')::int, 3),
  COALESCE((p."settingsJson"->>'notifyOnRoundAdvance')::boolean, true),
  COALESCE((p."settingsJson"->>'notifyOnDeadlineApproach')::boolean, true),
  COALESCE(
    (p."settingsJson"->'deadlineReminderDays')::int[],
    ARRAY[7, 3, 1]
  ),
  p."createdAt",
  p."updatedAt"
FROM "Pipeline" p;

-- Validation: Count should match
DO $$
DECLARE
  pipeline_count INT;
  competition_count INT;
BEGIN
  SELECT COUNT(*) INTO pipeline_count FROM "Pipeline";
  SELECT COUNT(*) INTO competition_count FROM "Competition";

  IF pipeline_count != competition_count THEN
    RAISE EXCEPTION 'Pipeline count (%) != Competition count (%)',
      pipeline_count, competition_count;
  END IF;

  RAISE NOTICE 'Successfully migrated % pipelines to competitions', competition_count;
END $$;

COMMIT;

4.3 Migration 2: Track Stages → Rounds (Linear)

-- =============================================================================
-- PHASE 2.2: MIGRATE TRACK STAGES → ROUNDS (LINEAR)
-- =============================================================================

BEGIN;

-- For each MAIN track, create Rounds from its Stages
-- (drop Track layer, promote Stages to top level)
INSERT INTO "Round" (
  "id",
  "competitionId",
  "name",
  "slug",
  "roundType",
  "status",
  "sortOrder",
  "windowOpenAt",
  "windowCloseAt",
  "configJson",
  "createdAt",
  "updatedAt"
)
SELECT
  s.id,                                        -- Keep Stage ID as Round ID
  t."pipelineId",                              -- Track's Pipeline becomes Round's Competition
  s.name,
  s.slug,
  -- Map StageType to RoundType
  CASE s."stageType"
    WHEN 'INTAKE' THEN 'INTAKE'::text::"RoundType"
    WHEN 'FILTER' THEN 'FILTERING'::text::"RoundType"  -- Renamed
    WHEN 'EVALUATION' THEN 'EVALUATION'::text::"RoundType"
    WHEN 'SELECTION' THEN 'EVALUATION'::text::"RoundType"  -- Merged into EVALUATION
    WHEN 'LIVE_FINAL' THEN 'LIVE_FINAL'::text::"RoundType"
    WHEN 'RESULTS' THEN 'CONFIRMATION'::text::"RoundType"  -- Renamed
  END,
  -- Map StageStatus to RoundStatus
  CASE s.status
    WHEN 'STAGE_DRAFT' THEN 'ROUND_DRAFT'::text::"RoundStatus"
    WHEN 'STAGE_ACTIVE' THEN 'ROUND_ACTIVE'::text::"RoundStatus"
    WHEN 'STAGE_CLOSED' THEN 'ROUND_CLOSED'::text::"RoundStatus"
    WHEN 'STAGE_ARCHIVED' THEN 'ROUND_ARCHIVED'::text::"RoundStatus"
  END,
  s."sortOrder",
  s."windowOpenAt",
  s."windowCloseAt",
  s."configJson",
  s."createdAt",
  s."updatedAt"
FROM "Stage" s
JOIN "Track" t ON s."trackId" = t.id
WHERE t.kind = 'MAIN'  -- Only migrate MAIN track stages
ORDER BY t."pipelineId", s."sortOrder";

-- Validation: Count MAIN track stages vs Rounds
DO $$
DECLARE
  stage_count INT;
  round_count INT;
BEGIN
  SELECT COUNT(*) INTO stage_count
  FROM "Stage" s
  JOIN "Track" t ON s."trackId" = t.id
  WHERE t.kind = 'MAIN';

  SELECT COUNT(*) INTO round_count FROM "Round";

  IF stage_count != round_count THEN
    RAISE EXCEPTION 'MAIN stage count (%) != Round count (%)',
      stage_count, round_count;
  END IF;

  RAISE NOTICE 'Successfully migrated % stages to rounds', round_count;
END $$;

COMMIT;

4.4 Migration 3: ProjectStageState → ProjectRoundState

-- =============================================================================
-- PHASE 2.3: MIGRATE PROJECTSTAGESTATE → PROJECTROUNDSTATE
-- =============================================================================

BEGIN;

-- Copy all ProjectStageState records, dropping trackId
INSERT INTO "ProjectRoundState" (
  "id",
  "projectId",
  "roundId",
  "state",
  "enteredAt",
  "exitedAt",
  "metadataJson",
  "createdAt",
  "updatedAt"
)
SELECT
  pss.id,
  pss."projectId",
  pss."stageId",                                -- stageId becomes roundId (same ID preserved)
  -- Map ProjectStageStateValue (preserve existing enum values)
  pss.state,
  pss."enteredAt",
  pss."exitedAt",
  pss."metadataJson",
  pss."createdAt",
  pss."updatedAt"
FROM "ProjectStageState" pss
JOIN "Stage" s ON pss."stageId" = s.id
JOIN "Track" t ON s."trackId" = t.id
WHERE t.kind = 'MAIN';  -- Only migrate MAIN track states

-- Validation: Count should match MAIN track PSS
DO $$
DECLARE
  pss_count INT;
  prs_count INT;
BEGIN
  SELECT COUNT(*) INTO pss_count
  FROM "ProjectStageState" pss
  JOIN "Stage" s ON pss."stageId" = s.id
  JOIN "Track" t ON s."trackId" = t.id
  WHERE t.kind = 'MAIN';

  SELECT COUNT(*) INTO prs_count FROM "ProjectRoundState";

  IF pss_count != prs_count THEN
    RAISE EXCEPTION 'PSS count (%) != PRS count (%)', pss_count, prs_count;
  END IF;

  RAISE NOTICE 'Successfully migrated % project state records', prs_count;
END $$;

COMMIT;

4.5 Migration 4: AWARD Tracks → SpecialAward Enhancement

-- =============================================================================
-- PHASE 2.4: MIGRATE AWARD TRACKS → SPECIALAWARD ENHANCEMENT
-- =============================================================================

BEGIN;

-- Update existing SpecialAward records linked to AWARD tracks
UPDATE "SpecialAward" sa
SET
  "competitionId" = t."pipelineId",
  "eligibilityMode" = 'STAY_IN_MAIN'::text::"AwardEligibilityMode",  -- Default assumption
  "decisionMode" = COALESCE(t."decisionMode"::text, 'JURY_VOTE')
FROM "Track" t
WHERE sa."trackId" = t.id
  AND t.kind = 'AWARD';

-- For awards without a track, link to program's competition
UPDATE "SpecialAward" sa
SET "competitionId" = (
  SELECT c.id
  FROM "Competition" c
  WHERE c."programId" = sa."programId"
  LIMIT 1
)
WHERE sa."trackId" IS NULL
  AND sa."competitionId" IS NULL;

-- Validation: All awards should now have competitionId
DO $$
DECLARE
  unlinked_count INT;
BEGIN
  SELECT COUNT(*) INTO unlinked_count
  FROM "SpecialAward"
  WHERE "competitionId" IS NULL;

  IF unlinked_count > 0 THEN
    RAISE WARNING '% awards still have no competitionId', unlinked_count;
  ELSE
    RAISE NOTICE 'All awards successfully linked to competitions';
  END IF;
END $$;

COMMIT;

4.6 Migration 5: FileRequirement → SubmissionWindow + SubmissionFileRequirement

-- =============================================================================
-- PHASE 2.5: MIGRATE FILEREQUIREMENT → SUBMISSIONWINDOW + REQUIREMENTS
-- =============================================================================

BEGIN;

-- For each INTAKE stage, create a SubmissionWindow
INSERT INTO "SubmissionWindow" (
  "id",
  "competitionId",
  "name",
  "slug",
  "roundNumber",
  "sortOrder",
  "windowOpenAt",
  "windowCloseAt",
  "deadlinePolicy",
  "lockOnClose",
  "createdAt",
  "updatedAt"
)
SELECT
  gen_random_uuid()::text,
  r."competitionId",
  r.name || ' Documents',
  r.slug || '-docs',
  ROW_NUMBER() OVER (PARTITION BY r."competitionId" ORDER BY r."sortOrder"),
  r."sortOrder",
  r."windowOpenAt",
  r."windowCloseAt",
  'FLAG'::text::"DeadlinePolicy",  -- Default
  true,
  r."createdAt",
  r."updatedAt"
FROM "Round" r
WHERE r."roundType" = 'INTAKE';

-- Link rounds to their submission windows
UPDATE "Round" r
SET "submissionWindowId" = sw.id
FROM "SubmissionWindow" sw
WHERE r."roundType" = 'INTAKE'
  AND sw."competitionId" = r."competitionId"
  AND sw.slug = r.slug || '-docs';

-- Migrate FileRequirements to SubmissionFileRequirements
INSERT INTO "SubmissionFileRequirement" (
  "id",
  "submissionWindowId",
  "name",
  "description",
  "acceptedMimeTypes",
  "maxSizeMB",
  "isRequired",
  "sortOrder",
  "createdAt",
  "updatedAt"
)
SELECT
  fr.id,
  sw.id,
  fr.name,
  fr.description,
  fr."acceptedMimeTypes",
  fr."maxSizeMB",
  fr."isRequired",
  fr."sortOrder",
  fr."createdAt",
  fr."updatedAt"
FROM "FileRequirement" fr
JOIN "Stage" s ON fr."stageId" = s.id
JOIN "Round" r ON s.id = r.id  -- Stage ID preserved as Round ID
JOIN "SubmissionWindow" sw ON r."submissionWindowId" = sw.id;

-- Link ProjectFiles to SubmissionWindows
UPDATE "ProjectFile" pf
SET "submissionWindowId" = r."submissionWindowId"
FROM "FileRequirement" fr
JOIN "Stage" s ON fr."stageId" = s.id
JOIN "Round" r ON s.id = r.id
WHERE pf."requirementId" = fr.id
  AND r."submissionWindowId" IS NOT NULL;

-- Validation
DO $$
DECLARE
  fr_count INT;
  sfr_count INT;
  sw_count INT;
  intake_count INT;
BEGIN
  SELECT COUNT(*) INTO fr_count FROM "FileRequirement";
  SELECT COUNT(*) INTO sfr_count FROM "SubmissionFileRequirement";
  SELECT COUNT(*) INTO sw_count FROM "SubmissionWindow";
  SELECT COUNT(*) INTO intake_count FROM "Round" WHERE "roundType" = 'INTAKE';

  RAISE NOTICE 'SubmissionWindows created: % (one per INTAKE round: %)', sw_count, intake_count;
  RAISE NOTICE 'FileRequirements migrated: % → %', fr_count, sfr_count;
END $$;

COMMIT;

4.7 Migration 6: Create Default JuryGroups from Assignments

-- =============================================================================
-- PHASE 2.6: CREATE DEFAULT JURYGROUPS FROM EXISTING ASSIGNMENTS
-- =============================================================================

BEGIN;

-- For each EVALUATION round, create a default JuryGroup
INSERT INTO "JuryGroup" (
  "id",
  "competitionId",
  "name",
  "slug",
  "description",
  "sortOrder",
  "defaultMaxAssignments",
  "defaultCapMode",
  "softCapBuffer",
  "categoryQuotasEnabled",
  "createdAt",
  "updatedAt"
)
SELECT
  gen_random_uuid()::text,
  r."competitionId",
  'Jury for ' || r.name,
  'jury-' || r.slug,
  'Automatically migrated from existing assignments for ' || r.name,
  r."sortOrder",
  20,  -- Default
  'SOFT'::text::"CapMode",
  2,
  false,
  r."createdAt",
  NOW()
FROM "Round" r
WHERE r."roundType" IN ('EVALUATION', 'LIVE_FINAL');

-- Link rounds to their jury groups
UPDATE "Round" r
SET "juryGroupId" = jg.id
FROM "JuryGroup" jg
WHERE r."roundType" IN ('EVALUATION', 'LIVE_FINAL')
  AND jg.slug = 'jury-' || r.slug;

-- Populate JuryGroupMembers from existing Assignments
INSERT INTO "JuryGroupMember" (
  "id",
  "juryGroupId",
  "userId",
  "isLead",
  "joinedAt",
  "createdAt",
  "updatedAt"
)
SELECT DISTINCT
  gen_random_uuid()::text,
  r."juryGroupId",
  a."userId",
  false,  -- No lead designation in old system
  MIN(a."createdAt"),
  MIN(a."createdAt"),
  NOW()
FROM "Assignment" a
JOIN "Stage" s ON a."stageId" = s.id
JOIN "Round" r ON s.id = r.id
WHERE r."juryGroupId" IS NOT NULL
GROUP BY r."juryGroupId", a."userId";

-- Link existing assignments to their jury groups
UPDATE "Assignment" a
SET "juryGroupId" = r."juryGroupId"
FROM "Stage" s
JOIN "Round" r ON s.id = r.id
WHERE a."stageId" = s.id
  AND r."juryGroupId" IS NOT NULL;

-- Validation
DO $$
DECLARE
  jg_count INT;
  jgm_count INT;
  eval_round_count INT;
BEGIN
  SELECT COUNT(*) INTO jg_count FROM "JuryGroup";
  SELECT COUNT(*) INTO jgm_count FROM "JuryGroupMember";
  SELECT COUNT(*) INTO eval_round_count
    FROM "Round" WHERE "roundType" IN ('EVALUATION', 'LIVE_FINAL');

  RAISE NOTICE 'JuryGroups created: % (one per EVALUATION/LIVE_FINAL round: %)',
    jg_count, eval_round_count;
  RAISE NOTICE 'JuryGroupMembers created: %', jgm_count;
END $$;

COMMIT;
-- =============================================================================
-- PHASE 2.7: LINK PROJECTS TO COMPETITIONS
-- =============================================================================

BEGIN;

-- Link each project to its competition via ProjectRoundState
UPDATE "Project" p
SET "competitionId" = (
  SELECT DISTINCT r."competitionId"
  FROM "ProjectRoundState" prs
  JOIN "Round" r ON prs."roundId" = r.id
  WHERE prs."projectId" = p.id
  LIMIT 1
)
WHERE "competitionId" IS NULL;

-- Validation
DO $$
DECLARE
  linked_count INT;
  total_count INT;
BEGIN
  SELECT COUNT(*) INTO total_count FROM "Project";
  SELECT COUNT(*) INTO linked_count FROM "Project" WHERE "competitionId" IS NOT NULL;

  RAISE NOTICE 'Projects linked to competitions: % / %', linked_count, total_count;

  IF linked_count < total_count THEN
    RAISE WARNING '% projects have no competition link', total_count - linked_count;
  END IF;
END $$;

COMMIT;

4.9 Phase 2 Validation Report

-- =============================================================================
-- PHASE 2: COMPREHENSIVE VALIDATION REPORT
-- =============================================================================

-- Summary report
SELECT
  'Pipelines → Competitions' AS migration,
  (SELECT COUNT(*) FROM "Pipeline") AS source_count,
  (SELECT COUNT(*) FROM "Competition") AS target_count,
  (SELECT COUNT(*) FROM "Pipeline") = (SELECT COUNT(*) FROM "Competition") AS match;

SELECT
  'MAIN Track Stages → Rounds' AS migration,
  (SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') AS source_count,
  (SELECT COUNT(*) FROM "Round") AS target_count,
  (SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') = (SELECT COUNT(*) FROM "Round") AS match;

SELECT
  'ProjectStageStates → ProjectRoundStates' AS migration,
  (SELECT COUNT(*) FROM "ProjectStageState" pss JOIN "Stage" s ON pss."stageId" = s.id JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') AS source_count,
  (SELECT COUNT(*) FROM "ProjectRoundState") AS target_count,
  (SELECT COUNT(*) FROM "ProjectStageState" pss JOIN "Stage" s ON pss."stageId" = s.id JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') = (SELECT COUNT(*) FROM "ProjectRoundState") AS match;

SELECT
  'FileRequirements → SubmissionFileRequirements' AS migration,
  (SELECT COUNT(*) FROM "FileRequirement") AS source_count,
  (SELECT COUNT(*) FROM "SubmissionFileRequirement") AS target_count,
  (SELECT COUNT(*) FROM "FileRequirement") = (SELECT COUNT(*) FROM "SubmissionFileRequirement") AS match;

-- Orphan checks
SELECT 'Orphaned Rounds (no competition)' AS check, COUNT(*) AS count
FROM "Round" WHERE "competitionId" NOT IN (SELECT id FROM "Competition")
UNION ALL
SELECT 'Orphaned ProjectRoundStates (no round)', COUNT(*)
FROM "ProjectRoundState" WHERE "roundId" NOT IN (SELECT id FROM "Round")
UNION ALL
SELECT 'Orphaned ProjectRoundStates (no project)', COUNT(*)
FROM "ProjectRoundState" WHERE "projectId" NOT IN (SELECT id FROM "Project")
UNION ALL
SELECT 'Projects with no competition link', COUNT(*)
FROM "Project" WHERE "competitionId" IS NULL;

-- Data integrity checks
SELECT
  'Round types distribution' AS check,
  "roundType",
  COUNT(*) AS count
FROM "Round"
GROUP BY "roundType"
ORDER BY "roundType";

SELECT
  'ProjectRoundState distribution' AS check,
  state,
  COUNT(*) AS count
FROM "ProjectRoundState"
GROUP BY state
ORDER BY state;

4.10 Phase 2 Rollback Script

-- =============================================================================
-- PHASE 2 ROLLBACK: DELETE ALL MIGRATED DATA
-- =============================================================================

BEGIN;

-- Delete in dependency order
DELETE FROM "WinnerApproval";
DELETE FROM "WinnerProposal";
DELETE FROM "MentorFileComment";
DELETE FROM "MentorFile";
DELETE FROM "RoundSubmissionVisibility";
DELETE FROM "SubmissionFileRequirement";
DELETE FROM "SubmissionWindow";
DELETE FROM "JuryGroupMember";
DELETE FROM "JuryGroup";
DELETE FROM "AdvancementRule";
DELETE FROM "ProjectRoundState";
DELETE FROM "Round";
DELETE FROM "Competition";

-- Reset new columns to NULL
UPDATE "Project" SET "competitionId" = NULL;
UPDATE "ProjectFile" SET "submissionWindowId" = NULL;
UPDATE "Assignment" SET "juryGroupId" = NULL;
UPDATE "MentorAssignment"
SET "workspaceEnabled" = false,
    "workspaceOpenAt" = NULL,
    "workspaceCloseAt" = NULL;
UPDATE "SpecialAward"
SET "competitionId" = NULL,
    "eligibilityMode" = NULL,
    "evaluationRoundId" = NULL,
    "juryGroupId" = NULL,
    "decisionMode" = 'JURY_VOTE';

-- Validation: Verify all new tables empty
DO $$
DECLARE
  total_count INT;
BEGIN
  SELECT
    (SELECT COUNT(*) FROM "Competition") +
    (SELECT COUNT(*) FROM "Round") +
    (SELECT COUNT(*) FROM "ProjectRoundState") +
    (SELECT COUNT(*) FROM "JuryGroup") +
    (SELECT COUNT(*) FROM "SubmissionWindow")
  INTO total_count;

  IF total_count != 0 THEN
    RAISE EXCEPTION 'Rollback incomplete: % records remain in new tables', total_count;
  ELSE
    RAISE NOTICE 'Phase 2 successfully rolled back — all migrated data deleted';
  END IF;
END $$;

COMMIT;

5. Phase 3: Code Migration

5.1 Overview

Update all services, routers, and UI components to use new Competition/Round models. Use feature flags to run old + new code in parallel during transition.

5.2 Service Layer Changes

5.2.1 File Renames

# Rename stage-engine.ts → round-engine.ts
mv src/server/services/stage-engine.ts src/server/services/round-engine.ts

# Rename stage-filtering.ts → round-filtering.ts
mv src/server/services/stage-filtering.ts src/server/services/round-filtering.ts

# Rename stage-assignment.ts → round-assignment.ts
mv src/server/services/stage-assignment.ts src/server/services/round-assignment.ts

# Rename stage-notifications.ts → round-notifications.ts
mv src/server/services/stage-notifications.ts src/server/services/round-notifications.ts

5.2.2 Service Function Signature Changes

Before (stage-engine.ts):

export async function validateTransition(
  prisma: PrismaClient,
  projectId: string,
  fromStageId: string,
  toStageId: string
): Promise<{ valid: boolean; reason?: string }> {
  // Check if transition exists
  const transition = await prisma.stageTransition.findFirst({
    where: { fromStageId, toStageId }
  });
  // ...
}

After (round-engine.ts):

export async function validateAdvancement(
  prisma: PrismaClient,
  projectId: string,
  fromRoundId: string,
  toRoundId?: string  // null = next round by sortOrder
): Promise<{ valid: boolean; reason?: string }> {
  // Check advancement rule
  const rule = await prisma.advancementRule.findFirst({
    where: { roundId: fromRoundId, targetRoundId: toRoundId }
  });
  // ...
}

Changes:

  • Rename validateTransitionvalidateAdvancement
  • Rename fromStageIdfromRoundId
  • Rename toStageIdtoRoundId (nullable)
  • Rename StageTransitionAdvancementRule

5.2.3 round-filtering.ts Changes

Before:

export async function runStageFiltering(
  prisma: PrismaClient,
  stageId: string,
  options: FilteringOptions
): Promise<FilteringJob> {
  const stage = await prisma.stage.findUnique({ where: { id: stageId } });
  // ...
}

After:

export async function runRoundFiltering(
  prisma: PrismaClient,
  roundId: string,
  options: FilteringOptions
): Promise<FilteringJob> {
  const round = await prisma.round.findUnique({ where: { id: roundId } });
  // ...
}

Changes:

  • Rename function runStageFilteringrunRoundFiltering
  • Rename parameter stageIdroundId
  • Rename query prisma.stageprisma.round
  • All internal references to stageround

5.2.4 round-assignment.ts Changes

Before:

export async function previewStageAssignment(
  prisma: PrismaClient,
  stageId: string,
  options: AssignmentOptions
): Promise<AssignmentPreview> {
  const stage = await prisma.stage.findUnique({
    where: { id: stageId },
    include: { track: { include: { pipeline: true } } }
  });

  const jurors = await prisma.user.findMany({
    where: { role: 'JURY_MEMBER' }
  });
  // ...
}

After:

export async function previewRoundAssignment(
  prisma: PrismaClient,
  roundId: string,
  options: AssignmentOptions
): Promise<AssignmentPreview> {
  const round = await prisma.round.findUnique({
    where: { id: roundId },
    include: {
      competition: true,
      juryGroup: { include: { members: { include: { user: true } } } }
    }
  });

  const jurors = round.juryGroup?.members.map(m => m.user) || [];
  // ...
}

Changes:

  • Rename function previewStageAssignmentpreviewRoundAssignment
  • Replace track.pipeline include with competition
  • Replace generic juror query with juryGroup.members traversal
  • Use jury group's members instead of all JURY_MEMBER users

5.3 Router Changes

5.3.1 Router File Renames

# Rename routers
mv src/server/routers/pipeline.ts src/server/routers/competition.ts
mv src/server/routers/stage.ts src/server/routers/round.ts
mv src/server/routers/stageFiltering.ts src/server/routers/roundFiltering.ts
mv src/server/routers/stageAssignment.ts src/server/routers/roundAssignment.ts

5.3.2 competition.ts Router (replaces pipeline.ts)

Before:

export const pipelineRouter = router({
  create: adminProcedure
    .input(z.object({
      programId: z.string(),
      name: z.string(),
      slug: z.string(),
      settingsJson: z.any().optional()
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.pipeline.create({
        data: input
      });
    }),

  // ... more procedures
});

After:

export const competitionRouter = router({
  create: adminProcedure
    .input(z.object({
      programId: z.string(),
      name: z.string(),
      slug: z.string(),
      categoryMode: z.enum(['SHARED', 'SPLIT']).default('SHARED'),
      startupFinalistCount: z.number().default(3),
      conceptFinalistCount: z.number().default(3),
      notifyOnRoundAdvance: z.boolean().default(true),
      notifyOnDeadlineApproach: z.boolean().default(true),
      deadlineReminderDays: z.array(z.number()).default([7, 3, 1])
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.competition.create({
        data: input
      });
    }),

  // ... more procedures
});

Changes:

  • Rename pipelineRoutercompetitionRouter
  • Replace settingsJson with typed fields
  • Update ctx.prisma.pipelinectx.prisma.competition

5.3.3 round.ts Router (replaces stage.ts)

Before:

export const stageRouter = router({
  create: adminProcedure
    .input(z.object({
      trackId: z.string(),
      stageType: z.enum(['INTAKE', 'FILTER', 'EVALUATION', 'SELECTION', 'LIVE_FINAL', 'RESULTS']),
      name: z.string(),
      slug: z.string(),
      configJson: z.any().optional()
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.stage.create({ data: input });
    }),

  // ... more procedures
});

After:

export const roundRouter = router({
  create: adminProcedure
    .input(z.object({
      competitionId: z.string(),
      roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'CONFIRMATION']),
      name: z.string(),
      slug: z.string(),
      sortOrder: z.number(),
      configJson: z.any().optional(),  // TODO: Add per-type validation
      juryGroupId: z.string().optional(),
      submissionWindowId: z.string().optional()
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.round.create({ data: input });
    }),

  // ... more procedures
});

Changes:

  • Rename stageRouterroundRouter
  • Replace trackId with competitionId
  • Update stageType enum to roundType enum (new values)
  • Add juryGroupId and submissionWindowId links
  • Update ctx.prisma.stagectx.prisma.round

5.3.4 New Routers

// src/server/routers/juryGroup.ts (NEW)
export const juryGroupRouter = router({
  create: adminProcedure
    .input(z.object({
      competitionId: z.string(),
      name: z.string(),
      slug: z.string(),
      defaultMaxAssignments: z.number().default(20),
      defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).default('SOFT'),
      softCapBuffer: z.number().default(2)
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.juryGroup.create({ data: input });
    }),

  addMember: adminProcedure
    .input(z.object({
      juryGroupId: z.string(),
      userId: z.string(),
      isLead: z.boolean().default(false),
      maxAssignmentsOverride: z.number().optional()
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.juryGroupMember.create({ data: input });
    }),

  // ... more procedures
});

// src/server/routers/submissionWindow.ts (NEW)
export const submissionWindowRouter = router({
  create: adminProcedure
    .input(z.object({
      competitionId: z.string(),
      name: z.string(),
      slug: z.string(),
      roundNumber: z.number(),
      windowOpenAt: z.date().optional(),
      windowCloseAt: z.date().optional(),
      deadlinePolicy: z.enum(['HARD', 'FLAG', 'GRACE']).default('FLAG'),
      graceHours: z.number().optional()
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.submissionWindow.create({ data: input });
    }),

  addRequirement: adminProcedure
    .input(z.object({
      submissionWindowId: z.string(),
      name: z.string(),
      acceptedMimeTypes: z.array(z.string()),
      maxSizeMB: z.number().optional(),
      isRequired: z.boolean().default(true)
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.submissionFileRequirement.create({ data: input });
    }),

  // ... more procedures
});

// src/server/routers/winnerProposal.ts (NEW)
export const winnerProposalRouter = router({
  create: adminProcedure
    .input(z.object({
      competitionId: z.string(),
      category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']),
      rankedProjectIds: z.array(z.string()),
      sourceRoundId: z.string(),
      selectionBasis: z.any()
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.winnerProposal.create({
        data: {
          ...input,
          proposedById: ctx.session.user.id
        }
      });
    }),

  approve: juryProcedure
    .input(z.object({
      winnerProposalId: z.string(),
      approved: z.boolean(),
      comments: z.string().optional()
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.winnerApproval.create({
        data: {
          winnerProposalId: input.winnerProposalId,
          userId: ctx.session.user.id,
          role: 'JURY_MEMBER',
          approved: input.approved,
          comments: input.comments,
          respondedAt: new Date()
        }
      });
    }),

  // ... more procedures
});

5.4 Import Path Updates

All files that import from renamed services/routers need updates:

# Find all imports of old paths
grep -r "from.*stage-engine" src/
grep -r "from.*stage-filtering" src/
grep -r "from.*pipeline.router" src/

# Update with sed (example)
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/stage-engine/round-engine/g'
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/stage-filtering/round-filtering/g'
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/pipelineRouter/competitionRouter/g'

5.5 Type Definition Updates

// src/types/pipeline-wizard.ts → src/types/competition-wizard.ts

// BEFORE
export type PipelineWizardStep =
  | 'pipeline-info'
  | 'tracks'
  | 'stages'
  | 'transitions'
  | 'review';

export interface PipelineWizardData {
  pipeline: {
    name: string;
    slug: string;
    programId: string;
  };
  tracks: TrackData[];
  stages: StageData[];
  transitions: TransitionData[];
}

// AFTER
export type CompetitionWizardStep =
  | 'competition-info'
  | 'rounds'
  | 'jury-groups'
  | 'submission-windows'
  | 'review';

export interface CompetitionWizardData {
  competition: {
    name: string;
    slug: string;
    programId: string;
    categoryMode: 'SHARED' | 'SPLIT';
    startupFinalistCount: number;
    conceptFinalistCount: number;
  };
  rounds: RoundData[];
  juryGroups: JuryGroupData[];
  submissionWindows: SubmissionWindowData[];
  advancementRules: AdvancementRuleData[];
}

5.6 UI Component Updates

5.6.1 Page Renames

# Rename admin pages
mv src/app/\(admin\)/admin/rounds/pipelines src/app/\(admin\)/admin/rounds/competitions
mv src/app/\(admin\)/admin/rounds/pipeline src/app/\(admin\)/admin/rounds/competition

# Update route references in all files
find src/app -type f \( -name "*.ts" -o -name "*.tsx" \) | xargs sed -i 's|/admin/rounds/pipeline|/admin/rounds/competition|g'

5.6.2 Component Updates (Example: Competition List)

Before (src/app/(admin)/admin/rounds/pipelines/page.tsx):

export default function PipelinesPage() {
  const { data: pipelines } = trpc.pipeline.list.useQuery();

  return (
    <div>
      <h1>Pipelines</h1>
      {pipelines?.map(pipeline => (
        <Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}`}>
          {pipeline.name}
        </Link>
      ))}
    </div>
  );
}

After (src/app/(admin)/admin/rounds/competitions/page.tsx):

export default function CompetitionsPage() {
  const { data: competitions } = trpc.competition.list.useQuery();

  return (
    <div>
      <h1>Competitions</h1>
      {competitions?.map(competition => (
        <Link key={competition.id} href={`/admin/rounds/competition/${competition.id}`}>
          {competition.name} ({competition.status})
        </Link>
      ))}
    </div>
  );
}

5.7 Feature Flag Strategy

Use environment variable to enable parallel execution:

// src/lib/feature-flags.ts
export const FEATURE_FLAGS = {
  USE_NEW_COMPETITION_MODEL: process.env.NEXT_PUBLIC_USE_NEW_COMPETITION_MODEL === 'true'
};

// Usage in code
import { FEATURE_FLAGS } from '@/lib/feature-flags';

export async function getCompetitionData(id: string) {
  if (FEATURE_FLAGS.USE_NEW_COMPETITION_MODEL) {
    return prisma.competition.findUnique({ where: { id } });
  } else {
    return prisma.pipeline.findUnique({ where: { id } });
  }
}

5.8 Phase 3 Validation

# TypeScript compilation
npm run typecheck

# Linting
npm run lint

# Unit tests
npm run test

# Integration tests
npm run test:integration

# Build check
npm run build

5.9 Phase 3 Rollback

# Revert all code changes
git reset --hard <commit-before-phase-3>

# Or manual revert
git revert <phase-3-commit-hash>

# Restore old import paths
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/round-engine/stage-engine/g'
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/competitionRouter/pipelineRouter/g'

# Rebuild
npm run build

6. Phase 4: Cleanup (Breaking)

6.1 Overview

WARNING: This phase is IRREVERSIBLE. Once old tables are dropped, rollback is impossible. Only proceed after:

  1. Full Phase 3 deployment to production
  2. At least 2 weeks of monitoring
  3. No critical bugs reported
  4. Stakeholder approval
  5. Complete database backup

6.2 Pre-Cleanup Checklist

-- =============================================================================
-- PHASE 4: PRE-CLEANUP VALIDATION CHECKLIST
-- =============================================================================

-- ✅ 1. Verify no code references old tables
-- Run: grep -r "prisma.pipeline\|prisma.track\|prisma.stage\|prisma.projectStageState" src/
-- Should return 0 results

-- ✅ 2. Verify new tables have data
SELECT 'Competition' AS table_name, COUNT(*) AS count FROM "Competition"
UNION ALL
SELECT 'Round', COUNT(*) FROM "Round"
UNION ALL
SELECT 'ProjectRoundState', COUNT(*) FROM "ProjectRoundState"
UNION ALL
SELECT 'JuryGroup', COUNT(*) FROM "JuryGroup"
UNION ALL
SELECT 'SubmissionWindow', COUNT(*) FROM "SubmissionWindow";
-- All should be > 0

-- ✅ 3. Verify old/new data parity
SELECT
  (SELECT COUNT(*) FROM "Pipeline") AS old_pipelines,
  (SELECT COUNT(*) FROM "Competition") AS new_competitions,
  (SELECT COUNT(*) FROM "Pipeline") = (SELECT COUNT(*) FROM "Competition") AS match;

SELECT
  (SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') AS old_stages,
  (SELECT COUNT(*) FROM "Round") AS new_rounds,
  (SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') = (SELECT COUNT(*) FROM "Round") AS match;

-- ✅ 4. Verify no foreign key references to old tables
SELECT
  tc.table_name,
  kcu.column_name,
  ccu.table_name AS foreign_table_name,
  ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
  ON tc.constraint_name = kcu.constraint_name
  AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
  ON ccu.constraint_name = tc.constraint_name
  AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND ccu.table_name IN ('Pipeline', 'Track', 'Stage', 'ProjectStageState', 'StageTransition')
ORDER BY tc.table_name;
-- Should return 0 rows

-- ✅ 5. Backup verification
-- Ensure recent backup exists:
-- SELECT pg_last_wal_replay_lsn();

6.3 Drop Old Tables

-- =============================================================================
-- PHASE 4.1: DROP OLD TABLES (IRREVERSIBLE)
-- =============================================================================

BEGIN;

-- Drop tables in dependency order
DROP TABLE IF EXISTS "CohortProject" CASCADE;
DROP TABLE IF EXISTS "Cohort" CASCADE;
DROP TABLE IF EXISTS "LiveProgressCursor" CASCADE;
DROP TABLE IF EXISTS "ProjectStageState" CASCADE;
DROP TABLE IF EXISTS "StageTransition" CASCADE;
DROP TABLE IF EXISTS "Assignment" CASCADE;  -- Will be recreated with new FK
DROP TABLE IF EXISTS "FileRequirement" CASCADE;
DROP TABLE IF EXISTS "FilteringRule" CASCADE;
DROP TABLE IF EXISTS "FilteringResult" CASCADE;
DROP TABLE IF EXISTS "FilteringJob" CASCADE;
DROP TABLE IF EXISTS "AssignmentJob" CASCADE;
DROP TABLE IF EXISTS "GracePeriod" CASCADE;
DROP TABLE IF EXISTS "ReminderLog" CASCADE;
DROP TABLE IF EXISTS "EvaluationSummary" CASCADE;
DROP TABLE IF EXISTS "EvaluationDiscussion" CASCADE;
DROP TABLE IF EXISTS "LiveVotingSession" CASCADE;
DROP TABLE IF EXISTS "Message" CASCADE;
DROP TABLE IF EXISTS "Stage" CASCADE;
DROP TABLE IF EXISTS "Track" CASCADE;
DROP TABLE IF EXISTS "Pipeline" CASCADE;

-- Verification
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
  AND table_name IN ('Pipeline', 'Track', 'Stage', 'ProjectStageState', 'StageTransition')
ORDER BY table_name;
-- Should return 0 rows

COMMIT;

6.4 Drop Old Enums

-- =============================================================================
-- PHASE 4.2: DROP OLD ENUMS (IRREVERSIBLE)
-- =============================================================================

BEGIN;

-- Drop old enums (only if no columns reference them)
DROP TYPE IF EXISTS "TrackKind" CASCADE;
DROP TYPE IF EXISTS "RoutingMode" CASCADE;
DROP TYPE IF EXISTS "DecisionMode" CASCADE;
DROP TYPE IF EXISTS "StageType" CASCADE;
DROP TYPE IF EXISTS "StageStatus" CASCADE;
DROP TYPE IF EXISTS "ProjectStageStateValue" CASCADE;

-- Verification
SELECT enumtypid::regtype AS enum_type
FROM pg_enum
WHERE enumtypid::regtype::text IN (
  'TrackKind', 'RoutingMode', 'DecisionMode',
  'StageType', 'StageStatus', 'ProjectStageStateValue'
)
GROUP BY enumtypid::regtype;
-- Should return 0 rows

COMMIT;

6.5 Remove Legacy Columns

-- =============================================================================
-- PHASE 4.3: REMOVE LEGACY roundId COLUMNS
-- =============================================================================

BEGIN;

-- Drop all legacy roundId columns (marked "Legacy — kept for historical data")
ALTER TABLE "EvaluationForm" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "FileRequirement" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "GracePeriod" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "FilteringRule" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "FilteringResult" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "FilteringJob" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "AssignmentJob" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "TaggingJob" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "ReminderLog" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "ConflictOfInterest" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "EvaluationSummary" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "EvaluationDiscussion" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "LiveVotingSession" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table
ALTER TABLE "Message" DROP COLUMN IF EXISTS "roundId";  -- Already dropped table

-- Drop legacy roundId from Project
ALTER TABLE "Project" DROP COLUMN IF EXISTS "roundId";

-- Drop trackId from SpecialAward (now links to competitionId)
ALTER TABLE "SpecialAward" DROP CONSTRAINT IF EXISTS "SpecialAward_trackId_fkey";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "trackId";

-- Verification: Check for remaining roundId columns
SELECT table_name, column_name
FROM information_schema.columns
WHERE column_name = 'roundId'
  AND table_schema = 'public'
ORDER BY table_name;
-- Should return 0 rows

COMMIT;

6.6 Recreate Assignment Table with New FK

-- =============================================================================
-- PHASE 4.4: RECREATE ASSIGNMENT TABLE (with roundId → Round FK)
-- =============================================================================

BEGIN;

-- Assignment was dropped in 4.3, recreate with correct FK
CREATE TABLE "Assignment" (
  "id" TEXT NOT NULL,
  "userId" TEXT NOT NULL,
  "projectId" TEXT NOT NULL,
  "roundId" TEXT NOT NULL,  -- Now FK to Round, not Stage
  "method" "AssignmentMethod" NOT NULL DEFAULT 'MANUAL',
  "isRequired" BOOLEAN NOT NULL DEFAULT true,
  "isCompleted" BOOLEAN NOT NULL DEFAULT false,
  "aiConfidenceScore" DOUBLE PRECISION,
  "expertiseMatchScore" DOUBLE PRECISION,
  "aiReasoning" TEXT,
  "juryGroupId" TEXT,
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "createdBy" TEXT,

  CONSTRAINT "Assignment_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "Assignment_userId_fkey"
    FOREIGN KEY ("userId") REFERENCES "User"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "Assignment_projectId_fkey"
    FOREIGN KEY ("projectId") REFERENCES "Project"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "Assignment_roundId_fkey"
    FOREIGN KEY ("roundId") REFERENCES "Round"("id")
    ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT "Assignment_juryGroupId_fkey"
    FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
    ON DELETE SET NULL ON UPDATE CASCADE,
  CONSTRAINT "Assignment_userId_projectId_roundId_key" UNIQUE ("userId", "projectId", "roundId")
);

CREATE INDEX "Assignment_userId_idx" ON "Assignment"("userId");
CREATE INDEX "Assignment_projectId_idx" ON "Assignment"("projectId");
CREATE INDEX "Assignment_roundId_idx" ON "Assignment"("roundId");
CREATE INDEX "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
CREATE INDEX "Assignment_isCompleted_idx" ON "Assignment"("isCompleted");
CREATE INDEX "Assignment_projectId_userId_idx" ON "Assignment"("projectId", "userId");

-- Repopulate from backup or leave empty (assignments will be regenerated)
-- If you have a backup:
-- INSERT INTO "Assignment" SELECT * FROM "Assignment_backup";

COMMIT;

6.7 Clean Up Service Files

# Delete old service files (after verifying no references)
rm src/server/services/stage-engine.ts
rm src/server/services/stage-filtering.ts
rm src/server/services/stage-assignment.ts
rm src/server/services/stage-notifications.ts

# Delete old router files
rm src/server/routers/pipeline.ts
rm src/server/routers/stage.ts
rm src/server/routers/stageFiltering.ts
rm src/server/routers/stageAssignment.ts

# Delete old type files
rm src/types/pipeline-wizard.ts

6.8 Phase 4 Final Validation

-- =============================================================================
-- PHASE 4: FINAL VALIDATION
-- =============================================================================

-- Verify old tables gone
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
  AND table_name IN ('Pipeline', 'Track', 'Stage', 'ProjectStageState', 'StageTransition')
ORDER BY table_name;
-- Should return 0 rows

-- Verify new tables intact
SELECT table_name,
       (SELECT COUNT(*) FROM information_schema.columns c WHERE c.table_name = t.table_name) AS column_count
FROM information_schema.tables t
WHERE table_schema = 'public'
  AND table_name IN ('Competition', 'Round', 'ProjectRoundState', 'JuryGroup', 'SubmissionWindow')
ORDER BY table_name;
-- All should have column_count > 0

-- Verify FK integrity
SELECT
  tc.table_name,
  kcu.column_name,
  ccu.table_name AS foreign_table_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
  ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage AS ccu
  ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND tc.table_name IN ('Competition', 'Round', 'ProjectRoundState', 'JuryGroup', 'Assignment')
ORDER BY tc.table_name, kcu.column_name;
-- Should show all expected FKs

-- Verify data counts
SELECT
  (SELECT COUNT(*) FROM "Competition") AS competitions,
  (SELECT COUNT(*) FROM "Round") AS rounds,
  (SELECT COUNT(*) FROM "ProjectRoundState") AS project_states,
  (SELECT COUNT(*) FROM "JuryGroup") AS jury_groups,
  (SELECT COUNT(*) FROM "SubmissionWindow") AS submission_windows;

-- Test a complete query
SELECT
  c.name AS competition_name,
  r.name AS round_name,
  r."roundType",
  jg.name AS jury_group_name,
  COUNT(DISTINCT prs."projectId") AS project_count,
  COUNT(DISTINCT a."userId") AS juror_count
FROM "Competition" c
JOIN "Round" r ON c.id = r."competitionId"
LEFT JOIN "JuryGroup" jg ON r."juryGroupId" = jg.id
LEFT JOIN "ProjectRoundState" prs ON r.id = prs."roundId"
LEFT JOIN "Assignment" a ON r.id = a."roundId"
WHERE c.status = 'ACTIVE'
GROUP BY c.id, c.name, r.id, r.name, r."roundType", jg.id, jg.name
ORDER BY c.name, r."sortOrder";
-- Should return valid data with no errors

6.9 Phase 4 Completion Checklist

  • All old tables dropped
  • All old enums dropped
  • All legacy columns removed
  • All old service files deleted
  • All old router files deleted
  • All old type files deleted
  • TypeScript compiles without errors
  • All tests pass
  • Application runs in production
  • No references to old models in codebase
  • Database backup created and verified
  • Migration documented and tagged in git

7. Rollback Plan

7.1 Rollback by Phase

Phase Rollback Difficulty Rollback Procedure Data Loss Risk
Phase 1 Easy Drop new tables with SQL script None
Phase 2 Easy Delete migrated data, keep old tables None (if no new data created)
Phase 3 Medium Revert code commits, rebuild app ⚠️ New data in new tables lost
Phase 4 IMPOSSIBLE Cannot restore dropped tables PERMANENT

7.2 Phase 1 Rollback (Already Documented Above)

See section 3.11 for Phase 1 rollback script.

7.3 Phase 2 Rollback (Already Documented Above)

See section 4.10 for Phase 2 rollback script.

7.4 Phase 3 Rollback

# Git-based rollback
git log --oneline  # Find commit hash before Phase 3
git revert <phase-3-commit>  # Create revert commit
npm run build  # Rebuild with old code

# Or hard reset (destructive)
git reset --hard <commit-before-phase-3>
git push --force origin main  # DANGEROUS: overwrites remote history

# Restore .env feature flag
# Change NEXT_PUBLIC_USE_NEW_COMPETITION_MODEL=false

# Rebuild and redeploy
npm run build
# Deploy to production

7.5 Phase 4 Rollback (Restore from Backup)

Phase 4 cannot be rolled back without a database backup.

# Stop application
systemctl stop mopc-app

# Restore from backup (PostgreSQL example)
pg_restore -U postgres -d mopc_db -c backup_before_phase4.dump

# Revert code to pre-Phase-4 commit
git reset --hard <commit-before-phase-4>

# Rebuild
npm run build

# Restart application
systemctl start mopc-app

# Verify old tables present
psql -U postgres -d mopc_db -c "SELECT table_name FROM information_schema.tables WHERE table_name IN ('Pipeline', 'Track', 'Stage');"

8. Complete Data Mapping Table

8.1 Table-Level Mapping

Old Table New Table Mapping Notes
Pipeline Competition 1:1 — Keep same ID, unpack settingsJson to typed fields
Track (MAIN only) (eliminated) MAIN track stages promoted to Competition.rounds
Track (AWARD) SpecialAward (enhanced) Link to Competition via competitionId, add eligibilityMode
Stage Round 1:1 for MAIN track stages, rename stageType → roundType
StageTransition AdvancementRule guardJson → configJson, isDefault preserved
ProjectStageState ProjectRoundState Drop trackId, stageId → roundId
FileRequirement SubmissionFileRequirement Group by stage → create SubmissionWindow parent
(new) Competition Created from Pipeline
(new) JuryGroup Created from distinct evaluation stage assignments
(new) JuryGroupMember Populated from Assignment.userId (distinct per stage)
(new) SubmissionWindow Created for each INTAKE stage
(new) RoundSubmissionVisibility Controls which docs jury sees (manual config)
(new) MentorFile New mentor workspace file storage
(new) MentorFileComment Threaded comments on mentor files
(new) WinnerProposal Winner confirmation workflow
(new) WinnerApproval Jury approvals for winners

8.2 Field-Level Mapping (Core Models)

Pipeline → Competition

Pipeline Field Competition Field Transformation
id id Direct copy
programId programId Direct copy
name name Direct copy
slug slug Direct copy
status status Map 'DRAFT'/'ACTIVE'/'ARCHIVED' to CompetitionStatus enum
settingsJson.categoryMode categoryMode Extract from JSON → typed field
settingsJson.startupFinalistCount startupFinalistCount Extract from JSON → typed field
settingsJson.conceptFinalistCount conceptFinalistCount Extract from JSON → typed field
settingsJson.notifyOnRoundAdvance notifyOnRoundAdvance Extract from JSON → typed field
settingsJson.notifyOnDeadlineApproach notifyOnDeadlineApproach Extract from JSON → typed field
settingsJson.deadlineReminderDays deadlineReminderDays Extract from JSON → typed array
createdAt createdAt Direct copy
updatedAt updatedAt Direct copy

Stage → Round

Stage Field Round Field Transformation
id id Direct copy (preserve IDs for FK integrity)
trackId competitionId Resolve track.pipelineId
stageType roundType Enum mapping (see below)
name name Direct copy
slug slug Direct copy
status status Enum mapping (see below)
sortOrder sortOrder Direct copy (within track → within competition)
configJson configJson Direct copy (TODO: migrate to typed configs)
windowOpenAt windowOpenAt Direct copy
windowCloseAt windowCloseAt Direct copy
(new) juryGroupId Link to created JuryGroup (for EVALUATION/LIVE_FINAL)
(new) submissionWindowId Link to created SubmissionWindow (for INTAKE)
createdAt createdAt Direct copy
updatedAt updatedAt Direct copy

StageType → RoundType Enum Mapping

StageType (old) RoundType (new) Notes
INTAKE INTAKE No change
FILTER FILTERING Renamed for clarity
EVALUATION EVALUATION No change
SELECTION EVALUATION Merged into EVALUATION (handle via advancementRules)
LIVE_FINAL LIVE_FINAL No change
RESULTS CONFIRMATION Renamed — RESULTS is a view, CONFIRMATION is a workflow round
(new) SUBMISSION New round type for multi-round doc collection
(new) MENTORING New round type for mentor-team workspace

StageStatus → RoundStatus Enum Mapping

StageStatus (old) RoundStatus (new)
STAGE_DRAFT ROUND_DRAFT
STAGE_ACTIVE ROUND_ACTIVE
STAGE_CLOSED ROUND_CLOSED
STAGE_ARCHIVED ROUND_ARCHIVED

ProjectStageState → ProjectRoundState

ProjectStageState Field ProjectRoundState Field Transformation
id id Direct copy
projectId projectId Direct copy
trackId (dropped) Removed — project is in round, not track
stageId roundId Direct copy (Stage ID = Round ID after migration)
state state Enum mapping (see below)
enteredAt enteredAt Direct copy
exitedAt exitedAt Direct copy
metadataJson metadataJson Direct copy
createdAt createdAt Direct copy
updatedAt updatedAt Direct copy

ProjectStageStateValue → ProjectRoundStateValue Enum Mapping

ProjectStageStateValue (old) ProjectRoundStateValue (new) Notes
PENDING PENDING No change
IN_PROGRESS IN_PROGRESS No change
PASSED PASSED No change
REJECTED REJECTED No change
ROUTED (dropped) No longer needed (no track routing)
COMPLETED COMPLETED No change
WITHDRAWN WITHDRAWN No change

8.3 Relation Mapping

Old Relation New Relation Changes
Pipeline → Track[] Competition → Round[] Direct children, no intermediate Track layer
Track → Stage[] (eliminated) Stages promoted to Competition.rounds
Stage → ProjectStageState[] Round → ProjectRoundState[] 1:1 rename
Stage → StageTransition[] Round → AdvancementRule[] 1:N, enhanced with rule types
Stage → Assignment[] Round → Assignment[] Assignment.stageId → Assignment.roundId
Stage → FileRequirement[] SubmissionWindow → SubmissionFileRequirement[] Grouped under SubmissionWindow
(new) Competition → JuryGroup[] New explicit jury management
(new) JuryGroup → JuryGroupMember[] New jury membership
(new) Round → JuryGroup Link evaluation rounds to juries
(new) Round → SubmissionWindow Link intake/submission rounds to doc windows
(new) Competition → SubmissionWindow[] Multi-round doc collection
(new) Round → RoundSubmissionVisibility[] Control which docs jury sees
(new) MentorAssignment → MentorFile[] Workspace files
(new) MentorFile → MentorFileComment[] File comments
(new) Competition → WinnerProposal[] Winner confirmation
(new) WinnerProposal → WinnerApproval[] Jury approvals

9. Risk Assessment

9.1 Technical Risks

Risk Probability Impact Mitigation
Data loss during migration Low Critical Full backup before each phase, validation queries after each step
FK constraint violations Medium High Drop FKs before migration, recreate after validation
Enum type mismatches Low Medium Explicit CAST in migration SQL, validation queries
Performance degradation Low Medium Index all FK columns, benchmark before/after
Incomplete data migration Medium High Count validation queries, row-by-row comparison scripts
Orphaned records Low Medium Orphan detection queries in Phase 2 validation
Code references old models High High Comprehensive grep/search, TypeScript compilation checks
Breaking existing API clients High Critical Parallel endpoints during Phase 3, deprecation warnings
Test failures Medium Medium Run full test suite after each phase
Production downtime Low Critical Blue-green deployment, rollback plan tested

9.2 Business Risks

Risk Probability Impact Mitigation
Competition interruption Low Critical Execute during off-season, no active rounds
Data integrity concerns Medium High Stakeholder review of migration plan, detailed validation reports
Training required High Medium Admin training on new UI/concepts before Phase 4
User confusion Medium Medium Clear communication, UI help tooltips, migration announcement

9.3 Risk Mitigation Checklist

Pre-Migration:

  • Full database backup (verified restorable)
  • Migration tested on staging environment
  • Rollback plan tested on staging
  • Stakeholder approval obtained
  • Maintenance window scheduled
  • Support team briefed

During Migration:

  • Monitor logs for errors
  • Run validation queries after each phase
  • Keep backup connection to production DB
  • Document any deviations from plan

Post-Migration:

  • Full test suite run
  • Smoke tests on production
  • Monitor error logs for 48 hours
  • Performance metrics compared to baseline
  • Stakeholder confirmation of data integrity

10. Testing Strategy

10.1 Unit Tests

Existing tests to update:

  • tests/unit/stage-engine.test.tstests/unit/round-engine.test.ts
  • Update all prisma.stageprisma.round references
  • Update mock data to use new schema

New tests to create:

  • tests/unit/jury-group.test.ts — Jury group CRUD, member management
  • tests/unit/submission-window.test.ts — Multi-round submission logic
  • tests/unit/winner-proposal.test.ts — Winner confirmation workflow
  • tests/unit/advancement-rule.test.ts — Rule evaluation logic

10.2 Integration Tests

Schema migration tests:

// tests/integration/migration.test.ts

describe('Phase 2: Data Migration', () => {
  it('should migrate all pipelines to competitions', async () => {
    const pipelineCount = await prisma.pipeline.count();
    const competitionCount = await prisma.competition.count();
    expect(competitionCount).toBe(pipelineCount);
  });

  it('should preserve all pipeline IDs', async () => {
    const pipelineIds = await prisma.pipeline.findMany({ select: { id: true } });
    const competitionIds = await prisma.competition.findMany({ select: { id: true } });
    expect(competitionIds).toEqual(expect.arrayContaining(pipelineIds));
  });

  it('should migrate all MAIN track stages to rounds', async () => {
    const mainStageCount = await prisma.stage.count({
      where: { track: { kind: 'MAIN' } }
    });
    const roundCount = await prisma.round.count();
    expect(roundCount).toBe(mainStageCount);
  });

  it('should preserve stage-to-round sortOrder', async () => {
    const stages = await prisma.stage.findMany({
      where: { track: { kind: 'MAIN' } },
      orderBy: { sortOrder: 'asc' },
      select: { id: true, sortOrder: true }
    });

    const rounds = await prisma.round.findMany({
      orderBy: { sortOrder: 'asc' },
      select: { id: true, sortOrder: true }
    });

    expect(rounds.map(r => r.sortOrder)).toEqual(stages.map(s => s.sortOrder));
  });
});

API compatibility tests:

// tests/integration/api-compatibility.test.ts

describe('Phase 3: API Compatibility', () => {
  it('should preserve competition list endpoint behavior', async () => {
    const oldResponse = await caller.pipeline.list();
    const newResponse = await caller.competition.list();

    expect(newResponse.length).toBe(oldResponse.length);
    expect(newResponse[0]).toMatchObject({
      id: expect.any(String),
      name: expect.any(String),
      status: expect.any(String)
    });
  });

  it('should handle round creation with new fields', async () => {
    const round = await caller.round.create({
      competitionId: 'comp-1',
      roundType: 'EVALUATION',
      name: 'Test Round',
      slug: 'test-round',
      sortOrder: 1,
      juryGroupId: 'jury-1'
    });

    expect(round.juryGroupId).toBe('jury-1');
  });
});

10.3 End-to-End Tests

Critical user flows:

// tests/e2e/competition-workflow.test.ts

describe('Complete Competition Flow', () => {
  it('should create competition → rounds → jury groups → assignments', async () => {
    // 1. Create competition
    const competition = await createTestCompetition();

    // 2. Create rounds
    const round1 = await createTestRound({ competitionId: competition.id, roundType: 'INTAKE' });
    const round2 = await createTestRound({ competitionId: competition.id, roundType: 'EVALUATION' });

    // 3. Create jury group
    const juryGroup = await createTestJuryGroup({ competitionId: competition.id });

    // 4. Link jury to evaluation round
    await caller.round.update({
      id: round2.id,
      juryGroupId: juryGroup.id
    });

    // 5. Create assignments
    const assignments = await caller.roundAssignment.executeAssignment({
      roundId: round2.id,
      projectIds: ['proj-1', 'proj-2'],
      options: { reviewsPerProject: 3 }
    });

    expect(assignments.length).toBeGreaterThan(0);
    expect(assignments[0].juryGroupId).toBe(juryGroup.id);
  });
});

10.4 Performance Tests

Benchmark queries:

// tests/performance/query-benchmarks.test.ts

describe('Query Performance', () => {
  it('should fetch competition with rounds in <100ms', async () => {
    const start = performance.now();

    const competition = await prisma.competition.findUnique({
      where: { id: 'comp-1' },
      include: {
        rounds: {
          include: {
            juryGroup: { include: { members: true } },
            submissionWindow: { include: { fileRequirements: true } }
          }
        }
      }
    });

    const elapsed = performance.now() - start;
    expect(elapsed).toBeLessThan(100);
    expect(competition).toBeTruthy();
  });

  it('should handle 1000 project round state queries efficiently', async () => {
    const projects = await createTestProjects(1000);
    const round = await createTestRound();

    const start = performance.now();

    await prisma.projectRoundState.createMany({
      data: projects.map(p => ({
        projectId: p.id,
        roundId: round.id,
        state: 'PENDING'
      }))
    });

    const elapsed = performance.now() - start;
    expect(elapsed).toBeLessThan(5000); // 5 seconds for 1000 inserts
  });
});

10.5 Rollback Tests

Rollback validation:

// tests/rollback/phase-rollback.test.ts

describe('Phase 2 Rollback', () => {
  it('should cleanly delete all migrated data', async () => {
    // Run Phase 2 migration
    await runPhase2Migration();

    // Verify data migrated
    const competitionCount = await prisma.competition.count();
    expect(competitionCount).toBeGreaterThan(0);

    // Run rollback
    await runPhase2Rollback();

    // Verify data deleted
    const afterCount = await prisma.competition.count();
    expect(afterCount).toBe(0);

    // Verify old tables intact
    const pipelineCount = await prisma.pipeline.count();
    expect(pipelineCount).toBeGreaterThan(0);
  });
});

10.6 Test Execution Plan

Test Suite When to Run Pass Criteria
Unit tests After each code change 100% pass, coverage >80%
Integration tests After each phase 100% pass
E2E tests After Phase 3 deployment All critical flows work
Performance tests After Phase 2 & 3 No regression >10%
Rollback tests Before each phase deployment Rollback completes without errors

11. Timeline

11.1 Estimated Duration

Phase Tasks Estimated Time Dependencies
Phase 0: Preparation Planning, stakeholder approval, backup setup 1 week None
Phase 1: Schema Write migration SQL, test on staging 3 days Phase 0 complete
Phase 2: Data Write data migration scripts, validate 1 week Phase 1 complete
Phase 3: Code Update services/routers/UI, test 2-3 weeks Phase 2 complete
Phase 4: Cleanup Drop old tables, final validation 1 week Phase 3 stable in production for 2+ weeks
Total 5-6 weeks

11.2 Detailed Schedule (Example)

Week 1: Preparation

  • Day 1-2: Stakeholder review of migration plan
  • Day 3: Database backup procedures established
  • Day 4: Staging environment provisioned
  • Day 5: Rollback procedures tested on staging

Week 2: Phase 1 (Schema Additions)

  • Day 1: Write Phase 1 migration SQL
  • Day 2: Test Phase 1 on local + staging
  • Day 3: Deploy Phase 1 to production (evening, low traffic)
  • Day 4: Validate Phase 1 deployment, monitor logs
  • Day 5: Buffer for issues

Week 3-4: Phase 2 (Data Migration)

  • Week 3 Day 1-3: Write Phase 2 migration scripts (6 migrations)
  • Week 3 Day 4-5: Test migrations on staging with production data snapshot
  • Week 4 Day 1: Run Phase 2 on production (scheduled maintenance window)
  • Week 4 Day 2-5: Validate migrated data, run integrity checks, fix any issues

Week 5-7: Phase 3 (Code Migration)

  • Week 5: Update service layer (engine, filtering, assignment, notifications)
  • Week 6: Update routers + types, write new routers (juryGroup, submissionWindow, etc.)
  • Week 7: Update UI components, test end-to-end flows
  • Week 7 End: Deploy Phase 3 to production with feature flag OFF
  • Week 8: Monitor production, gradually enable feature flag, collect feedback

Week 9-10: Monitoring & Stabilization

  • Week 9: Production monitoring, bug fixes, performance tuning
  • Week 10: Stakeholder UAT (User Acceptance Testing), admin training

Week 11: Phase 4 (Cleanup)

  • Day 1: Final backup before Phase 4
  • Day 2: Run Phase 4 cleanup scripts on staging
  • Day 3: Deploy Phase 4 to production (IRREVERSIBLE)
  • Day 4-5: Final validation, performance checks, celebrate 🎉

11.3 Deployment Schedule

Deployment Date (Example) Rollback Window Monitoring Period
Phase 1 (Staging) 2026-02-20 24 hours 48 hours
Phase 1 (Production) 2026-02-22 1 week 1 week
Phase 2 (Staging) 2026-02-27 48 hours 72 hours
Phase 2 (Production) 2026-03-03 1 week 2 weeks
Phase 3 (Staging) 2026-03-10 1 week 1 week
Phase 3 (Production) 2026-03-17 2 weeks 2 weeks
Phase 4 (Staging) 2026-04-01 N/A (test only) 1 week
Phase 4 (Production) 2026-04-08 NONE Permanent

12. Appendices

12.1 Appendix A: Complete Prisma Schema Diff

(Too large for this document — see separate file: docs/claude-architecture-redesign/prisma-schema-diff.md)

12.2 Appendix B: Full Migration SQL Scripts

(Generated scripts stored in: prisma/migrations/20260220000000_phase1_schema_additions/, etc.)

12.3 Appendix C: Data Validation Queries

See sections 3.10, 4.9, and 6.8 for comprehensive validation queries.

12.4 Appendix D: Service Function Mapping

Old Function (stage-engine.ts) New Function (round-engine.ts)
validateTransition() validateAdvancement()
executeTransition() executeAdvancement()
executeBatchTransition() executeBatchAdvancement()
evaluateGuardCondition() evaluateRuleCondition()
evaluateGuard() evaluateAdvancementRule()
Old Function (stage-filtering.ts) New Function (round-filtering.ts)
runStageFiltering() runRoundFiltering()
resolveManualDecision() resolveManualDecision() (unchanged)
getManualQueue() getManualQueue() (unchanged)
evaluateFieldCondition() evaluateFieldCondition() (unchanged)

12.5 Appendix E: Stakeholder Communication Template

Subject: MOPC Platform Architecture Migration — Action Required

To: Program Admins, Jury Members, Development Team

From: Technical Lead

Date: 2026-02-15

Dear MOPC Community,

We are planning a major platform upgrade to improve competition management and add new features including:

Multi-round document submissions — Request additional docs from semi-finalists Explicit jury management — Named jury groups (Jury 1, Jury 2, Jury 3) Mentoring workspace — Private file exchange with comments Winner confirmation — Multi-party approval before announcing results

What's Changing:

  • Backend architecture (no visible UI changes during Phase 1-2)
  • New competition setup wizard (simplified, more intuitive)
  • Enhanced jury assignment with caps and quotas

When:

  • Phase 1-2: February 22 - March 3 (data migration, no downtime expected)
  • Phase 3: March 17 (new UI deployed with old UI still available)
  • Phase 4: April 8 (old system fully retired)

Action Required:

  • Admins: Attend training session on March 15 (new competition wizard)
  • All: Report any issues immediately to support@monaco-opc.com

Rollback Plan: We can revert changes until April 8. After that, the migration is permanent.

Questions? Reply to this email or join the Q&A session on March 10.

Best regards, MOPC Technical Team


13. Conclusion

This migration strategy provides a comprehensive, phased approach to transitioning from the Pipeline→Track→Stage architecture to the Competition→Round architecture. Key takeaways:

  1. Reversibility: Phases 1-3 are fully reversible. Phase 4 is irreversible and requires stakeholder approval.
  2. Zero Data Loss: All existing data is preserved throughout migration with validation at each step.
  3. Incremental Deployment: Each phase can be deployed independently with monitoring periods.
  4. Comprehensive Testing: Unit, integration, E2E, performance, and rollback tests ensure safety.
  5. Timeline: 5-6 weeks total, with Phase 4 only after 2+ weeks of stable Phase 3 operation.

Next Steps:

  1. Stakeholder approval of this plan
  2. Staging environment setup
  3. Execution of Phase 1 (schema additions)

Document History:

  • v1.0 (2026-02-15): Initial complete migration strategy

End of Document