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 ─────────────────────────────────────────────────────── /**