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

@@ -37,7 +37,7 @@ export const fileRouter = router({
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true, stageId: true },
select: { id: true, roundId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId },
@@ -63,25 +63,25 @@ export const fileRouter = router({
}
if (juryAssignment && !mentorAssignment && !teamMembership) {
const assignedStage = await ctx.prisma.stage.findUnique({
where: { id: juryAssignment.stageId },
select: { trackId: true, sortOrder: true },
const assignedRound = await ctx.prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { competitionId: true, sortOrder: true },
})
if (assignedStage) {
const priorOrCurrentStages = await ctx.prisma.stage.findMany({
if (assignedRound) {
const priorOrCurrentRounds = await ctx.prisma.round.findMany({
where: {
trackId: assignedStage.trackId,
sortOrder: { lte: assignedStage.sortOrder },
competitionId: assignedRound.competitionId,
sortOrder: { lte: assignedRound.sortOrder },
},
select: { id: true },
})
const stageIds = priorOrCurrentStages.map((s) => s.id)
const roundIds = priorOrCurrentRounds.map((r) => r.id)
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
where: {
stageId: { in: stageIds },
roundId: { in: roundIds },
files: { some: { bucket: input.bucket, objectKey: input.objectKey } },
},
select: { id: true },
@@ -135,7 +135,7 @@ export const fileRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
mimeType: z.string(),
size: z.number().int().positive(),
stageId: z.string().optional(),
roundId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -150,9 +150,9 @@ export const fileRouter = router({
}
let isLate = false
if (input.stageId) {
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
if (input.roundId) {
const stage = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { windowCloseAt: true },
})
@@ -191,7 +191,7 @@ export const fileRouter = router({
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
stageId: input.stageId,
roundId: input.roundId,
isLate,
},
ipAddress: ctx.ip,
@@ -262,7 +262,7 @@ export const fileRouter = router({
listByProject: protectedProcedure
.input(z.object({
projectId: z.string(),
stageId: z.string().optional(),
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
@@ -298,8 +298,8 @@ export const fileRouter = router({
}
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.stageId) {
where.requirement = { stageId: input.stageId }
if (input.roundId) {
where.requirement = { roundId: input.roundId }
}
return ctx.prisma.projectFile.findMany({
@@ -311,8 +311,8 @@ export const fileRouter = router({
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
roundId: true,
round: { select: { id: true, name: true, sortOrder: true } },
},
},
},
@@ -327,7 +327,7 @@ export const fileRouter = router({
listByProjectForStage: protectedProcedure
.input(z.object({
projectId: z.string(),
stageId: z.string(),
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
@@ -336,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, stageId: true },
select: { id: true, roundId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
@@ -362,27 +362,27 @@ export const fileRouter = router({
}
}
const targetStage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { trackId: true, sortOrder: true },
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competitionId: true, sortOrder: true },
})
const eligibleStages = await ctx.prisma.stage.findMany({
const eligibleRounds = await ctx.prisma.round.findMany({
where: {
trackId: targetStage.trackId,
sortOrder: { lte: targetStage.sortOrder },
competitionId: targetRound.competitionId,
sortOrder: { lte: targetRound.sortOrder },
},
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
const eligibleStageIds = eligibleStages.map((s) => s.id)
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
const files = await ctx.prisma.projectFile.findMany({
where: {
projectId: input.projectId,
OR: [
{ requirement: { stageId: { in: eligibleStageIds } } },
{ requirement: { roundId: { in: eligibleRoundIds } } },
{ requirementId: null },
],
},
@@ -393,8 +393,8 @@ export const fileRouter = router({
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
roundId: true,
round: { select: { id: true, name: true, sortOrder: true } },
},
},
},
@@ -402,8 +402,8 @@ export const fileRouter = router({
})
const grouped: Array<{
stageId: string | null
stageName: string
roundId: string | null
roundName: string
sortOrder: number
files: typeof files
}> = []
@@ -411,21 +411,21 @@ export const fileRouter = router({
const generalFiles = files.filter((f) => !f.requirementId)
if (generalFiles.length > 0) {
grouped.push({
stageId: null,
stageName: 'General',
roundId: null,
roundName: 'General',
sortOrder: -1,
files: generalFiles,
})
}
for (const stage of eligibleStages) {
const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id)
if (stageFiles.length > 0) {
for (const round of eligibleRounds) {
const roundFiles = files.filter((f) => f.requirement?.roundId === round.id)
if (roundFiles.length > 0) {
grouped.push({
stageId: stage.id,
stageName: stage.name,
sortOrder: stage.sortOrder,
files: stageFiles,
roundId: round.id,
roundName: round.name,
sortOrder: round.sortOrder,
files: roundFiles,
})
}
}
@@ -696,239 +696,116 @@ export const fileRouter = router({
return results
}),
// NOTE: getProjectRequirements procedure removed - depends on deleted Pipeline/Track/Stage models
// Will need to be reimplemented with new Competition/Round architecture
// =========================================================================
// FILE REQUIREMENTS
// =========================================================================
/**
* Get file requirements for a project from its pipeline's intake stage.
* Returns both configJson-based requirements and actual FileRequirement records,
* along with which ones are already fulfilled by uploaded files.
* Materialize legacy configJson file requirements into FileRequirement rows.
* No-op if the stage already has DB-backed requirements.
*/
getProjectRequirements: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// 1. Get the project and its program
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { programId: true },
})
// 2. Find the pipeline for this program
const pipeline = await ctx.prisma.pipeline.findFirst({
where: { programId: project.programId },
include: {
tracks: {
where: { kind: 'MAIN' },
include: {
stages: {
where: { stageType: 'INTAKE' },
take: 1,
},
},
},
materializeRequirementsFromConfig: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const stage = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
id: true,
roundType: true,
configJson: true,
},
})
if (!pipeline) return null
if (stage.roundType !== 'INTAKE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Requirements can only be materialized for INTAKE stages',
})
}
const mainTrack = pipeline.tracks[0]
if (!mainTrack) return null
const intakeStage = mainTrack.stages[0]
if (!intakeStage) return null
// 3. Check for actual FileRequirement records first
const dbRequirements = await ctx.prisma.fileRequirement.findMany({
where: { stageId: intakeStage.id },
orderBy: { sortOrder: 'asc' },
include: {
files: {
where: { projectId: input.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
createdAt: true,
},
},
},
const existingCount = await ctx.prisma.fileRequirement.count({
where: { roundId: input.roundId },
})
if (existingCount > 0) {
return { created: 0, skipped: true, reason: 'already_materialized' as const }
}
// 4. If we have DB requirements, return those (they're the canonical source)
if (dbRequirements.length > 0) {
return {
stageId: intakeStage.id,
requirements: dbRequirements.map((req) => ({
id: req.id,
name: req.name,
description: req.description,
acceptedMimeTypes: req.acceptedMimeTypes,
maxSizeMB: req.maxSizeMB,
isRequired: req.isRequired,
fulfilled: req.files.length > 0,
fulfilledFile: req.files[0] ?? null,
})),
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
const configRequirements = Array.isArray(config.fileRequirements)
? (config.fileRequirements as Array<Record<string, unknown>>)
: []
if (configRequirements.length === 0) {
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
}
const mapLegacyMimeType = (type: unknown): string[] => {
switch (String(type ?? '').toUpperCase()) {
case 'PDF':
return ['application/pdf']
case 'VIDEO':
return ['video/*']
case 'IMAGE':
return ['image/*']
case 'DOC':
case 'DOCX':
return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
case 'PPT':
case 'PPTX':
return ['application/vnd.openxmlformats-officedocument.presentationml.presentation']
default:
return []
}
}
// 5. Fall back to configJson requirements
const configJson = intakeStage.configJson as Record<string, unknown> | null
const fileRequirements = (configJson?.fileRequirements as Array<{
name: string
description?: string
acceptedMimeTypes?: string[]
maxSizeMB?: number
isRequired?: boolean
type?: string
required?: boolean
}>) ?? []
let created = 0
await ctx.prisma.$transaction(async (tx) => {
for (let i = 0; i < configRequirements.length; i++) {
const raw = configRequirements[i]
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
if (!name) continue
if (fileRequirements.length === 0) return null
const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes)
? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string')
: mapLegacyMimeType(raw.type)
// 6. Get project files to check fulfillment
const projectFiles = await ctx.prisma.projectFile.findMany({
where: { projectId: input.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
createdAt: true,
},
await tx.fileRequirement.create({
data: {
roundId: input.roundId,
name,
description:
typeof raw.description === 'string' && raw.description.trim().length > 0
? raw.description.trim()
: undefined,
acceptedMimeTypes,
maxSizeMB:
typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB)
? Math.trunc(raw.maxSizeMB)
: undefined,
isRequired:
(raw.isRequired as boolean | undefined) ??
((raw.required as boolean | undefined) ?? false),
sortOrder: i,
},
})
created++
}
})
return {
stageId: intakeStage.id,
requirements: fileRequirements.map((req) => {
const reqName = req.name.toLowerCase()
// Match by checking if any uploaded file's fileName contains the requirement name
const matchingFile = projectFiles.find((f) =>
f.fileName.toLowerCase().includes(reqName) ||
reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, ''))
)
return {
id: null as string | null,
name: req.name,
description: req.description ?? null,
acceptedMimeTypes: req.acceptedMimeTypes ?? [],
maxSizeMB: req.maxSizeMB ?? null,
// Handle both formats: isRequired (wizard type) and required (seed data)
isRequired: req.isRequired ?? req.required ?? false,
fulfilled: !!matchingFile,
fulfilledFile: matchingFile ?? null,
}
}),
}
return { created, skipped: false as const }
}),
// =========================================================================
// FILE REQUIREMENTS
// =========================================================================
/**
* Materialize legacy configJson file requirements into FileRequirement rows.
* No-op if the stage already has DB-backed requirements.
*/
materializeRequirementsFromConfig: adminProcedure
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: {
id: true,
stageType: true,
configJson: true,
},
})
if (stage.stageType !== 'INTAKE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Requirements can only be materialized for INTAKE stages',
})
}
const existingCount = await ctx.prisma.fileRequirement.count({
where: { stageId: input.stageId },
})
if (existingCount > 0) {
return { created: 0, skipped: true, reason: 'already_materialized' as const }
}
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
const configRequirements = Array.isArray(config.fileRequirements)
? (config.fileRequirements as Array<Record<string, unknown>>)
: []
if (configRequirements.length === 0) {
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
}
const mapLegacyMimeType = (type: unknown): string[] => {
switch (String(type ?? '').toUpperCase()) {
case 'PDF':
return ['application/pdf']
case 'VIDEO':
return ['video/*']
case 'IMAGE':
return ['image/*']
case 'DOC':
case 'DOCX':
return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
case 'PPT':
case 'PPTX':
return ['application/vnd.openxmlformats-officedocument.presentationml.presentation']
default:
return []
}
}
let created = 0
await ctx.prisma.$transaction(async (tx) => {
for (let i = 0; i < configRequirements.length; i++) {
const raw = configRequirements[i]
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
if (!name) continue
const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes)
? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string')
: mapLegacyMimeType(raw.type)
await tx.fileRequirement.create({
data: {
stageId: input.stageId,
name,
description:
typeof raw.description === 'string' && raw.description.trim().length > 0
? raw.description.trim()
: undefined,
acceptedMimeTypes,
maxSizeMB:
typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB)
? Math.trunc(raw.maxSizeMB)
: undefined,
isRequired:
(raw.isRequired as boolean | undefined) ??
((raw.required as boolean | undefined) ?? false),
sortOrder: i,
},
})
created++
}
})
return { created, skipped: false as const }
}),
/**
* List file requirements for a stage (available to any authenticated user)
*/
/**
* List file requirements for a stage (available to any authenticated user)
*/
listRequirements: protectedProcedure
.input(z.object({ stageId: z.string() }))
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.fileRequirement.findMany({
where: { stageId: input.stageId },
where: { roundId: input.roundId },
orderBy: { sortOrder: 'asc' },
})
}),
@@ -939,7 +816,7 @@ export const fileRouter = router({
createRequirement: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
@@ -960,7 +837,7 @@ export const fileRouter = router({
action: 'CREATE',
entityType: 'FileRequirement',
entityId: requirement.id,
detailsJson: { name: input.name, stageId: input.stageId },
detailsJson: { name: input.name, roundId: input.roundId },
})
} catch {}
@@ -1032,7 +909,7 @@ export const fileRouter = router({
reorderRequirements: adminProcedure
.input(
z.object({
stageId: z.string(),
roundId: z.string(),
orderedIds: z.array(z.string()),
})
)