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 = {} 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, })), } }), })