Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

561
src/server/routers/award.ts Normal file
View File

@@ -0,0 +1,561 @@
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'
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(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).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,
})),
}
}),
})