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:
@@ -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 ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user