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:
252
src/server/routers/competition.ts
Normal file
252
src/server/routers/competition.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const competitionRouter = router({
|
||||
/**
|
||||
* Create a new competition for a program
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
categoryMode: z.string().default('SHARED'),
|
||||
startupFinalistCount: z.number().int().positive().default(3),
|
||||
conceptFinalistCount: z.number().int().positive().default(3),
|
||||
notifyOnRoundAdvance: z.boolean().default(true),
|
||||
notifyOnDeadlineApproach: z.boolean().default(true),
|
||||
deadlineReminderDays: z.array(z.number().int().positive()).default([7, 3, 1]),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.competition.findUnique({
|
||||
where: { slug: input.slug },
|
||||
})
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A competition with slug "${input.slug}" already exists`,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
const competition = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.competition.create({
|
||||
data: input,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Competition',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, programId: input.programId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get competition by ID with rounds, jury groups, and submission windows
|
||||
*/
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const competition = await ctx.prisma.competition.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
roundType: true,
|
||||
status: true,
|
||||
sortOrder: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
},
|
||||
juryGroups: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
sortOrder: true,
|
||||
defaultMaxAssignments: true,
|
||||
defaultCapMode: true,
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
},
|
||||
submissionWindows: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
roundNumber: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
isLocked: true,
|
||||
_count: { select: { fileRequirements: true, projectFiles: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!competition) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Competition not found' })
|
||||
}
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* List competitions for a program
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { programId: input.programId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { rounds: true, juryGroups: true, submissionWindows: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update competition settings
|
||||
*/
|
||||
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(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']).optional(),
|
||||
categoryMode: z.string().optional(),
|
||||
startupFinalistCount: z.number().int().positive().optional(),
|
||||
conceptFinalistCount: z.number().int().positive().optional(),
|
||||
notifyOnRoundAdvance: z.boolean().optional(),
|
||||
notifyOnDeadlineApproach: z.boolean().optional(),
|
||||
deadlineReminderDays: z.array(z.number().int().positive()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
if (data.slug) {
|
||||
const existing = await ctx.prisma.competition.findFirst({
|
||||
where: { slug: data.slug, NOT: { id } },
|
||||
})
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A competition with slug "${data.slug}" already exists`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const competition = await ctx.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.competition.findUniqueOrThrow({ where: { id } })
|
||||
|
||||
const updated = await tx.competition.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Competition',
|
||||
entityId: id,
|
||||
detailsJson: {
|
||||
changes: data,
|
||||
previous: {
|
||||
name: previous.name,
|
||||
status: previous.status,
|
||||
slug: previous.slug,
|
||||
},
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete (archive) a competition
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const competition = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.competition.update({
|
||||
where: { id: input.id },
|
||||
data: { status: 'ARCHIVED' },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Competition',
|
||||
entityId: input.id,
|
||||
detailsJson: { action: 'archived' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get competitions where the current user is a jury group member
|
||||
*/
|
||||
getMyCompetitions: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Find competitions where the user is a jury group member
|
||||
const memberships = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
select: { juryGroup: { select: { competitionId: true } } },
|
||||
})
|
||||
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
||||
if (competitionIds.length === 0) return []
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
|
||||
include: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true, roundType: true, status: true },
|
||||
},
|
||||
_count: { select: { rounds: true, juryGroups: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user