Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
|
|
|
|
import {
|
|
|
|
|
activateRound,
|
|
|
|
|
closeRound,
|
|
|
|
|
archiveRound,
|
2026-02-16 12:06:07 +01:00
|
|
|
reopenRound,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
transitionProject,
|
|
|
|
|
batchTransitionProjects,
|
|
|
|
|
getProjectRoundStates,
|
|
|
|
|
getProjectRoundState,
|
|
|
|
|
} from '../services/round-engine'
|
|
|
|
|
|
|
|
|
|
const projectRoundStateEnum = z.enum([
|
|
|
|
|
'PENDING',
|
|
|
|
|
'IN_PROGRESS',
|
|
|
|
|
'PASSED',
|
|
|
|
|
'REJECTED',
|
|
|
|
|
'COMPLETED',
|
|
|
|
|
'WITHDRAWN',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
export const roundEngineRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Activate a round: ROUND_DRAFT → ROUND_ACTIVE
|
|
|
|
|
*/
|
|
|
|
|
activate: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await activateRound(input.roundId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to activate round',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Close a round: ROUND_ACTIVE → ROUND_CLOSED
|
|
|
|
|
*/
|
|
|
|
|
close: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await closeRound(input.roundId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to close round',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-16 12:06:07 +01:00
|
|
|
/**
|
|
|
|
|
* Reopen a round: ROUND_CLOSED → ROUND_ACTIVE
|
|
|
|
|
* Pauses any subsequent active rounds in the same competition.
|
|
|
|
|
*/
|
|
|
|
|
reopen: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await reopenRound(input.roundId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to reopen round',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
/**
|
|
|
|
|
* Archive a round: ROUND_CLOSED → ROUND_ARCHIVED
|
|
|
|
|
*/
|
|
|
|
|
archive: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await archiveRound(input.roundId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to archive round',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transition a single project within a round
|
|
|
|
|
*/
|
|
|
|
|
transitionProject: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
newState: projectRoundStateEnum,
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await transitionProject(
|
|
|
|
|
input.projectId,
|
|
|
|
|
input.roundId,
|
|
|
|
|
input.newState,
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to transition project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Batch transition multiple projects within a round
|
|
|
|
|
*/
|
|
|
|
|
batchTransition: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectIds: z.array(z.string()).min(1),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
newState: projectRoundStateEnum,
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
return batchTransitionProjects(
|
|
|
|
|
input.projectIds,
|
|
|
|
|
input.roundId,
|
|
|
|
|
input.newState,
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all project round states for a round
|
|
|
|
|
*/
|
|
|
|
|
getProjectStates: protectedProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return getProjectRoundStates(input.roundId, ctx.prisma)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a single project's state within a round
|
|
|
|
|
*/
|
|
|
|
|
getProjectState: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return getProjectRoundState(input.projectId, input.roundId, ctx.prisma)
|
|
|
|
|
}),
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a project from a round (and all subsequent rounds in that competition).
|
|
|
|
|
* The project remains in all prior rounds.
|
|
|
|
|
*/
|
|
|
|
|
removeFromRound: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Get the round to know its competition and sort order
|
|
|
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { id: true, competitionId: true, sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Find all rounds at this sort order or later in the same competition
|
|
|
|
|
const roundsToRemoveFrom = await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competitionId: round.competitionId,
|
|
|
|
|
sortOrder: { gte: round.sortOrder },
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
|
|
|
|
|
|
|
|
|
// Delete ProjectRoundState entries for this project in all affected rounds
|
|
|
|
|
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
roundId: { in: roundIds },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Check if the project is still in any round at all
|
|
|
|
|
const remainingStates = await ctx.prisma.projectRoundState.count({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// If no longer in any round, reset project status back to SUBMITTED
|
|
|
|
|
if (remainingStates === 0) {
|
|
|
|
|
await ctx.prisma.project.update({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
data: { status: 'SUBMITTED' },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true, removedFromRounds: deleted.count }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Batch remove projects from a round (and all subsequent rounds).
|
|
|
|
|
*/
|
|
|
|
|
batchRemoveFromRound: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectIds: z.array(z.string()).min(1),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { id: true, competitionId: true, sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const roundsToRemoveFrom = await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competitionId: round.competitionId,
|
|
|
|
|
sortOrder: { gte: round.sortOrder },
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
|
|
|
|
|
|
|
|
|
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: { in: input.projectIds },
|
|
|
|
|
roundId: { in: roundIds },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// For projects with no remaining round states, reset to SUBMITTED
|
|
|
|
|
const projectsStillInRounds = await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: { in: input.projectIds } },
|
|
|
|
|
select: { projectId: true },
|
|
|
|
|
distinct: ['projectId'],
|
|
|
|
|
})
|
|
|
|
|
const stillInRoundIds = new Set(projectsStillInRounds.map((p) => p.projectId))
|
|
|
|
|
const orphanedIds = input.projectIds.filter((id) => !stillInRoundIds.has(id))
|
|
|
|
|
|
|
|
|
|
if (orphanedIds.length > 0) {
|
|
|
|
|
await ctx.prisma.project.updateMany({
|
|
|
|
|
where: { id: { in: orphanedIds } },
|
|
|
|
|
data: { status: 'SUBMITTED' },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true, removedCount: deleted.count }
|
|
|
|
|
}),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
})
|