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:
457
src/server/routers/round.ts
Normal file
457
src/server/routers/round.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||
import {
|
||||
openWindow,
|
||||
closeWindow,
|
||||
lockWindow,
|
||||
checkDeadlinePolicy,
|
||||
validateSubmission,
|
||||
getVisibleWindows,
|
||||
} from '../services/submission-manager'
|
||||
|
||||
const roundTypeEnum = z.enum([
|
||||
'INTAKE',
|
||||
'FILTERING',
|
||||
'EVALUATION',
|
||||
'SUBMISSION',
|
||||
'MENTORING',
|
||||
'LIVE_FINAL',
|
||||
'DELIBERATION',
|
||||
])
|
||||
|
||||
export const roundRouter = router({
|
||||
/**
|
||||
* Create a new round within a competition
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
roundType: roundTypeEnum,
|
||||
sortOrder: z.number().int().nonnegative(),
|
||||
configJson: z.record(z.unknown()).optional(),
|
||||
windowOpenAt: z.date().nullable().optional(),
|
||||
windowCloseAt: z.date().nullable().optional(),
|
||||
juryGroupId: z.string().nullable().optional(),
|
||||
submissionWindowId: z.string().nullable().optional(),
|
||||
purposeKey: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify competition exists
|
||||
await ctx.prisma.competition.findUniqueOrThrow({
|
||||
where: { id: input.competitionId },
|
||||
})
|
||||
|
||||
// Validate configJson against the Zod schema for this roundType
|
||||
const config = input.configJson
|
||||
? validateRoundConfig(input.roundType, input.configJson)
|
||||
: defaultRoundConfig(input.roundType)
|
||||
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.round.create({
|
||||
data: {
|
||||
competitionId: input.competitionId,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
roundType: input.roundType,
|
||||
sortOrder: input.sortOrder,
|
||||
configJson: config as unknown as Prisma.InputJsonValue,
|
||||
windowOpenAt: input.windowOpenAt ?? undefined,
|
||||
windowCloseAt: input.windowCloseAt ?? undefined,
|
||||
juryGroupId: input.juryGroupId ?? undefined,
|
||||
submissionWindowId: input.submissionWindowId ?? undefined,
|
||||
purposeKey: input.purposeKey ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Round',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
name: input.name,
|
||||
roundType: input.roundType,
|
||||
competitionId: input.competitionId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get round by ID with all relations
|
||||
*/
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
juryGroup: {
|
||||
include: { members: true },
|
||||
},
|
||||
submissionWindow: {
|
||||
include: { fileRequirements: true },
|
||||
},
|
||||
advancementRules: { orderBy: { sortOrder: 'asc' } },
|
||||
visibleSubmissionWindows: {
|
||||
include: { submissionWindow: true },
|
||||
},
|
||||
_count: {
|
||||
select: { projectRoundStates: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||||
}
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update round settings/config
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
||||
status: z.enum(['ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED']).optional(),
|
||||
configJson: z.record(z.unknown()).optional(),
|
||||
windowOpenAt: z.date().nullable().optional(),
|
||||
windowCloseAt: z.date().nullable().optional(),
|
||||
juryGroupId: z.string().nullable().optional(),
|
||||
submissionWindowId: z.string().nullable().optional(),
|
||||
purposeKey: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, configJson, ...data } = input
|
||||
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.round.findUniqueOrThrow({ where: { id } })
|
||||
|
||||
// If configJson provided, validate it against the round type
|
||||
let validatedConfig: Prisma.InputJsonValue | undefined
|
||||
if (configJson) {
|
||||
const parsed = validateRoundConfig(existing.roundType, configJson)
|
||||
validatedConfig = parsed as unknown as Prisma.InputJsonValue
|
||||
}
|
||||
|
||||
const updated = await tx.round.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Round',
|
||||
entityId: id,
|
||||
detailsJson: {
|
||||
changes: input,
|
||||
previous: {
|
||||
name: existing.name,
|
||||
status: existing.status,
|
||||
},
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder rounds within a competition
|
||||
*/
|
||||
updateOrder: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
roundIds: z.array(z.string()),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.$transaction(
|
||||
input.roundIds.map((roundId, index) =>
|
||||
ctx.prisma.round.update({
|
||||
where: { id: roundId },
|
||||
data: { sortOrder: index },
|
||||
})
|
||||
)
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a round
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.round.findUniqueOrThrow({ where: { id: input.id } })
|
||||
|
||||
await tx.round.delete({ where: { id: input.id } })
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Round',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
name: existing.name,
|
||||
roundType: existing.roundType,
|
||||
competitionId: existing.competitionId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return existing
|
||||
})
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Submission Window Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a submission window for a round
|
||||
*/
|
||||
createSubmissionWindow: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
roundNumber: z.number().int().min(1),
|
||||
windowOpenAt: z.date().optional(),
|
||||
windowCloseAt: z.date().optional(),
|
||||
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).default('HARD_DEADLINE'),
|
||||
graceHours: z.number().int().min(0).optional(),
|
||||
lockOnClose: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const window = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.submissionWindow.create({
|
||||
data: {
|
||||
competitionId: input.competitionId,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
roundNumber: input.roundNumber,
|
||||
windowOpenAt: input.windowOpenAt,
|
||||
windowCloseAt: input.windowCloseAt,
|
||||
deadlinePolicy: input.deadlinePolicy,
|
||||
graceHours: input.graceHours,
|
||||
lockOnClose: input.lockOnClose,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, competitionId: input.competitionId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return window
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open a submission window
|
||||
*/
|
||||
openSubmissionWindow: adminProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await openWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to open window',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close a submission window
|
||||
*/
|
||||
closeSubmissionWindow: adminProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await closeWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to close window',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Lock a submission window
|
||||
*/
|
||||
lockSubmissionWindow: adminProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await lockWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to lock window',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check deadline status of a window
|
||||
*/
|
||||
checkDeadline: protectedProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return checkDeadlinePolicy(input.windowId, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Validate files against window requirements
|
||||
*/
|
||||
validateSubmission: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
windowId: z.string(),
|
||||
files: z.array(
|
||||
z.object({
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
requirementId: z.string().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return validateSubmission(input.projectId, input.windowId, input.files, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get visible submission windows for a round
|
||||
*/
|
||||
getVisibleWindows: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getVisibleWindows(input.roundId, ctx.prisma)
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// File Requirements Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a file requirement for a submission window
|
||||
*/
|
||||
createFileRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
submissionWindowId: z.string(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
label: z.string().min(1).max(255),
|
||||
description: z.string().max(2000).optional(),
|
||||
mimeTypes: z.array(z.string()).default([]),
|
||||
maxSizeMb: z.number().int().min(0).optional(),
|
||||
required: z.boolean().default(false),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.submissionFileRequirement.create({
|
||||
data: input,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a file requirement
|
||||
*/
|
||||
updateFileRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
mimeTypes: z.array(z.string()).optional(),
|
||||
maxSizeMb: z.number().min(0).optional().nullable(),
|
||||
required: z.boolean().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
return ctx.prisma.submissionFileRequirement.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a file requirement
|
||||
*/
|
||||
deleteFileRequirement: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.submissionFileRequirement.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get submission windows for applicants in a competition
|
||||
*/
|
||||
getApplicantWindows: protectedProcedure
|
||||
.input(z.object({ competitionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.submissionWindow.findMany({
|
||||
where: { competitionId: input.competitionId },
|
||||
include: {
|
||||
fileRequirements: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user