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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user