refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s

The AWARD_MASTER role split sponsor jurors into a parallel UI that hid
project files (only showed when the award was anchored to an evaluation
round) and duplicated the jury voting path with no real difference in
authority — tie-break and finalize were already governed by AwardJuror.isChair
regardless of the user's global role. Inviting a juror via the award page
defaulted to AWARD_MASTER, randomly fragmenting jury panels.

This collapses the role into JURY_MEMBER + isChair:

- specialAward.getMyAwardDetail now returns evaluation scores, chair
  visibility into other jurors' votes, and juror roster
- specialAward.submitVote accepts an optional justification per vote
- specialAward.confirmWinner moves from awardMasterProcedure to
  protectedProcedure (juror+chair check inside)
- bulkInviteJurors creates JURY_MEMBER accounts and, when the award has
  a juryGroupId, also adds them to that JuryGroup so they appear on
  the round-page jury panel
- jury award page renders justification, eval-score badges, and a
  chair tools panel with vote tally + finalize-winner CTA
- juryGroup.list includes attached SpecialAwards; the jury-list UI
  shows a trophy pill alongside round pills
- (award-master) route group, awardMasterProcedure, AWARD_MASTER role
  enum value, and AWARD_MASTER_DECISION decisionMode are deleted
- migration demotes any residual AWARD_MASTER users to JURY_MEMBER and
  recreates the UserRole enum without the value

Coup de Coeur on prod: Didier (the sponsor juror added today as
AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and
attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward
itself was linked to that group (juryGroupId was NULL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-07 15:21:09 +02:00
parent a9116b5833
commit 7bc2b84d1d
26 changed files with 344 additions and 912 deletions

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
@@ -267,7 +267,7 @@ export const specialAwardRouter = router({
evaluationRoundId: z.string().nullable().optional(),
juryGroupId: z.string().nullable().optional(),
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(),
decisionMode: z.enum(['JURY_VOTE', 'ADMIN_DECISION']).nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -656,13 +656,15 @@ export const specialAwardRouter = router({
}),
/**
* Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails
* Bulk invite new users as award jurors. Creates JURY_MEMBER accounts,
* attaches them as AwardJuror, and (if the award has an assigned jury
* group) also adds them as JuryGroupMember so they appear on the
* round-page jury panel alongside existing members.
*/
bulkInviteJurors: adminProcedure
.input(
z.object({
awardId: z.string(),
role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'),
invitees: z.array(
z.object({
name: z.string().optional(),
@@ -674,7 +676,7 @@ export const specialAwardRouter = router({
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { id: true, name: true },
select: { id: true, name: true, juryGroupId: true },
})
const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []
@@ -694,7 +696,7 @@ export const specialAwardRouter = router({
data: {
email: invitee.email,
name: invitee.name || null,
role: input.role,
role: 'JURY_MEMBER',
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
@@ -734,6 +736,23 @@ export const specialAwardRouter = router({
create: { awardId: input.awardId, userId: user.id },
})
if (award.juryGroupId) {
await ctx.prisma.juryGroupMember.upsert({
where: {
juryGroupId_userId: {
juryGroupId: award.juryGroupId,
userId: user.id,
},
},
update: {},
create: {
juryGroupId: award.juryGroupId,
userId: user.id,
role: 'MEMBER',
},
})
}
// For existing-user invitees the new-account invite email above
// never fired (no `created` branch). Send the juror-assignment
// notification so they know they were added — but only if this
@@ -760,7 +779,7 @@ export const specialAwardRouter = router({
detailsJson: {
action: 'BULK_INVITE',
awardName: award.name,
role: input.role,
juryGroupId: award.juryGroupId,
count: input.invitees.length,
results,
},
@@ -842,12 +861,14 @@ export const specialAwardRouter = router({
}),
/**
* Get award detail for voting (jury view)
* Get award detail for voting (jury view). Includes upstream evaluation
* scores per project (when the award is anchored to an evaluation round)
* and — for the chair — the votes cast by other jurors so they can
* tie-break and finalize the award.
*/
getMyAwardDetail: protectedProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user is a juror
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: {
@@ -864,8 +885,7 @@ export const specialAwardRouter = router({
})
}
// Fetch award, eligible projects, and votes in parallel
const [award, eligibleProjects, myVotes] = await Promise.all([
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
}),
@@ -917,62 +937,12 @@ export const specialAwardRouter = router({
ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
])
const projectsRaw = eligibleProjects.map((e) => e.project)
const projectsWithLogos = await attachProjectLogoUrls(projectsRaw)
return {
award,
projects: projectsWithLogos,
myVotes,
}
}),
/**
* 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,
logoKey: true, logoProvider: 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 }> = {}
@@ -1007,7 +977,6 @@ export const specialAwardRouter = router({
}
}
// 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 }> = []
@@ -1047,7 +1016,8 @@ export const specialAwardRouter = router({
// ─── Voting ─────────────────────────────────────────────────────────────
/**
* Submit vote (PICK_WINNER or RANKED)
* Submit vote (PICK_WINNER or RANKED). For PICK_WINNER, jurors may attach
* an optional justification — visible to the chair when they review votes.
*/
submitVote: protectedProcedure
.input(
@@ -1057,12 +1027,12 @@ export const specialAwardRouter = router({
z.object({
projectId: z.string(),
rank: z.number().int().min(1).optional(),
justification: z.string().max(2000).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Verify juror
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: {
@@ -1079,7 +1049,6 @@ export const specialAwardRouter = router({
})
}
// Verify award is open for voting
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
@@ -1091,7 +1060,6 @@ export const specialAwardRouter = router({
})
}
// Delete existing votes and create new ones
await ctx.prisma.$transaction([
ctx.prisma.awardVote.deleteMany({
where: { awardId: input.awardId, userId: ctx.user.id },
@@ -1103,6 +1071,7 @@ export const specialAwardRouter = router({
userId: ctx.user.id,
projectId: vote.projectId,
rank: vote.rank,
justification: vote.justification || null,
},
})
),
@@ -1123,55 +1092,6 @@ 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 ────────────────────────────────────────────────────────────
/**
@@ -1286,7 +1206,7 @@ export const specialAwardRouter = router({
/**
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
*/
confirmWinner: awardMasterProcedure
confirmWinner: protectedProcedure
.input(z.object({ awardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const allJurors = await ctx.prisma.awardJuror.findMany({