# Implementation Sequence: MOPC Architecture Redesign ## Overview This document defines the complete implementation sequence for the MOPC architecture redesign, broken into **7 sequential phases** with clear dependencies, acceptance criteria, and estimated complexity. The redesign eliminates the Pipeline->Track->Stage abstraction in favor of a streamlined Competition->Round model with explicit jury groups, multi-round submissions, mentoring workspace, and winner confirmation. ### Phasing Principles 1. **Incremental value** — Each phase delivers testable, self-contained functionality 2. **Backward compatibility** — New system runs alongside old until Phase 6 3. **Feature flags** — Gradual rollout controlled by flags 4. **Clear acceptance gates** — No phase progresses without passing criteria 5. **Parallel work streams** — Phases overlap where dependencies allow 6. **Rollback points** — Can stop and revert at any phase boundary ### Duration Estimates | Phase | Name | Duration | Dependencies | Complexity | |-------|------|----------|--------------|------------| | **Phase 0** | Contract Freeze & Preparation | Week 1 | None | Low | | **Phase 1** | Schema & Runtime Foundation | Weeks 2-3 | Phase 0 | High | | **Phase 2** | Backend Orchestration | Weeks 3-5 | Phase 1 | High | | **Phase 3** | Admin Control Plane | Weeks 4-6 | Phase 1, partial Phase 2 | High | | **Phase 4** | Participant Journeys | Weeks 5-7 | Phase 2, Phase 3 | Medium | | **Phase 5** | Special Awards & Governance | Weeks 6-7 | Phase 2, Phase 4 | Medium | | **Phase 6** | Platform-Wide Refit | Weeks 7-8 | All previous | High | | **Phase 7** | Validation & Release | Weeks 8-9 | All previous | Medium | Total: **8-9 weeks** --- ## Dependency Graph ``` Phase 0: Contract Freeze & Preparation │ ▼ Phase 1: Schema & Runtime Foundation ──────────────┐ │ │ ├──► Phase 2: Backend Orchestration │ │ │ │ │ ├──► Phase 5: Special Awards │ │ │ │ │ │ │ ▼ │ ▼ ▼ │ │ Phase 3: Admin Control Plane │ │ │ │ │ │ ▼ │ │ └──► Phase 4: Participant Journeys │ │ │ │ ▼ ▼ │ Phase 6: Platform-Wide Refit ◄─────────────┘ │ ▼ Phase 7: Validation & Release ``` ### Critical Path **Critical path (delays here delay the entire project):** 1. Phase 0 → Phase 1 → Phase 2 → Phase 6 → Phase 7 **Parallel paths (can progress independently):** - Phase 3 (admin UI) can start once Phase 1 is complete - Phase 4 (participant UI) needs Phase 2 + Phase 3 - Phase 5 (awards) needs Phase 2 but can run parallel to Phase 4 --- ## Phase 0: Contract Freeze & Preparation **Week 1** ### Goals 1. Finalize all TypeScript type definitions and Zod schemas 2. Create feature flags for gradual rollout 3. Set up test infrastructure for new models 4. Document all API contracts 5. Prepare development environment ### Tasks #### 0.1 Type System Preparation **Files to Create:** - `src/types/competition.ts` — Competition types - `src/types/round.ts` — Round types and RoundType enum - `src/types/round-configs.ts` — All round config shapes (Intake, Filtering, Evaluation, Submission, Mentoring, LiveFinal, Confirmation) - `src/types/jury-group.ts` — JuryGroup and JuryGroupMember types - `src/types/submission-window.ts` — SubmissionWindow types - `src/types/winner-confirmation.ts` — WinnerProposal and WinnerApproval types - `src/types/advancement-rule.ts` — AdvancementRule types **Tasks:** ```typescript // 0.1.1 Define all enums export enum RoundType { INTAKE = "INTAKE", FILTERING = "FILTERING", EVALUATION = "EVALUATION", SUBMISSION = "SUBMISSION", MENTORING = "MENTORING", LIVE_FINAL = "LIVE_FINAL", CONFIRMATION = "CONFIRMATION", } export enum RoundStatus { ROUND_DRAFT = "ROUND_DRAFT", ROUND_ACTIVE = "ROUND_ACTIVE", ROUND_CLOSED = "ROUND_CLOSED", ROUND_ARCHIVED = "ROUND_ARCHIVED", } export enum CompetitionStatus { DRAFT = "DRAFT", ACTIVE = "ACTIVE", CLOSED = "CLOSED", ARCHIVED = "ARCHIVED", } // 0.1.2 Define round config shapes with Zod validation export const IntakeConfigSchema = z.object({ allowDrafts: z.boolean(), draftExpiryDays: z.number(), acceptedCategories: z.array(z.enum(["STARTUP", "BUSINESS_CONCEPT"])), publicFormEnabled: z.boolean(), customFields: z.array(CustomFieldSchema), }); export const FilteringConfigSchema = z.object({ rules: z.array(FilterRuleDefSchema), aiScreeningEnabled: z.boolean(), aiCriteriaText: z.string(), aiConfidenceThresholds: z.object({ high: z.number(), medium: z.number(), low: z.number(), }), manualReviewEnabled: z.boolean(), batchSize: z.number(), }); export const EvaluationConfigSchema = z.object({ requiredReviewsPerProject: z.number(), scoringMode: z.enum(["criteria", "global", "binary"]), requireFeedback: z.boolean(), coiRequired: z.boolean(), peerReviewEnabled: z.boolean(), anonymizationLevel: z.enum(["fully_anonymous", "show_initials", "named"]), aiSummaryEnabled: z.boolean(), advancementMode: z.enum(["auto_top_n", "admin_selection", "ai_recommended"]), advancementConfig: z.object({ perCategory: z.boolean(), startupCount: z.number(), conceptCount: z.number(), tieBreaker: z.enum(["admin_decides", "highest_individual", "revote"]), }), }); export const SubmissionConfigSchema = z.object({ eligibleStatuses: z.array(z.enum(["PASSED", "PENDING", "IN_PROGRESS"])), notifyEligibleTeams: z.boolean(), lockPreviousWindows: z.boolean(), }); export const MentoringConfigSchema = z.object({ eligibility: z.enum(["all_advancing", "requested_only"]), chatEnabled: z.boolean(), fileUploadEnabled: z.boolean(), fileCommentsEnabled: z.boolean(), filePromotionEnabled: z.boolean(), promotionTargetWindowId: z.string().nullable(), autoAssignMentors: z.boolean(), }); export const LiveFinalConfigSchema = z.object({ juryVotingEnabled: z.boolean(), votingMode: z.enum(["simple", "criteria"]), audienceVotingEnabled: z.boolean(), audienceVoteWeight: z.number(), audienceVotingMode: z.enum(["per_project", "per_category", "favorites"]), audienceMaxFavorites: z.number().nullable(), audienceRequireIdentification: z.boolean(), deliberationEnabled: z.boolean(), deliberationDurationMinutes: z.number(), showAudienceVotesToJury: z.boolean(), presentationOrderMode: z.enum(["manual", "random", "score_based"]), revealPolicy: z.enum(["immediate", "delayed", "ceremony"]), }); export const ConfirmationConfigSchema = z.object({ requireAllJuryApproval: z.boolean(), juryGroupId: z.string().nullable(), adminOverrideEnabled: z.boolean(), overrideModes: z.array(z.enum(["FORCE_MAJORITY", "ADMIN_DECISION"])), autoFreezeOnApproval: z.boolean(), perCategory: z.boolean(), }); // 0.1.3 Config union type with discriminated union export const RoundConfigSchema = z.discriminatedUnion("roundType", [ z.object({ roundType: z.literal(RoundType.INTAKE), config: IntakeConfigSchema }), z.object({ roundType: z.literal(RoundType.FILTERING), config: FilteringConfigSchema }), z.object({ roundType: z.literal(RoundType.EVALUATION), config: EvaluationConfigSchema }), z.object({ roundType: z.literal(RoundType.SUBMISSION), config: SubmissionConfigSchema }), z.object({ roundType: z.literal(RoundType.MENTORING), config: MentoringConfigSchema }), z.object({ roundType: z.literal(RoundType.LIVE_FINAL), config: LiveFinalConfigSchema }), z.object({ roundType: z.literal(RoundType.CONFIRMATION), config: ConfirmationConfigSchema }), ]); ``` #### 0.2 Feature Flags Setup **File to Create:** - `src/lib/feature-flags.ts` ```typescript export const FEATURE_FLAGS = { // Phase 1 ENABLE_COMPETITION_MODEL: false, ENABLE_ROUND_MODEL: false, ENABLE_JURY_GROUPS: false, ENABLE_SUBMISSION_WINDOWS: false, // Phase 2 ENABLE_ENHANCED_ASSIGNMENT: false, ENABLE_MENTOR_WORKSPACE: false, ENABLE_WINNER_CONFIRMATION: false, // Phase 3 ENABLE_COMPETITION_WIZARD: false, ENABLE_ROUND_MANAGEMENT_UI: false, // Phase 4 ENABLE_MULTI_ROUND_APPLICANT_UI: false, ENABLE_MENTOR_UI: false, // Phase 5 ENABLE_ENHANCED_AWARDS: false, // Phase 6 REMOVE_LEGACY_MODELS: false, } as const; export function isFeatureEnabled(flag: keyof typeof FEATURE_FLAGS): boolean { return FEATURE_FLAGS[flag] || process.env.NODE_ENV === "test"; } ``` #### 0.3 Test Infrastructure **Files to Create:** - `tests/helpers-redesign.ts` — New factories for Competition, Round, JuryGroup, etc. ```typescript import { prisma } from "./setup"; // Factory: Competition export async function createTestCompetition(programId: string, overrides?: Partial) { return prisma.competition.create({ data: { programId, name: overrides?.name || "Test Competition 2026", slug: overrides?.slug || `comp-${uid()}`, status: overrides?.status || "DRAFT", categoryMode: overrides?.categoryMode || "SHARED", startupFinalistCount: overrides?.startupFinalistCount || 3, conceptFinalistCount: overrides?.conceptFinalistCount || 3, ...overrides, }, }); } // Factory: Round export async function createTestRound(competitionId: string, roundType: RoundType, overrides?: Partial) { return prisma.round.create({ data: { competitionId, name: overrides?.name || `Test ${roundType} Round`, slug: overrides?.slug || `round-${uid()}`, roundType, status: overrides?.status || "ROUND_DRAFT", sortOrder: overrides?.sortOrder || 0, ...overrides, }, }); } // Factory: JuryGroup export async function createTestJuryGroup(competitionId: string, overrides?: Partial) { return prisma.juryGroup.create({ data: { competitionId, name: overrides?.name || "Test Jury Group", slug: overrides?.slug || `jury-${uid()}`, defaultMaxAssignments: overrides?.defaultMaxAssignments || 20, defaultCapMode: overrides?.defaultCapMode || "SOFT", softCapBuffer: overrides?.softCapBuffer || 2, ...overrides, }, }); } // Factory: JuryGroupMember export async function createTestJuryMember(juryGroupId: string, userId: string, overrides?: Partial) { return prisma.juryGroupMember.create({ data: { juryGroupId, userId, isLead: overrides?.isLead || false, ...overrides, }, }); } // Factory: SubmissionWindow export async function createTestSubmissionWindow(competitionId: string, overrides?: Partial) { return prisma.submissionWindow.create({ data: { competitionId, name: overrides?.name || "Test Submission Window", slug: overrides?.slug || `window-${uid()}`, roundNumber: overrides?.roundNumber || 1, sortOrder: overrides?.sortOrder || 0, deadlinePolicy: overrides?.deadlinePolicy || "FLAG", lockOnClose: overrides?.lockOnClose ?? true, ...overrides, }, }); } // Factory: ProjectRoundState export async function createTestProjectRoundState(projectId: string, roundId: string, state: ProjectRoundStateValue) { return prisma.projectRoundState.create({ data: { projectId, roundId, state, }, }); } // Factory: WinnerProposal export async function createTestWinnerProposal(competitionId: string, category: CompetitionCategory, overrides?: Partial) { return prisma.winnerProposal.create({ data: { competitionId, category, status: overrides?.status || "PENDING", rankedProjectIds: overrides?.rankedProjectIds || [], sourceRoundId: overrides?.sourceRoundId || "", selectionBasis: overrides?.selectionBasis || {}, proposedById: overrides?.proposedById || "", ...overrides, }, }); } // Cleanup helper (add to existing cleanupTestData) export async function cleanupTestDataRedesign() { await prisma.winnerApproval.deleteMany(); await prisma.winnerProposal.deleteMany(); await prisma.projectRoundState.deleteMany(); await prisma.advancementRule.deleteMany(); await prisma.juryGroupMember.deleteMany(); await prisma.juryGroup.deleteMany(); await prisma.submissionFileRequirement.deleteMany(); await prisma.roundSubmissionVisibility.deleteMany(); await prisma.submissionWindow.deleteMany(); await prisma.round.deleteMany(); await prisma.competition.deleteMany(); } ``` #### 0.4 API Contract Documentation **File to Create:** - `docs/claude-architecture-redesign/api-contracts.md` Document all new tRPC endpoints with input/output shapes (covered in doc 19). ### Acceptance Criteria - [ ] All TypeScript types compile without errors - [ ] All Zod schemas validate correctly - [ ] Feature flags file exists and all flags default to `false` - [ ] Test helper factories exist for all new models - [ ] API contract documentation complete - [ ] Development environment runs without issues - [ ] All team members have reviewed and approved contracts ### Deliverables 1. `src/types/` directory with all new type definitions 2. `src/lib/feature-flags.ts` with all flags 3. `tests/helpers-redesign.ts` with factory functions 4. API contract documentation 5. Updated `CLAUDE.md` with redesign context ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Type definitions change during implementation | High | Lock contracts via code review + sign-off | | Test helpers incomplete | Medium | Review against data model doc systematically | | Feature flag conflicts | Low | Namespace with `ENABLE_` prefix | --- ## Phase 1: Schema & Runtime Foundation **Weeks 2-3** ### Goals 1. Prisma schema migration — add all new tables (Phase 1 of migration) 2. Competition model CRUD 3. Round model CRUD (basic, no type-specific logic yet) 4. JuryGroup and JuryGroupMember CRUD 5. SubmissionWindow CRUD 6. ProjectRoundState basic operations 7. Round engine (simplified state machine) ### Dependencies - Phase 0 complete ### Tasks #### 1.1 Prisma Schema Migration (Phase 1 — Additive Only) **Files to Modify:** - `prisma/schema.prisma` **Tasks:** ```bash # 1.1.1 Create migration npx prisma migrate dev --name add_competition_round_jury_models --create-only # 1.1.2 Review generated migration SQL # Verify: # - New tables created: Competition, Round, JuryGroup, JuryGroupMember, # SubmissionWindow, SubmissionFileRequirement, RoundSubmissionVisibility, # ProjectRoundState, AdvancementRule, WinnerProposal, WinnerApproval, # MentorFile, MentorFileComment # - New columns added to existing tables: # - Assignment.juryGroupId, Assignment.roundId (alongside stageId) # - ProjectFile.submissionWindowId, ProjectFile.requirementId # - Project.competitionId # - SpecialAward.competitionId, evaluationRoundId, juryGroupId, eligibilityMode # - MentorAssignment.workspaceEnabled, workspaceOpenAt, workspaceCloseAt # - New enums: RoundType, RoundStatus, CompetitionStatus, CapMode, DeadlinePolicy, # ProjectRoundStateValue, AdvancementRuleType, AwardEligibilityMode, # WinnerProposalStatus, WinnerApprovalRole # 1.1.3 Apply migration npx prisma migrate dev # 1.1.4 Regenerate Prisma client npx prisma generate ``` **Schema Changes:** Add all models from doc 03-data-model.md: - Competition (with enums) - Round (with enums) - JuryGroup - JuryGroupMember - SubmissionWindow - SubmissionFileRequirement - RoundSubmissionVisibility - ProjectRoundState (with enums) - AdvancementRule (with enums) - WinnerProposal (with enums) - WinnerApproval (with enums) - MentorFile - MentorFileComment Modify existing models: - Assignment: add `juryGroupId`, `roundId` (keep `stageId` for now) - ProjectFile: add `submissionWindowId`, `requirementId` - Project: add `competitionId` - SpecialAward: add `competitionId`, `evaluationRoundId`, `juryGroupId`, `eligibilityMode` - MentorAssignment: add `workspaceEnabled`, `workspaceOpenAt`, `workspaceCloseAt` - User: add relations for jury memberships, mentor files, winner proposals/approvals #### 1.2 Competition CRUD Router **File to Create:** - `src/server/routers/competition.ts` ```typescript import { router, protectedProcedure, adminProcedure } from "../trpc"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { isFeatureEnabled } from "@/lib/feature-flags"; export const competitionRouter = router({ // Create competition 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 }) => { if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } return ctx.prisma.competition.create({ data: input, }); }), // List competitions for a program listByProgram: protectedProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) { return []; } return ctx.prisma.competition.findMany({ where: { programId: input.programId }, include: { rounds: { orderBy: { sortOrder: "asc" } }, juryGroups: true, submissionWindows: { orderBy: { sortOrder: "asc" } }, specialAwards: true, }, orderBy: { createdAt: "desc" }, }); }), // Get competition by ID getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) { throw new TRPCError({ code: "NOT_FOUND" }); } const competition = await ctx.prisma.competition.findUnique({ where: { id: input.id }, include: { program: true, rounds: { orderBy: { sortOrder: "asc" } }, juryGroups: { include: { members: { include: { user: true } } } }, submissionWindows: { orderBy: { sortOrder: "asc" } }, specialAwards: true, }, }); if (!competition) { throw new TRPCError({ code: "NOT_FOUND", message: "Competition not found" }); } return competition; }), // Update competition update: adminProcedure .input( z.object({ id: z.string(), name: z.string().optional(), status: z.enum(["DRAFT", "ACTIVE", "CLOSED", "ARCHIVED"]).optional(), categoryMode: z.enum(["SHARED", "SPLIT"]).optional(), startupFinalistCount: z.number().optional(), conceptFinalistCount: z.number().optional(), notifyOnRoundAdvance: z.boolean().optional(), notifyOnDeadlineApproach: z.boolean().optional(), deadlineReminderDays: z.array(z.number()).optional(), }) ) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } const { id, ...data } = input; return ctx.prisma.competition.update({ where: { id }, data, }); }), // Delete competition delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } return ctx.prisma.competition.delete({ where: { id: input.id }, }); }), }); ``` #### 1.3 Round CRUD Router **File to Create:** - `src/server/routers/round.ts` ```typescript import { router, protectedProcedure, adminProcedure } from "../trpc"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { isFeatureEnabled } from "@/lib/feature-flags"; import { RoundType, RoundStatus } from "@prisma/client"; export const roundRouter = router({ // Create round create: adminProcedure .input( z.object({ competitionId: z.string(), name: z.string(), slug: z.string(), roundType: z.nativeEnum(RoundType), sortOrder: z.number(), windowOpenAt: z.date().optional(), windowCloseAt: z.date().optional(), configJson: z.any().optional(), // Validated per roundType juryGroupId: z.string().optional(), submissionWindowId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } return ctx.prisma.round.create({ data: input, }); }), // List rounds for competition listByCompetition: protectedProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) { return []; } return ctx.prisma.round.findMany({ where: { competitionId: input.competitionId }, include: { juryGroup: { include: { members: { include: { user: true } } } }, submissionWindow: { include: { fileRequirements: true } }, projectRoundStates: { include: { project: true } }, visibleSubmissionWindows: { include: { submissionWindow: true } }, }, orderBy: { sortOrder: "asc" }, }); }), // Get round by ID getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) { throw new TRPCError({ code: "NOT_FOUND" }); } const round = await ctx.prisma.round.findUnique({ where: { id: input.id }, include: { competition: true, juryGroup: { include: { members: { include: { user: true } } } }, submissionWindow: { include: { fileRequirements: true } }, projectRoundStates: { include: { project: true } }, advancementRules: true, visibleSubmissionWindows: { include: { submissionWindow: true } }, }, }); if (!round) { throw new TRPCError({ code: "NOT_FOUND", message: "Round not found" }); } return round; }), // Update round update: adminProcedure .input( z.object({ id: z.string(), name: z.string().optional(), status: z.nativeEnum(RoundStatus).optional(), windowOpenAt: z.date().optional(), windowCloseAt: z.date().optional(), configJson: z.any().optional(), juryGroupId: z.string().optional(), submissionWindowId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } const { id, ...data } = input; return ctx.prisma.round.update({ where: { id }, data, }); }), // Delete round delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } return ctx.prisma.round.delete({ where: { id: input.id }, }); }), }); ``` #### 1.4 JuryGroup CRUD Router **File to Create:** - `src/server/routers/jury-group.ts` ```typescript import { router, protectedProcedure, adminProcedure } from "../trpc"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { isFeatureEnabled } from "@/lib/feature-flags"; import { CapMode } from "@prisma/client"; export const juryGroupRouter = router({ // Create jury group create: adminProcedure .input( z.object({ competitionId: z.string(), name: z.string(), slug: z.string(), description: z.string().optional(), sortOrder: z.number().default(0), defaultMaxAssignments: z.number().default(20), defaultCapMode: z.nativeEnum(CapMode).default("SOFT"), softCapBuffer: z.number().default(2), categoryQuotasEnabled: z.boolean().default(false), defaultCategoryQuotas: z.any().optional(), allowJurorCapAdjustment: z.boolean().default(false), allowJurorRatioAdjustment: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } return ctx.prisma.juryGroup.create({ data: input, }); }), // Add member to jury group addMember: adminProcedure .input( z.object({ juryGroupId: z.string(), userId: z.string(), isLead: z.boolean().default(false), maxAssignmentsOverride: z.number().optional(), capModeOverride: z.nativeEnum(CapMode).optional(), categoryQuotasOverride: z.any().optional(), preferredStartupRatio: z.number().optional(), availabilityNotes: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } return ctx.prisma.juryGroupMember.create({ data: input, }); }), // Remove member from jury group removeMember: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } return ctx.prisma.juryGroupMember.delete({ where: { id: input.id }, }); }), // List jury groups for competition listByCompetition: protectedProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) { return []; } return ctx.prisma.juryGroup.findMany({ where: { competitionId: input.competitionId }, include: { members: { include: { user: true } }, rounds: true, }, orderBy: { sortOrder: "asc" }, }); }), // Get jury group by ID getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) { throw new TRPCError({ code: "NOT_FOUND" }); } const juryGroup = await ctx.prisma.juryGroup.findUnique({ where: { id: input.id }, include: { competition: true, members: { include: { user: true } }, rounds: true, assignments: { include: { project: true, user: true } }, }, }); if (!juryGroup) { throw new TRPCError({ code: "NOT_FOUND", message: "Jury group not found" }); } return juryGroup; }), // Update jury group update: adminProcedure .input( z.object({ id: z.string(), name: z.string().optional(), description: z.string().optional(), defaultMaxAssignments: z.number().optional(), defaultCapMode: z.nativeEnum(CapMode).optional(), softCapBuffer: z.number().optional(), categoryQuotasEnabled: z.boolean().optional(), defaultCategoryQuotas: z.any().optional(), allowJurorCapAdjustment: z.boolean().optional(), allowJurorRatioAdjustment: z.boolean().optional(), }) ) .mutation(async ({ ctx, input }) => { if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) { throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" }); } const { id, ...data } = input; return ctx.prisma.juryGroup.update({ where: { id }, data, }); }), }); ``` #### 1.5 SubmissionWindow CRUD Router **File to Create:** - `src/server/routers/submission-window.ts` Similar structure to jury-group.ts, with create/update/delete/list/getById for SubmissionWindow and SubmissionFileRequirement. #### 1.6 Round Engine (Simplified State Machine) **File to Create:** - `src/server/services/round-engine.ts` ```typescript import { prisma } from "@/lib/prisma"; import { ProjectRoundStateValue, RoundStatus } from "@prisma/client"; import { TRPCError } from "@trpc/server"; export class RoundEngine { // Transition project to a round async transitionProjectToRound( projectId: string, roundId: string, state: ProjectRoundStateValue = "PENDING" ) { const existingState = await prisma.projectRoundState.findUnique({ where: { projectId_roundId: { projectId, roundId } }, }); if (existingState) { // Update existing return prisma.projectRoundState.update({ where: { id: existingState.id }, data: { state }, }); } else { // Create new return prisma.projectRoundState.create({ data: { projectId, roundId, state }, }); } } // Advance project to PASSED (ready for next round) async markProjectPassed(projectId: string, roundId: string) { return this.transitionProjectToRound(projectId, roundId, "PASSED"); } // Reject project async markProjectRejected(projectId: string, roundId: string) { return this.transitionProjectToRound(projectId, roundId, "REJECTED"); } // Get all projects in a round with specific state async getProjectsInRoundByState(roundId: string, state?: ProjectRoundStateValue) { return prisma.projectRoundState.findMany({ where: { roundId, ...(state && { state }), }, include: { project: true }, }); } // Advance all PASSED projects from source round to target round async advancePassedProjects(sourceRoundId: string, targetRoundId: string) { const passedProjects = await this.getProjectsInRoundByState(sourceRoundId, "PASSED"); const results = await Promise.all( passedProjects.map((prs) => this.transitionProjectToRound(prs.projectId, targetRoundId, "PENDING") ) ); return results; } // Open round (set status to ROUND_ACTIVE) async openRound(roundId: string) { return prisma.round.update({ where: { id: roundId }, data: { status: "ROUND_ACTIVE" }, }); } // Close round async closeRound(roundId: string) { return prisma.round.update({ where: { id: roundId }, data: { status: "ROUND_CLOSED" }, }); } } export const roundEngine = new RoundEngine(); ``` #### 1.7 Register New Routers in App Router **File to Modify:** - `src/server/routers/_app.ts` ```typescript import { competitionRouter } from "./competition"; import { roundRouter } from "./round"; import { juryGroupRouter } from "./jury-group"; import { submissionWindowRouter } from "./submission-window"; export const appRouter = router({ // ... existing routers competition: competitionRouter, round: roundRouter, juryGroup: juryGroupRouter, submissionWindow: submissionWindowRouter, }); ``` #### 1.8 Unit Tests for Phase 1 **Files to Create:** - `tests/unit/competition.test.ts` - `tests/unit/round.test.ts` - `tests/unit/jury-group.test.ts` - `tests/unit/submission-window.test.ts` - `tests/unit/round-engine.test.ts` Example test structure: ```typescript // tests/unit/competition.test.ts import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { prisma } from "../setup"; import { createTestUser, createTestProgram, createTestCompetition, cleanupTestDataRedesign } from "../helpers-redesign"; import { createCaller } from "../setup"; import { competitionRouter } from "@/server/routers/competition"; describe("Competition Router", () => { let adminUser: any; let program: any; beforeAll(async () => { adminUser = await createTestUser({ role: "PROGRAM_ADMIN" }); program = await createTestProgram(); }); afterAll(async () => { await cleanupTestDataRedesign(); }); it("should create a competition", async () => { const caller = createCaller(competitionRouter, adminUser); const competition = await caller.create({ programId: program.id, name: "Test Competition 2026", slug: "test-comp-2026", }); expect(competition).toBeDefined(); expect(competition.name).toBe("Test Competition 2026"); expect(competition.status).toBe("DRAFT"); }); it("should list competitions by program", async () => { const caller = createCaller(competitionRouter, adminUser); const competitions = await caller.listByProgram({ programId: program.id }); expect(competitions.length).toBeGreaterThan(0); }); // ... more tests }); ``` ### Acceptance Criteria - [ ] Prisma migration applied successfully - [ ] All new tables exist in database - [ ] Competition CRUD works (create, read, update, delete) - [ ] Round CRUD works - [ ] JuryGroup CRUD works (create group, add/remove members) - [ ] SubmissionWindow CRUD works - [ ] ProjectRoundState can be created and queried - [ ] Round engine can transition projects between states - [ ] Round engine can advance projects to next round - [ ] All unit tests pass (100% coverage for new routers) - [ ] TypeScript compiles without errors - [ ] No regressions in existing system (old pipelines still work) ### Deliverables 1. Prisma schema with all new models 2. Applied migration 3. Competition router with full CRUD 4. Round router with full CRUD 5. JuryGroup router with full CRUD 6. SubmissionWindow router with full CRUD 7. Round engine service with state transitions 8. Complete unit test coverage 9. Documentation updates ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Migration fails on existing data | High | Additive-only migration, no destructive changes | | Performance issues with new indexes | Medium | Load test with seed data, optimize indexes | | Type mismatches Prisma vs TypeScript | Low | Regenerate client after each schema change | --- ## Phase 2: Backend Orchestration **Weeks 3-5** ### Goals 1. Enhanced assignment algorithm with jury groups, caps, quotas 2. Filtering service updates (stageId → roundId) 3. Submission round manager (window locking, deadline enforcement) 4. Mentor workspace service (file upload, comments, promotion) 5. Winner confirmation service (proposals, approvals, freeze) 6. Live control enhancements (category windows) ### Dependencies - Phase 1 complete ### Tasks #### 2.1 Enhanced Assignment Service **File to Modify:** - `src/server/services/stage-assignment.ts` → rename to `src/server/services/round-assignment.ts` **New Features:** 1. **Jury group awareness** — Generate assignments respecting juryGroupId 2. **Hard/soft caps** — Enforce per-juror max assignments with buffer 3. **Category quotas** — Respect min/max quotas per category 4. **Juror preferences** — Factor in preferredStartupRatio ```typescript // src/server/services/round-assignment.ts import { prisma } from "@/lib/prisma"; import { AssignmentMethod, CapMode } from "@prisma/client"; interface AssignmentConfig { roundId: string; juryGroupId: string; requiredReviewsPerProject: number; } export class RoundAssignmentService { async generateAssignments(config: AssignmentConfig) { const { roundId, juryGroupId, requiredReviewsPerProject } = config; // 1. Get jury group with members const juryGroup = await prisma.juryGroup.findUnique({ where: { id: juryGroupId }, include: { members: { include: { user: true } } }, }); if (!juryGroup) { throw new Error("Jury group not found"); } // 2. Get projects in round (PENDING or IN_PROGRESS) const projectRoundStates = await prisma.projectRoundState.findMany({ where: { roundId, state: { in: ["PENDING", "IN_PROGRESS"] }, }, include: { project: true }, }); const projects = projectRoundStates.map((prs) => prs.project); // 3. Get existing assignments const existingAssignments = await prisma.assignment.findMany({ where: { roundId, juryGroupId }, }); // 4. Calculate caps per juror const jurorCaps = juryGroup.members.map((member) => { const maxAssignments = member.maxAssignmentsOverride ?? juryGroup.defaultMaxAssignments; const capMode = member.capModeOverride ?? juryGroup.defaultCapMode; return { userId: member.userId, maxAssignments, capMode, softCapBuffer: juryGroup.softCapBuffer, categoryQuotas: member.categoryQuotasOverride ?? juryGroup.defaultCategoryQuotas, preferredStartupRatio: member.preferredStartupRatio, currentCount: existingAssignments.filter((a) => a.userId === member.userId).length, }; }); // 5. Generate assignments using algorithm (AI or round-robin) const newAssignments = []; for (const project of projects) { const projectAssignments = existingAssignments.filter( (a) => a.projectId === project.id ); // How many more assignments needed? const needed = requiredReviewsPerProject - projectAssignments.length; if (needed <= 0) continue; // Find available jurors (respecting caps and quotas) const availableJurors = jurorCaps.filter((juror) => { // Check hard cap if (juror.capMode === "HARD" && juror.currentCount >= juror.maxAssignments) { return false; } // Check soft cap (can exceed by buffer) if (juror.capMode === "SOFT") { const softMax = juror.maxAssignments + juror.softCapBuffer; if (juror.currentCount >= softMax) { return false; } } // Check category quotas if (juror.categoryQuotas && project.competitionCategory) { const quota = juror.categoryQuotas[project.competitionCategory]; if (quota) { const categoryCount = existingAssignments.filter( (a) => a.userId === juror.userId && a.project?.competitionCategory === project.competitionCategory ).length; if (categoryCount >= quota.max) { return false; } } } // Check COI (not implemented here, would query ConflictOfInterest) return true; }); // Select jurors (round-robin or AI-based) const selectedJurors = availableJurors.slice(0, needed); for (const juror of selectedJurors) { newAssignments.push({ userId: juror.userId, projectId: project.id, roundId, juryGroupId, method: "ALGORITHM" as AssignmentMethod, isRequired: true, }); juror.currentCount++; } } // 6. Insert assignments const created = await prisma.assignment.createMany({ data: newAssignments, }); return { created: created.count, assignments: newAssignments }; } // Check if juror can take more assignments async canJurorTakeAssignment(userId: string, roundId: string, projectId: string) { const assignment = await prisma.assignment.findFirst({ where: { userId, roundId }, include: { juryGroup: { include: { members: true } } }, }); if (!assignment) return false; const juryGroup = assignment.juryGroup; if (!juryGroup) return false; const member = juryGroup.members.find((m) => m.userId === userId); if (!member) return false; const maxAssignments = member.maxAssignmentsOverride ?? juryGroup.defaultMaxAssignments; const capMode = member.capModeOverride ?? juryGroup.defaultCapMode; const currentCount = await prisma.assignment.count({ where: { userId, roundId, juryGroupId: juryGroup.id }, }); if (capMode === "HARD") { return currentCount < maxAssignments; } else if (capMode === "SOFT") { const softMax = maxAssignments + juryGroup.softCapBuffer; return currentCount < softMax; } return true; // NONE } } export const roundAssignmentService = new RoundAssignmentService(); ``` #### 2.2 Filtering Service Updates **File to Modify:** - `src/server/services/stage-filtering.ts` → keep name, just update references **Changes:** 1. Replace `stageId` with `roundId` in all queries 2. Update FilteringJob, FilteringResult, FilteringRule to use roundId This is mostly a search-and-replace task with careful testing. #### 2.3 Submission Round Manager Service **File to Create:** - `src/server/services/submission-round-manager.ts` ```typescript import { prisma } from "@/lib/prisma"; import { DeadlinePolicy } from "@prisma/client"; export class SubmissionRoundManager { // Open a submission window async openWindow(submissionWindowId: string) { const window = await prisma.submissionWindow.update({ where: { id: submissionWindowId }, data: { windowOpenAt: new Date() }, }); // Lock all previous windows (if configured) const competition = await prisma.competition.findUnique({ where: { id: window.competitionId }, include: { submissionWindows: { orderBy: { roundNumber: "asc" } } }, }); if (competition) { const previousWindows = competition.submissionWindows.filter( (w) => w.roundNumber < window.roundNumber ); for (const prevWindow of previousWindows) { if (prevWindow.lockOnClose) { await prisma.submissionWindow.update({ where: { id: prevWindow.id }, data: { windowCloseAt: new Date() }, }); } } } return window; } // Close a submission window async closeWindow(submissionWindowId: string) { return prisma.submissionWindow.update({ where: { id: submissionWindowId }, data: { windowCloseAt: new Date() }, }); } // Check if submission is allowed (respects deadline policy) async canSubmit(projectId: string, submissionWindowId: string) { const window = await prisma.submissionWindow.findUnique({ where: { id: submissionWindowId }, }); if (!window) return { allowed: false, reason: "Window not found" }; const now = new Date(); // Before window opens if (window.windowOpenAt && now < window.windowOpenAt) { return { allowed: false, reason: "Window not yet open" }; } // After window closes if (window.windowCloseAt && now > window.windowCloseAt) { if (window.deadlinePolicy === "HARD") { return { allowed: false, reason: "Deadline passed (hard cutoff)" }; } else if (window.deadlinePolicy === "GRACE" && window.graceHours) { const graceCutoff = new Date( window.windowCloseAt.getTime() + window.graceHours * 60 * 60 * 1000 ); if (now > graceCutoff) { return { allowed: false, reason: "Grace period expired" }; } else { return { allowed: true, isLate: true }; } } else if (window.deadlinePolicy === "FLAG") { return { allowed: true, isLate: true }; } } return { allowed: true, isLate: false }; } // Upload file for submission window async uploadFile( projectId: string, submissionWindowId: string, requirementId: string, fileData: any ) { const canSubmitResult = await this.canSubmit(projectId, submissionWindowId); if (!canSubmitResult.allowed) { throw new Error(canSubmitResult.reason); } return prisma.projectFile.create({ data: { projectId, submissionWindowId, requirementId, isLate: canSubmitResult.isLate || false, ...fileData, }, }); } } export const submissionRoundManager = new SubmissionRoundManager(); ``` #### 2.4 Mentor Workspace Service **File to Create:** - `src/server/services/mentor-workspace.ts` ```typescript import { prisma } from "@/lib/prisma"; export class MentorWorkspaceService { // Activate workspace for a mentoring round async activateWorkspace(mentorAssignmentId: string, openAt: Date, closeAt: Date) { return prisma.mentorAssignment.update({ where: { id: mentorAssignmentId }, data: { workspaceEnabled: true, workspaceOpenAt: openAt, workspaceCloseAt: closeAt, }, }); } // Upload file to workspace async uploadFile( mentorAssignmentId: string, uploadedByUserId: string, fileData: { fileName: string; mimeType: string; size: number; bucket: string; objectKey: string; description?: string; } ) { return prisma.mentorFile.create({ data: { mentorAssignmentId, uploadedByUserId, ...fileData, }, }); } // Add comment to file async addComment( mentorFileId: string, authorId: string, content: string, parentCommentId?: string ) { return prisma.mentorFileComment.create({ data: { mentorFileId, authorId, content, parentCommentId, }, }); } // Promote file to official submission async promoteFileToSubmission( mentorFileId: string, promotedByUserId: string, targetSubmissionWindowId: string, targetRequirementId: string ) { const mentorFile = await prisma.mentorFile.findUnique({ where: { id: mentorFileId }, include: { mentorAssignment: { include: { project: true } } }, }); if (!mentorFile) throw new Error("File not found"); if (mentorFile.isPromoted) throw new Error("File already promoted"); // Create ProjectFile const projectFile = await prisma.projectFile.create({ data: { projectId: mentorFile.mentorAssignment.projectId, submissionWindowId: targetSubmissionWindowId, requirementId: targetRequirementId, fileType: "APPLICATION_DOC", fileName: mentorFile.fileName, mimeType: mentorFile.mimeType, size: mentorFile.size, bucket: mentorFile.bucket, objectKey: mentorFile.objectKey, }, }); // Mark mentor file as promoted await prisma.mentorFile.update({ where: { id: mentorFileId }, data: { isPromoted: true, promotedToFileId: projectFile.id, promotedAt: new Date(), promotedByUserId, }, }); return projectFile; } // Get workspace files for mentor assignment async getWorkspaceFiles(mentorAssignmentId: string) { return prisma.mentorFile.findMany({ where: { mentorAssignmentId }, include: { uploadedBy: true, comments: { include: { author: true } }, promotedFile: true, }, orderBy: { createdAt: "desc" }, }); } } export const mentorWorkspaceService = new MentorWorkspaceService(); ``` #### 2.5 Winner Confirmation Service **File to Create:** - `src/server/services/winner-confirmation.ts` ```typescript import { prisma } from "@/lib/prisma"; import { CompetitionCategory, WinnerProposalStatus } from "@prisma/client"; export class WinnerConfirmationService { // Create winner proposal async createProposal( competitionId: string, category: CompetitionCategory, rankedProjectIds: string[], sourceRoundId: string, selectionBasis: any, proposedById: string ) { return prisma.winnerProposal.create({ data: { competitionId, category, rankedProjectIds, sourceRoundId, selectionBasis, proposedById, status: "PENDING", }, }); } // Request approvals from jury members async requestApprovals(winnerProposalId: string, juryMemberUserIds: string[]) { const approvals = juryMemberUserIds.map((userId) => ({ winnerProposalId, userId, role: "JURY_MEMBER" as const, })); return prisma.winnerApproval.createMany({ data: approvals, }); } // Submit approval async submitApproval( winnerProposalId: string, userId: string, approved: boolean, comments?: string ) { const approval = await prisma.winnerApproval.update({ where: { winnerProposalId_userId: { winnerProposalId, userId }, }, data: { approved, comments, respondedAt: new Date(), }, }); // Check if all approvals are in await this.checkAndUpdateProposalStatus(winnerProposalId); return approval; } // Check approval status and update proposal async checkAndUpdateProposalStatus(winnerProposalId: string) { const proposal = await prisma.winnerProposal.findUnique({ where: { id: winnerProposalId }, include: { approvals: true }, }); if (!proposal) return; const allResponded = proposal.approvals.every((a) => a.approved !== null); if (allResponded) { const allApproved = proposal.approvals.every((a) => a.approved === true); const anyRejected = proposal.approvals.some((a) => a.approved === false); if (allApproved) { await prisma.winnerProposal.update({ where: { id: winnerProposalId }, data: { status: "APPROVED" }, }); } else if (anyRejected) { await prisma.winnerProposal.update({ where: { id: winnerProposalId }, data: { status: "REJECTED" }, }); } } } // Admin override async applyOverride( winnerProposalId: string, overrideMode: string, overrideReason: string, overrideById: string ) { return prisma.winnerProposal.update({ where: { id: winnerProposalId }, data: { status: "OVERRIDDEN", overrideUsed: true, overrideMode, overrideReason, overrideById, }, }); } // Freeze results (lock as official) async freezeResults(winnerProposalId: string, frozenById: string) { return prisma.winnerProposal.update({ where: { id: winnerProposalId }, data: { status: "FROZEN", frozenAt: new Date(), frozenById, }, }); } } export const winnerConfirmationService = new WinnerConfirmationService(); ``` #### 2.6 Integration Tests for Phase 2 **Files to Create:** - `tests/integration/assignment-service.test.ts` - `tests/integration/submission-round-manager.test.ts` - `tests/integration/mentor-workspace.test.ts` - `tests/integration/winner-confirmation.test.ts` ### Acceptance Criteria - [ ] Assignment service generates assignments with jury group caps/quotas - [ ] Filtering service works with roundId instead of stageId - [ ] Submission round manager enforces deadline policies correctly - [ ] Mentor workspace service allows file upload, comments, and promotion - [ ] Winner confirmation service creates proposals and tracks approvals - [ ] All integration tests pass - [ ] No performance regressions (load test with 1000 projects, 50 jurors) ### Deliverables 1. Enhanced assignment service 2. Updated filtering service 3. Submission round manager service 4. Mentor workspace service 5. Winner confirmation service 6. Complete integration test coverage 7. Performance test report ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Assignment algorithm too slow | High | Optimize queries, add caching | | Deadline logic has edge cases | Medium | Comprehensive test matrix | | File promotion conflicts | Medium | Transaction isolation, unique constraints | --- ## Phase 3: Admin Control Plane **Weeks 4-6** ### Goals 1. Competition setup wizard (replaces pipeline wizard) 2. Round management UI 3. Jury group management UI 4. Submission window configuration UI 5. Stage manager enhancements (live control) ### Dependencies - Phase 1 complete - Partial Phase 2 (services can be stubbed for UI development) ### Tasks #### 3.1 Competition Setup Wizard **Files to Create:** - `src/app/(admin)/competitions/new/page.tsx` - `src/components/admin/competition-wizard/WizardSteps.tsx` - `src/components/admin/competition-wizard/Step1-BasicInfo.tsx` - `src/components/admin/competition-wizard/Step2-Rounds.tsx` - `src/components/admin/competition-wizard/Step3-JuryGroups.tsx` - `src/components/admin/competition-wizard/Step4-SubmissionWindows.tsx` - `src/components/admin/competition-wizard/Step5-SpecialAwards.tsx` - `src/components/admin/competition-wizard/Step6-Review.tsx` **Wizard Flow:** 1. **Basic Info** — Name, slug, category mode, finalist counts 2. **Rounds** — Add rounds with type, config, order 3. **Jury Groups** — Create groups, add members, set caps 4. **Submission Windows** — Define doc requirements per round 5. **Special Awards** — Configure awards (if any) 6. **Review & Create** — Summary and save Similar structure to existing pipeline wizard, but with clearer types. #### 3.2 Round Management UI **Files to Create:** - `src/app/(admin)/competitions/[id]/rounds/page.tsx` - `src/components/admin/rounds/RoundList.tsx` - `src/components/admin/rounds/RoundCard.tsx` - `src/components/admin/rounds/RoundEditor.tsx` - `src/components/admin/rounds/config-editors/IntakeConfigEditor.tsx` - `src/components/admin/rounds/config-editors/FilteringConfigEditor.tsx` - `src/components/admin/rounds/config-editors/EvaluationConfigEditor.tsx` - ... (one per round type) **Features:** - Drag-and-drop reordering - Round status badges - Click to edit round config - Link rounds to jury groups and submission windows - Preview advancement rules #### 3.3 Jury Group Management UI **Files to Create:** - `src/app/(admin)/competitions/[id]/jury-groups/page.tsx` - `src/components/admin/jury-groups/JuryGroupList.tsx` - `src/components/admin/jury-groups/JuryGroupCard.tsx` - `src/components/admin/jury-groups/MemberManager.tsx` - `src/components/admin/jury-groups/CapQuotaEditor.tsx` **Features:** - Add/remove jury members - Set per-juror overrides (caps, quotas) - View assignment distribution - See which rounds this jury is assigned to #### 3.4 Submission Window Configuration UI **Files to Create:** - `src/app/(admin)/competitions/[id]/submission-windows/page.tsx` - `src/components/admin/submission-windows/WindowList.tsx` - `src/components/admin/submission-windows/WindowEditor.tsx` - `src/components/admin/submission-windows/RequirementManager.tsx` **Features:** - Add/edit submission windows - Configure file requirements (type, size, required/optional) - Set deadline policies (hard/flag/grace) - Configure visibility (which jury rounds see which windows) #### 3.5 Enhanced Stage Manager (Live Control) **Files to Modify:** - `src/app/(admin)/stage-manager/[stageId]/page.tsx` → keep, but add support for roundId - Add support for category-specific windows in live finals ### Acceptance Criteria - [ ] Admin can create full competition through wizard - [ ] Admin can edit rounds and reorder them - [ ] Admin can manage jury groups and members - [ ] Admin can configure submission windows - [ ] Stage manager works with rounds (backward compatible with stages) - [ ] All admin UI flows tested manually - [ ] Responsive design works on tablet/desktop ### Deliverables 1. Competition wizard (6-step flow) 2. Round management UI 3. Jury group management UI 4. Submission window UI 5. Enhanced stage manager 6. UI/UX documentation ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Wizard too complex | Medium | User testing, simplify steps | | Config editors error-prone | High | Zod validation + clear error messages | | Performance with large jury groups | Low | Pagination, virtualized lists | --- ## Phase 4: Participant Journeys **Weeks 5-7** ### Goals 1. Applicant UI: multi-round submissions, mentoring workspace 2. Jury UI: multi-jury dashboard, cross-round document visibility 3. Mentor UI: dedicated dashboard and workspace 4. Audience voting UI (enhanced) ### Dependencies - Phase 2 complete (backend services) - Phase 3 complete (admin can create competitions) ### Tasks #### 4.1 Applicant Multi-Round Submission UI **Files to Modify:** - `src/app/(applicant)/dashboard/page.tsx` — Add multi-round status - `src/app/(applicant)/submit/page.tsx` — Support multiple submission windows **Files to Create:** - `src/components/applicant/SubmissionTimeline.tsx` — Shows all windows - `src/components/applicant/WindowUploader.tsx` — Upload per window - `src/components/applicant/MentoringWorkspace.tsx` — Mentor file exchange **Features:** - See all submission windows (upcoming, active, closed) - Upload files to active window - View previous round submissions (read-only when locked) - Access mentoring workspace when available #### 4.2 Applicant Mentoring Workspace **Files to Create:** - `src/app/(applicant)/mentoring/page.tsx` - `src/components/applicant/workspace/FileList.tsx` - `src/components/applicant/workspace/FileUpload.tsx` - `src/components/applicant/workspace/FileComments.tsx` **Features:** - Upload files for mentor review - See mentor-uploaded files - Comment on files - See which files were promoted to official submission #### 4.3 Jury Multi-Round Dashboard **Files to Modify:** - `src/app/(jury)/dashboard/page.tsx` — Show assignments from multiple rounds **Files to Create:** - `src/components/jury/MultiRoundAssignments.tsx` - `src/components/jury/RoundTabs.tsx` - `src/components/jury/CrossRoundDocViewer.tsx` **Features:** - See assignments across all jury groups user belongs to - Filter by round - View documents from multiple submission windows (as configured per round) - See evaluation history (from previous rounds) #### 4.4 Mentor Dashboard and Workspace **Files to Create:** - `src/app/(mentor)/dashboard/page.tsx` - `src/app/(mentor)/workspace/[projectId]/page.tsx` - `src/components/mentor/MentorAssignmentList.tsx` - `src/components/mentor/WorkspaceFileManager.tsx` - `src/components/mentor/PromoteFileDialog.tsx` **Features:** - See all assigned teams - Upload files to workspace - Comment on team files - Promote files to official submission #### 4.5 Enhanced Audience Voting UI **Files to Modify:** - `src/app/(public)/live-finals/[slug]/page.tsx` — Add category-specific voting **Features:** - Vote per category or overall (based on round config) - Favorites mode (pick top N) - Real-time vote count updates ### Acceptance Criteria - [ ] Applicant can upload to multiple submission windows - [ ] Applicant can access mentoring workspace - [ ] Jury can see assignments from multiple rounds - [ ] Jury can view documents from multiple windows - [ ] Mentor can upload files and promote them - [ ] Audience voting supports all modes (per-project, per-category, favorites) - [ ] All participant UIs tested manually - [ ] Mobile responsive ### Deliverables 1. Applicant multi-round submission UI 2. Applicant mentoring workspace UI 3. Jury multi-round dashboard 4. Mentor dashboard and workspace 5. Enhanced audience voting UI 6. End-to-end user journey documentation ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Confusion with multiple windows | High | Clear timeline UI, status badges | | File upload errors | Medium | Robust error handling, retry logic | | Mentor workspace access control | High | Strict auth checks, audit logging | --- ## Phase 5: Special Awards & Governance **Weeks 6-7** ### Goals 1. Enhanced special awards (two modes: STAY_IN_MAIN, SEPARATE_POOL) 2. Award jury integration 3. Winner confirmation UI 4. PDF export of results ### Dependencies - Phase 2 complete (backend services) - Phase 4 complete (participant UIs for award voting) ### Tasks #### 5.1 Enhanced Special Awards Backend **File to Modify:** - `src/server/routers/special-award.ts` — Add new fields (competitionId, evaluationRoundId, juryGroupId, eligibilityMode) **New Logic:** - STAY_IN_MAIN: Projects remain in main flow, flagged as award-eligible - SEPARATE_POOL: Projects pulled out of main flow (set ProjectRoundState to WITHDRAWN from main, create separate state for award) #### 5.2 Award Jury Management UI **Files to Create:** - `src/app/(admin)/competitions/[id]/awards/page.tsx` - `src/components/admin/awards/AwardList.tsx` - `src/components/admin/awards/AwardEditor.tsx` - `src/components/admin/awards/AwardJuryManager.tsx` **Features:** - Create award with eligibility mode - Link to jury group (shared or dedicated) - Set voting window - Configure scoring mode (pick winner, ranked, scored) #### 5.3 Winner Confirmation UI **Files to Create:** - `src/app/(admin)/competitions/[id]/confirm-winners/page.tsx` - `src/components/admin/winner-confirmation/ProposalList.tsx` - `src/components/admin/winner-confirmation/ProposalEditor.tsx` - `src/components/admin/winner-confirmation/ApprovalStatus.tsx` - `src/components/admin/winner-confirmation/OverrideDialog.tsx` **Features:** - Create winner proposal (ranked list) - Request approvals from jury - Track approval status - Admin override options (force majority, admin decision) - Freeze results #### 5.4 Jury Approval UI **Files to Create:** - `src/app/(jury)/confirm-winners/page.tsx` - `src/components/jury/WinnerApprovalCard.tsx` **Features:** - View proposed rankings - Approve/reject with comments - See selection basis (scores, votes) #### 5.5 PDF Export of Results **File to Create:** - `src/server/services/pdf-export.ts` Use `@react-pdf/renderer` or similar to generate PDF with: - Competition name and date - Final rankings per category - Jury signatures (names + approval timestamps) - Special award winners ### Acceptance Criteria - [ ] Admin can create awards with both modes - [ ] STAY_IN_MAIN mode keeps projects in main flow - [ ] SEPARATE_POOL mode removes projects from main - [ ] Award jury can vote on eligible projects - [ ] Admin can propose winners and request approvals - [ ] Jury can approve/reject proposals - [ ] Admin can override with audit trail - [ ] PDF export generates correctly - [ ] All award workflows tested end-to-end ### Deliverables 1. Enhanced special award backend 2. Award management UI 3. Winner confirmation UI (admin + jury) 4. PDF export service 5. Award flow documentation ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | SEPARATE_POOL logic complex | High | Extensive testing, clear state transitions | | Approval deadlock (no consensus) | Medium | Admin override path well-documented | | PDF generation fails | Low | Fallback to HTML view | --- ## Phase 6: Platform-Wide Refit **Weeks 7-8** ### Goals 1. Remove all Pipeline/Track/Stage references from codebase 2. Drop old tables and enums (Phase 4 of migration) 3. Update all imports and types 4. Remove feature flags 5. Clean build with no legacy code ### Dependencies - All previous phases complete - Full competition created and tested with new system ### Tasks #### 6.1 Code Audit and Replacement **Search and Replace:** - `Pipeline` → `Competition` (400+ occurrences expected) - `Stage` → `Round` (600+ occurrences expected) - `Track` → (remove, refactor) - `stageId` → `roundId` - `trackId` → (remove) **Files to Audit:** - All routers (`src/server/routers/*.ts`) - All services (`src/server/services/*.ts`) - All components (`src/components/**/*.tsx`) - All pages (`src/app/**/*.tsx`) - All types (`src/types/*.ts`) **Tools:** ```bash # Search for legacy references npx ripgrep "Pipeline" --type ts --type tsx npx ripgrep "Track(?!ing)" --type ts --type tsx npx ripgrep "Stage(?!Manager)" --type ts --type tsx npx ripgrep "stageId" --type ts --type tsx npx ripgrep "trackId" --type ts --type tsx # After replacement, verify no references remain npx ripgrep "Pipeline|Track(?!ing)|Stage(?!Manager)|stageId|trackId" --type ts --type tsx ``` #### 6.2 Prisma Schema Cleanup (Phase 4 — Destructive) **File to Modify:** - `prisma/schema.prisma` **Tasks:** ```bash # 6.2.1 Create migration to drop old tables npx prisma migrate dev --name drop_legacy_pipeline_track_stage_models --create-only # 6.2.2 Review generated SQL # Verify: # - DROP TABLE Pipeline # - DROP TABLE Track # - DROP TABLE Stage # - DROP TABLE StageTransition # - DROP TABLE ProjectStageState # - DROP TYPE TrackKind # - DROP TYPE RoutingMode # - DROP TYPE DecisionMode (if not used by SpecialAward) # - Remove legacy columns: Assignment.stageId, Project.roundId (old), etc. # 6.2.3 Apply migration npx prisma migrate dev # 6.2.4 Regenerate client npx prisma generate ``` #### 6.3 Remove Feature Flags **File to Modify:** - `src/lib/feature-flags.ts` — Delete file entirely - Search and remove all `isFeatureEnabled()` checks #### 6.4 Update Documentation **Files to Modify:** - `CLAUDE.md` — Remove pipeline references, add competition/round context - `docs/architecture.md` — Update diagrams - `README.md` — Update feature list #### 6.5 Full Regression Test Suite **Run all tests:** ```bash npx vitest run npm run typecheck npm run lint npm run build ``` **Manual testing:** - Create competition through wizard - Configure all round types - Run full competition end-to-end (intake → filtering → evaluation → submission → mentoring → live finals → confirmation) - Verify data integrity - Check all participant journeys ### Acceptance Criteria - [ ] No references to Pipeline, Track, Stage in codebase (except comments/docs) - [ ] Old tables dropped from schema - [ ] All tests pass - [ ] TypeScript compiles without errors - [ ] Production build succeeds - [ ] Full competition tested end-to-end - [ ] Documentation updated - [ ] Feature flags removed ### Deliverables 1. Clean codebase (no legacy code) 2. Updated Prisma schema (old tables dropped) 3. Passing test suite 4. Updated documentation 5. Migration runbook ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Missed legacy references cause runtime errors | High | Comprehensive search, TypeScript strict mode | | Data loss during migration | Critical | Backup database before Phase 6, test on staging first | | Tests fail after refactor | High | Fix tests incrementally, don't skip | --- ## Phase 7: Validation & Release **Weeks 8-9** ### Goals 1. Full integration testing 2. Performance testing 3. UAT with stakeholders 4. Data migration on staging 5. Production deployment ### Dependencies - All previous phases complete ### Tasks #### 7.1 Integration Testing **Test Scenarios:** 1. Create competition with all round types 2. Invite and onboard jury members across multiple groups 3. Applicants submit to multiple windows 4. Jury evaluates across multiple rounds 5. Mentor workspace file exchange 6. Live finals with audience voting 7. Winner confirmation with approvals 8. Special awards (both modes) **Files to Create:** - `tests/integration/full-competition-flow.test.ts` (comprehensive E2E test) #### 7.2 Performance Testing **Benchmarks:** - 1000 projects, 50 jurors, 3 rounds - Assignment generation time < 5 seconds - Filtering batch time < 30 seconds - Page load times < 2 seconds (P95) - Database query performance (no N+1 queries) **Tools:** - Vitest benchmarks - Prisma query logging - Chrome DevTools profiling #### 7.3 UAT with Stakeholders **Participants:** - Program admin - Jury members (2-3) - Applicant - Mentor **Scenarios:** - Admin: Create competition, configure rounds - Applicant: Submit application, upload to multiple windows - Jury: Evaluate projects, see cross-round docs - Mentor: Upload files, promote to submission **Feedback Collection:** - Screen recordings - Bug reports - UX feedback #### 7.4 Data Migration on Staging **Tasks:** 1. Backup production database 2. Restore to staging 3. Run migration scripts (Phases 1-4) 4. Verify data integrity 5. Test with production data **Migration Script:** ```sql -- See docs/claude-architecture-redesign/21-migration-strategy.md -- for full migration SQL ``` #### 7.5 Production Deployment **Checklist:** - [ ] All tests pass on main branch - [ ] Staging migration successful - [ ] UAT sign-off received - [ ] Deployment runbook reviewed - [ ] Rollback plan documented - [ ] Monitoring and alerts configured - [ ] Database backup verified **Deployment Steps:** 1. Announce maintenance window 2. Backup production database 3. Deploy new code to VPS 4. Run migrations 5. Restart application 6. Smoke test critical paths 7. Monitor for errors (15 minutes) 8. Announce completion **Rollback Plan:** If critical issues found: 1. Restore database from backup 2. Revert code deployment to previous version 3. Restart application 4. Verify old system operational ### Acceptance Criteria - [ ] All integration tests pass - [ ] Performance benchmarks met - [ ] UAT participants approve - [ ] Staging migration successful - [ ] Production deployment successful - [ ] No critical bugs in first 24 hours - [ ] Monitoring shows stable metrics ### Deliverables 1. Integration test suite 2. Performance test report 3. UAT sign-off documentation 4. Migration runbook 5. Deployment runbook 6. Post-deployment monitoring report ### Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | Migration fails on production data | Critical | Test on staging with production clone first | | Performance issues at scale | High | Load testing, query optimization | | UAT uncovers major issues | High | Allow 1-2 weeks buffer for fixes | | Rollback needed | Medium | Practice rollback on staging, clear procedures | --- ## Critical Path Summary The **critical path** (longest dependency chain) is: ``` Phase 0 (1 week) → Phase 1 (2 weeks) → Phase 2 (2 weeks) → Phase 6 (1 week) → Phase 7 (1 week) Total: 7 weeks minimum ``` **Parallel work streams** add 2 weeks: - Phase 3 (admin UI) overlaps with Phase 2 - Phase 4 (participant UI) overlaps with Phase 3 - Phase 5 (awards) overlaps with Phase 4 **Total estimated time: 8-9 weeks** --- ## Per-Phase File Checklist ### Phase 0: Contract Freeze **Create:** - `src/types/competition.ts` - `src/types/round.ts` - `src/types/round-configs.ts` - `src/types/jury-group.ts` - `src/types/submission-window.ts` - `src/types/winner-confirmation.ts` - `src/types/advancement-rule.ts` - `src/lib/feature-flags.ts` - `tests/helpers-redesign.ts` **Modify:** - `docs/claude-architecture-redesign/api-contracts.md` - `CLAUDE.md` --- ### Phase 1: Schema & Runtime Foundation **Create:** - `src/server/routers/competition.ts` - `src/server/routers/round.ts` - `src/server/routers/jury-group.ts` - `src/server/routers/submission-window.ts` - `src/server/services/round-engine.ts` - `tests/unit/competition.test.ts` - `tests/unit/round.test.ts` - `tests/unit/jury-group.test.ts` - `tests/unit/submission-window.test.ts` - `tests/unit/round-engine.test.ts` - `prisma/migrations/XXXXXX_add_competition_round_jury_models/migration.sql` **Modify:** - `prisma/schema.prisma` (add all new models) - `src/server/routers/_app.ts` (register new routers) --- ### Phase 2: Backend Orchestration **Create:** - `src/server/services/round-assignment.ts` - `src/server/services/submission-round-manager.ts` - `src/server/services/mentor-workspace.ts` - `src/server/services/winner-confirmation.ts` - `tests/integration/assignment-service.test.ts` - `tests/integration/submission-round-manager.test.ts` - `tests/integration/mentor-workspace.test.ts` - `tests/integration/winner-confirmation.test.ts` **Modify:** - `src/server/services/stage-filtering.ts` (update references) - `src/server/services/live-control.ts` (add category windows) **Rename:** - `src/server/services/stage-assignment.ts` → `src/server/services/round-assignment.ts` --- ### Phase 3: Admin Control Plane **Create:** - `src/app/(admin)/competitions/new/page.tsx` - `src/app/(admin)/competitions/[id]/rounds/page.tsx` - `src/app/(admin)/competitions/[id]/jury-groups/page.tsx` - `src/app/(admin)/competitions/[id]/submission-windows/page.tsx` - `src/components/admin/competition-wizard/` (6 step components) - `src/components/admin/rounds/` (list, card, editor, config editors) - `src/components/admin/jury-groups/` (list, card, member manager) - `src/components/admin/submission-windows/` (list, editor, requirement manager) **Modify:** - `src/app/(admin)/stage-manager/[stageId]/page.tsx` (add round support) --- ### Phase 4: Participant Journeys **Create:** - `src/app/(applicant)/mentoring/page.tsx` - `src/app/(mentor)/dashboard/page.tsx` - `src/app/(mentor)/workspace/[projectId]/page.tsx` - `src/components/applicant/SubmissionTimeline.tsx` - `src/components/applicant/WindowUploader.tsx` - `src/components/applicant/MentoringWorkspace.tsx` - `src/components/applicant/workspace/` (file list, upload, comments) - `src/components/jury/MultiRoundAssignments.tsx` - `src/components/jury/RoundTabs.tsx` - `src/components/jury/CrossRoundDocViewer.tsx` - `src/components/mentor/` (assignment list, workspace, promote dialog) **Modify:** - `src/app/(applicant)/dashboard/page.tsx` - `src/app/(applicant)/submit/page.tsx` - `src/app/(jury)/dashboard/page.tsx` - `src/app/(public)/live-finals/[slug]/page.tsx` --- ### Phase 5: Special Awards & Governance **Create:** - `src/app/(admin)/competitions/[id]/awards/page.tsx` - `src/app/(admin)/competitions/[id]/confirm-winners/page.tsx` - `src/app/(jury)/confirm-winners/page.tsx` - `src/components/admin/awards/` (list, editor, jury manager) - `src/components/admin/winner-confirmation/` (proposal list, editor, approval status, override) - `src/components/jury/WinnerApprovalCard.tsx` - `src/server/services/pdf-export.ts` **Modify:** - `src/server/routers/special-award.ts` --- ### Phase 6: Platform-Wide Refit **Delete:** - `src/lib/feature-flags.ts` - All legacy Pipeline/Track/Stage routers (if any standalone files) **Modify (search/replace in ALL files):** - `src/server/routers/*.ts` (all routers) - `src/server/services/*.ts` (all services) - `src/components/**/*.tsx` (all components) - `src/app/**/*.tsx` (all pages) - `src/types/*.ts` (all types) - `prisma/schema.prisma` (drop old tables) - `CLAUDE.md` - `README.md` **Create:** - `prisma/migrations/XXXXXX_drop_legacy_pipeline_track_stage_models/migration.sql` --- ### Phase 7: Validation & Release **Create:** - `tests/integration/full-competition-flow.test.ts` - `docs/deployment-runbook.md` - `docs/rollback-plan.md` - `docs/performance-test-report.md` - `docs/uat-sign-off.md` **Modify:** - `docs/architecture.md` (final diagrams) --- ## Rollback Points ### Phase 0-1: Low Risk - Feature flags off → old system still operational - Rollback: Drop new tables, revert code ### Phase 2: Medium Risk - Services not yet exposed to users - Rollback: Disable feature flags, revert code ### Phase 3-4: Medium Risk - New UIs available but optional - Rollback: Hide admin pages, disable routes ### Phase 5: High Risk - Winner confirmation in use - Rollback: Complex (may need data export/import) ### Phase 6: CRITICAL — Point of No Return - After old tables dropped, rollback requires full database restore - **Do not proceed with Phase 6 until Phases 1-5 fully validated** ### Phase 7: Production - Rollback: Restore database backup, revert deployment --- ## Risk Register (All Phases) | Risk | Phase | Impact | Probability | Mitigation | |------|-------|--------|-------------|------------| | Schema migration fails | 1 | Critical | Low | Additive-only, test on staging | | Performance degradation | 2 | High | Medium | Benchmark early, optimize queries | | Admin UI too complex | 3 | Medium | Medium | User testing, iterative refinement | | Participant confusion | 4 | Medium | High | Clear messaging, onboarding tooltips | | Award logic bugs | 5 | High | Medium | Extensive testing, audit logging | | Missed legacy references | 6 | Critical | Medium | Comprehensive search, strict TypeScript | | Production migration fails | 7 | Critical | Low | Staging rehearsal, backup plan | | UAT uncovers blockers | 7 | High | Medium | Allow 2-week buffer for fixes | --- ## Conclusion This 7-phase implementation plan provides a clear, dependency-aware path from contract freeze to production release. Each phase has defined goals, tasks, acceptance criteria, and deliverables. The critical path is 7 weeks, with parallel work streams extending to 8-9 weeks total. **Key success factors:** 1. **Strict acceptance gates** — Don't advance until criteria met 2. **Incremental testing** — Test each phase thoroughly before proceeding 3. **Feature flags** — Gradual rollout reduces risk 4. **Staging validation** — Rehearse migrations and deployments 5. **Clear rollback plan** — Know how to revert at each stage **Next steps:** 1. Review and approve this implementation plan 2. Begin Phase 0 (contract freeze) 3. Set up project tracking (Jira, Linear, etc.) 4. Schedule weekly checkpoint meetings 5. Begin development with Phase 1