From fbc8b5165a9ad6f92a5467e62d0037f048dd4d0f Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 6 Apr 2026 16:42:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20award=20master=20tRPC=20procedure?= =?UTF-8?q?s=20=E2=80=94=20enhanced=20detail,=20vote=20with=20justificatio?= =?UTF-8?q?n,=20confirm=20winner,=20set=20chair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/routers/specialAward.ts | 263 ++++++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 1 deletion(-) diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index 34bdd1b..b519a6c 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -1,10 +1,11 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' -import { router, protectedProcedure, adminProcedure } from '../trpc' +import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc' import { getUserAvatarUrl } from '../utils/avatar-url' import { logAudit } from '../utils/audit' import { processEligibilityJob } from '../services/award-eligibility-job' +import { resolveAwardWinner } from '../services/award-winner-resolver' import { getAwardSelectionNotificationTemplate } from '@/lib/email' import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' import { sendBatchNotifications } from '../services/notification-sender' @@ -652,6 +653,117 @@ export const specialAwardRouter = router({ } }), + /** + * Enhanced award detail for Award Master — includes project scores and chair vote visibility + */ + getMyAwardDetailEnhanced: awardMasterProcedure + .input(z.object({ awardId: z.string() })) + .query(async ({ ctx, input }) => { + const juror = await ctx.prisma.awardJuror.findUnique({ + where: { + awardId_userId: { awardId: input.awardId, userId: ctx.user.id }, + }, + }) + if (!juror) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not assigned to this award' }) + } + + const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([ + ctx.prisma.specialAward.findUniqueOrThrow({ + where: { id: input.awardId }, + include: { + competition: { select: { id: true, name: true } }, + }, + }), + ctx.prisma.awardEligibility.findMany({ + where: { awardId: input.awardId, eligible: true }, + include: { + project: { + select: { + id: true, title: true, teamName: true, description: true, + competitionCategory: true, country: true, tags: true, + }, + }, + }, + }), + ctx.prisma.awardVote.findMany({ + where: { awardId: input.awardId, userId: ctx.user.id }, + }), + ctx.prisma.awardJuror.findMany({ + where: { awardId: input.awardId }, + select: { userId: true, isChair: true, user: { select: { name: true } } }, + }), + ]) + + // Fetch evaluation scores for eligible projects + const projectIds = eligibleProjects.map((e) => e.project.id) + const projectScores: Record = {} + + if (award.evaluationRoundId) { + const evaluations = await ctx.prisma.evaluation.findMany({ + where: { + status: 'SUBMITTED', + assignment: { + roundId: award.evaluationRoundId, + projectId: { in: projectIds }, + }, + }, + select: { + globalScore: true, + assignment: { select: { projectId: true } }, + }, + }) + + const scoreMap = new Map() + for (const ev of evaluations) { + if (ev.globalScore !== null) { + const pid = ev.assignment.projectId + if (!scoreMap.has(pid)) scoreMap.set(pid, []) + scoreMap.get(pid)!.push(ev.globalScore) + } + } + for (const [pid, scores] of scoreMap) { + projectScores[pid] = { + avg: scores.reduce((a, b) => a + b, 0) / scores.length, + count: scores.length, + } + } + } + + // Chair sees other votes + const isSolo = allJurors.length === 1 + const isChair = juror.isChair || isSolo + let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = [] + if (isChair && !isSolo) { + const votes = await ctx.prisma.awardVote.findMany({ + where: { awardId: input.awardId, userId: { not: ctx.user.id } }, + select: { + userId: true, projectId: true, justification: true, + user: { select: { name: true } }, + }, + }) + otherVotes = votes.map((v) => ({ + userId: v.userId, + userName: v.user.name, + projectId: v.projectId, + justification: v.justification, + })) + } + + return { + award, + projects: eligibleProjects.map((e) => ({ + ...e.project, + evaluationScore: projectScores[e.project.id] ?? null, + })), + myVotes, + isChair, + otherVotes, + totalJurors: allJurors.length, + jurors: allJurors.map((j) => ({ userId: j.userId, name: j.user.name, isChair: j.isChair })), + } + }), + // ─── Voting ───────────────────────────────────────────────────────────── /** @@ -731,6 +843,55 @@ export const specialAwardRouter = router({ return { submitted: input.votes.length } }), + /** + * Submit award master vote with optional justification (PICK_WINNER only) + */ + submitAwardMasterVote: awardMasterProcedure + .input(z.object({ + awardId: z.string(), + projectId: z.string(), + justification: z.string().max(2000).optional(), + })) + .mutation(async ({ ctx, input }) => { + const juror = await ctx.prisma.awardJuror.findUnique({ + where: { awardId_userId: { awardId: input.awardId, userId: ctx.user.id } }, + }) + if (!juror) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' }) + } + + const award = await ctx.prisma.specialAward.findUniqueOrThrow({ + where: { id: input.awardId }, + }) + if (award.status !== 'VOTING_OPEN') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not open' }) + } + + await ctx.prisma.$transaction([ + ctx.prisma.awardVote.deleteMany({ + where: { awardId: input.awardId, userId: ctx.user.id }, + }), + ctx.prisma.awardVote.create({ + data: { + awardId: input.awardId, + userId: ctx.user.id, + projectId: input.projectId, + justification: input.justification || null, + }, + }), + ]) + + await logAudit({ + userId: ctx.user.id, + action: 'CREATE', + entityType: 'AwardVote', + entityId: input.awardId, + detailsJson: { awardId: input.awardId, projectId: input.projectId, mode: 'AWARD_MASTER_PICK' }, + }) + + return { submitted: true } + }), + // ─── Results ──────────────────────────────────────────────────────────── /** @@ -842,6 +1003,106 @@ export const specialAwardRouter = router({ return award }), + /** + * Chair confirms the winner — resolves tiebreaks, sets winner, closes the award + */ + confirmWinner: awardMasterProcedure + .input(z.object({ awardId: z.string() })) + .mutation(async ({ ctx, input }) => { + const allJurors = await ctx.prisma.awardJuror.findMany({ + where: { awardId: input.awardId }, + select: { userId: true, isChair: true }, + }) + const myJuror = allJurors.find((j) => j.userId === ctx.user.id) + if (!myJuror) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' }) + } + + const isSolo = allJurors.length === 1 + if (!myJuror.isChair && !isSolo) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the chair can confirm the winner' }) + } + + const award = await ctx.prisma.specialAward.findUniqueOrThrow({ + where: { id: input.awardId }, + }) + if (award.status !== 'VOTING_OPEN') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be in VOTING_OPEN status' }) + } + + const chairVote = await ctx.prisma.awardVote.findFirst({ + where: { awardId: input.awardId, userId: ctx.user.id }, + }) + if (!chairVote) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'You must vote before confirming' }) + } + + const allVotes = await ctx.prisma.awardVote.findMany({ + where: { awardId: input.awardId }, + select: { projectId: true, userId: true }, + }) + + const winnerId = resolveAwardWinner(allVotes, ctx.user.id) + + await ctx.prisma.specialAward.update({ + where: { id: input.awardId }, + data: { + winnerProjectId: winnerId, + status: 'CLOSED', + winnerOverridden: false, + winnerOverriddenBy: null, + }, + }) + + await logAudit({ + userId: ctx.user.id, + action: 'UPDATE', + entityType: 'SpecialAward', + entityId: input.awardId, + detailsJson: { + action: 'CONFIRM_WINNER', + winnerId, + totalVotes: allVotes.length, + confirmedBy: ctx.user.id, + }, + }) + + return { winnerId, closed: true } + }), + + /** + * Admin: set/unset chair status for an award juror (only one chair per award) + */ + setChair: adminProcedure + .input(z.object({ + awardId: z.string(), + userId: z.string(), + isChair: z.boolean(), + })) + .mutation(async ({ ctx, input }) => { + if (input.isChair) { + await ctx.prisma.awardJuror.updateMany({ + where: { awardId: input.awardId, isChair: true }, + data: { isChair: false }, + }) + } + + await ctx.prisma.awardJuror.update({ + where: { awardId_userId: { awardId: input.awardId, userId: input.userId } }, + data: { isChair: input.isChair }, + }) + + await logAudit({ + userId: ctx.user.id, + action: 'UPDATE', + entityType: 'AwardJuror', + entityId: `${input.awardId}:${input.userId}`, + detailsJson: { action: 'SET_CHAIR', isChair: input.isChair }, + }) + + return { success: true } + }), + // ─── Round-Scoped Eligibility & Shortlists ────────────────────────────── /**