diff --git a/src/app/(admin)/admin/awards/[id]/edit/page.tsx b/src/app/(admin)/admin/awards/[id]/edit/page.tsx
index 0ec34f3..8594a97 100644
--- a/src/app/(admin)/admin/awards/[id]/edit/page.tsx
+++ b/src/app/(admin)/admin/awards/[id]/edit/page.tsx
@@ -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({
+
+
+
+
+
{scoringMode === 'RANKED' && (
diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts
index b519a6c..572be3c 100644
--- a/src/server/routers/specialAward.ts
+++ b/src/server/routers/specialAward.ts
@@ -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 ───────────────────────────────────────────────────────
/**