Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -11,38 +11,31 @@ export const liveRouter = router({
start: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
projectOrder: z.array(z.string()).min(1), // Ordered project IDs
})
)
.mutation(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
if (stage.stageType !== 'LIVE_FINAL') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Live sessions can only be started for LIVE_FINAL stages',
})
}
if (stage.status !== 'STAGE_ACTIVE') {
if (round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Stage must be ACTIVE to start a live session',
message: 'Round must be ACTIVE to start a live session',
})
}
// Check for existing active cursor
const existingCursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
if (existingCursor) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A live session already exists for this stage. Use jump/reorder to modify it.',
message: 'A live session already exists for this round. Use jump/reorder to modify it.',
})
}
@@ -59,12 +52,12 @@ export const liveRouter = router({
}
const cursor = await ctx.prisma.$transaction(async (tx) => {
// Store the project order in stage config
await tx.stage.update({
where: { id: input.stageId },
// Store the project order in round config
await tx.round.update({
where: { id: input.roundId },
data: {
configJson: {
...(stage.configJson as Record<string, unknown> ?? {}),
...(round.configJson as Record<string, unknown> ?? {}),
projectOrder: input.projectOrder,
} as Prisma.InputJsonValue,
},
@@ -72,7 +65,7 @@ export const liveRouter = router({
const created = await tx.liveProgressCursor.create({
data: {
stageId: input.stageId,
roundId: input.roundId,
activeProjectId: input.projectOrder[0],
activeOrderIndex: 0,
isPaused: false,
@@ -83,8 +76,8 @@ export const liveRouter = router({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_SESSION_STARTED',
entityType: 'Stage',
entityId: input.stageId,
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
sessionId: created.sessionId,
projectCount: input.projectOrder.length,
@@ -106,20 +99,20 @@ export const liveRouter = router({
setActiveProject: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
projectId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
// Get project order from stage config
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
// Get project order from round config
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const config = (stage.configJson as Record<string, unknown>) ?? {}
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
const index = projectOrder.indexOf(input.projectId)
@@ -165,19 +158,19 @@ export const liveRouter = router({
jump: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
index: z.number().int().min(0),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const config = (stage.configJson as Record<string, unknown>) ?? {}
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
if (input.index >= projectOrder.length) {
@@ -225,26 +218,26 @@ export const liveRouter = router({
reorder: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
projectOrder: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
// Update config with new order
const updated = await ctx.prisma.$transaction(async (tx) => {
await tx.stage.update({
where: { id: input.stageId },
await tx.round.update({
where: { id: input.roundId },
data: {
configJson: {
...(stage.configJson as Record<string, unknown> ?? {}),
...(round.configJson as Record<string, unknown> ?? {}),
projectOrder: input.projectOrder,
} as Prisma.InputJsonValue,
},
@@ -285,10 +278,10 @@ export const liveRouter = router({
* Pause the live session
*/
pause: adminProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
if (cursor.isPaused) {
@@ -325,10 +318,10 @@ export const liveRouter = router({
* Resume the live session
*/
resume: adminProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
if (!cursor.isPaused) {
@@ -365,21 +358,21 @@ export const liveRouter = router({
* Get current cursor state (for all users, including audience)
*/
getCursor: protectedProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
if (!cursor) {
return null
}
// Get stage config for project order
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
// Get round config for project order
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const config = (stage.configJson as Record<string, unknown>) ?? {}
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
// Get current project details
@@ -397,9 +390,9 @@ export const liveRouter = router({
})
}
// Get open cohorts for this stage (if any)
// Get open cohorts for this round (if any)
const openCohorts = await ctx.prisma.cohort.findMany({
where: { stageId: input.stageId, isOpen: true },
where: { roundId: input.roundId, isOpen: true },
select: {
id: true,
name: true,
@@ -424,7 +417,7 @@ export const liveRouter = router({
castVote: audienceProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
criterionScoresJson: z.record(z.number()).optional(),
@@ -433,7 +426,7 @@ export const liveRouter = router({
.mutation(async ({ ctx, input }) => {
// Verify live session exists and is not paused
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
})
if (cursor.isPaused) {
@@ -446,7 +439,7 @@ export const liveRouter = router({
// Check if there's an open cohort containing this project
const openCohort = await ctx.prisma.cohort.findFirst({
where: {
stageId: input.stageId,
roundId: input.roundId,
isOpen: true,
projects: { some: { projectId: input.projectId } },
},
@@ -463,25 +456,16 @@ export const liveRouter = router({
}
}
// Find the LiveVotingSession linked to this stage's round
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
include: {
track: {
include: {
pipeline: { select: { programId: true } },
},
},
},
// Find the LiveVotingSession linked to this round
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competition: { select: { programId: true } } },
})
// Find or check existing LiveVotingSession for this stage
// We look for any session linked to a round in this program
// Find or check existing LiveVotingSession for this round
const session = await ctx.prisma.liveVotingSession.findFirst({
where: {
stage: {
track: { pipeline: { programId: stage.track.pipeline.programId } },
},
round: { competition: { programId: round.competition.programId } },
status: 'IN_PROGRESS',
},
})
@@ -560,15 +544,12 @@ export const liveRouter = router({
})
}
// Get stage info
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: cursor.stageId },
// Get round info
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: cursor.roundId },
select: {
id: true,
name: true,
stageType: true,
windowOpenAt: true,
windowCloseAt: true,
status: true,
configJson: true,
},
@@ -592,7 +573,7 @@ export const liveRouter = router({
// Get open cohorts
const openCohorts = await ctx.prisma.cohort.findMany({
where: { stageId: cursor.stageId, isOpen: true },
where: { roundId: cursor.roundId, isOpen: true },
select: {
id: true,
name: true,
@@ -605,27 +586,17 @@ export const liveRouter = router({
},
})
const config = (stage.configJson as Record<string, unknown>) ?? {}
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
const now = new Date()
const isWindowOpen =
stage.status === 'STAGE_ACTIVE' &&
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
(!stage.windowCloseAt || now <= stage.windowCloseAt)
const isWindowOpen = round.status === 'ROUND_ACTIVE'
// Aggregate project scores from LiveVote for the scoreboard
// Find the active LiveVotingSession for this stage's program
const stageWithTrack = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: cursor.stageId },
select: { track: { select: { pipeline: { select: { programId: true } } } } },
})
// Find the active LiveVotingSession for this round's program
const votingSession = await ctx.prisma.liveVotingSession.findFirst({
where: {
stage: {
track: { pipeline: { programId: stageWithTrack.track.pipeline.programId } },
},
round: { competition: { programId: round.id } },
status: 'IN_PROGRESS',
},
select: { id: true },
@@ -692,14 +663,13 @@ export const liveRouter = router({
projectIds: c.projects.map((p) => p.projectId),
})),
projectScores,
stageInfo: {
id: stage.id,
name: stage.name,
stageType: stage.stageType,
roundInfo: {
id: round.id,
name: round.name,
},
windowStatus: {
isOpen: isWindowOpen,
closesAt: stage.windowCloseAt,
isOpen: true,
closesAt: null,
},
}
}),
@@ -739,7 +709,7 @@ export const liveRouter = router({
// Check if there's an open cohort containing this project
const openCohort = await ctx.prisma.cohort.findFirst({
where: {
stageId: cursor.stageId,
roundId: cursor.roundId,
isOpen: true,
projects: { some: { projectId: input.projectId } },
},
@@ -756,22 +726,14 @@ export const liveRouter = router({
}
// Find an active LiveVotingSession
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: cursor.stageId },
include: {
track: {
include: {
pipeline: { select: { programId: true } },
},
},
},
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: cursor.roundId },
select: { competition: { select: { programId: true } } },
})
const session = await ctx.prisma.liveVotingSession.findFirst({
where: {
stage: {
track: { pipeline: { programId: stage.track.pipeline.programId } },
},
round: { competition: { programId: round.competition.programId } },
status: 'IN_PROGRESS',
},
})