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

@@ -17,22 +17,23 @@ import {
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
async function runAIAssignmentJob(jobId: string, stageId: string, userId: string) {
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
try {
await prisma.assignmentJob.update({
where: { id: jobId },
data: { status: 'RUNNING', startedAt: new Date() },
})
const stage = await prisma.stage.findUniqueOrThrow({
where: { id: stageId },
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: {
name: true,
configJson: true,
competitionId: true,
},
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const config = (round.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
@@ -53,17 +54,17 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId } },
assignments: { where: { roundId } },
},
},
},
})
const projectStageStates = await prisma.projectStageState.findMany({
where: { stageId },
const projectRoundStates = await prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projectIds = projectRoundStates.map((prs) => prs.projectId)
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
@@ -73,12 +74,12 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
description: true,
tags: true,
teamName: true,
_count: { select: { assignments: { where: { stageId } } } },
_count: { select: { assignments: { where: { roundId } } } },
},
})
const existingAssignments = await prisma.assignment.findMany({
where: { stageId },
where: { roundId },
select: { userId: true, projectId: true },
})
@@ -126,7 +127,7 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
projects,
constraints,
userId,
stageId,
roundId,
onProgress
)
@@ -157,12 +158,12 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
await notifyAdmins({
type: NotificationTypes.AI_SUGGESTIONS_READY,
title: 'AI Assignment Suggestions Ready',
message: `AI generated ${result.suggestions.length} assignment suggestions for ${stage.name || 'stage'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/assignments`,
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/competitions/${round.competitionId}/assignments`,
linkLabel: 'View Suggestions',
priority: 'high',
metadata: {
stageId,
roundId,
jobId,
projectCount: projects.length,
suggestionsCount: result.suggestions.length,
@@ -187,10 +188,10 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
export const assignmentRouter = router({
listByStage: adminProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.assignment.findMany({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
project: { select: { id: true, title: true, tags: true } },
@@ -233,18 +234,18 @@ export const assignmentRouter = router({
myAssignments: protectedProcedure
.input(
z.object({
stageId: z.string().optional(),
roundId: z.string().optional(),
status: z.enum(['all', 'pending', 'completed']).default('all'),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
userId: ctx.user.id,
stage: { status: 'STAGE_ACTIVE' },
round: { status: 'STAGE_ACTIVE' },
}
if (input.stageId) {
where.stageId = input.stageId
if (input.roundId) {
where.roundId = input.roundId
}
if (input.status === 'pending') {
@@ -259,7 +260,7 @@ export const assignmentRouter = router({
project: {
include: { files: true },
},
stage: true,
round: true,
evaluation: true,
},
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
@@ -277,7 +278,7 @@ export const assignmentRouter = router({
include: {
user: { select: { id: true, name: true, email: true } },
project: { include: { files: true } },
stage: { include: { evaluationForms: { where: { isActive: true } } } },
round: { include: { evaluationForms: { where: { isActive: true } } } },
evaluation: true,
},
})
@@ -304,7 +305,7 @@ export const assignmentRouter = router({
z.object({
userId: z.string(),
projectId: z.string(),
stageId: z.string(),
roundId: z.string(),
isRequired: z.boolean().default(true),
forceOverride: z.boolean().default(false),
})
@@ -312,10 +313,10 @@ export const assignmentRouter = router({
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.assignment.findUnique({
where: {
userId_projectId_stageId: {
userId_projectId_roundId: {
userId: input.userId,
projectId: input.projectId,
stageId: input.stageId,
roundId: input.roundId,
},
},
})
@@ -328,8 +329,8 @@ export const assignmentRouter = router({
}
const [stage, user] = await Promise.all([
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
}),
ctx.prisma.user.findUniqueOrThrow({
@@ -346,7 +347,7 @@ export const assignmentRouter = router({
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
const currentCount = await ctx.prisma.assignment.count({
where: { userId: input.userId, stageId: input.stageId },
where: { userId: input.userId, roundId: input.roundId },
})
// Check if at or over limit
@@ -387,8 +388,8 @@ export const assignmentRouter = router({
where: { id: input.projectId },
select: { title: true },
}),
ctx.prisma.stage.findUnique({
where: { id: input.stageId },
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, windowCloseAt: true },
}),
])
@@ -408,7 +409,7 @@ export const assignmentRouter = router({
type: NotificationTypes.ASSIGNED_TO_PROJECT,
title: 'New Project Assignment',
message: `You have been assigned to evaluate "${project.title}" for ${stageInfo.name}.`,
linkUrl: `/jury/stages`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignment',
metadata: {
projectName: project.title,
@@ -428,7 +429,7 @@ export const assignmentRouter = router({
bulkCreate: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
@@ -448,7 +449,7 @@ export const assignmentRouter = router({
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId: input.stageId } },
assignments: { where: { roundId: input.roundId } },
},
},
},
@@ -456,8 +457,8 @@ export const assignmentRouter = router({
const userMap = new Map(users.map((u) => [u.id, u]))
// Get stage default max
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const stage = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true, name: true, windowCloseAt: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
@@ -494,7 +495,7 @@ export const assignmentRouter = router({
const result = await ctx.prisma.assignment.createMany({
data: allowedAssignments.map((a) => ({
...a,
stageId: input.stageId,
roundId: input.roundId,
method: 'BULK',
createdBy: ctx.user.id,
})),
@@ -550,7 +551,7 @@ export const assignmentRouter = router({
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
@@ -601,20 +602,20 @@ export const assignmentRouter = router({
* Get assignment statistics for a round
*/
getStats: adminProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const stage = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const projectStageStates = await ctx.prisma.projectStageState.findMany({
where: { stageId: input.stageId },
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projectIds = projectRoundStates.map((pss) => pss.projectId)
const [
totalAssignments,
@@ -622,13 +623,13 @@ export const assignmentRouter = router({
assignmentsByUser,
projectCoverage,
] = await Promise.all([
ctx.prisma.assignment.count({ where: { stageId: input.stageId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({
where: { stageId: input.stageId, isCompleted: true },
where: { roundId: input.roundId, isCompleted: true },
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { stageId: input.stageId },
where: { roundId: input.roundId },
_count: true,
}),
ctx.prisma.project.findMany({
@@ -636,7 +637,7 @@ export const assignmentRouter = router({
select: {
id: true,
title: true,
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
},
}),
])
@@ -670,12 +671,12 @@ export const assignmentRouter = router({
getSuggestions: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const stage = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
@@ -705,17 +706,17 @@ export const assignmentRouter = router({
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId: input.stageId } },
assignments: { where: { roundId: input.roundId } },
},
},
},
})
const projectStageStates = await ctx.prisma.projectStageState.findMany({
where: { stageId: input.stageId },
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projectIds = projectRoundStates.map((pss) => pss.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
@@ -727,12 +728,12 @@ export const assignmentRouter = router({
projectTags: {
include: { tag: { select: { name: true } } },
},
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
},
})
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const assignmentSet = new Set(
@@ -743,7 +744,7 @@ export const assignmentRouter = router({
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
if (categoryQuotas) {
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
select: {
userId: true,
project: { select: { competitionCategory: true } },
@@ -884,14 +885,14 @@ export const assignmentRouter = router({
getAISuggestions: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
useAI: z.boolean().default(true),
})
)
.query(async ({ ctx, input }) => {
const completedJob = await ctx.prisma.assignmentJob.findFirst({
where: {
stageId: input.stageId,
roundId: input.roundId,
status: 'COMPLETED',
},
orderBy: { completedAt: 'desc' },
@@ -914,7 +915,7 @@ export const assignmentRouter = router({
}>
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const assignmentSet = new Set(
@@ -949,7 +950,7 @@ export const assignmentRouter = router({
applyAISuggestions: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
@@ -977,15 +978,15 @@ export const assignmentRouter = router({
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId: input.stageId } },
assignments: { where: { roundId: input.roundId } },
},
},
},
})
const userMap = new Map(users.map((u) => [u.id, u]))
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const stageData = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
@@ -1020,7 +1021,7 @@ export const assignmentRouter = router({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
stageId: input.stageId,
roundId: input.roundId,
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
aiConfidenceScore: a.confidenceScore,
expertiseMatchScore: a.expertiseMatchScore,
@@ -1036,7 +1037,7 @@ export const assignmentRouter = router({
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
stageId: input.stageId,
roundId: input.roundId,
count: created.count,
usedAI: input.usedAI,
forceOverride: input.forceOverride,
@@ -1055,8 +1056,8 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
const stage = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, windowCloseAt: true },
})
@@ -1083,7 +1084,7 @@ export const assignmentRouter = router({
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
@@ -1107,7 +1108,7 @@ export const assignmentRouter = router({
applySuggestions: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
@@ -1132,15 +1133,15 @@ export const assignmentRouter = router({
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId: input.stageId } },
assignments: { where: { roundId: input.roundId } },
},
},
},
})
const userMap = new Map(users.map((u) => [u.id, u]))
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
const stageData = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
@@ -1175,7 +1176,7 @@ export const assignmentRouter = router({
data: assignmentsToCreate.map((a) => ({
userId: a.userId,
projectId: a.projectId,
stageId: input.stageId,
roundId: input.roundId,
method: 'ALGORITHM',
aiReasoning: a.reasoning,
createdBy: ctx.user.id,
@@ -1189,7 +1190,7 @@ export const assignmentRouter = router({
action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
stageId: input.stageId,
roundId: input.roundId,
count: created.count,
forceOverride: input.forceOverride,
skippedDueToCapacity,
@@ -1207,8 +1208,8 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
const stage = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, windowCloseAt: true },
})
@@ -1235,7 +1236,7 @@ export const assignmentRouter = router({
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
@@ -1257,11 +1258,11 @@ export const assignmentRouter = router({
* Start an AI assignment job (background processing)
*/
startAIAssignmentJob: adminProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const existingJob = await ctx.prisma.assignmentJob.findFirst({
where: {
stageId: input.stageId,
roundId: input.roundId,
status: { in: ['PENDING', 'RUNNING'] },
},
})
@@ -1282,12 +1283,12 @@ export const assignmentRouter = router({
const job = await ctx.prisma.assignmentJob.create({
data: {
stageId: input.stageId,
roundId: input.roundId,
status: 'PENDING',
},
})
runAIAssignmentJob(job.id, input.stageId, ctx.user.id).catch(console.error)
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
return { jobId: job.id }
}),
@@ -1321,10 +1322,10 @@ export const assignmentRouter = router({
* Get the latest AI assignment job for a round
*/
getLatestAIAssignmentJob: adminProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.assignmentJob.findFirst({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
orderBy: { createdAt: 'desc' },
})