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
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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user