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

@@ -20,13 +20,10 @@ export const fileRouter = router({
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
// Find the file record to get the project and round info
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: {
projectId: true,
roundId: true,
round: { select: { programId: true, sortOrder: true } },
},
})
@@ -37,11 +34,10 @@ export const fileRouter = router({
})
}
// Check if user is assigned as jury, mentor, or team member for this project
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true, roundId: true },
select: { id: true, stageId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId },
@@ -66,23 +62,47 @@ export const fileRouter = router({
})
}
// For jury members, verify round-scoped access:
// File must belong to the jury's assigned round or a prior round in the same program
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
const assignedRound = await ctx.prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { programId: true, sortOrder: true },
if (juryAssignment && !mentorAssignment && !teamMembership) {
const assignedStage = await ctx.prisma.stage.findUnique({
where: { id: juryAssignment.stageId },
select: { trackId: true, sortOrder: true },
})
if (assignedRound) {
const sameProgram = assignedRound.programId === file.round.programId
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
if (assignedStage) {
const priorOrCurrentStages = await ctx.prisma.stage.findMany({
where: {
trackId: assignedStage.trackId,
sortOrder: { lte: assignedStage.sortOrder },
},
select: { id: true },
})
if (!sameProgram || !priorOrSameRound) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
const stageIds = priorOrCurrentStages.map((s) => s.id)
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
where: {
stageId: { in: stageIds },
files: { some: { bucket: input.bucket, objectKey: input.objectKey } },
},
select: { id: true },
})
if (!hasFileRequirement) {
const fileInProject = await ctx.prisma.projectFile.findFirst({
where: {
bucket: input.bucket,
objectKey: input.objectKey,
requirementId: null,
},
select: { id: true },
})
if (!fileInProject) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
}
}
}
@@ -115,7 +135,7 @@ export const fileRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
mimeType: z.string(),
size: z.number().int().positive(),
roundId: z.string().optional(),
stageId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -129,16 +149,15 @@ export const fileRouter = router({
})
}
// Calculate isLate flag if roundId is provided
let isLate = false
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingEndAt: true },
if (input.stageId) {
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { windowCloseAt: true },
})
if (round?.votingEndAt) {
isLate = new Date() > round.votingEndAt
if (stage?.windowCloseAt) {
isLate = new Date() > stage.windowCloseAt
}
}
@@ -157,7 +176,6 @@ export const fileRouter = router({
size: input.size,
bucket,
objectKey,
roundId: input.roundId,
isLate,
},
})
@@ -173,7 +191,7 @@ export const fileRouter = router({
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
roundId: input.roundId,
stageId: input.stageId,
isLate,
},
ipAddress: ctx.ip,
@@ -244,7 +262,7 @@ export const fileRouter = router({
listByProject: protectedProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string().optional(),
stageId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
@@ -280,28 +298,36 @@ export const fileRouter = router({
}
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.roundId) {
where.roundId = input.roundId
if (input.stageId) {
where.requirement = { stageId: input.stageId }
}
return ctx.prisma.projectFile.findMany({
where,
include: {
round: { select: { id: true, name: true, sortOrder: true } },
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
requirement: {
select: {
id: true,
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
},
},
},
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
})
}),
/**
* List files for a project grouped by round
* Returns files for the specified round + all prior rounds in the same program
* List files for a project grouped by stage
* Returns files for the specified stage + all prior stages in the same track
*/
listByProjectForRound: protectedProcedure
listByProjectForStage: protectedProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
@@ -310,7 +336,7 @@ export const fileRouter = router({
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true, roundId: true },
select: { id: true, stageId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
@@ -336,68 +362,70 @@ export const fileRouter = router({
}
}
// Get the target round with its program and sortOrder
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true, sortOrder: true },
const targetStage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { trackId: true, sortOrder: true },
})
// Get all rounds in the same program with sortOrder <= target
const eligibleRounds = await ctx.prisma.round.findMany({
const eligibleStages = await ctx.prisma.stage.findMany({
where: {
programId: targetRound.programId,
sortOrder: { lte: targetRound.sortOrder },
trackId: targetStage.trackId,
sortOrder: { lte: targetStage.sortOrder },
},
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
const eligibleStageIds = eligibleStages.map((s) => s.id)
// Get files for these rounds (or files with no roundId)
const files = await ctx.prisma.projectFile.findMany({
where: {
projectId: input.projectId,
OR: [
{ roundId: { in: eligibleRoundIds } },
{ roundId: null },
{ requirement: { stageId: { in: eligibleStageIds } } },
{ requirementId: null },
],
},
include: {
round: { select: { id: true, name: true, sortOrder: true } },
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
requirement: {
select: {
id: true,
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
},
},
},
orderBy: [{ createdAt: 'asc' }],
})
// Group by round
const grouped: Array<{
roundId: string | null
roundName: string
stageId: string | null
stageName: string
sortOrder: number
files: typeof files
}> = []
// Add "General" group for files with no round
const generalFiles = files.filter((f) => !f.roundId)
const generalFiles = files.filter((f) => !f.requirementId)
if (generalFiles.length > 0) {
grouped.push({
roundId: null,
roundName: 'General',
stageId: null,
stageName: 'General',
sortOrder: -1,
files: generalFiles,
})
}
// Add groups for each round
for (const round of eligibleRounds) {
const roundFiles = files.filter((f) => f.roundId === round.id)
if (roundFiles.length > 0) {
for (const stage of eligibleStages) {
const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id)
if (stageFiles.length > 0) {
grouped.push({
roundId: round.id,
roundName: round.name,
sortOrder: round.sortOrder,
files: roundFiles,
stageId: stage.id,
stageName: stage.name,
sortOrder: stage.sortOrder,
files: stageFiles,
})
}
}
@@ -673,24 +701,24 @@ export const fileRouter = router({
// =========================================================================
/**
* List file requirements for a round (available to any authenticated user)
* List file requirements for a stage (available to any authenticated user)
*/
listRequirements: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.fileRequirement.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
orderBy: { sortOrder: 'asc' },
})
}),
/**
* Create a file requirement for a round (admin only)
* Create a file requirement for a stage (admin only)
*/
createRequirement: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
@@ -711,7 +739,7 @@ export const fileRouter = router({
action: 'CREATE',
entityType: 'FileRequirement',
entityId: requirement.id,
detailsJson: { name: input.name, roundId: input.roundId },
detailsJson: { name: input.name, stageId: input.stageId },
})
} catch {}
@@ -783,7 +811,7 @@ export const fileRouter = router({
reorderRequirements: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
orderedIds: z.array(z.string()),
})
)