2973 lines
96 KiB
Markdown
2973 lines
96 KiB
Markdown
|
|
# 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<FilteringJob> {
|
||
|
|
const stage = await prisma.stage.findUnique({ where: { id: stageId } });
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**After:**
|
||
|
|
```typescript
|
||
|
|
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:**
|
||
|
|
```typescript
|
||
|
|
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:**
|
||
|
|
```typescript
|
||
|
|
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.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 (
|
||
|
|
<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):**
|
||
|
|
```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:
|
||
|
|
|
||
|
|
```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 <commit-before-phase-3>
|
||
|
|
|
||
|
|
# Or manual revert
|
||
|
|
git revert <phase-3-commit-hash>
|
||
|
|
|
||
|
|
# Restore old import paths
|
||
|
|
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/round-engine/stage-engine/g'
|
||
|
|
find src/ -type f -name "*.ts" -o -name "*.tsx" | xargs sed -i 's/competitionRouter/pipelineRouter/g'
|
||
|
|
|
||
|
|
# Rebuild
|
||
|
|
npm run build
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Phase 4: Cleanup (Breaking)
|
||
|
|
|
||
|
|
### 6.1 Overview
|
||
|
|
|
||
|
|
**WARNING: This phase is IRREVERSIBLE.** Once old tables are dropped, rollback is impossible. Only proceed after:
|
||
|
|
|
||
|
|
1. Full Phase 3 deployment to production
|
||
|
|
2. At least 2 weeks of monitoring
|
||
|
|
3. No critical bugs reported
|
||
|
|
4. Stakeholder approval
|
||
|
|
5. Complete database backup
|
||
|
|
|
||
|
|
### 6.2 Pre-Cleanup Checklist
|
||
|
|
|
||
|
|
```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 <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.**
|
||
|
|
|
||
|
|
```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 <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.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**
|