76 KiB
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
- Incremental value — Each phase delivers testable, self-contained functionality
- Backward compatibility — New system runs alongside old until Phase 6
- Feature flags — Gradual rollout controlled by flags
- Clear acceptance gates — No phase progresses without passing criteria
- Parallel work streams — Phases overlap where dependencies allow
- 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):
- 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
- Finalize all TypeScript type definitions and Zod schemas
- Create feature flags for gradual rollout
- Set up test infrastructure for new models
- Document all API contracts
- Prepare development environment
Tasks
0.1 Type System Preparation
Files to Create:
src/types/competition.ts— Competition typessrc/types/round.ts— Round types and RoundType enumsrc/types/round-configs.ts— All round config shapes (Intake, Filtering, Evaluation, Submission, Mentoring, LiveFinal, Confirmation)src/types/jury-group.ts— JuryGroup and JuryGroupMember typessrc/types/submission-window.ts— SubmissionWindow typessrc/types/winner-confirmation.ts— WinnerProposal and WinnerApproval typessrc/types/advancement-rule.ts— AdvancementRule types
Tasks:
// 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
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.
import { prisma } from "./setup";
// Factory: Competition
export async function createTestCompetition(programId: string, overrides?: Partial<Competition>) {
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<Round>) {
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<JuryGroup>) {
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<JuryGroupMember>) {
return prisma.juryGroupMember.create({
data: {
juryGroupId,
userId,
isLead: overrides?.isLead || false,
...overrides,
},
});
}
// Factory: SubmissionWindow
export async function createTestSubmissionWindow(competitionId: string, overrides?: Partial<SubmissionWindow>) {
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<WinnerProposal>) {
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
src/types/directory with all new type definitionssrc/lib/feature-flags.tswith all flagstests/helpers-redesign.tswith factory functions- API contract documentation
- Updated
CLAUDE.mdwith 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
- Prisma schema migration — add all new tables (Phase 1 of migration)
- Competition model CRUD
- Round model CRUD (basic, no type-specific logic yet)
- JuryGroup and JuryGroupMember CRUD
- SubmissionWindow CRUD
- ProjectRoundState basic operations
- 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:
# 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(keepstageIdfor 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
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
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
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
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
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.tstests/unit/round.test.tstests/unit/jury-group.test.tstests/unit/submission-window.test.tstests/unit/round-engine.test.ts
Example test structure:
// 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
- Prisma schema with all new models
- Applied migration
- Competition router with full CRUD
- Round router with full CRUD
- JuryGroup router with full CRUD
- SubmissionWindow router with full CRUD
- Round engine service with state transitions
- Complete unit test coverage
- 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
- Enhanced assignment algorithm with jury groups, caps, quotas
- Filtering service updates (stageId → roundId)
- Submission round manager (window locking, deadline enforcement)
- Mentor workspace service (file upload, comments, promotion)
- Winner confirmation service (proposals, approvals, freeze)
- 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 tosrc/server/services/round-assignment.ts
New Features:
- Jury group awareness — Generate assignments respecting juryGroupId
- Hard/soft caps — Enforce per-juror max assignments with buffer
- Category quotas — Respect min/max quotas per category
- Juror preferences — Factor in preferredStartupRatio
// 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:
- Replace
stageIdwithroundIdin all queries - 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
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
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
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.tstests/integration/submission-round-manager.test.tstests/integration/mentor-workspace.test.tstests/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
- Enhanced assignment service
- Updated filtering service
- Submission round manager service
- Mentor workspace service
- Winner confirmation service
- Complete integration test coverage
- 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
- Competition setup wizard (replaces pipeline wizard)
- Round management UI
- Jury group management UI
- Submission window configuration UI
- 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.tsxsrc/components/admin/competition-wizard/WizardSteps.tsxsrc/components/admin/competition-wizard/Step1-BasicInfo.tsxsrc/components/admin/competition-wizard/Step2-Rounds.tsxsrc/components/admin/competition-wizard/Step3-JuryGroups.tsxsrc/components/admin/competition-wizard/Step4-SubmissionWindows.tsxsrc/components/admin/competition-wizard/Step5-SpecialAwards.tsxsrc/components/admin/competition-wizard/Step6-Review.tsx
Wizard Flow:
- Basic Info — Name, slug, category mode, finalist counts
- Rounds — Add rounds with type, config, order
- Jury Groups — Create groups, add members, set caps
- Submission Windows — Define doc requirements per round
- Special Awards — Configure awards (if any)
- 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.tsxsrc/components/admin/rounds/RoundList.tsxsrc/components/admin/rounds/RoundCard.tsxsrc/components/admin/rounds/RoundEditor.tsxsrc/components/admin/rounds/config-editors/IntakeConfigEditor.tsxsrc/components/admin/rounds/config-editors/FilteringConfigEditor.tsxsrc/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.tsxsrc/components/admin/jury-groups/JuryGroupList.tsxsrc/components/admin/jury-groups/JuryGroupCard.tsxsrc/components/admin/jury-groups/MemberManager.tsxsrc/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.tsxsrc/components/admin/submission-windows/WindowList.tsxsrc/components/admin/submission-windows/WindowEditor.tsxsrc/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
- Competition wizard (6-step flow)
- Round management UI
- Jury group management UI
- Submission window UI
- Enhanced stage manager
- 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
- Applicant UI: multi-round submissions, mentoring workspace
- Jury UI: multi-jury dashboard, cross-round document visibility
- Mentor UI: dedicated dashboard and workspace
- 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 statussrc/app/(applicant)/submit/page.tsx— Support multiple submission windows
Files to Create:
src/components/applicant/SubmissionTimeline.tsx— Shows all windowssrc/components/applicant/WindowUploader.tsx— Upload per windowsrc/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.tsxsrc/components/applicant/workspace/FileList.tsxsrc/components/applicant/workspace/FileUpload.tsxsrc/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.tsxsrc/components/jury/RoundTabs.tsxsrc/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.tsxsrc/app/(mentor)/workspace/[projectId]/page.tsxsrc/components/mentor/MentorAssignmentList.tsxsrc/components/mentor/WorkspaceFileManager.tsxsrc/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
- Applicant multi-round submission UI
- Applicant mentoring workspace UI
- Jury multi-round dashboard
- Mentor dashboard and workspace
- Enhanced audience voting UI
- 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
- Enhanced special awards (two modes: STAY_IN_MAIN, SEPARATE_POOL)
- Award jury integration
- Winner confirmation UI
- 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.tsxsrc/components/admin/awards/AwardList.tsxsrc/components/admin/awards/AwardEditor.tsxsrc/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.tsxsrc/components/admin/winner-confirmation/ProposalList.tsxsrc/components/admin/winner-confirmation/ProposalEditor.tsxsrc/components/admin/winner-confirmation/ApprovalStatus.tsxsrc/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.tsxsrc/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
- Enhanced special award backend
- Award management UI
- Winner confirmation UI (admin + jury)
- PDF export service
- 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
- Remove all Pipeline/Track/Stage references from codebase
- Drop old tables and enums (Phase 4 of migration)
- Update all imports and types
- Remove feature flags
- 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→roundIdtrackId→ (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:
# 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:
# 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 contextdocs/architecture.md— Update diagramsREADME.md— Update feature list
6.5 Full Regression Test Suite
Run all tests:
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
- Clean codebase (no legacy code)
- Updated Prisma schema (old tables dropped)
- Passing test suite
- Updated documentation
- 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
- Full integration testing
- Performance testing
- UAT with stakeholders
- Data migration on staging
- Production deployment
Dependencies
- All previous phases complete
Tasks
7.1 Integration Testing
Test Scenarios:
- Create competition with all round types
- Invite and onboard jury members across multiple groups
- Applicants submit to multiple windows
- Jury evaluates across multiple rounds
- Mentor workspace file exchange
- Live finals with audience voting
- Winner confirmation with approvals
- 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:
- Backup production database
- Restore to staging
- Run migration scripts (Phases 1-4)
- Verify data integrity
- Test with production data
Migration Script:
-- 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:
- Announce maintenance window
- Backup production database
- Deploy new code to VPS
- Run migrations
- Restart application
- Smoke test critical paths
- Monitor for errors (15 minutes)
- Announce completion
Rollback Plan: If critical issues found:
- Restore database from backup
- Revert code deployment to previous version
- Restart application
- 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
- Integration test suite
- Performance test report
- UAT sign-off documentation
- Migration runbook
- Deployment runbook
- 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.tssrc/types/round.tssrc/types/round-configs.tssrc/types/jury-group.tssrc/types/submission-window.tssrc/types/winner-confirmation.tssrc/types/advancement-rule.tssrc/lib/feature-flags.tstests/helpers-redesign.ts
Modify:
docs/claude-architecture-redesign/api-contracts.mdCLAUDE.md
Phase 1: Schema & Runtime Foundation
Create:
src/server/routers/competition.tssrc/server/routers/round.tssrc/server/routers/jury-group.tssrc/server/routers/submission-window.tssrc/server/services/round-engine.tstests/unit/competition.test.tstests/unit/round.test.tstests/unit/jury-group.test.tstests/unit/submission-window.test.tstests/unit/round-engine.test.tsprisma/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.tssrc/server/services/submission-round-manager.tssrc/server/services/mentor-workspace.tssrc/server/services/winner-confirmation.tstests/integration/assignment-service.test.tstests/integration/submission-round-manager.test.tstests/integration/mentor-workspace.test.tstests/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.tsxsrc/app/(admin)/competitions/[id]/rounds/page.tsxsrc/app/(admin)/competitions/[id]/jury-groups/page.tsxsrc/app/(admin)/competitions/[id]/submission-windows/page.tsxsrc/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.tsxsrc/app/(mentor)/dashboard/page.tsxsrc/app/(mentor)/workspace/[projectId]/page.tsxsrc/components/applicant/SubmissionTimeline.tsxsrc/components/applicant/WindowUploader.tsxsrc/components/applicant/MentoringWorkspace.tsxsrc/components/applicant/workspace/(file list, upload, comments)src/components/jury/MultiRoundAssignments.tsxsrc/components/jury/RoundTabs.tsxsrc/components/jury/CrossRoundDocViewer.tsxsrc/components/mentor/(assignment list, workspace, promote dialog)
Modify:
src/app/(applicant)/dashboard/page.tsxsrc/app/(applicant)/submit/page.tsxsrc/app/(jury)/dashboard/page.tsxsrc/app/(public)/live-finals/[slug]/page.tsx
Phase 5: Special Awards & Governance
Create:
src/app/(admin)/competitions/[id]/awards/page.tsxsrc/app/(admin)/competitions/[id]/confirm-winners/page.tsxsrc/app/(jury)/confirm-winners/page.tsxsrc/components/admin/awards/(list, editor, jury manager)src/components/admin/winner-confirmation/(proposal list, editor, approval status, override)src/components/jury/WinnerApprovalCard.tsxsrc/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.mdREADME.md
Create:
prisma/migrations/XXXXXX_drop_legacy_pipeline_track_stage_models/migration.sql
Phase 7: Validation & Release
Create:
tests/integration/full-competition-flow.test.tsdocs/deployment-runbook.mddocs/rollback-plan.mddocs/performance-test-report.mddocs/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:
- Strict acceptance gates — Don't advance until criteria met
- Incremental testing — Test each phase thoroughly before proceeding
- Feature flags — Gradual rollout reduces risk
- Staging validation — Rehearse migrations and deployments
- Clear rollback plan — Know how to revert at each stage
Next steps:
- Review and approve this implementation plan
- Begin Phase 0 (contract freeze)
- Set up project tracking (Jira, Linear, etc.)
- Schedule weekly checkpoint meetings
- Begin development with Phase 1