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 { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
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 { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||||
|
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
||||||
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
||||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||||
import { sendBatchNotifications } from '../services/notification-sender'
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Voting ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -731,6 +843,55 @@ export const specialAwardRouter = router({
|
|||||||
return { submitted: input.votes.length }
|
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 ────────────────────────────────────────────────────────────
|
// ─── Results ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -842,6 +1003,106 @@ export const specialAwardRouter = router({
|
|||||||
return award
|
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 ──────────────────────────────
|
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user