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

@@ -22,36 +22,42 @@ export const applicantRouter = router({
getSubmissionBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
// Find the round by slug
const round = await ctx.prisma.round.findFirst({
const stage = await ctx.prisma.stage.findFirst({
where: { slug: input.slug },
include: {
program: { select: { id: true, name: true, year: true, description: true } },
track: {
include: {
pipeline: {
include: {
program: { select: { id: true, name: true, year: true, description: true } },
},
},
},
},
},
})
if (!round) {
if (!stage) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
message: 'Stage not found',
})
}
// Check if submissions are open
const now = new Date()
const isOpen = round.submissionDeadline
? now < round.submissionDeadline
: round.status === 'ACTIVE'
const isOpen = stage.windowCloseAt
? now < stage.windowCloseAt
: stage.status === 'STAGE_ACTIVE'
return {
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionDeadline: round.submissionDeadline,
stage: {
id: stage.id,
name: stage.name,
slug: stage.slug,
windowCloseAt: stage.windowCloseAt,
isOpen,
},
program: round.program,
program: stage.track.pipeline.program,
}
}),
@@ -59,7 +65,7 @@ export const applicantRouter = router({
* Get the current user's submission for a round (as submitter or team member)
*/
getMySubmission: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string().optional(), programId: z.string().optional() }))
.query(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
@@ -69,25 +75,29 @@ export const applicantRouter = router({
})
}
const project = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
files: true,
round: {
include: {
program: { select: { name: true, year: true } },
const where: Record<string, unknown> = {
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
}
if (input.stageId) {
where.stageStates = { some: { stageId: input.stageId } }
}
if (input.programId) {
where.programId = input.programId
}
const project = await ctx.prisma.project.findFirst({
where,
include: {
files: true,
program: { select: { id: true, name: true, year: true } },
teamMembers: {
include: {
user: {
@@ -116,14 +126,14 @@ export const applicantRouter = router({
saveSubmission: protectedProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string().optional(), // If updating existing
programId: z.string().optional(),
projectId: z.string().optional(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
submit: z.boolean().default(false), // Whether to submit or just save draft
submit: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
@@ -135,20 +145,9 @@ export const applicantRouter = router({
})
}
// Check if the round is open for submissions
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const now = new Date()
if (round.submissionDeadline && now > round.submissionDeadline) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Submission deadline has passed',
})
}
const { projectId, submit, roundId, metadataJson, ...data } = input
const { projectId, submit, programId, metadataJson, ...data } = input
if (projectId) {
// Update existing
@@ -193,17 +192,17 @@ export const applicantRouter = router({
return project
} else {
// Get the round to find the programId
const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { programId: true },
})
if (!programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'programId is required when creating a new submission',
})
}
// Create new project
const project = await ctx.prisma.project.create({
data: {
programId: roundForCreate.programId,
roundId,
programId,
...data,
metadataJson: metadataJson as unknown ?? undefined,
submittedByUserId: ctx.user.id,
@@ -240,7 +239,7 @@ export const applicantRouter = router({
fileName: z.string(),
mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
roundId: z.string().optional(),
stageId: z.string().optional(),
requirementId: z.string().optional(),
})
)
@@ -269,9 +268,6 @@ export const applicantRouter = router({
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
},
})
if (!project) {
@@ -306,37 +302,9 @@ export const applicantRouter = router({
}
}
// Check round upload deadline policy if roundId provided
let isLate = false
const targetRoundId = input.roundId || project.roundId
if (targetRoundId) {
const round = input.roundId
? await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingStartAt: true, settingsJson: true },
})
: project.round
if (round) {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const now = new Date()
const roundStarted = round.votingStartAt && now > round.votingStartAt
if (roundStarted && uploadPolicy === 'BLOCK') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Uploads are blocked after the round has started',
})
}
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
isLate = true
}
}
}
// Can't upload if already submitted (unless round allows it)
// Can't upload if already submitted
if (project.submittedAt && !isLate) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -355,7 +323,7 @@ export const applicantRouter = router({
bucket: SUBMISSIONS_BUCKET,
objectKey,
isLate,
roundId: targetRoundId,
stageId: input.stageId || null,
}
}),
@@ -372,7 +340,7 @@ export const applicantRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
bucket: z.string(),
objectKey: z.string(),
roundId: z.string().optional(),
stageId: z.string().optional(),
isLate: z.boolean().optional(),
requirementId: z.string().optional(),
})
@@ -410,15 +378,14 @@ export const applicantRouter = router({
})
}
const { projectId, roundId, isLate, requirementId, ...fileData } = input
const { projectId, stageId, isLate, requirementId, ...fileData } = input
// Delete existing file: by requirementId if provided, otherwise by fileType+roundId
// Delete existing file: by requirementId if provided, otherwise by fileType
if (requirementId) {
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
requirementId,
...(roundId ? { roundId } : {}),
},
})
} else {
@@ -426,17 +393,16 @@ export const applicantRouter = router({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
}
// Create new file record
// Create new file record (roundId column kept null for new data)
const file = await ctx.prisma.projectFile.create({
data: {
projectId,
...fileData,
roundId: roundId || null,
roundId: null,
isLate: isLate || false,
requirementId: requirementId || null,
},
@@ -543,11 +509,7 @@ export const applicantRouter = router({
],
},
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
files: true,
teamMembers: {
include: {
@@ -686,11 +648,7 @@ export const applicantRouter = router({
],
},
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
files: true,
teamMembers: {
include: {
@@ -764,7 +722,6 @@ export const applicantRouter = router({
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
roundId: project.roundId,
}
}),
@@ -1166,11 +1123,7 @@ export const applicantRouter = router({
],
},
include: {
round: {
include: {
program: { select: { id: true, name: true, year: true, status: true } },
},
},
program: { select: { id: true, name: true, year: true, status: true } },
files: {
orderBy: { createdAt: 'desc' },
},
@@ -1200,7 +1153,7 @@ export const applicantRouter = router({
})
if (!project) {
return { project: null, openRounds: [], timeline: [], currentStatus: null }
return { project: null, openStages: [], timeline: [], currentStatus: null }
}
const currentStatus = project.status ?? 'SUBMITTED'
@@ -1285,32 +1238,25 @@ export const applicantRouter = router({
}
}
// Find open rounds in the same program where documents can be submitted
const programId = project.round?.programId || project.programId
const now = new Date()
const openRounds = programId
? await ctx.prisma.round.findMany({
const programId = project.programId
const openStages = programId
? await ctx.prisma.stage.findMany({
where: {
programId,
status: 'ACTIVE',
track: { pipeline: { programId } },
status: 'STAGE_ACTIVE',
},
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
slug: true,
stageType: true,
windowOpenAt: true,
windowCloseAt: true,
},
})
: []
// Filter: only rounds that still accept uploads
const uploadableRounds = openRounds.filter((round) => {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const roundStarted = round.votingStartAt && now > round.votingStartAt
// If deadline passed and policy is BLOCK, skip
if (roundStarted && uploadPolicy === 'BLOCK') return false
return true
})
// Determine user's role in the project
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
@@ -1321,7 +1267,7 @@ export const applicantRouter = router({
isTeamLead,
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
},
openRounds: uploadableRounds,
openStages,
timeline,
currentStatus,
}