# 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](#1-overview) 2. [Migration Principles](#2-migration-principles) 3. [Phase 1: Schema Additions (Non-Breaking)](#3-phase-1-schema-additions-non-breaking) 4. [Phase 2: Data Migration](#4-phase-2-data-migration) 5. [Phase 3: Code Migration](#5-phase-3-code-migration) 6. [Phase 4: Cleanup (Breaking)](#6-phase-4-cleanup-breaking) 7. [Rollback Plan](#7-rollback-plan) 8. [Complete Data Mapping Table](#8-complete-data-mapping-table) 9. [Risk Assessment](#9-risk-assessment) 10. [Testing Strategy](#10-testing-strategy) 11. [Timeline](#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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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) ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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; ``` ### 4.8 Migration 7: Link Projects to Competitions ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```bash # 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):** ```typescript 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):** ```typescript 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 `validateTransition` → `validateAdvancement` - Rename `fromStageId` → `fromRoundId` - Rename `toStageId` → `toRoundId` (nullable) - Rename `StageTransition` → `AdvancementRule` #### 5.2.3 round-filtering.ts Changes **Before:** ```typescript export async function runStageFiltering( prisma: PrismaClient, stageId: string, options: FilteringOptions ): Promise { const stage = await prisma.stage.findUnique({ where: { id: stageId } }); // ... } ``` **After:** ```typescript export async function runRoundFiltering( prisma: PrismaClient, roundId: string, options: FilteringOptions ): Promise { const round = await prisma.round.findUnique({ where: { id: roundId } }); // ... } ``` **Changes:** - Rename function `runStageFiltering` → `runRoundFiltering` - Rename parameter `stageId` → `roundId` - Rename query `prisma.stage` → `prisma.round` - All internal references to `stage` → `round` #### 5.2.4 round-assignment.ts Changes **Before:** ```typescript export async function previewStageAssignment( prisma: PrismaClient, stageId: string, options: AssignmentOptions ): Promise { 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:** ```typescript export async function previewRoundAssignment( prisma: PrismaClient, roundId: string, options: AssignmentOptions ): Promise { 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 `previewStageAssignment` → `previewRoundAssignment` - 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 ```bash # 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:** ```typescript 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:** ```typescript 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 `pipelineRouter` → `competitionRouter` - Replace `settingsJson` with typed fields - Update `ctx.prisma.pipeline` → `ctx.prisma.competition` #### 5.3.3 round.ts Router (replaces stage.ts) **Before:** ```typescript 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:** ```typescript 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 `stageRouter` → `roundRouter` - Replace `trackId` with `competitionId` - Update `stageType` enum to `roundType` enum (new values) - Add `juryGroupId` and `submissionWindowId` links - Update `ctx.prisma.stage` → `ctx.prisma.round` #### 5.3.4 New Routers ```typescript // 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: ```bash # 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 ```typescript // 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 ```bash # 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):** ```tsx export default function PipelinesPage() { const { data: pipelines } = trpc.pipeline.list.useQuery(); return (

Pipelines

{pipelines?.map(pipeline => ( {pipeline.name} ))}
); } ``` **After (src/app/(admin)/admin/rounds/competitions/page.tsx):** ```tsx export default function CompetitionsPage() { const { data: competitions } = trpc.competition.list.useQuery(); return (

Competitions

{competitions?.map(competition => ( {competition.name} ({competition.status}) ))}
); } ``` ### 5.7 Feature Flag Strategy Use environment variable to enable parallel execution: ```typescript // 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 ```bash # 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 ```bash # Revert all code changes git reset --hard # Or manual revert git revert # 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```sql -- ============================================================================= -- 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 ```bash # 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 ```sql -- ============================================================================= -- 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 ```bash # Git-based rollback git log --oneline # Find commit hash before Phase 3 git revert # Create revert commit npm run build # Rebuild with old code # Or hard reset (destructive) git reset --hard 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.** ```bash # 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 # 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.ts` → `tests/unit/round-engine.test.ts` - Update all `prisma.stage` → `prisma.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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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**