feat: add Decision Mode dropdown to award edit page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-07 20:13:51 -04:00
parent b901047418
commit 29502a2b88
2 changed files with 124 additions and 1 deletions

View File

@@ -58,6 +58,7 @@ export default function EditAwardPage({
const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('')
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE')
// Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -80,6 +81,7 @@ export default function EditAwardPage({
setVotingEndAt(formatDateForInput(award.votingEndAt))
setEvaluationRoundId(award.evaluationRoundId || '')
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
setDecisionMode((award.decisionMode as typeof decisionMode) || 'JURY_VOTE')
}
}, [award])
@@ -98,6 +100,7 @@ export default function EditAwardPage({
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
evaluationRoundId: evaluationRoundId || undefined,
eligibilityMode,
decisionMode,
})
toast.success('Award updated')
router.push(`/admin/awards/${awardId}`)
@@ -222,6 +225,23 @@ export default function EditAwardPage({
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="decisionMode">Decision Mode</Label>
<Select
value={decisionMode}
onValueChange={(v) => setDecisionMode(v as typeof decisionMode)}
>
<SelectTrigger id="decisionMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote tallied from all jurors</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">Award Master sponsor picks winner</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision admin selects winner</SelectItem>
</SelectContent>
</Select>
</div>
{scoringMode === 'RANKED' && (
<div className="space-y-2">
<Label htmlFor="maxPicks">Max Ranked Picks</Label>

View File

@@ -6,7 +6,7 @@ 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 { getAwardSelectionNotificationTemplate, sendJuryInvitationEmail } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
@@ -210,6 +210,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(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -575,6 +576,108 @@ export const specialAwardRouter = router({
return { added: input.userIds.length }
}),
/**
* Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails
*/
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(),
email: z.string().email(),
})
).min(1).max(50),
})
)
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { id: true, name: true },
})
const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []
for (const invitee of input.invitees) {
try {
let user = await ctx.prisma.user.findUnique({
where: { email: invitee.email },
select: { id: true, status: true, role: true },
})
if (!user) {
const inviteToken = generateInviteToken()
const expiryMs = await getInviteExpiryMs(ctx.prisma)
user = await ctx.prisma.user.create({
data: {
email: invitee.email,
name: invitee.name || null,
role: input.role,
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
select: { id: true, status: true, role: true },
})
const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}`
try {
await sendJuryInvitationEmail(
invitee.email,
invitee.name || null,
inviteUrl,
award.name
)
} catch {
// Email failure shouldn't block the invite
}
results.push({ email: invitee.email, status: 'created' })
} else {
results.push({ email: invitee.email, status: 'existing' })
}
await ctx.prisma.awardJuror.upsert({
where: {
awardId_userId: { awardId: input.awardId, userId: user.id },
},
update: {},
create: { awardId: input.awardId, userId: user.id },
})
} catch (err) {
results.push({
email: invitee.email,
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'AwardJuror',
entityId: input.awardId,
detailsJson: {
action: 'BULK_INVITE',
awardName: award.name,
role: input.role,
count: input.invitees.length,
results,
},
})
return {
created: results.filter((r) => r.status === 'created').length,
existing: results.filter((r) => r.status === 'existing').length,
errors: results.filter((r) => r.status === 'error').length,
results,
}
}),
// ─── Jury Queries ───────────────────────────────────────────────────────
/**