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:
@@ -1,561 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { router } from '../trpc'
|
||||
|
||||
// NOTE: All award procedures have been temporarily disabled because they depended on
|
||||
// deleted models: Pipeline, Track (AWARD kind), SpecialAward linked via Track.
|
||||
// This router will need complete reimplementation with the new Competition/Round/Award architecture.
|
||||
// The SpecialAward model still exists and is linked directly to Competition (competitionId FK).
|
||||
|
||||
export const awardRouter = router({
|
||||
/**
|
||||
* Create a new award track within a pipeline
|
||||
*/
|
||||
createTrack: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
routingMode: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
awardConfig: z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(5000).optional(),
|
||||
criteriaText: z.string().max(5000).optional(),
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).default('PICK_WINNER'),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
useAiEligibility: z.boolean().default(true),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify pipeline exists
|
||||
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
|
||||
where: { id: input.pipelineId },
|
||||
})
|
||||
|
||||
// Check slug uniqueness within pipeline
|
||||
const existingTrack = await ctx.prisma.track.findFirst({
|
||||
where: {
|
||||
pipelineId: input.pipelineId,
|
||||
slug: input.slug,
|
||||
},
|
||||
})
|
||||
if (existingTrack) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A track with slug "${input.slug}" already exists in this pipeline`,
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-set sortOrder
|
||||
const maxOrder = await ctx.prisma.track.aggregate({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
||||
|
||||
const { awardConfig, settingsJson, ...trackData } = input
|
||||
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create the track
|
||||
const track = await tx.track.create({
|
||||
data: {
|
||||
pipelineId: input.pipelineId,
|
||||
name: trackData.name,
|
||||
slug: trackData.slug,
|
||||
kind: 'AWARD',
|
||||
routingMode: trackData.routingMode ?? null,
|
||||
decisionMode: trackData.decisionMode ?? 'JURY_VOTE',
|
||||
sortOrder,
|
||||
settingsJson: (settingsJson as Prisma.InputJsonValue) ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Create the linked SpecialAward
|
||||
const award = await tx.specialAward.create({
|
||||
data: {
|
||||
programId: pipeline.programId,
|
||||
name: awardConfig.name,
|
||||
description: awardConfig.description ?? null,
|
||||
criteriaText: awardConfig.criteriaText ?? null,
|
||||
scoringMode: awardConfig.scoringMode,
|
||||
maxRankedPicks: awardConfig.maxRankedPicks ?? null,
|
||||
useAiEligibility: awardConfig.useAiEligibility,
|
||||
trackId: track.id,
|
||||
sortOrder,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_AWARD_TRACK',
|
||||
entityType: 'Track',
|
||||
entityId: track.id,
|
||||
detailsJson: {
|
||||
pipelineId: input.pipelineId,
|
||||
trackName: track.name,
|
||||
awardId: award.id,
|
||||
awardName: award.name,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { track, award }
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Configure governance settings for an award track
|
||||
*/
|
||||
configureGovernance: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||
jurorIds: z.array(z.string()).optional(),
|
||||
votingStartAt: z.date().optional().nullable(),
|
||||
votingEndAt: z.date().optional().nullable(),
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (track.kind !== 'AWARD') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This track is not an AWARD track',
|
||||
})
|
||||
}
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate voting dates
|
||||
if (input.votingStartAt && input.votingEndAt) {
|
||||
if (input.votingEndAt <= input.votingStartAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Voting end date must be after start date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Update track decision mode
|
||||
if (input.decisionMode) {
|
||||
await tx.track.update({
|
||||
where: { id: input.trackId },
|
||||
data: { decisionMode: input.decisionMode },
|
||||
})
|
||||
}
|
||||
|
||||
// Update award config
|
||||
const awardUpdate: Record<string, unknown> = {}
|
||||
if (input.votingStartAt !== undefined) awardUpdate.votingStartAt = input.votingStartAt
|
||||
if (input.votingEndAt !== undefined) awardUpdate.votingEndAt = input.votingEndAt
|
||||
if (input.scoringMode) awardUpdate.scoringMode = input.scoringMode
|
||||
if (input.maxRankedPicks !== undefined) awardUpdate.maxRankedPicks = input.maxRankedPicks
|
||||
|
||||
let updatedAward = track.specialAward
|
||||
if (Object.keys(awardUpdate).length > 0) {
|
||||
updatedAward = await tx.specialAward.update({
|
||||
where: { id: track.specialAward!.id },
|
||||
data: awardUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
// Manage jurors if provided
|
||||
if (input.jurorIds) {
|
||||
// Remove all existing jurors
|
||||
await tx.awardJuror.deleteMany({
|
||||
where: { awardId: track.specialAward!.id },
|
||||
})
|
||||
|
||||
// Add new jurors
|
||||
if (input.jurorIds.length > 0) {
|
||||
await tx.awardJuror.createMany({
|
||||
data: input.jurorIds.map((userId) => ({
|
||||
awardId: track.specialAward!.id,
|
||||
userId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CONFIGURE_AWARD_GOVERNANCE',
|
||||
entityType: 'Track',
|
||||
entityId: input.trackId,
|
||||
detailsJson: {
|
||||
awardId: track.specialAward!.id,
|
||||
decisionMode: input.decisionMode,
|
||||
jurorCount: input.jurorIds?.length,
|
||||
scoringMode: input.scoringMode,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
track: { id: track.id, decisionMode: input.decisionMode ?? track.decisionMode },
|
||||
award: updatedAward,
|
||||
jurorsSet: input.jurorIds?.length ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Route projects to an award track (set eligibility)
|
||||
*/
|
||||
routeProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
eligible: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
const awardId = track.specialAward.id
|
||||
|
||||
// Upsert eligibility records
|
||||
let createdCount = 0
|
||||
let updatedCount = 0
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const projectId of input.projectIds) {
|
||||
const existing = await tx.awardEligibility.findUnique({
|
||||
where: { awardId_projectId: { awardId, projectId } },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await tx.awardEligibility.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
eligible: input.eligible,
|
||||
method: 'MANUAL',
|
||||
overriddenBy: ctx.user.id,
|
||||
overriddenAt: new Date(),
|
||||
},
|
||||
})
|
||||
updatedCount++
|
||||
} else {
|
||||
await tx.awardEligibility.create({
|
||||
data: {
|
||||
awardId,
|
||||
projectId,
|
||||
eligible: input.eligible,
|
||||
method: 'MANUAL',
|
||||
overriddenBy: ctx.user.id,
|
||||
overriddenAt: new Date(),
|
||||
},
|
||||
})
|
||||
createdCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Also create ProjectStageState entries for routing through pipeline
|
||||
const firstStage = await tx.stage.findFirst({
|
||||
where: { trackId: input.trackId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
if (firstStage) {
|
||||
for (const projectId of input.projectIds) {
|
||||
await tx.projectStageState.upsert({
|
||||
where: {
|
||||
projectId_trackId_stageId: {
|
||||
projectId,
|
||||
trackId: input.trackId,
|
||||
stageId: firstStage.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
trackId: input.trackId,
|
||||
stageId: firstStage.id,
|
||||
state: input.eligible ? 'PENDING' : 'REJECTED',
|
||||
},
|
||||
update: {
|
||||
state: input.eligible ? 'PENDING' : 'REJECTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'ROUTE_PROJECTS_TO_AWARD',
|
||||
entityType: 'Track',
|
||||
entityId: input.trackId,
|
||||
detailsJson: {
|
||||
awardId,
|
||||
projectCount: input.projectIds.length,
|
||||
eligible: input.eligible,
|
||||
created: createdCount,
|
||||
updated: updatedCount,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
return { created: createdCount, updated: updatedCount, total: input.projectIds.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Finalize winners for an award (Award Master only)
|
||||
*/
|
||||
finalizeWinners: awardMasterProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
winnerProjectId: z.string(),
|
||||
override: z.boolean().default(false),
|
||||
reasonText: z.string().max(2000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
const award = track.specialAward
|
||||
|
||||
// Verify the winning project is eligible
|
||||
const eligibility = await ctx.prisma.awardEligibility.findUnique({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId: award.id,
|
||||
projectId: input.winnerProjectId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!eligibility || !eligibility.eligible) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Selected project is not eligible for this award',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if award already has a winner
|
||||
if (award.winnerProjectId && !input.override) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `Award already has a winner. Set override=true to change it.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate award is in VOTING_OPEN or CLOSED status (appropriate for finalization)
|
||||
if (!['VOTING_OPEN', 'CLOSED'].includes(award.status)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: `Award must be in VOTING_OPEN or CLOSED status to finalize. Current: ${award.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
const previousWinnerId = award.winnerProjectId
|
||||
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.specialAward.update({
|
||||
where: { id: award.id },
|
||||
data: {
|
||||
winnerProjectId: input.winnerProjectId,
|
||||
winnerOverridden: input.override,
|
||||
winnerOverriddenBy: input.override ? ctx.user.id : null,
|
||||
status: 'CLOSED',
|
||||
},
|
||||
})
|
||||
|
||||
// Mark winner project as COMPLETED in the award track
|
||||
const firstStage = await tx.stage.findFirst({
|
||||
where: { trackId: input.trackId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
if (firstStage) {
|
||||
await tx.projectStageState.updateMany({
|
||||
where: {
|
||||
trackId: input.trackId,
|
||||
stageId: firstStage.id,
|
||||
projectId: input.winnerProjectId,
|
||||
},
|
||||
data: { state: 'COMPLETED' },
|
||||
})
|
||||
}
|
||||
|
||||
// Record in decision audit
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'award.winner_finalized',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
winnerProjectId: input.winnerProjectId,
|
||||
previousWinnerId,
|
||||
override: input.override,
|
||||
reasonText: input.reasonText,
|
||||
} as Prisma.InputJsonValue,
|
||||
snapshotJson: {
|
||||
awardName: award.name,
|
||||
previousStatus: award.status,
|
||||
previousWinner: previousWinnerId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
if (input.override && previousWinnerId) {
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
previousValue: { winnerProjectId: previousWinnerId } as Prisma.InputJsonValue,
|
||||
newValueJson: { winnerProjectId: input.winnerProjectId } as Prisma.InputJsonValue,
|
||||
reasonCode: 'ADMIN_DISCRETION',
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'AWARD_WINNER_FINALIZED',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
detailsJson: {
|
||||
awardName: award.name,
|
||||
winnerProjectId: input.winnerProjectId,
|
||||
override: input.override,
|
||||
previousWinnerId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get projects routed to an award track with eligibility and votes
|
||||
*/
|
||||
getTrackProjects: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
eligibleOnly: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
const awardId = track.specialAward.id
|
||||
|
||||
const eligibilityWhere: Prisma.AwardEligibilityWhereInput = {
|
||||
awardId,
|
||||
}
|
||||
if (input.eligibleOnly) {
|
||||
eligibilityWhere.eligible = true
|
||||
}
|
||||
|
||||
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
||||
where: eligibilityWhere,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
tags: true,
|
||||
description: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// Get vote counts per project
|
||||
const projectIds = eligibilities.map((e) => e.projectId)
|
||||
const voteSummary =
|
||||
projectIds.length > 0
|
||||
? await ctx.prisma.awardVote.groupBy({
|
||||
by: ['projectId'],
|
||||
where: { awardId, projectId: { in: projectIds } },
|
||||
_count: true,
|
||||
})
|
||||
: []
|
||||
|
||||
const voteMap = new Map(
|
||||
voteSummary.map((v) => [v.projectId, v._count])
|
||||
)
|
||||
|
||||
return {
|
||||
trackId: input.trackId,
|
||||
awardId,
|
||||
awardName: track.specialAward.name,
|
||||
winnerProjectId: track.specialAward.winnerProjectId,
|
||||
status: track.specialAward.status,
|
||||
projects: eligibilities.map((e) => ({
|
||||
...e,
|
||||
voteCount: voteMap.get(e.projectId) ?? 0,
|
||||
isWinner: e.projectId === track.specialAward!.winnerProjectId,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
// TODO: Reimplement award procedures with new Competition/Round architecture
|
||||
// Procedures to reimplement:
|
||||
// - createAwardTrack → createAward (link SpecialAward to Competition directly)
|
||||
// - configureGovernance → configureAwardGovernance
|
||||
// - routeProjects → setAwardEligibility
|
||||
// - finalizeWinners → finalizeAwardWinner
|
||||
// - getTrackProjects → getAwardProjects
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user