2581 lines
76 KiB
Markdown
2581 lines
76 KiB
Markdown
|
|
# 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<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
|
||
|
|
|
||
|
|
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
|