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

@@ -170,23 +170,19 @@ export const applicationRouter = router({
competitionCategories: wizardConfig.competitionCategories ?? [],
}
} else {
// Stage-specific application mode (backward compatible with round slug)
const stage = await ctx.prisma.stage.findFirst({
// Round-specific application mode (backward compatible with round slug)
const round = await ctx.prisma.round.findFirst({
where: { slug: input.slug },
include: {
track: {
competition: {
include: {
pipeline: {
include: {
program: {
select: {
id: true,
name: true,
year: true,
description: true,
settingsJson: true,
},
},
program: {
select: {
id: true,
name: true,
year: true,
description: true,
settingsJson: true,
},
},
},
@@ -194,38 +190,36 @@ export const applicationRouter = router({
},
})
if (!stage) {
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application stage not found',
message: 'Application round not found',
})
}
const stageProgram = stage.track.pipeline.program
const isOpen = stage.windowOpenAt && stage.windowCloseAt
? now >= stage.windowOpenAt && now <= stage.windowCloseAt
: stage.status === 'STAGE_ACTIVE'
const roundProgram = round.competition.program
const isOpen = round.status === 'ROUND_ACTIVE'
const stageWizardConfig = parseWizardConfig(stageProgram.settingsJson)
const { settingsJson: _s, ...programData } = stageProgram
const roundWizardConfig = parseWizardConfig(roundProgram.settingsJson)
const { settingsJson: _s, ...programData } = roundProgram
return {
mode: 'stage' as const,
stage: {
id: stage.id,
name: stage.name,
slug: stage.slug,
submissionStartDate: stage.windowOpenAt,
submissionEndDate: stage.windowCloseAt,
submissionDeadline: stage.windowCloseAt,
id: round.id,
name: round.name,
slug: round.slug,
submissionStartDate: null,
submissionEndDate: null,
submissionDeadline: null,
lateSubmissionGrace: null,
gracePeriodEnd: null,
isOpen,
},
program: programData,
wizardConfig: stageWizardConfig,
oceanIssueOptions: stageWizardConfig.oceanIssues ?? [],
competitionCategories: stageWizardConfig.competitionCategories ?? [],
wizardConfig: roundWizardConfig,
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
competitionCategories: roundWizardConfig.competitionCategories ?? [],
}
}
}),
@@ -238,7 +232,7 @@ export const applicationRouter = router({
z.object({
mode: z.enum(['edition', 'stage']).default('stage'),
programId: z.string().optional(),
stageId: z.string().optional(),
roundId: z.string().optional(),
data: applicationInputSchema,
})
)
@@ -253,7 +247,7 @@ export const applicationRouter = router({
})
}
const { mode, programId, stageId, data } = input
const { mode, programId, roundId, data } = input
// Validate input based on mode
if (mode === 'edition' && !programId) {
@@ -263,10 +257,10 @@ export const applicationRouter = router({
})
}
if (mode === 'stage' && !stageId) {
if (mode === 'stage' && !roundId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'stageId is required for stage-specific applications',
message: 'roundId is required for round-specific applications',
})
}
@@ -341,35 +335,31 @@ export const applicationRouter = router({
})
}
} else {
// Stage-specific application
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: stageId! },
// Round-specific application
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId! },
include: {
track: {
competition: {
include: {
pipeline: { include: { program: true } },
program: true,
},
},
},
})
program = stage.track.pipeline.program
program = round.competition.program
// Check submission window
if (stage.windowOpenAt && stage.windowCloseAt) {
isOpen = now >= stage.windowOpenAt && now <= stage.windowCloseAt
} else {
isOpen = stage.status === 'STAGE_ACTIVE'
}
isOpen = round.status === 'ROUND_ACTIVE'
if (!isOpen) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Applications are currently closed for this stage',
message: 'Applications are currently closed for this round',
})
}
// Check if email already submitted for this stage
// Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({
where: {
programId: program.id,
@@ -380,7 +370,7 @@ export const applicationRouter = router({
if (existingProject) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An application with this email already exists for this stage',
message: 'An application with this email already exists for this round',
})
}
}
@@ -546,7 +536,7 @@ export const applicationRouter = router({
z.object({
mode: z.enum(['edition', 'stage']).default('stage'),
programId: z.string().optional(),
stageId: z.string().optional(),
roundId: z.string().optional(),
email: z.string().email(),
})
)
@@ -570,16 +560,16 @@ export const applicationRouter = router({
},
})
} else {
// For stage-specific applications, check by program (derived from stage)
if (input.stageId) {
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
include: { track: { include: { pipeline: { select: { programId: true } } } } },
// For round-specific applications, check by program (derived from round)
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { competition: { select: { programId: true } } },
})
if (stage) {
if (round) {
existing = await ctx.prisma.project.findFirst({
where: {
programId: stage.track.pipeline.programId,
programId: round.competition.programId,
submittedByEmail: input.email,
},
})
@@ -613,40 +603,38 @@ export const applicationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Find stage by slug
const stage = await ctx.prisma.stage.findFirst({
// Find round by slug
const round = await ctx.prisma.round.findFirst({
where: { slug: input.roundSlug },
include: {
track: {
include: {
pipeline: { select: { programId: true } },
},
},
select: {
id: true,
competition: { select: { programId: true } },
configJson: true,
},
})
if (!stage) {
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Stage not found',
message: 'Round not found',
})
}
const stageConfig = (stage.configJson as Record<string, unknown>) || {}
if (stageConfig.drafts_enabled === false) {
const roundConfig = (round.configJson as Record<string, unknown>) || {}
if (roundConfig.drafts_enabled === false) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Draft saving is not enabled for this stage',
message: 'Draft saving is not enabled for this round',
})
}
const draftExpiryDays = (stageConfig.draft_expiry_days as number) || 30
const draftExpiryDays = (roundConfig.draft_expiry_days as number) || 30
const draftExpiresAt = new Date()
draftExpiresAt.setDate(draftExpiresAt.getDate() + draftExpiryDays)
const draftToken = `draft_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
const programId = input.programId || stage.track.pipeline.programId
const programId = input.programId || round.competition.programId
const existingDraft = await ctx.prisma.project.findFirst({
where: {