feat: add award master tRPC procedures — enhanced detail, vote with justification, confirm winner, set chair

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-06 16:42:21 -04:00
parent 9368c1221f
commit fbc8b5165a

View File

@@ -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<string, { avg: number; count: number }> = {}
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<string, number[]>()
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 ──────────────────────────────
/**