Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
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:
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user