96 KiB
Migration Strategy: Pipeline→Track→Stage to Competition→Round
Document Version: 1.0 Date: 2026-02-15 Status: Complete Purpose: Comprehensive migration plan from current Pipeline/Track/Stage architecture to redesigned Competition/Round architecture with zero data loss and full rollback capability.
Table of Contents
- Overview
- Migration Principles
- Phase 1: Schema Additions (Non-Breaking)
- Phase 2: Data Migration
- Phase 3: Code Migration
- Phase 4: Cleanup (Breaking)
- Rollback Plan
- Complete Data Mapping Table
- Risk Assessment
- Testing Strategy
- Timeline
1. Overview
Why This Migration
The current Pipeline→Track→Stage model introduces unnecessary abstraction for a fundamentally linear competition flow. This migration:
- Eliminates the Track layer — Main flow is linear; awards become standalone entities
- Renames for domain clarity — Pipeline→Competition, Stage→Round
- Adds missing features — Multi-round submissions, mentoring workspace, winner confirmation, explicit jury groups
- Improves type safety — Replace generic
configJsonwith typed round-specific configs
Architecture Before & After
BEFORE:
Program
└── Pipeline (generic container)
├── Track: "Main Competition" (MAIN)
│ ├── Stage: "Intake" (INTAKE, configJson: {...})
│ ├── Stage: "Filtering" (FILTER)
│ └── Stage: "Evaluation" (EVALUATION)
└── Track: "Award 1" (AWARD)
└── Stage: "Evaluation" (EVALUATION)
AFTER:
Program
└── Competition (purpose-built)
├── Rounds (linear sequence):
│ ├── Round 1: "Application Window" (INTAKE)
│ ├── Round 2: "AI Screening" (FILTERING)
│ ├── Round 3: "Jury 1" (EVALUATION) → linked to JuryGroup 1
│ └── Round 4: "Semi-finalist Docs" (SUBMISSION) → linked to SubmissionWindow 2
├── Jury Groups (explicit):
│ └── "Jury 1" → members: [judge-a, judge-b]
├── Submission Windows:
│ ├── Window 1: "Round 1 Docs" → requirements: [Exec Summary]
│ └── Window 2: "Round 2 Docs" → requirements: [Updated Plan]
└── Special Awards (standalone):
└── "Innovation Award" → juryGroup, eligibilityMode
Four-Phase Approach
| Phase | Type | Description | Reversible? |
|---|---|---|---|
| Phase 1 | Schema Addition | Add new tables without touching existing ones | ✅ Yes (drop new tables) |
| Phase 2 | Data Migration | Copy data from old to new tables | ✅ Yes (delete new data) |
| Phase 3 | Code Migration | Update services/routers/UI to use new tables | ✅ Yes (revert commits) |
| Phase 4 | Cleanup | Drop old tables and enums | ❌ No (permanent) |
2. Migration Principles
Zero Data Loss
- All existing data must be preserved during migration
- No data deleted until Phase 4 (after full validation)
- Old tables remain read-only during Phase 2-3
Reversibility at Each Phase
- Phase 1: Can drop new tables without impact
- Phase 2: Can delete migrated data, old tables untouched
- Phase 3: Can revert code changes, both schemas functional
- Phase 4: Point of no return — full testing before this phase
Incremental Execution
- Each phase can be deployed independently
- Not all-or-nothing — can pause between phases
- Feature flags allow running old + new code in parallel
Tests Pass at Every Step
- Full test suite runs after each phase
- Integration tests cover both old and new data models
- Performance benchmarks ensure no regression
API Continuity During Transition
- Existing tRPC endpoints continue working
- New endpoints added alongside old ones
- Deprecation warnings before removal
3. Phase 1: Schema Additions (Non-Breaking)
3.1 Overview
Add all new tables and columns without modifying existing schema. This phase is completely non-breaking — existing code continues to work unchanged.
3.2 New Enums
-- =============================================================================
-- PHASE 1: NEW ENUMS
-- =============================================================================
-- Competition enum (replaces Pipeline status string)
CREATE TYPE "CompetitionStatus" AS ENUM (
'DRAFT',
'ACTIVE',
'CLOSED',
'ARCHIVED'
);
-- Round type (expands StageType)
CREATE TYPE "RoundType" AS ENUM (
'INTAKE',
'FILTERING', -- Renamed from FILTER
'EVALUATION',
'SUBMISSION', -- NEW: Multi-round document collection
'MENTORING', -- NEW: Mentor-team workspace activation
'LIVE_FINAL',
'CONFIRMATION' -- NEW: Winner confirmation, replaces RESULTS
);
-- Round status (replaces StageStatus)
CREATE TYPE "RoundStatus" AS ENUM (
'ROUND_DRAFT',
'ROUND_ACTIVE',
'ROUND_CLOSED',
'ROUND_ARCHIVED'
);
-- Project round state (replaces ProjectStageStateValue)
CREATE TYPE "ProjectRoundStateValue" AS ENUM (
'PENDING',
'IN_PROGRESS',
'PASSED',
'REJECTED',
'COMPLETED',
'WITHDRAWN'
);
-- Advancement rule types
CREATE TYPE "AdvancementRuleType" AS ENUM (
'AUTO_ADVANCE',
'SCORE_THRESHOLD',
'TOP_N',
'ADMIN_SELECTION',
'AI_RECOMMENDED'
);
-- Jury cap modes
CREATE TYPE "CapMode" AS ENUM (
'HARD', -- Absolute maximum
'SOFT', -- Target with buffer
'NONE' -- Unlimited
);
-- Deadline policies
CREATE TYPE "DeadlinePolicy" AS ENUM (
'HARD', -- Reject after close
'FLAG', -- Accept but mark late
'GRACE' -- Grace period then hard cutoff
);
-- Winner proposal statuses
CREATE TYPE "WinnerProposalStatus" AS ENUM (
'PENDING',
'APPROVED',
'REJECTED',
'OVERRIDDEN',
'FROZEN'
);
-- Winner approval roles
CREATE TYPE "WinnerApprovalRole" AS ENUM (
'JURY_MEMBER',
'ADMIN'
);
-- Award eligibility modes
CREATE TYPE "AwardEligibilityMode" AS ENUM (
'SEPARATE_POOL', -- Remove from main flow
'STAY_IN_MAIN' -- Keep in main, flag as eligible
);
3.3 Core Competition Tables
-- =============================================================================
-- PHASE 1: CORE COMPETITION STRUCTURE
-- =============================================================================
-- Competition (replaces Pipeline)
CREATE TABLE "Competition" (
"id" TEXT NOT NULL,
"programId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL UNIQUE,
"status" "CompetitionStatus" NOT NULL DEFAULT 'DRAFT',
-- Competition-wide settings (typed, not generic JSON)
"categoryMode" TEXT NOT NULL DEFAULT 'SHARED', -- 'SHARED' | 'SPLIT'
"startupFinalistCount" INTEGER NOT NULL DEFAULT 3,
"conceptFinalistCount" INTEGER NOT NULL DEFAULT 3,
-- Notification preferences
"notifyOnRoundAdvance" BOOLEAN NOT NULL DEFAULT true,
"notifyOnDeadlineApproach" BOOLEAN NOT NULL DEFAULT true,
"deadlineReminderDays" INTEGER[] NOT NULL DEFAULT ARRAY[7, 3, 1],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Competition_pkey" PRIMARY KEY ("id"),
CONSTRAINT "Competition_programId_fkey"
FOREIGN KEY ("programId") REFERENCES "Program"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "Competition_programId_idx" ON "Competition"("programId");
CREATE INDEX "Competition_status_idx" ON "Competition"("status");
-- Round (replaces Stage)
CREATE TABLE "Round" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"roundType" "RoundType" NOT NULL,
"status" "RoundStatus" NOT NULL DEFAULT 'ROUND_DRAFT',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
-- Time windows
"windowOpenAt" TIMESTAMP(3),
"windowCloseAt" TIMESTAMP(3),
-- Round-type-specific configuration (validated by Zod per RoundType)
"configJson" JSONB,
-- Links to other entities
"juryGroupId" TEXT, -- Which jury evaluates this round
"submissionWindowId" TEXT, -- Which submission window this collects docs for
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Round_pkey" PRIMARY KEY ("id"),
CONSTRAINT "Round_competitionId_fkey"
FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Round_competitionId_slug_key" UNIQUE ("competitionId", "slug"),
CONSTRAINT "Round_competitionId_sortOrder_key" UNIQUE ("competitionId", "sortOrder")
);
CREATE INDEX "Round_competitionId_idx" ON "Round"("competitionId");
CREATE INDEX "Round_roundType_idx" ON "Round"("roundType");
CREATE INDEX "Round_status_idx" ON "Round"("status");
-- ProjectRoundState (replaces ProjectStageState, drops trackId)
CREATE TABLE "ProjectRoundState" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"state" "ProjectRoundStateValue" NOT NULL DEFAULT 'PENDING',
"enteredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"exitedAt" TIMESTAMP(3),
"metadataJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectRoundState_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ProjectRoundState_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectRoundState_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectRoundState_projectId_roundId_key" UNIQUE ("projectId", "roundId")
);
CREATE INDEX "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
CREATE INDEX "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
CREATE INDEX "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");
-- AdvancementRule (replaces StageTransition + guardJson)
CREATE TABLE "AdvancementRule" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"targetRoundId" TEXT, -- null = next round by sortOrder
"ruleType" "AdvancementRuleType" NOT NULL,
"configJson" JSONB NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdvancementRule_pkey" PRIMARY KEY ("id"),
CONSTRAINT "AdvancementRule_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");
3.4 Jury System Tables
-- =============================================================================
-- PHASE 1: JURY SYSTEM
-- =============================================================================
-- JuryGroup (new first-class entity)
CREATE TABLE "JuryGroup" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
-- Default assignment configuration
"defaultMaxAssignments" INTEGER NOT NULL DEFAULT 20,
"defaultCapMode" "CapMode" NOT NULL DEFAULT 'SOFT',
"softCapBuffer" INTEGER NOT NULL DEFAULT 2,
-- Category quotas
"categoryQuotasEnabled" BOOLEAN NOT NULL DEFAULT false,
"defaultCategoryQuotas" JSONB,
-- Onboarding settings
"allowJurorCapAdjustment" BOOLEAN NOT NULL DEFAULT false,
"allowJurorRatioAdjustment" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "JuryGroup_pkey" PRIMARY KEY ("id"),
CONSTRAINT "JuryGroup_competitionId_fkey"
FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "JuryGroup_competitionId_slug_key" UNIQUE ("competitionId", "slug")
);
CREATE INDEX "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");
-- JuryGroupMember
CREATE TABLE "JuryGroupMember" (
"id" TEXT NOT NULL,
"juryGroupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"isLead" BOOLEAN NOT NULL DEFAULT false,
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Per-juror overrides
"maxAssignmentsOverride" INTEGER,
"capModeOverride" "CapMode",
"categoryQuotasOverride" JSONB,
-- Juror preferences
"preferredStartupRatio" DOUBLE PRECISION,
"availabilityNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "JuryGroupMember_pkey" PRIMARY KEY ("id"),
CONSTRAINT "JuryGroupMember_juryGroupId_fkey"
FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "JuryGroupMember_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "JuryGroupMember_juryGroupId_userId_key" UNIQUE ("juryGroupId", "userId")
);
CREATE INDEX "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
CREATE INDEX "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");
3.5 Multi-Round Submission Tables
-- =============================================================================
-- PHASE 1: MULTI-ROUND SUBMISSION SYSTEM
-- =============================================================================
-- SubmissionWindow
CREATE TABLE "SubmissionWindow" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"roundNumber" INTEGER NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
-- Window timing
"windowOpenAt" TIMESTAMP(3),
"windowCloseAt" TIMESTAMP(3),
-- Deadline behavior
"deadlinePolicy" "DeadlinePolicy" NOT NULL DEFAULT 'FLAG',
"graceHours" INTEGER,
-- Locking
"lockOnClose" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SubmissionWindow_pkey" PRIMARY KEY ("id"),
CONSTRAINT "SubmissionWindow_competitionId_fkey"
FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SubmissionWindow_competitionId_slug_key" UNIQUE ("competitionId", "slug"),
CONSTRAINT "SubmissionWindow_competitionId_roundNumber_key" UNIQUE ("competitionId", "roundNumber")
);
CREATE INDEX "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");
-- SubmissionFileRequirement (replaces FileRequirement)
CREATE TABLE "SubmissionFileRequirement" (
"id" TEXT NOT NULL,
"submissionWindowId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"acceptedMimeTypes" TEXT[] NOT NULL,
"maxSizeMB" INTEGER,
"isRequired" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SubmissionFileRequirement_pkey" PRIMARY KEY ("id"),
CONSTRAINT "SubmissionFileRequirement_submissionWindowId_fkey"
FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "SubmissionFileRequirement_submissionWindowId_idx"
ON "SubmissionFileRequirement"("submissionWindowId");
-- RoundSubmissionVisibility (controls which docs jury sees)
CREATE TABLE "RoundSubmissionVisibility" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"submissionWindowId" TEXT NOT NULL,
"canView" BOOLEAN NOT NULL DEFAULT true,
"displayLabel" TEXT,
CONSTRAINT "RoundSubmissionVisibility_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RoundSubmissionVisibility_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RoundSubmissionVisibility_submissionWindowId_fkey"
FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RoundSubmissionVisibility_roundId_submissionWindowId_key"
UNIQUE ("roundId", "submissionWindowId")
);
CREATE INDEX "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");
3.6 Mentoring Workspace Tables
-- =============================================================================
-- PHASE 1: MENTORING WORKSPACE
-- =============================================================================
-- MentorFile (new workspace file storage)
CREATE TABLE "MentorFile" (
"id" TEXT NOT NULL,
"mentorAssignmentId" TEXT NOT NULL,
"uploadedByUserId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"bucket" TEXT NOT NULL,
"objectKey" TEXT NOT NULL,
"description" TEXT,
-- Promotion to official submission
"isPromoted" BOOLEAN NOT NULL DEFAULT false,
"promotedToFileId" TEXT UNIQUE,
"promotedAt" TIMESTAMP(3),
"promotedByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MentorFile_pkey" PRIMARY KEY ("id"),
CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "MentorFile_uploadedByUserId_fkey"
FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id")
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "MentorFile_promotedByUserId_fkey"
FOREIGN KEY ("promotedByUserId") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
CREATE INDEX "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");
-- MentorFileComment (threaded comments on mentor files)
CREATE TABLE "MentorFileComment" (
"id" TEXT NOT NULL,
"mentorFileId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"parentCommentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MentorFileComment_pkey" PRIMARY KEY ("id"),
CONSTRAINT "MentorFileComment_mentorFileId_fkey"
FOREIGN KEY ("mentorFileId") REFERENCES "MentorFile"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "MentorFileComment_authorId_fkey"
FOREIGN KEY ("authorId") REFERENCES "User"("id")
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "MentorFileComment_parentCommentId_fkey"
FOREIGN KEY ("parentCommentId") REFERENCES "MentorFileComment"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
CREATE INDEX "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
CREATE INDEX "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");
3.7 Winner Confirmation Tables
-- =============================================================================
-- PHASE 1: WINNER CONFIRMATION SYSTEM
-- =============================================================================
-- WinnerProposal
CREATE TABLE "WinnerProposal" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"category" "CompetitionCategory" NOT NULL,
"status" "WinnerProposalStatus" NOT NULL DEFAULT 'PENDING',
-- Proposed rankings
"rankedProjectIds" TEXT[] NOT NULL,
-- Selection basis
"sourceRoundId" TEXT NOT NULL,
"selectionBasis" JSONB NOT NULL,
-- Proposer
"proposedById" TEXT NOT NULL,
"proposedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Finalization
"frozenAt" TIMESTAMP(3),
"frozenById" TEXT,
-- Admin override
"overrideUsed" BOOLEAN NOT NULL DEFAULT false,
"overrideMode" TEXT,
"overrideReason" TEXT,
"overrideById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WinnerProposal_pkey" PRIMARY KEY ("id"),
CONSTRAINT "WinnerProposal_competitionId_fkey"
FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WinnerProposal_sourceRoundId_fkey"
FOREIGN KEY ("sourceRoundId") REFERENCES "Round"("id")
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WinnerProposal_proposedById_fkey"
FOREIGN KEY ("proposedById") REFERENCES "User"("id")
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WinnerProposal_frozenById_fkey"
FOREIGN KEY ("frozenById") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "WinnerProposal_overrideById_fkey"
FOREIGN KEY ("overrideById") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "WinnerProposal_competitionId_idx" ON "WinnerProposal"("competitionId");
CREATE INDEX "WinnerProposal_status_idx" ON "WinnerProposal"("status");
-- WinnerApproval
CREATE TABLE "WinnerApproval" (
"id" TEXT NOT NULL,
"winnerProposalId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "WinnerApprovalRole" NOT NULL,
-- Response
"approved" BOOLEAN,
"comments" TEXT,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WinnerApproval_pkey" PRIMARY KEY ("id"),
CONSTRAINT "WinnerApproval_winnerProposalId_fkey"
FOREIGN KEY ("winnerProposalId") REFERENCES "WinnerProposal"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WinnerApproval_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id")
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WinnerApproval_winnerProposalId_userId_key" UNIQUE ("winnerProposalId", "userId")
);
CREATE INDEX "WinnerApproval_winnerProposalId_idx" ON "WinnerApproval"("winnerProposalId");
CREATE INDEX "WinnerApproval_userId_idx" ON "WinnerApproval"("userId");
3.8 Modified Existing Tables
-- =============================================================================
-- PHASE 1: MODIFICATIONS TO EXISTING TABLES
-- =============================================================================
-- Add competitionId to Project (nullable for now)
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "competitionId" TEXT;
ALTER TABLE "Project" ADD CONSTRAINT "Project_competitionId_fkey"
FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "Project_competitionId_idx" ON "Project"("competitionId");
-- Add submissionWindowId to ProjectFile
ALTER TABLE "ProjectFile" ADD COLUMN IF NOT EXISTS "submissionWindowId" TEXT;
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionWindowId_fkey"
FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");
-- Add juryGroupId to Assignment
ALTER TABLE "Assignment" ADD COLUMN IF NOT EXISTS "juryGroupId" TEXT;
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_juryGroupId_fkey"
FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
-- Add workspace fields to MentorAssignment
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "workspaceEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "workspaceOpenAt" TIMESTAMP(3);
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "workspaceCloseAt" TIMESTAMP(3);
-- Enhance SpecialAward (link to Competition + jury groups)
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "competitionId" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityMode" "AwardEligibilityMode" DEFAULT 'STAY_IN_MAIN';
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "evaluationRoundId" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "juryGroupId" TEXT;
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "decisionMode" TEXT DEFAULT 'JURY_VOTE';
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_competitionId_fkey"
FOREIGN KEY ("competitionId") REFERENCES "Competition"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_evaluationRoundId_fkey"
FOREIGN KEY ("evaluationRoundId") REFERENCES "Round"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_juryGroupId_fkey"
FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
CREATE INDEX IF NOT EXISTS "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");
3.9 Foreign Key Constraint for Round Relations
-- =============================================================================
-- PHASE 1: ROUND FOREIGN KEY CONSTRAINTS (deferred until Round exists)
-- =============================================================================
ALTER TABLE "Round" ADD CONSTRAINT "Round_juryGroupId_fkey"
FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Round" ADD CONSTRAINT "Round_submissionWindowId_fkey"
FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
3.10 Phase 1 Validation Queries
-- =============================================================================
-- PHASE 1: VALIDATION QUERIES
-- =============================================================================
-- Verify all new tables created
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN (
'Competition', 'Round', 'ProjectRoundState', 'AdvancementRule',
'JuryGroup', 'JuryGroupMember', 'SubmissionWindow',
'SubmissionFileRequirement', 'RoundSubmissionVisibility',
'MentorFile', 'MentorFileComment', 'WinnerProposal', 'WinnerApproval'
)
ORDER BY table_name;
-- Verify all new enums created
SELECT enumtypid::regtype AS enum_type, enumlabel
FROM pg_enum
WHERE enumtypid::regtype::text IN (
'CompetitionStatus', 'RoundType', 'RoundStatus', 'ProjectRoundStateValue',
'AdvancementRuleType', 'CapMode', 'DeadlinePolicy', 'WinnerProposalStatus',
'WinnerApprovalRole', 'AwardEligibilityMode'
)
ORDER BY enum_type, enumsortorder;
-- Verify new columns added
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Project' AND column_name = 'competitionId'
UNION ALL
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'ProjectFile' AND column_name = 'submissionWindowId'
UNION ALL
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Assignment' AND column_name = 'juryGroupId'
UNION ALL
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'MentorAssignment' AND column_name = 'workspaceEnabled';
-- Verify no data in new tables (should all return 0)
SELECT 'Competition' AS table_name, COUNT(*) FROM "Competition"
UNION ALL
SELECT 'Round', COUNT(*) FROM "Round"
UNION ALL
SELECT 'ProjectRoundState', COUNT(*) FROM "ProjectRoundState"
UNION ALL
SELECT 'JuryGroup', COUNT(*) FROM "JuryGroup"
UNION ALL
SELECT 'SubmissionWindow', COUNT(*) FROM "SubmissionWindow";
3.11 Phase 1 Rollback Script
-- =============================================================================
-- PHASE 1 ROLLBACK: Drop all new tables and columns
-- =============================================================================
-- Drop new tables (in dependency order)
DROP TABLE IF EXISTS "WinnerApproval" CASCADE;
DROP TABLE IF EXISTS "WinnerProposal" CASCADE;
DROP TABLE IF EXISTS "MentorFileComment" CASCADE;
DROP TABLE IF EXISTS "MentorFile" CASCADE;
DROP TABLE IF EXISTS "RoundSubmissionVisibility" CASCADE;
DROP TABLE IF EXISTS "SubmissionFileRequirement" CASCADE;
DROP TABLE IF EXISTS "SubmissionWindow" CASCADE;
DROP TABLE IF EXISTS "JuryGroupMember" CASCADE;
DROP TABLE IF EXISTS "JuryGroup" CASCADE;
DROP TABLE IF EXISTS "AdvancementRule" CASCADE;
DROP TABLE IF EXISTS "ProjectRoundState" CASCADE;
DROP TABLE IF EXISTS "Round" CASCADE;
DROP TABLE IF EXISTS "Competition" CASCADE;
-- Remove new columns from existing tables
ALTER TABLE "Project" DROP COLUMN IF EXISTS "competitionId";
ALTER TABLE "ProjectFile" DROP COLUMN IF EXISTS "submissionWindowId";
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "juryGroupId";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "workspaceEnabled";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "workspaceOpenAt";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "workspaceCloseAt";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "competitionId";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "eligibilityMode";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "evaluationRoundId";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "juryGroupId";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "decisionMode";
-- Drop new enums
DROP TYPE IF EXISTS "AwardEligibilityMode" CASCADE;
DROP TYPE IF EXISTS "WinnerApprovalRole" CASCADE;
DROP TYPE IF EXISTS "WinnerProposalStatus" CASCADE;
DROP TYPE IF EXISTS "DeadlinePolicy" CASCADE;
DROP TYPE IF EXISTS "CapMode" CASCADE;
DROP TYPE IF EXISTS "AdvancementRuleType" CASCADE;
DROP TYPE IF EXISTS "ProjectRoundStateValue" CASCADE;
DROP TYPE IF EXISTS "RoundStatus" CASCADE;
DROP TYPE IF EXISTS "RoundType" CASCADE;
DROP TYPE IF EXISTS "CompetitionStatus" CASCADE;
4. Phase 2: Data Migration
4.1 Overview
Copy data from old Pipeline/Track/Stage tables to new Competition/Round tables. Old tables remain unchanged as backup. All migrations are wrapped in transactions with validation checks.
4.2 Migration 1: Pipeline → Competition
-- =============================================================================
-- PHASE 2.1: MIGRATE PIPELINE → COMPETITION
-- =============================================================================
BEGIN;
-- Create Competition for each Pipeline
INSERT INTO "Competition" (
"id",
"programId",
"name",
"slug",
"status",
"categoryMode",
"startupFinalistCount",
"conceptFinalistCount",
"notifyOnRoundAdvance",
"notifyOnDeadlineApproach",
"deadlineReminderDays",
"createdAt",
"updatedAt"
)
SELECT
p.id, -- Keep same ID
p."programId",
p.name,
p.slug,
-- Map status string to enum
CASE
WHEN p.status = 'DRAFT' THEN 'DRAFT'::text::"CompetitionStatus"
WHEN p.status = 'ACTIVE' THEN 'ACTIVE'::text::"CompetitionStatus"
WHEN p.status = 'ARCHIVED' THEN 'ARCHIVED'::text::"CompetitionStatus"
ELSE 'DRAFT'::text::"CompetitionStatus"
END,
-- Extract from settingsJson or use defaults
COALESCE((p."settingsJson"->>'categoryMode')::text, 'SHARED'),
COALESCE((p."settingsJson"->>'startupFinalistCount')::int, 3),
COALESCE((p."settingsJson"->>'conceptFinalistCount')::int, 3),
COALESCE((p."settingsJson"->>'notifyOnRoundAdvance')::boolean, true),
COALESCE((p."settingsJson"->>'notifyOnDeadlineApproach')::boolean, true),
COALESCE(
(p."settingsJson"->'deadlineReminderDays')::int[],
ARRAY[7, 3, 1]
),
p."createdAt",
p."updatedAt"
FROM "Pipeline" p;
-- Validation: Count should match
DO $$
DECLARE
pipeline_count INT;
competition_count INT;
BEGIN
SELECT COUNT(*) INTO pipeline_count FROM "Pipeline";
SELECT COUNT(*) INTO competition_count FROM "Competition";
IF pipeline_count != competition_count THEN
RAISE EXCEPTION 'Pipeline count (%) != Competition count (%)',
pipeline_count, competition_count;
END IF;
RAISE NOTICE 'Successfully migrated % pipelines to competitions', competition_count;
END $$;
COMMIT;
4.3 Migration 2: Track Stages → Rounds (Linear)
-- =============================================================================
-- PHASE 2.2: MIGRATE TRACK STAGES → ROUNDS (LINEAR)
-- =============================================================================
BEGIN;
-- For each MAIN track, create Rounds from its Stages
-- (drop Track layer, promote Stages to top level)
INSERT INTO "Round" (
"id",
"competitionId",
"name",
"slug",
"roundType",
"status",
"sortOrder",
"windowOpenAt",
"windowCloseAt",
"configJson",
"createdAt",
"updatedAt"
)
SELECT
s.id, -- Keep Stage ID as Round ID
t."pipelineId", -- Track's Pipeline becomes Round's Competition
s.name,
s.slug,
-- Map StageType to RoundType
CASE s."stageType"
WHEN 'INTAKE' THEN 'INTAKE'::text::"RoundType"
WHEN 'FILTER' THEN 'FILTERING'::text::"RoundType" -- Renamed
WHEN 'EVALUATION' THEN 'EVALUATION'::text::"RoundType"
WHEN 'SELECTION' THEN 'EVALUATION'::text::"RoundType" -- Merged into EVALUATION
WHEN 'LIVE_FINAL' THEN 'LIVE_FINAL'::text::"RoundType"
WHEN 'RESULTS' THEN 'CONFIRMATION'::text::"RoundType" -- Renamed
END,
-- Map StageStatus to RoundStatus
CASE s.status
WHEN 'STAGE_DRAFT' THEN 'ROUND_DRAFT'::text::"RoundStatus"
WHEN 'STAGE_ACTIVE' THEN 'ROUND_ACTIVE'::text::"RoundStatus"
WHEN 'STAGE_CLOSED' THEN 'ROUND_CLOSED'::text::"RoundStatus"
WHEN 'STAGE_ARCHIVED' THEN 'ROUND_ARCHIVED'::text::"RoundStatus"
END,
s."sortOrder",
s."windowOpenAt",
s."windowCloseAt",
s."configJson",
s."createdAt",
s."updatedAt"
FROM "Stage" s
JOIN "Track" t ON s."trackId" = t.id
WHERE t.kind = 'MAIN' -- Only migrate MAIN track stages
ORDER BY t."pipelineId", s."sortOrder";
-- Validation: Count MAIN track stages vs Rounds
DO $$
DECLARE
stage_count INT;
round_count INT;
BEGIN
SELECT COUNT(*) INTO stage_count
FROM "Stage" s
JOIN "Track" t ON s."trackId" = t.id
WHERE t.kind = 'MAIN';
SELECT COUNT(*) INTO round_count FROM "Round";
IF stage_count != round_count THEN
RAISE EXCEPTION 'MAIN stage count (%) != Round count (%)',
stage_count, round_count;
END IF;
RAISE NOTICE 'Successfully migrated % stages to rounds', round_count;
END $$;
COMMIT;
4.4 Migration 3: ProjectStageState → ProjectRoundState
-- =============================================================================
-- PHASE 2.3: MIGRATE PROJECTSTAGESTATE → PROJECTROUNDSTATE
-- =============================================================================
BEGIN;
-- Copy all ProjectStageState records, dropping trackId
INSERT INTO "ProjectRoundState" (
"id",
"projectId",
"roundId",
"state",
"enteredAt",
"exitedAt",
"metadataJson",
"createdAt",
"updatedAt"
)
SELECT
pss.id,
pss."projectId",
pss."stageId", -- stageId becomes roundId (same ID preserved)
-- Map ProjectStageStateValue (preserve existing enum values)
pss.state,
pss."enteredAt",
pss."exitedAt",
pss."metadataJson",
pss."createdAt",
pss."updatedAt"
FROM "ProjectStageState" pss
JOIN "Stage" s ON pss."stageId" = s.id
JOIN "Track" t ON s."trackId" = t.id
WHERE t.kind = 'MAIN'; -- Only migrate MAIN track states
-- Validation: Count should match MAIN track PSS
DO $$
DECLARE
pss_count INT;
prs_count INT;
BEGIN
SELECT COUNT(*) INTO pss_count
FROM "ProjectStageState" pss
JOIN "Stage" s ON pss."stageId" = s.id
JOIN "Track" t ON s."trackId" = t.id
WHERE t.kind = 'MAIN';
SELECT COUNT(*) INTO prs_count FROM "ProjectRoundState";
IF pss_count != prs_count THEN
RAISE EXCEPTION 'PSS count (%) != PRS count (%)', pss_count, prs_count;
END IF;
RAISE NOTICE 'Successfully migrated % project state records', prs_count;
END $$;
COMMIT;
4.5 Migration 4: AWARD Tracks → SpecialAward Enhancement
-- =============================================================================
-- PHASE 2.4: MIGRATE AWARD TRACKS → SPECIALAWARD ENHANCEMENT
-- =============================================================================
BEGIN;
-- Update existing SpecialAward records linked to AWARD tracks
UPDATE "SpecialAward" sa
SET
"competitionId" = t."pipelineId",
"eligibilityMode" = 'STAY_IN_MAIN'::text::"AwardEligibilityMode", -- Default assumption
"decisionMode" = COALESCE(t."decisionMode"::text, 'JURY_VOTE')
FROM "Track" t
WHERE sa."trackId" = t.id
AND t.kind = 'AWARD';
-- For awards without a track, link to program's competition
UPDATE "SpecialAward" sa
SET "competitionId" = (
SELECT c.id
FROM "Competition" c
WHERE c."programId" = sa."programId"
LIMIT 1
)
WHERE sa."trackId" IS NULL
AND sa."competitionId" IS NULL;
-- Validation: All awards should now have competitionId
DO $$
DECLARE
unlinked_count INT;
BEGIN
SELECT COUNT(*) INTO unlinked_count
FROM "SpecialAward"
WHERE "competitionId" IS NULL;
IF unlinked_count > 0 THEN
RAISE WARNING '% awards still have no competitionId', unlinked_count;
ELSE
RAISE NOTICE 'All awards successfully linked to competitions';
END IF;
END $$;
COMMIT;
4.6 Migration 5: FileRequirement → SubmissionWindow + SubmissionFileRequirement
-- =============================================================================
-- PHASE 2.5: MIGRATE FILEREQUIREMENT → SUBMISSIONWINDOW + REQUIREMENTS
-- =============================================================================
BEGIN;
-- For each INTAKE stage, create a SubmissionWindow
INSERT INTO "SubmissionWindow" (
"id",
"competitionId",
"name",
"slug",
"roundNumber",
"sortOrder",
"windowOpenAt",
"windowCloseAt",
"deadlinePolicy",
"lockOnClose",
"createdAt",
"updatedAt"
)
SELECT
gen_random_uuid()::text,
r."competitionId",
r.name || ' Documents',
r.slug || '-docs',
ROW_NUMBER() OVER (PARTITION BY r."competitionId" ORDER BY r."sortOrder"),
r."sortOrder",
r."windowOpenAt",
r."windowCloseAt",
'FLAG'::text::"DeadlinePolicy", -- Default
true,
r."createdAt",
r."updatedAt"
FROM "Round" r
WHERE r."roundType" = 'INTAKE';
-- Link rounds to their submission windows
UPDATE "Round" r
SET "submissionWindowId" = sw.id
FROM "SubmissionWindow" sw
WHERE r."roundType" = 'INTAKE'
AND sw."competitionId" = r."competitionId"
AND sw.slug = r.slug || '-docs';
-- Migrate FileRequirements to SubmissionFileRequirements
INSERT INTO "SubmissionFileRequirement" (
"id",
"submissionWindowId",
"name",
"description",
"acceptedMimeTypes",
"maxSizeMB",
"isRequired",
"sortOrder",
"createdAt",
"updatedAt"
)
SELECT
fr.id,
sw.id,
fr.name,
fr.description,
fr."acceptedMimeTypes",
fr."maxSizeMB",
fr."isRequired",
fr."sortOrder",
fr."createdAt",
fr."updatedAt"
FROM "FileRequirement" fr
JOIN "Stage" s ON fr."stageId" = s.id
JOIN "Round" r ON s.id = r.id -- Stage ID preserved as Round ID
JOIN "SubmissionWindow" sw ON r."submissionWindowId" = sw.id;
-- Link ProjectFiles to SubmissionWindows
UPDATE "ProjectFile" pf
SET "submissionWindowId" = r."submissionWindowId"
FROM "FileRequirement" fr
JOIN "Stage" s ON fr."stageId" = s.id
JOIN "Round" r ON s.id = r.id
WHERE pf."requirementId" = fr.id
AND r."submissionWindowId" IS NOT NULL;
-- Validation
DO $$
DECLARE
fr_count INT;
sfr_count INT;
sw_count INT;
intake_count INT;
BEGIN
SELECT COUNT(*) INTO fr_count FROM "FileRequirement";
SELECT COUNT(*) INTO sfr_count FROM "SubmissionFileRequirement";
SELECT COUNT(*) INTO sw_count FROM "SubmissionWindow";
SELECT COUNT(*) INTO intake_count FROM "Round" WHERE "roundType" = 'INTAKE';
RAISE NOTICE 'SubmissionWindows created: % (one per INTAKE round: %)', sw_count, intake_count;
RAISE NOTICE 'FileRequirements migrated: % → %', fr_count, sfr_count;
END $$;
COMMIT;
4.7 Migration 6: Create Default JuryGroups from Assignments
-- =============================================================================
-- PHASE 2.6: CREATE DEFAULT JURYGROUPS FROM EXISTING ASSIGNMENTS
-- =============================================================================
BEGIN;
-- For each EVALUATION round, create a default JuryGroup
INSERT INTO "JuryGroup" (
"id",
"competitionId",
"name",
"slug",
"description",
"sortOrder",
"defaultMaxAssignments",
"defaultCapMode",
"softCapBuffer",
"categoryQuotasEnabled",
"createdAt",
"updatedAt"
)
SELECT
gen_random_uuid()::text,
r."competitionId",
'Jury for ' || r.name,
'jury-' || r.slug,
'Automatically migrated from existing assignments for ' || r.name,
r."sortOrder",
20, -- Default
'SOFT'::text::"CapMode",
2,
false,
r."createdAt",
NOW()
FROM "Round" r
WHERE r."roundType" IN ('EVALUATION', 'LIVE_FINAL');
-- Link rounds to their jury groups
UPDATE "Round" r
SET "juryGroupId" = jg.id
FROM "JuryGroup" jg
WHERE r."roundType" IN ('EVALUATION', 'LIVE_FINAL')
AND jg.slug = 'jury-' || r.slug;
-- Populate JuryGroupMembers from existing Assignments
INSERT INTO "JuryGroupMember" (
"id",
"juryGroupId",
"userId",
"isLead",
"joinedAt",
"createdAt",
"updatedAt"
)
SELECT DISTINCT
gen_random_uuid()::text,
r."juryGroupId",
a."userId",
false, -- No lead designation in old system
MIN(a."createdAt"),
MIN(a."createdAt"),
NOW()
FROM "Assignment" a
JOIN "Stage" s ON a."stageId" = s.id
JOIN "Round" r ON s.id = r.id
WHERE r."juryGroupId" IS NOT NULL
GROUP BY r."juryGroupId", a."userId";
-- Link existing assignments to their jury groups
UPDATE "Assignment" a
SET "juryGroupId" = r."juryGroupId"
FROM "Stage" s
JOIN "Round" r ON s.id = r.id
WHERE a."stageId" = s.id
AND r."juryGroupId" IS NOT NULL;
-- Validation
DO $$
DECLARE
jg_count INT;
jgm_count INT;
eval_round_count INT;
BEGIN
SELECT COUNT(*) INTO jg_count FROM "JuryGroup";
SELECT COUNT(*) INTO jgm_count FROM "JuryGroupMember";
SELECT COUNT(*) INTO eval_round_count
FROM "Round" WHERE "roundType" IN ('EVALUATION', 'LIVE_FINAL');
RAISE NOTICE 'JuryGroups created: % (one per EVALUATION/LIVE_FINAL round: %)',
jg_count, eval_round_count;
RAISE NOTICE 'JuryGroupMembers created: %', jgm_count;
END $$;
COMMIT;
4.8 Migration 7: Link Projects to Competitions
-- =============================================================================
-- PHASE 2.7: LINK PROJECTS TO COMPETITIONS
-- =============================================================================
BEGIN;
-- Link each project to its competition via ProjectRoundState
UPDATE "Project" p
SET "competitionId" = (
SELECT DISTINCT r."competitionId"
FROM "ProjectRoundState" prs
JOIN "Round" r ON prs."roundId" = r.id
WHERE prs."projectId" = p.id
LIMIT 1
)
WHERE "competitionId" IS NULL;
-- Validation
DO $$
DECLARE
linked_count INT;
total_count INT;
BEGIN
SELECT COUNT(*) INTO total_count FROM "Project";
SELECT COUNT(*) INTO linked_count FROM "Project" WHERE "competitionId" IS NOT NULL;
RAISE NOTICE 'Projects linked to competitions: % / %', linked_count, total_count;
IF linked_count < total_count THEN
RAISE WARNING '% projects have no competition link', total_count - linked_count;
END IF;
END $$;
COMMIT;
4.9 Phase 2 Validation Report
-- =============================================================================
-- PHASE 2: COMPREHENSIVE VALIDATION REPORT
-- =============================================================================
-- Summary report
SELECT
'Pipelines → Competitions' AS migration,
(SELECT COUNT(*) FROM "Pipeline") AS source_count,
(SELECT COUNT(*) FROM "Competition") AS target_count,
(SELECT COUNT(*) FROM "Pipeline") = (SELECT COUNT(*) FROM "Competition") AS match;
SELECT
'MAIN Track Stages → Rounds' AS migration,
(SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') AS source_count,
(SELECT COUNT(*) FROM "Round") AS target_count,
(SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') = (SELECT COUNT(*) FROM "Round") AS match;
SELECT
'ProjectStageStates → ProjectRoundStates' AS migration,
(SELECT COUNT(*) FROM "ProjectStageState" pss JOIN "Stage" s ON pss."stageId" = s.id JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') AS source_count,
(SELECT COUNT(*) FROM "ProjectRoundState") AS target_count,
(SELECT COUNT(*) FROM "ProjectStageState" pss JOIN "Stage" s ON pss."stageId" = s.id JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') = (SELECT COUNT(*) FROM "ProjectRoundState") AS match;
SELECT
'FileRequirements → SubmissionFileRequirements' AS migration,
(SELECT COUNT(*) FROM "FileRequirement") AS source_count,
(SELECT COUNT(*) FROM "SubmissionFileRequirement") AS target_count,
(SELECT COUNT(*) FROM "FileRequirement") = (SELECT COUNT(*) FROM "SubmissionFileRequirement") AS match;
-- Orphan checks
SELECT 'Orphaned Rounds (no competition)' AS check, COUNT(*) AS count
FROM "Round" WHERE "competitionId" NOT IN (SELECT id FROM "Competition")
UNION ALL
SELECT 'Orphaned ProjectRoundStates (no round)', COUNT(*)
FROM "ProjectRoundState" WHERE "roundId" NOT IN (SELECT id FROM "Round")
UNION ALL
SELECT 'Orphaned ProjectRoundStates (no project)', COUNT(*)
FROM "ProjectRoundState" WHERE "projectId" NOT IN (SELECT id FROM "Project")
UNION ALL
SELECT 'Projects with no competition link', COUNT(*)
FROM "Project" WHERE "competitionId" IS NULL;
-- Data integrity checks
SELECT
'Round types distribution' AS check,
"roundType",
COUNT(*) AS count
FROM "Round"
GROUP BY "roundType"
ORDER BY "roundType";
SELECT
'ProjectRoundState distribution' AS check,
state,
COUNT(*) AS count
FROM "ProjectRoundState"
GROUP BY state
ORDER BY state;
4.10 Phase 2 Rollback Script
-- =============================================================================
-- PHASE 2 ROLLBACK: DELETE ALL MIGRATED DATA
-- =============================================================================
BEGIN;
-- Delete in dependency order
DELETE FROM "WinnerApproval";
DELETE FROM "WinnerProposal";
DELETE FROM "MentorFileComment";
DELETE FROM "MentorFile";
DELETE FROM "RoundSubmissionVisibility";
DELETE FROM "SubmissionFileRequirement";
DELETE FROM "SubmissionWindow";
DELETE FROM "JuryGroupMember";
DELETE FROM "JuryGroup";
DELETE FROM "AdvancementRule";
DELETE FROM "ProjectRoundState";
DELETE FROM "Round";
DELETE FROM "Competition";
-- Reset new columns to NULL
UPDATE "Project" SET "competitionId" = NULL;
UPDATE "ProjectFile" SET "submissionWindowId" = NULL;
UPDATE "Assignment" SET "juryGroupId" = NULL;
UPDATE "MentorAssignment"
SET "workspaceEnabled" = false,
"workspaceOpenAt" = NULL,
"workspaceCloseAt" = NULL;
UPDATE "SpecialAward"
SET "competitionId" = NULL,
"eligibilityMode" = NULL,
"evaluationRoundId" = NULL,
"juryGroupId" = NULL,
"decisionMode" = 'JURY_VOTE';
-- Validation: Verify all new tables empty
DO $$
DECLARE
total_count INT;
BEGIN
SELECT
(SELECT COUNT(*) FROM "Competition") +
(SELECT COUNT(*) FROM "Round") +
(SELECT COUNT(*) FROM "ProjectRoundState") +
(SELECT COUNT(*) FROM "JuryGroup") +
(SELECT COUNT(*) FROM "SubmissionWindow")
INTO total_count;
IF total_count != 0 THEN
RAISE EXCEPTION 'Rollback incomplete: % records remain in new tables', total_count;
ELSE
RAISE NOTICE 'Phase 2 successfully rolled back — all migrated data deleted';
END IF;
END $$;
COMMIT;
5. Phase 3: Code Migration
5.1 Overview
Update all services, routers, and UI components to use new Competition/Round models. Use feature flags to run old + new code in parallel during transition.
5.2 Service Layer Changes
5.2.1 File Renames
# Rename stage-engine.ts → round-engine.ts
mv src/server/services/stage-engine.ts src/server/services/round-engine.ts
# Rename stage-filtering.ts → round-filtering.ts
mv src/server/services/stage-filtering.ts src/server/services/round-filtering.ts
# Rename stage-assignment.ts → round-assignment.ts
mv src/server/services/stage-assignment.ts src/server/services/round-assignment.ts
# Rename stage-notifications.ts → round-notifications.ts
mv src/server/services/stage-notifications.ts src/server/services/round-notifications.ts
5.2.2 Service Function Signature Changes
Before (stage-engine.ts):
export async function validateTransition(
prisma: PrismaClient,
projectId: string,
fromStageId: string,
toStageId: string
): Promise<{ valid: boolean; reason?: string }> {
// Check if transition exists
const transition = await prisma.stageTransition.findFirst({
where: { fromStageId, toStageId }
});
// ...
}
After (round-engine.ts):
export async function validateAdvancement(
prisma: PrismaClient,
projectId: string,
fromRoundId: string,
toRoundId?: string // null = next round by sortOrder
): Promise<{ valid: boolean; reason?: string }> {
// Check advancement rule
const rule = await prisma.advancementRule.findFirst({
where: { roundId: fromRoundId, targetRoundId: toRoundId }
});
// ...
}
Changes:
- Rename
validateTransition→validateAdvancement - Rename
fromStageId→fromRoundId - Rename
toStageId→toRoundId(nullable) - Rename
StageTransition→AdvancementRule
5.2.3 round-filtering.ts Changes
Before:
export async function runStageFiltering(
prisma: PrismaClient,
stageId: string,
options: FilteringOptions
): Promise<FilteringJob> {
const stage = await prisma.stage.findUnique({ where: { id: stageId } });
// ...
}
After:
export async function runRoundFiltering(
prisma: PrismaClient,
roundId: string,
options: FilteringOptions
): Promise<FilteringJob> {
const round = await prisma.round.findUnique({ where: { id: roundId } });
// ...
}
Changes:
- Rename function
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:
export async function previewStageAssignment(
prisma: PrismaClient,
stageId: string,
options: AssignmentOptions
): Promise<AssignmentPreview> {
const stage = await prisma.stage.findUnique({
where: { id: stageId },
include: { track: { include: { pipeline: true } } }
});
const jurors = await prisma.user.findMany({
where: { role: 'JURY_MEMBER' }
});
// ...
}
After:
export async function previewRoundAssignment(
prisma: PrismaClient,
roundId: string,
options: AssignmentOptions
): Promise<AssignmentPreview> {
const round = await prisma.round.findUnique({
where: { id: roundId },
include: {
competition: true,
juryGroup: { include: { members: { include: { user: true } } } }
}
});
const jurors = round.juryGroup?.members.map(m => m.user) || [];
// ...
}
Changes:
- Rename function
previewStageAssignment→previewRoundAssignment - Replace
track.pipelineinclude withcompetition - Replace generic juror query with
juryGroup.memberstraversal - Use jury group's members instead of all JURY_MEMBER users
5.3 Router Changes
5.3.1 Router File Renames
# Rename routers
mv src/server/routers/pipeline.ts src/server/routers/competition.ts
mv src/server/routers/stage.ts src/server/routers/round.ts
mv src/server/routers/stageFiltering.ts src/server/routers/roundFiltering.ts
mv src/server/routers/stageAssignment.ts src/server/routers/roundAssignment.ts
5.3.2 competition.ts Router (replaces pipeline.ts)
Before:
export const pipelineRouter = router({
create: adminProcedure
.input(z.object({
programId: z.string(),
name: z.string(),
slug: z.string(),
settingsJson: z.any().optional()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.pipeline.create({
data: input
});
}),
// ... more procedures
});
After:
export const competitionRouter = router({
create: adminProcedure
.input(z.object({
programId: z.string(),
name: z.string(),
slug: z.string(),
categoryMode: z.enum(['SHARED', 'SPLIT']).default('SHARED'),
startupFinalistCount: z.number().default(3),
conceptFinalistCount: z.number().default(3),
notifyOnRoundAdvance: z.boolean().default(true),
notifyOnDeadlineApproach: z.boolean().default(true),
deadlineReminderDays: z.array(z.number()).default([7, 3, 1])
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.competition.create({
data: input
});
}),
// ... more procedures
});
Changes:
- Rename
pipelineRouter→competitionRouter - Replace
settingsJsonwith typed fields - Update
ctx.prisma.pipeline→ctx.prisma.competition
5.3.3 round.ts Router (replaces stage.ts)
Before:
export const stageRouter = router({
create: adminProcedure
.input(z.object({
trackId: z.string(),
stageType: z.enum(['INTAKE', 'FILTER', 'EVALUATION', 'SELECTION', 'LIVE_FINAL', 'RESULTS']),
name: z.string(),
slug: z.string(),
configJson: z.any().optional()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.stage.create({ data: input });
}),
// ... more procedures
});
After:
export const roundRouter = router({
create: adminProcedure
.input(z.object({
competitionId: z.string(),
roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'CONFIRMATION']),
name: z.string(),
slug: z.string(),
sortOrder: z.number(),
configJson: z.any().optional(), // TODO: Add per-type validation
juryGroupId: z.string().optional(),
submissionWindowId: z.string().optional()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.round.create({ data: input });
}),
// ... more procedures
});
Changes:
- Rename
stageRouter→roundRouter - Replace
trackIdwithcompetitionId - Update
stageTypeenum toroundTypeenum (new values) - Add
juryGroupIdandsubmissionWindowIdlinks - Update
ctx.prisma.stage→ctx.prisma.round
5.3.4 New Routers
// src/server/routers/juryGroup.ts (NEW)
export const juryGroupRouter = router({
create: adminProcedure
.input(z.object({
competitionId: z.string(),
name: z.string(),
slug: z.string(),
defaultMaxAssignments: z.number().default(20),
defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).default('SOFT'),
softCapBuffer: z.number().default(2)
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.juryGroup.create({ data: input });
}),
addMember: adminProcedure
.input(z.object({
juryGroupId: z.string(),
userId: z.string(),
isLead: z.boolean().default(false),
maxAssignmentsOverride: z.number().optional()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.juryGroupMember.create({ data: input });
}),
// ... more procedures
});
// src/server/routers/submissionWindow.ts (NEW)
export const submissionWindowRouter = router({
create: adminProcedure
.input(z.object({
competitionId: z.string(),
name: z.string(),
slug: z.string(),
roundNumber: z.number(),
windowOpenAt: z.date().optional(),
windowCloseAt: z.date().optional(),
deadlinePolicy: z.enum(['HARD', 'FLAG', 'GRACE']).default('FLAG'),
graceHours: z.number().optional()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.submissionWindow.create({ data: input });
}),
addRequirement: adminProcedure
.input(z.object({
submissionWindowId: z.string(),
name: z.string(),
acceptedMimeTypes: z.array(z.string()),
maxSizeMB: z.number().optional(),
isRequired: z.boolean().default(true)
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.submissionFileRequirement.create({ data: input });
}),
// ... more procedures
});
// src/server/routers/winnerProposal.ts (NEW)
export const winnerProposalRouter = router({
create: adminProcedure
.input(z.object({
competitionId: z.string(),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']),
rankedProjectIds: z.array(z.string()),
sourceRoundId: z.string(),
selectionBasis: z.any()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.winnerProposal.create({
data: {
...input,
proposedById: ctx.session.user.id
}
});
}),
approve: juryProcedure
.input(z.object({
winnerProposalId: z.string(),
approved: z.boolean(),
comments: z.string().optional()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.winnerApproval.create({
data: {
winnerProposalId: input.winnerProposalId,
userId: ctx.session.user.id,
role: 'JURY_MEMBER',
approved: input.approved,
comments: input.comments,
respondedAt: new Date()
}
});
}),
// ... more procedures
});
5.4 Import Path Updates
All files that import from renamed services/routers need updates:
# Find all imports of old paths
grep -r "from.*stage-engine" src/
grep -r "from.*stage-filtering" src/
grep -r "from.*pipeline.router" src/
# Update with sed (example)
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/stage-engine/round-engine/g'
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/stage-filtering/round-filtering/g'
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/pipelineRouter/competitionRouter/g'
5.5 Type Definition Updates
// src/types/pipeline-wizard.ts → src/types/competition-wizard.ts
// BEFORE
export type PipelineWizardStep =
| 'pipeline-info'
| 'tracks'
| 'stages'
| 'transitions'
| 'review';
export interface PipelineWizardData {
pipeline: {
name: string;
slug: string;
programId: string;
};
tracks: TrackData[];
stages: StageData[];
transitions: TransitionData[];
}
// AFTER
export type CompetitionWizardStep =
| 'competition-info'
| 'rounds'
| 'jury-groups'
| 'submission-windows'
| 'review';
export interface CompetitionWizardData {
competition: {
name: string;
slug: string;
programId: string;
categoryMode: 'SHARED' | 'SPLIT';
startupFinalistCount: number;
conceptFinalistCount: number;
};
rounds: RoundData[];
juryGroups: JuryGroupData[];
submissionWindows: SubmissionWindowData[];
advancementRules: AdvancementRuleData[];
}
5.6 UI Component Updates
5.6.1 Page Renames
# Rename admin pages
mv src/app/\(admin\)/admin/rounds/pipelines src/app/\(admin\)/admin/rounds/competitions
mv src/app/\(admin\)/admin/rounds/pipeline src/app/\(admin\)/admin/rounds/competition
# Update route references in all files
find src/app -type f \( -name "*.ts" -o -name "*.tsx" \) | xargs sed -i 's|/admin/rounds/pipeline|/admin/rounds/competition|g'
5.6.2 Component Updates (Example: Competition List)
Before (src/app/(admin)/admin/rounds/pipelines/page.tsx):
export default function PipelinesPage() {
const { data: pipelines } = trpc.pipeline.list.useQuery();
return (
<div>
<h1>Pipelines</h1>
{pipelines?.map(pipeline => (
<Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}`}>
{pipeline.name}
</Link>
))}
</div>
);
}
After (src/app/(admin)/admin/rounds/competitions/page.tsx):
export default function CompetitionsPage() {
const { data: competitions } = trpc.competition.list.useQuery();
return (
<div>
<h1>Competitions</h1>
{competitions?.map(competition => (
<Link key={competition.id} href={`/admin/rounds/competition/${competition.id}`}>
{competition.name} ({competition.status})
</Link>
))}
</div>
);
}
5.7 Feature Flag Strategy
Use environment variable to enable parallel execution:
// src/lib/feature-flags.ts
export const FEATURE_FLAGS = {
USE_NEW_COMPETITION_MODEL: process.env.NEXT_PUBLIC_USE_NEW_COMPETITION_MODEL === 'true'
};
// Usage in code
import { FEATURE_FLAGS } from '@/lib/feature-flags';
export async function getCompetitionData(id: string) {
if (FEATURE_FLAGS.USE_NEW_COMPETITION_MODEL) {
return prisma.competition.findUnique({ where: { id } });
} else {
return prisma.pipeline.findUnique({ where: { id } });
}
}
5.8 Phase 3 Validation
# TypeScript compilation
npm run typecheck
# Linting
npm run lint
# Unit tests
npm run test
# Integration tests
npm run test:integration
# Build check
npm run build
5.9 Phase 3 Rollback
# Revert all code changes
git reset --hard <commit-before-phase-3>
# Or manual revert
git revert <phase-3-commit-hash>
# Restore old import paths
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/round-engine/stage-engine/g'
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/competitionRouter/pipelineRouter/g'
# Rebuild
npm run build
6. Phase 4: Cleanup (Breaking)
6.1 Overview
WARNING: This phase is IRREVERSIBLE. Once old tables are dropped, rollback is impossible. Only proceed after:
- Full Phase 3 deployment to production
- At least 2 weeks of monitoring
- No critical bugs reported
- Stakeholder approval
- Complete database backup
6.2 Pre-Cleanup Checklist
-- =============================================================================
-- PHASE 4: PRE-CLEANUP VALIDATION CHECKLIST
-- =============================================================================
-- ✅ 1. Verify no code references old tables
-- Run: grep -r "prisma.pipeline\|prisma.track\|prisma.stage\|prisma.projectStageState" src/
-- Should return 0 results
-- ✅ 2. Verify new tables have data
SELECT 'Competition' AS table_name, COUNT(*) AS count FROM "Competition"
UNION ALL
SELECT 'Round', COUNT(*) FROM "Round"
UNION ALL
SELECT 'ProjectRoundState', COUNT(*) FROM "ProjectRoundState"
UNION ALL
SELECT 'JuryGroup', COUNT(*) FROM "JuryGroup"
UNION ALL
SELECT 'SubmissionWindow', COUNT(*) FROM "SubmissionWindow";
-- All should be > 0
-- ✅ 3. Verify old/new data parity
SELECT
(SELECT COUNT(*) FROM "Pipeline") AS old_pipelines,
(SELECT COUNT(*) FROM "Competition") AS new_competitions,
(SELECT COUNT(*) FROM "Pipeline") = (SELECT COUNT(*) FROM "Competition") AS match;
SELECT
(SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') AS old_stages,
(SELECT COUNT(*) FROM "Round") AS new_rounds,
(SELECT COUNT(*) FROM "Stage" s JOIN "Track" t ON s."trackId" = t.id WHERE t.kind = 'MAIN') = (SELECT COUNT(*) FROM "Round") AS match;
-- ✅ 4. Verify no foreign key references to old tables
SELECT
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND ccu.table_name IN ('Pipeline', 'Track', 'Stage', 'ProjectStageState', 'StageTransition')
ORDER BY tc.table_name;
-- Should return 0 rows
-- ✅ 5. Backup verification
-- Ensure recent backup exists:
-- SELECT pg_last_wal_replay_lsn();
6.3 Drop Old Tables
-- =============================================================================
-- PHASE 4.1: DROP OLD TABLES (IRREVERSIBLE)
-- =============================================================================
BEGIN;
-- Drop tables in dependency order
DROP TABLE IF EXISTS "CohortProject" CASCADE;
DROP TABLE IF EXISTS "Cohort" CASCADE;
DROP TABLE IF EXISTS "LiveProgressCursor" CASCADE;
DROP TABLE IF EXISTS "ProjectStageState" CASCADE;
DROP TABLE IF EXISTS "StageTransition" CASCADE;
DROP TABLE IF EXISTS "Assignment" CASCADE; -- Will be recreated with new FK
DROP TABLE IF EXISTS "FileRequirement" CASCADE;
DROP TABLE IF EXISTS "FilteringRule" CASCADE;
DROP TABLE IF EXISTS "FilteringResult" CASCADE;
DROP TABLE IF EXISTS "FilteringJob" CASCADE;
DROP TABLE IF EXISTS "AssignmentJob" CASCADE;
DROP TABLE IF EXISTS "GracePeriod" CASCADE;
DROP TABLE IF EXISTS "ReminderLog" CASCADE;
DROP TABLE IF EXISTS "EvaluationSummary" CASCADE;
DROP TABLE IF EXISTS "EvaluationDiscussion" CASCADE;
DROP TABLE IF EXISTS "LiveVotingSession" CASCADE;
DROP TABLE IF EXISTS "Message" CASCADE;
DROP TABLE IF EXISTS "Stage" CASCADE;
DROP TABLE IF EXISTS "Track" CASCADE;
DROP TABLE IF EXISTS "Pipeline" CASCADE;
-- Verification
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('Pipeline', 'Track', 'Stage', 'ProjectStageState', 'StageTransition')
ORDER BY table_name;
-- Should return 0 rows
COMMIT;
6.4 Drop Old Enums
-- =============================================================================
-- PHASE 4.2: DROP OLD ENUMS (IRREVERSIBLE)
-- =============================================================================
BEGIN;
-- Drop old enums (only if no columns reference them)
DROP TYPE IF EXISTS "TrackKind" CASCADE;
DROP TYPE IF EXISTS "RoutingMode" CASCADE;
DROP TYPE IF EXISTS "DecisionMode" CASCADE;
DROP TYPE IF EXISTS "StageType" CASCADE;
DROP TYPE IF EXISTS "StageStatus" CASCADE;
DROP TYPE IF EXISTS "ProjectStageStateValue" CASCADE;
-- Verification
SELECT enumtypid::regtype AS enum_type
FROM pg_enum
WHERE enumtypid::regtype::text IN (
'TrackKind', 'RoutingMode', 'DecisionMode',
'StageType', 'StageStatus', 'ProjectStageStateValue'
)
GROUP BY enumtypid::regtype;
-- Should return 0 rows
COMMIT;
6.5 Remove Legacy Columns
-- =============================================================================
-- PHASE 4.3: REMOVE LEGACY roundId COLUMNS
-- =============================================================================
BEGIN;
-- Drop all legacy roundId columns (marked "Legacy — kept for historical data")
ALTER TABLE "EvaluationForm" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "FileRequirement" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "GracePeriod" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "FilteringRule" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "FilteringResult" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "FilteringJob" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "AssignmentJob" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "TaggingJob" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "ReminderLog" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "ConflictOfInterest" DROP COLUMN IF EXISTS "roundId";
ALTER TABLE "EvaluationSummary" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "EvaluationDiscussion" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "LiveVotingSession" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
ALTER TABLE "Message" DROP COLUMN IF EXISTS "roundId"; -- Already dropped table
-- Drop legacy roundId from Project
ALTER TABLE "Project" DROP COLUMN IF EXISTS "roundId";
-- Drop trackId from SpecialAward (now links to competitionId)
ALTER TABLE "SpecialAward" DROP CONSTRAINT IF EXISTS "SpecialAward_trackId_fkey";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "trackId";
-- Verification: Check for remaining roundId columns
SELECT table_name, column_name
FROM information_schema.columns
WHERE column_name = 'roundId'
AND table_schema = 'public'
ORDER BY table_name;
-- Should return 0 rows
COMMIT;
6.6 Recreate Assignment Table with New FK
-- =============================================================================
-- PHASE 4.4: RECREATE ASSIGNMENT TABLE (with roundId → Round FK)
-- =============================================================================
BEGIN;
-- Assignment was dropped in 4.3, recreate with correct FK
CREATE TABLE "Assignment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roundId" TEXT NOT NULL, -- Now FK to Round, not Stage
"method" "AssignmentMethod" NOT NULL DEFAULT 'MANUAL',
"isRequired" BOOLEAN NOT NULL DEFAULT true,
"isCompleted" BOOLEAN NOT NULL DEFAULT false,
"aiConfidenceScore" DOUBLE PRECISION,
"expertiseMatchScore" DOUBLE PRECISION,
"aiReasoning" TEXT,
"juryGroupId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdBy" TEXT,
CONSTRAINT "Assignment_pkey" PRIMARY KEY ("id"),
CONSTRAINT "Assignment_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Assignment_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Assignment_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Assignment_juryGroupId_fkey"
FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id")
ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Assignment_userId_projectId_roundId_key" UNIQUE ("userId", "projectId", "roundId")
);
CREATE INDEX "Assignment_userId_idx" ON "Assignment"("userId");
CREATE INDEX "Assignment_projectId_idx" ON "Assignment"("projectId");
CREATE INDEX "Assignment_roundId_idx" ON "Assignment"("roundId");
CREATE INDEX "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
CREATE INDEX "Assignment_isCompleted_idx" ON "Assignment"("isCompleted");
CREATE INDEX "Assignment_projectId_userId_idx" ON "Assignment"("projectId", "userId");
-- Repopulate from backup or leave empty (assignments will be regenerated)
-- If you have a backup:
-- INSERT INTO "Assignment" SELECT * FROM "Assignment_backup";
COMMIT;
6.7 Clean Up Service Files
# Delete old service files (after verifying no references)
rm src/server/services/stage-engine.ts
rm src/server/services/stage-filtering.ts
rm src/server/services/stage-assignment.ts
rm src/server/services/stage-notifications.ts
# Delete old router files
rm src/server/routers/pipeline.ts
rm src/server/routers/stage.ts
rm src/server/routers/stageFiltering.ts
rm src/server/routers/stageAssignment.ts
# Delete old type files
rm src/types/pipeline-wizard.ts
6.8 Phase 4 Final Validation
-- =============================================================================
-- PHASE 4: FINAL VALIDATION
-- =============================================================================
-- Verify old tables gone
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('Pipeline', 'Track', 'Stage', 'ProjectStageState', 'StageTransition')
ORDER BY table_name;
-- Should return 0 rows
-- Verify new tables intact
SELECT table_name,
(SELECT COUNT(*) FROM information_schema.columns c WHERE c.table_name = t.table_name) AS column_count
FROM information_schema.tables t
WHERE table_schema = 'public'
AND table_name IN ('Competition', 'Round', 'ProjectRoundState', 'JuryGroup', 'SubmissionWindow')
ORDER BY table_name;
-- All should have column_count > 0
-- Verify FK integrity
SELECT
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name IN ('Competition', 'Round', 'ProjectRoundState', 'JuryGroup', 'Assignment')
ORDER BY tc.table_name, kcu.column_name;
-- Should show all expected FKs
-- Verify data counts
SELECT
(SELECT COUNT(*) FROM "Competition") AS competitions,
(SELECT COUNT(*) FROM "Round") AS rounds,
(SELECT COUNT(*) FROM "ProjectRoundState") AS project_states,
(SELECT COUNT(*) FROM "JuryGroup") AS jury_groups,
(SELECT COUNT(*) FROM "SubmissionWindow") AS submission_windows;
-- Test a complete query
SELECT
c.name AS competition_name,
r.name AS round_name,
r."roundType",
jg.name AS jury_group_name,
COUNT(DISTINCT prs."projectId") AS project_count,
COUNT(DISTINCT a."userId") AS juror_count
FROM "Competition" c
JOIN "Round" r ON c.id = r."competitionId"
LEFT JOIN "JuryGroup" jg ON r."juryGroupId" = jg.id
LEFT JOIN "ProjectRoundState" prs ON r.id = prs."roundId"
LEFT JOIN "Assignment" a ON r.id = a."roundId"
WHERE c.status = 'ACTIVE'
GROUP BY c.id, c.name, r.id, r.name, r."roundType", jg.id, jg.name
ORDER BY c.name, r."sortOrder";
-- Should return valid data with no errors
6.9 Phase 4 Completion Checklist
- All old tables dropped
- All old enums dropped
- All legacy columns removed
- All old service files deleted
- All old router files deleted
- All old type files deleted
- TypeScript compiles without errors
- All tests pass
- Application runs in production
- No references to old models in codebase
- Database backup created and verified
- Migration documented and tagged in git
7. Rollback Plan
7.1 Rollback by Phase
| Phase | Rollback Difficulty | Rollback Procedure | Data Loss Risk |
|---|---|---|---|
| Phase 1 | Easy | Drop new tables with SQL script | ✅ None |
| Phase 2 | Easy | Delete migrated data, keep old tables | ✅ None (if no new data created) |
| Phase 3 | Medium | Revert code commits, rebuild app | ⚠️ New data in new tables lost |
| Phase 4 | IMPOSSIBLE | Cannot restore dropped tables | ❌ PERMANENT |
7.2 Phase 1 Rollback (Already Documented Above)
See section 3.11 for Phase 1 rollback script.
7.3 Phase 2 Rollback (Already Documented Above)
See section 4.10 for Phase 2 rollback script.
7.4 Phase 3 Rollback
# Git-based rollback
git log --oneline # Find commit hash before Phase 3
git revert <phase-3-commit> # Create revert commit
npm run build # Rebuild with old code
# Or hard reset (destructive)
git reset --hard <commit-before-phase-3>
git push --force origin main # DANGEROUS: overwrites remote history
# Restore .env feature flag
# Change NEXT_PUBLIC_USE_NEW_COMPETITION_MODEL=false
# Rebuild and redeploy
npm run build
# Deploy to production
7.5 Phase 4 Rollback (Restore from Backup)
Phase 4 cannot be rolled back without a database backup.
# Stop application
systemctl stop mopc-app
# Restore from backup (PostgreSQL example)
pg_restore -U postgres -d mopc_db -c backup_before_phase4.dump
# Revert code to pre-Phase-4 commit
git reset --hard <commit-before-phase-4>
# Rebuild
npm run build
# Restart application
systemctl start mopc-app
# Verify old tables present
psql -U postgres -d mopc_db -c "SELECT table_name FROM information_schema.tables WHERE table_name IN ('Pipeline', 'Track', 'Stage');"
8. Complete Data Mapping Table
8.1 Table-Level Mapping
| Old Table | New Table | Mapping Notes |
|---|---|---|
Pipeline |
Competition |
1:1 — Keep same ID, unpack settingsJson to typed fields |
Track (MAIN only) |
(eliminated) | MAIN track stages promoted to Competition.rounds |
Track (AWARD) |
SpecialAward (enhanced) |
Link to Competition via competitionId, add eligibilityMode |
Stage |
Round |
1:1 for MAIN track stages, rename stageType → roundType |
StageTransition |
AdvancementRule |
guardJson → configJson, isDefault preserved |
ProjectStageState |
ProjectRoundState |
Drop trackId, stageId → roundId |
FileRequirement |
SubmissionFileRequirement |
Group by stage → create SubmissionWindow parent |
| (new) | Competition |
Created from Pipeline |
| (new) | JuryGroup |
Created from distinct evaluation stage assignments |
| (new) | JuryGroupMember |
Populated from Assignment.userId (distinct per stage) |
| (new) | SubmissionWindow |
Created for each INTAKE stage |
| (new) | RoundSubmissionVisibility |
Controls which docs jury sees (manual config) |
| (new) | MentorFile |
New mentor workspace file storage |
| (new) | MentorFileComment |
Threaded comments on mentor files |
| (new) | WinnerProposal |
Winner confirmation workflow |
| (new) | WinnerApproval |
Jury approvals for winners |
8.2 Field-Level Mapping (Core Models)
Pipeline → Competition
| Pipeline Field | Competition Field | Transformation |
|---|---|---|
id |
id |
Direct copy |
programId |
programId |
Direct copy |
name |
name |
Direct copy |
slug |
slug |
Direct copy |
status |
status |
Map 'DRAFT'/'ACTIVE'/'ARCHIVED' to CompetitionStatus enum |
settingsJson.categoryMode |
categoryMode |
Extract from JSON → typed field |
settingsJson.startupFinalistCount |
startupFinalistCount |
Extract from JSON → typed field |
settingsJson.conceptFinalistCount |
conceptFinalistCount |
Extract from JSON → typed field |
settingsJson.notifyOnRoundAdvance |
notifyOnRoundAdvance |
Extract from JSON → typed field |
settingsJson.notifyOnDeadlineApproach |
notifyOnDeadlineApproach |
Extract from JSON → typed field |
settingsJson.deadlineReminderDays |
deadlineReminderDays |
Extract from JSON → typed array |
createdAt |
createdAt |
Direct copy |
updatedAt |
updatedAt |
Direct copy |
Stage → Round
| Stage Field | Round Field | Transformation |
|---|---|---|
id |
id |
Direct copy (preserve IDs for FK integrity) |
trackId |
competitionId |
Resolve track.pipelineId |
stageType |
roundType |
Enum mapping (see below) |
name |
name |
Direct copy |
slug |
slug |
Direct copy |
status |
status |
Enum mapping (see below) |
sortOrder |
sortOrder |
Direct copy (within track → within competition) |
configJson |
configJson |
Direct copy (TODO: migrate to typed configs) |
windowOpenAt |
windowOpenAt |
Direct copy |
windowCloseAt |
windowCloseAt |
Direct copy |
| (new) | juryGroupId |
Link to created JuryGroup (for EVALUATION/LIVE_FINAL) |
| (new) | submissionWindowId |
Link to created SubmissionWindow (for INTAKE) |
createdAt |
createdAt |
Direct copy |
updatedAt |
updatedAt |
Direct copy |
StageType → RoundType Enum Mapping
| StageType (old) | RoundType (new) | Notes |
|---|---|---|
INTAKE |
INTAKE |
No change |
FILTER |
FILTERING |
Renamed for clarity |
EVALUATION |
EVALUATION |
No change |
SELECTION |
EVALUATION |
Merged into EVALUATION (handle via advancementRules) |
LIVE_FINAL |
LIVE_FINAL |
No change |
RESULTS |
CONFIRMATION |
Renamed — RESULTS is a view, CONFIRMATION is a workflow round |
| (new) | SUBMISSION |
New round type for multi-round doc collection |
| (new) | MENTORING |
New round type for mentor-team workspace |
StageStatus → RoundStatus Enum Mapping
| StageStatus (old) | RoundStatus (new) |
|---|---|
STAGE_DRAFT |
ROUND_DRAFT |
STAGE_ACTIVE |
ROUND_ACTIVE |
STAGE_CLOSED |
ROUND_CLOSED |
STAGE_ARCHIVED |
ROUND_ARCHIVED |
ProjectStageState → ProjectRoundState
| ProjectStageState Field | ProjectRoundState Field | Transformation |
|---|---|---|
id |
id |
Direct copy |
projectId |
projectId |
Direct copy |
trackId |
(dropped) | Removed — project is in round, not track |
stageId |
roundId |
Direct copy (Stage ID = Round ID after migration) |
state |
state |
Enum mapping (see below) |
enteredAt |
enteredAt |
Direct copy |
exitedAt |
exitedAt |
Direct copy |
metadataJson |
metadataJson |
Direct copy |
createdAt |
createdAt |
Direct copy |
updatedAt |
updatedAt |
Direct copy |
ProjectStageStateValue → ProjectRoundStateValue Enum Mapping
| ProjectStageStateValue (old) | ProjectRoundStateValue (new) | Notes |
|---|---|---|
PENDING |
PENDING |
No change |
IN_PROGRESS |
IN_PROGRESS |
No change |
PASSED |
PASSED |
No change |
REJECTED |
REJECTED |
No change |
ROUTED |
(dropped) | No longer needed (no track routing) |
COMPLETED |
COMPLETED |
No change |
WITHDRAWN |
WITHDRAWN |
No change |
8.3 Relation Mapping
| Old Relation | New Relation | Changes |
|---|---|---|
Pipeline → Track[] |
Competition → Round[] |
Direct children, no intermediate Track layer |
Track → Stage[] |
(eliminated) | Stages promoted to Competition.rounds |
Stage → ProjectStageState[] |
Round → ProjectRoundState[] |
1:1 rename |
Stage → StageTransition[] |
Round → AdvancementRule[] |
1:N, enhanced with rule types |
Stage → Assignment[] |
Round → Assignment[] |
Assignment.stageId → Assignment.roundId |
Stage → FileRequirement[] |
SubmissionWindow → SubmissionFileRequirement[] |
Grouped under SubmissionWindow |
| (new) | Competition → JuryGroup[] |
New explicit jury management |
| (new) | JuryGroup → JuryGroupMember[] |
New jury membership |
| (new) | Round → JuryGroup |
Link evaluation rounds to juries |
| (new) | Round → SubmissionWindow |
Link intake/submission rounds to doc windows |
| (new) | Competition → SubmissionWindow[] |
Multi-round doc collection |
| (new) | Round → RoundSubmissionVisibility[] |
Control which docs jury sees |
| (new) | MentorAssignment → MentorFile[] |
Workspace files |
| (new) | MentorFile → MentorFileComment[] |
File comments |
| (new) | Competition → WinnerProposal[] |
Winner confirmation |
| (new) | WinnerProposal → WinnerApproval[] |
Jury approvals |
9. Risk Assessment
9.1 Technical Risks
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Data loss during migration | Low | Critical | Full backup before each phase, validation queries after each step |
| FK constraint violations | Medium | High | Drop FKs before migration, recreate after validation |
| Enum type mismatches | Low | Medium | Explicit CAST in migration SQL, validation queries |
| Performance degradation | Low | Medium | Index all FK columns, benchmark before/after |
| Incomplete data migration | Medium | High | Count validation queries, row-by-row comparison scripts |
| Orphaned records | Low | Medium | Orphan detection queries in Phase 2 validation |
| Code references old models | High | High | Comprehensive grep/search, TypeScript compilation checks |
| Breaking existing API clients | High | Critical | Parallel endpoints during Phase 3, deprecation warnings |
| Test failures | Medium | Medium | Run full test suite after each phase |
| Production downtime | Low | Critical | Blue-green deployment, rollback plan tested |
9.2 Business Risks
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Competition interruption | Low | Critical | Execute during off-season, no active rounds |
| Data integrity concerns | Medium | High | Stakeholder review of migration plan, detailed validation reports |
| Training required | High | Medium | Admin training on new UI/concepts before Phase 4 |
| User confusion | Medium | Medium | Clear communication, UI help tooltips, migration announcement |
9.3 Risk Mitigation Checklist
Pre-Migration:
- Full database backup (verified restorable)
- Migration tested on staging environment
- Rollback plan tested on staging
- Stakeholder approval obtained
- Maintenance window scheduled
- Support team briefed
During Migration:
- Monitor logs for errors
- Run validation queries after each phase
- Keep backup connection to production DB
- Document any deviations from plan
Post-Migration:
- Full test suite run
- Smoke tests on production
- Monitor error logs for 48 hours
- Performance metrics compared to baseline
- Stakeholder confirmation of data integrity
10. Testing Strategy
10.1 Unit Tests
Existing tests to update:
tests/unit/stage-engine.test.ts→tests/unit/round-engine.test.ts- Update all
prisma.stage→prisma.roundreferences - Update mock data to use new schema
New tests to create:
tests/unit/jury-group.test.ts— Jury group CRUD, member managementtests/unit/submission-window.test.ts— Multi-round submission logictests/unit/winner-proposal.test.ts— Winner confirmation workflowtests/unit/advancement-rule.test.ts— Rule evaluation logic
10.2 Integration Tests
Schema migration tests:
// tests/integration/migration.test.ts
describe('Phase 2: Data Migration', () => {
it('should migrate all pipelines to competitions', async () => {
const pipelineCount = await prisma.pipeline.count();
const competitionCount = await prisma.competition.count();
expect(competitionCount).toBe(pipelineCount);
});
it('should preserve all pipeline IDs', async () => {
const pipelineIds = await prisma.pipeline.findMany({ select: { id: true } });
const competitionIds = await prisma.competition.findMany({ select: { id: true } });
expect(competitionIds).toEqual(expect.arrayContaining(pipelineIds));
});
it('should migrate all MAIN track stages to rounds', async () => {
const mainStageCount = await prisma.stage.count({
where: { track: { kind: 'MAIN' } }
});
const roundCount = await prisma.round.count();
expect(roundCount).toBe(mainStageCount);
});
it('should preserve stage-to-round sortOrder', async () => {
const stages = await prisma.stage.findMany({
where: { track: { kind: 'MAIN' } },
orderBy: { sortOrder: 'asc' },
select: { id: true, sortOrder: true }
});
const rounds = await prisma.round.findMany({
orderBy: { sortOrder: 'asc' },
select: { id: true, sortOrder: true }
});
expect(rounds.map(r => r.sortOrder)).toEqual(stages.map(s => s.sortOrder));
});
});
API compatibility tests:
// tests/integration/api-compatibility.test.ts
describe('Phase 3: API Compatibility', () => {
it('should preserve competition list endpoint behavior', async () => {
const oldResponse = await caller.pipeline.list();
const newResponse = await caller.competition.list();
expect(newResponse.length).toBe(oldResponse.length);
expect(newResponse[0]).toMatchObject({
id: expect.any(String),
name: expect.any(String),
status: expect.any(String)
});
});
it('should handle round creation with new fields', async () => {
const round = await caller.round.create({
competitionId: 'comp-1',
roundType: 'EVALUATION',
name: 'Test Round',
slug: 'test-round',
sortOrder: 1,
juryGroupId: 'jury-1'
});
expect(round.juryGroupId).toBe('jury-1');
});
});
10.3 End-to-End Tests
Critical user flows:
// tests/e2e/competition-workflow.test.ts
describe('Complete Competition Flow', () => {
it('should create competition → rounds → jury groups → assignments', async () => {
// 1. Create competition
const competition = await createTestCompetition();
// 2. Create rounds
const round1 = await createTestRound({ competitionId: competition.id, roundType: 'INTAKE' });
const round2 = await createTestRound({ competitionId: competition.id, roundType: 'EVALUATION' });
// 3. Create jury group
const juryGroup = await createTestJuryGroup({ competitionId: competition.id });
// 4. Link jury to evaluation round
await caller.round.update({
id: round2.id,
juryGroupId: juryGroup.id
});
// 5. Create assignments
const assignments = await caller.roundAssignment.executeAssignment({
roundId: round2.id,
projectIds: ['proj-1', 'proj-2'],
options: { reviewsPerProject: 3 }
});
expect(assignments.length).toBeGreaterThan(0);
expect(assignments[0].juryGroupId).toBe(juryGroup.id);
});
});
10.4 Performance Tests
Benchmark queries:
// tests/performance/query-benchmarks.test.ts
describe('Query Performance', () => {
it('should fetch competition with rounds in <100ms', async () => {
const start = performance.now();
const competition = await prisma.competition.findUnique({
where: { id: 'comp-1' },
include: {
rounds: {
include: {
juryGroup: { include: { members: true } },
submissionWindow: { include: { fileRequirements: true } }
}
}
}
});
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(100);
expect(competition).toBeTruthy();
});
it('should handle 1000 project round state queries efficiently', async () => {
const projects = await createTestProjects(1000);
const round = await createTestRound();
const start = performance.now();
await prisma.projectRoundState.createMany({
data: projects.map(p => ({
projectId: p.id,
roundId: round.id,
state: 'PENDING'
}))
});
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(5000); // 5 seconds for 1000 inserts
});
});
10.5 Rollback Tests
Rollback validation:
// tests/rollback/phase-rollback.test.ts
describe('Phase 2 Rollback', () => {
it('should cleanly delete all migrated data', async () => {
// Run Phase 2 migration
await runPhase2Migration();
// Verify data migrated
const competitionCount = await prisma.competition.count();
expect(competitionCount).toBeGreaterThan(0);
// Run rollback
await runPhase2Rollback();
// Verify data deleted
const afterCount = await prisma.competition.count();
expect(afterCount).toBe(0);
// Verify old tables intact
const pipelineCount = await prisma.pipeline.count();
expect(pipelineCount).toBeGreaterThan(0);
});
});
10.6 Test Execution Plan
| Test Suite | When to Run | Pass Criteria |
|---|---|---|
| Unit tests | After each code change | 100% pass, coverage >80% |
| Integration tests | After each phase | 100% pass |
| E2E tests | After Phase 3 deployment | All critical flows work |
| Performance tests | After Phase 2 & 3 | No regression >10% |
| Rollback tests | Before each phase deployment | Rollback completes without errors |
11. Timeline
11.1 Estimated Duration
| Phase | Tasks | Estimated Time | Dependencies |
|---|---|---|---|
| Phase 0: Preparation | Planning, stakeholder approval, backup setup | 1 week | None |
| Phase 1: Schema | Write migration SQL, test on staging | 3 days | Phase 0 complete |
| Phase 2: Data | Write data migration scripts, validate | 1 week | Phase 1 complete |
| Phase 3: Code | Update services/routers/UI, test | 2-3 weeks | Phase 2 complete |
| Phase 4: Cleanup | Drop old tables, final validation | 1 week | Phase 3 stable in production for 2+ weeks |
| Total | 5-6 weeks |
11.2 Detailed Schedule (Example)
Week 1: Preparation
- Day 1-2: Stakeholder review of migration plan
- Day 3: Database backup procedures established
- Day 4: Staging environment provisioned
- Day 5: Rollback procedures tested on staging
Week 2: Phase 1 (Schema Additions)
- Day 1: Write Phase 1 migration SQL
- Day 2: Test Phase 1 on local + staging
- Day 3: Deploy Phase 1 to production (evening, low traffic)
- Day 4: Validate Phase 1 deployment, monitor logs
- Day 5: Buffer for issues
Week 3-4: Phase 2 (Data Migration)
- Week 3 Day 1-3: Write Phase 2 migration scripts (6 migrations)
- Week 3 Day 4-5: Test migrations on staging with production data snapshot
- Week 4 Day 1: Run Phase 2 on production (scheduled maintenance window)
- Week 4 Day 2-5: Validate migrated data, run integrity checks, fix any issues
Week 5-7: Phase 3 (Code Migration)
- Week 5: Update service layer (engine, filtering, assignment, notifications)
- Week 6: Update routers + types, write new routers (juryGroup, submissionWindow, etc.)
- Week 7: Update UI components, test end-to-end flows
- Week 7 End: Deploy Phase 3 to production with feature flag OFF
- Week 8: Monitor production, gradually enable feature flag, collect feedback
Week 9-10: Monitoring & Stabilization
- Week 9: Production monitoring, bug fixes, performance tuning
- Week 10: Stakeholder UAT (User Acceptance Testing), admin training
Week 11: Phase 4 (Cleanup)
- Day 1: Final backup before Phase 4
- Day 2: Run Phase 4 cleanup scripts on staging
- Day 3: Deploy Phase 4 to production (IRREVERSIBLE)
- Day 4-5: Final validation, performance checks, celebrate 🎉
11.3 Deployment Schedule
| Deployment | Date (Example) | Rollback Window | Monitoring Period |
|---|---|---|---|
| Phase 1 (Staging) | 2026-02-20 | 24 hours | 48 hours |
| Phase 1 (Production) | 2026-02-22 | 1 week | 1 week |
| Phase 2 (Staging) | 2026-02-27 | 48 hours | 72 hours |
| Phase 2 (Production) | 2026-03-03 | 1 week | 2 weeks |
| Phase 3 (Staging) | 2026-03-10 | 1 week | 1 week |
| Phase 3 (Production) | 2026-03-17 | 2 weeks | 2 weeks |
| Phase 4 (Staging) | 2026-04-01 | N/A (test only) | 1 week |
| Phase 4 (Production) | 2026-04-08 | NONE | Permanent |
12. Appendices
12.1 Appendix A: Complete Prisma Schema Diff
(Too large for this document — see separate file: docs/claude-architecture-redesign/prisma-schema-diff.md)
12.2 Appendix B: Full Migration SQL Scripts
(Generated scripts stored in: prisma/migrations/20260220000000_phase1_schema_additions/, etc.)
12.3 Appendix C: Data Validation Queries
See sections 3.10, 4.9, and 6.8 for comprehensive validation queries.
12.4 Appendix D: Service Function Mapping
| Old Function (stage-engine.ts) | New Function (round-engine.ts) |
|---|---|
validateTransition() |
validateAdvancement() |
executeTransition() |
executeAdvancement() |
executeBatchTransition() |
executeBatchAdvancement() |
evaluateGuardCondition() |
evaluateRuleCondition() |
evaluateGuard() |
evaluateAdvancementRule() |
| Old Function (stage-filtering.ts) | New Function (round-filtering.ts) |
|---|---|
runStageFiltering() |
runRoundFiltering() |
resolveManualDecision() |
resolveManualDecision() (unchanged) |
getManualQueue() |
getManualQueue() (unchanged) |
evaluateFieldCondition() |
evaluateFieldCondition() (unchanged) |
12.5 Appendix E: Stakeholder Communication Template
Subject: MOPC Platform Architecture Migration — Action Required
To: Program Admins, Jury Members, Development Team
From: Technical Lead
Date: 2026-02-15
Dear MOPC Community,
We are planning a major platform upgrade to improve competition management and add new features including:
✅ Multi-round document submissions — Request additional docs from semi-finalists ✅ Explicit jury management — Named jury groups (Jury 1, Jury 2, Jury 3) ✅ Mentoring workspace — Private file exchange with comments ✅ Winner confirmation — Multi-party approval before announcing results
What's Changing:
- Backend architecture (no visible UI changes during Phase 1-2)
- New competition setup wizard (simplified, more intuitive)
- Enhanced jury assignment with caps and quotas
When:
- Phase 1-2: February 22 - March 3 (data migration, no downtime expected)
- Phase 3: March 17 (new UI deployed with old UI still available)
- Phase 4: April 8 (old system fully retired)
Action Required:
- Admins: Attend training session on March 15 (new competition wizard)
- All: Report any issues immediately to support@monaco-opc.com
Rollback Plan: We can revert changes until April 8. After that, the migration is permanent.
Questions? Reply to this email or join the Q&A session on March 10.
Best regards, MOPC Technical Team
13. Conclusion
This migration strategy provides a comprehensive, phased approach to transitioning from the Pipeline→Track→Stage architecture to the Competition→Round architecture. Key takeaways:
- Reversibility: Phases 1-3 are fully reversible. Phase 4 is irreversible and requires stakeholder approval.
- Zero Data Loss: All existing data is preserved throughout migration with validation at each step.
- Incremental Deployment: Each phase can be deployed independently with monitoring periods.
- Comprehensive Testing: Unit, integration, E2E, performance, and rollback tests ensure safety.
- Timeline: 5-6 weeks total, with Phase 4 only after 2+ weeks of stable Phase 3 operation.
Next Steps:
- Stakeholder approval of this plan
- Staging environment setup
- Execution of Phase 1 (schema additions)
Document History:
- v1.0 (2026-02-15): Initial complete migration strategy
End of Document