Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -6,13 +6,13 @@ import { logAudit } from '../utils/audit'
/**
* Project Pool Router
*
* Manages the pool of unassigned projects (projects not yet assigned to a round).
* Provides procedures for listing unassigned projects and bulk assigning them to rounds.
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
*/
export const projectPoolRouter = router({
/**
* List unassigned projects with filtering and pagination
* Projects where roundId IS NULL
* Projects not assigned to any stage
*/
listUnassigned: adminProcedure
.input(
@@ -33,7 +33,7 @@ export const projectPoolRouter = router({
// Build where clause
const where: Record<string, unknown> = {
programId,
roundId: null, // Only unassigned projects
stageStates: { none: {} }, // Only unassigned projects (not in any stage)
}
// Filter by competition category
@@ -92,47 +92,29 @@ export const projectPoolRouter = router({
}),
/**
* Bulk assign projects to a round
* Bulk assign projects to a stage
*
* Validates that:
* - All projects exist
* - All projects belong to the same program as the target round
* - Round exists and belongs to a program
* - Stage exists
*
* Updates:
* - Project.roundId
* - Project.status to 'ASSIGNED'
* - Creates ProjectStatusHistory records for each project
* - Creates audit log
* Creates:
* - ProjectStageState entries for each project
* - Project.status updated to 'ASSIGNED'
* - ProjectStatusHistory records for each project
* - Audit log
*/
assignToRound: adminProcedure
assignToStage: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
roundId: z.string(),
stageId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { projectIds, roundId } = input
const { projectIds, stageId } = input
// Step 1: Fetch round to get programId
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: {
id: true,
programId: true,
name: true,
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
})
}
// Step 2: Fetch all projects to validate
// Step 1: Fetch all projects to validate
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: projectIds },
@@ -154,28 +136,33 @@ export const projectPoolRouter = router({
})
}
// Validate all projects belong to the same program as the round
const invalidProjects = projects.filter(
(p) => p.programId !== round.programId
)
if (invalidProjects.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot assign projects from different programs. The following projects do not belong to the target program: ${invalidProjects
.map((p) => p.title)
.join(', ')}`,
})
}
// Verify stage exists and get its trackId
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: stageId },
select: { id: true, trackId: true },
})
// Step 3: Perform bulk assignment in a transaction
// Step 2: Perform bulk assignment in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Update all projects
// Create ProjectStageState entries for each project (skip existing)
const stageStateData = projectIds.map((projectId) => ({
projectId,
stageId,
trackId: stage.trackId,
state: 'PENDING' as const,
}))
await tx.projectStageState.createMany({
data: stageStateData,
skipDuplicates: true,
})
// Update project statuses
const updatedProjects = await tx.project.updateMany({
where: {
id: { in: projectIds },
},
data: {
roundId: roundId,
status: 'ASSIGNED',
},
})
@@ -193,11 +180,10 @@ export const projectPoolRouter = router({
await logAudit({
prisma: tx,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_TO_ROUND',
action: 'BULK_ASSIGN_TO_STAGE',
entityType: 'Project',
detailsJson: {
roundId,
roundName: round.name,
stageId,
projectCount: projectIds.length,
projectIds,
},
@@ -211,8 +197,7 @@ export const projectPoolRouter = router({
return {
success: true,
assignedCount: result.count,
roundId,
roundName: round.name,
stageId,
}
}),
})