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

@@ -22,7 +22,7 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
const file = await ctx.prisma.projectFile.findFirst({
@@ -321,7 +321,7 @@ export const fileRouter = router({
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -393,7 +393,7 @@ export const fileRouter = router({
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -716,7 +716,7 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
// For non-admin/observer callers, mirror the per-round scope used by
// file.getDownloadUrl: a juror assigned to round N may only pull URLs

View File

@@ -102,6 +102,10 @@ export const juryGroupRouter = router({
select: { id: true, name: true, roundType: true, status: true },
orderBy: { sortOrder: 'asc' },
},
awards: {
select: { id: true, name: true, status: true },
orderBy: { sortOrder: 'asc' },
},
members: {
take: 5,
orderBy: { joinedAt: 'asc' },

View File

@@ -177,11 +177,11 @@ export const projectRouter = router({
]
}
// Per-role visibility filters. Admin / Observer / Award master see all
// (these roles are designed for cross-program oversight). Other roles
// are scoped to projects they have a relationship with.
// Per-role visibility filters. Admin / Observer see all (these roles
// are designed for cross-program oversight). Other roles are scoped to
// projects they have a relationship with.
const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER')
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
if (!isAdmin && !isObserverLevel) {
const orClauses: Array<Record<string, unknown>> = []
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
@@ -534,10 +534,10 @@ export const projectRouter = router({
// ProjectTag table may not exist yet
}
// Per-role access check. Admin / Observer / Award master can read any
// project. Jury / Mentor / Applicant must have a relationship to it.
// Per-role access check. Admin / Observer can read any project.
// Jury / Mentor / Applicant must have a relationship to it.
const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER')
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
if (!isAdmin && !isObserverLevel) {
const checks: Array<Promise<unknown>> = []
if (userHasRole(ctx.user, 'JURY_MEMBER')) {

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({

View File

@@ -217,8 +217,8 @@ export const userRouter = router({
list: adminProcedure
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
@@ -364,8 +364,8 @@ export const userRouter = router({
listInvitableIds: adminProcedure
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
search: z.string().optional(),
})
)
@@ -457,7 +457,7 @@ export const userRouter = router({
z.object({
email: z.string().email(),
name: z.string().optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional(),
})
@@ -528,8 +528,8 @@ export const userRouter = router({
id: z.string(),
email: z.string().email().optional(),
name: z.string().optional().nullable(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
@@ -708,7 +708,7 @@ export const userRouter = router({
z.object({
email: z.string().email(),
name: z.string().optional(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
// Optional pre-assignments for jury members
assignments: z
@@ -1835,7 +1835,7 @@ export const userRouter = router({
}
// Set primary role to highest privilege role
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'AWARD_MASTER', 'APPLICANT', 'AUDIENCE']
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']
const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0]
return ctx.prisma.user.update({
@@ -1936,7 +1936,6 @@ export const userRouter = router({
'JURY_MEMBER',
'MENTOR',
'OBSERVER',
'AWARD_MASTER',
'APPLICANT',
'AUDIENCE',
]
@@ -2236,7 +2235,7 @@ export const userRouter = router({
* which the user has actionable work right now, or the highest-priority
* role they hold (static fallback) if nothing is actionable.
*
* Priority order: SUPER_ADMIN > PROGRAM_ADMIN > AWARD_MASTER > JURY_MEMBER >
* Priority order: SUPER_ADMIN > PROGRAM_ADMIN > JURY_MEMBER >
* MENTOR > APPLICANT > OBSERVER > AUDIENCE.
*
* Used by src/app/page.tsx to route users at login.
@@ -2253,14 +2252,6 @@ export const userRouter = router({
const PRIORITY: Entry[] = [
{ role: 'SUPER_ADMIN', path: '/admin', predicate: () => true },
{ role: 'PROGRAM_ADMIN', path: '/admin', predicate: () => true },
{
role: 'AWARD_MASTER',
path: '/award-master',
predicate: async () => {
const cnt = await ctx.prisma.awardJuror.count({ where: { userId: user.id } })
return cnt > 0
},
},
{
role: 'JURY_MEMBER',
path: '/jury',