import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' import { sendJuryInvitationEmail } from '@/lib/email' const capModeEnum = z.enum(['HARD', 'SOFT', 'NONE']) export const juryGroupRouter = router({ /** * Create a new jury group for a competition */ create: adminProcedure .input( z.object({ competitionId: z.string(), name: z.string().min(1).max(255), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), description: z.string().optional(), sortOrder: z.number().int().nonnegative().default(0), defaultMaxAssignments: z.number().int().positive().default(20), defaultCapMode: capModeEnum.default('SOFT'), softCapBuffer: z.number().int().nonnegative().default(2), categoryQuotasEnabled: z.boolean().default(false), defaultCategoryQuotas: z .record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() })) .optional(), allowJurorCapAdjustment: z.boolean().default(false), allowJurorRatioAdjustment: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { await ctx.prisma.competition.findUniqueOrThrow({ where: { id: input.competitionId }, }) const { defaultCategoryQuotas, ...rest } = input const juryGroup = await ctx.prisma.juryGroup.create({ data: { ...rest, defaultCategoryQuotas: defaultCategoryQuotas ?? undefined, }, }) // Audit outside transaction so failures don't roll back the create await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'JuryGroup', entityId: juryGroup.id, detailsJson: { name: input.name, competitionId: input.competitionId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return juryGroup }), /** * Get jury group by ID with members */ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const group = await ctx.prisma.juryGroup.findUnique({ where: { id: input.id }, include: { members: { include: { user: { select: { id: true, name: true, email: true, role: true }, }, }, orderBy: { joinedAt: 'asc' }, }, }, }) if (!group) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Jury group not found' }) } return group }), /** * List jury groups for a competition */ list: protectedProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.juryGroup.findMany({ where: { competitionId: input.competitionId }, orderBy: { sortOrder: 'asc' }, include: { _count: { select: { members: true, assignments: true } }, rounds: { select: { id: true, name: true, roundType: true, status: true }, orderBy: { sortOrder: 'asc' }, }, members: { take: 5, orderBy: { joinedAt: 'asc' }, select: { id: true, role: true, user: { select: { id: true, name: true, email: true } }, }, }, }, }) }), /** * Update jury group settings */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(), description: z.string().optional(), sortOrder: z.number().int().nonnegative().optional(), defaultMaxAssignments: z.number().int().positive().optional(), defaultCapMode: capModeEnum.optional(), softCapBuffer: z.number().int().nonnegative().optional(), categoryQuotasEnabled: z.boolean().optional(), defaultCategoryQuotas: z .record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() })) .nullable() .optional(), allowJurorCapAdjustment: z.boolean().optional(), allowJurorRatioAdjustment: z.boolean().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, defaultCategoryQuotas, ...rest } = input return ctx.prisma.juryGroup.update({ where: { id }, data: { ...rest, ...(defaultCategoryQuotas !== undefined ? { defaultCategoryQuotas: defaultCategoryQuotas === null ? Prisma.JsonNull : (defaultCategoryQuotas as Prisma.InputJsonValue), } : {}), }, }) }), /** * Add a member to a jury group */ addMember: adminProcedure .input( z.object({ juryGroupId: z.string(), userId: z.string(), role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'), maxAssignmentsOverride: z.number().int().positive().nullable().optional(), capModeOverride: capModeEnum.nullable().optional(), categoryQuotasOverride: z .record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() })) .nullable() .optional(), preferredStartupRatio: z.number().min(0).max(1).nullable().optional(), availabilityNotes: z.string().nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { // Verify the user exists await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.userId }, }) // Check if already a member const existing = await ctx.prisma.juryGroupMember.findUnique({ where: { juryGroupId_userId: { juryGroupId: input.juryGroupId, userId: input.userId, }, }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'User is already a member of this jury group', }) } const member = await ctx.prisma.juryGroupMember.create({ data: { juryGroupId: input.juryGroupId, userId: input.userId, role: input.role, maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined, capModeOverride: input.capModeOverride ?? undefined, categoryQuotasOverride: input.categoryQuotasOverride ?? undefined, preferredStartupRatio: input.preferredStartupRatio ?? undefined, availabilityNotes: input.availabilityNotes ?? undefined, }, include: { user: { select: { id: true, name: true, email: true, role: true } }, }, }) // Audit outside transaction so failures don't roll back the member add await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'JuryGroupMember', entityId: member.id, detailsJson: { juryGroupId: input.juryGroupId, addedUserId: input.userId, role: input.role, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return member }), /** * Remove a member from a jury group */ removeMember: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.juryGroupMember.findUniqueOrThrow({ where: { id: input.id }, }) await ctx.prisma.juryGroupMember.delete({ where: { id: input.id } }) // Audit outside transaction so failures don't roll back the member removal await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'JuryGroupMember', entityId: input.id, detailsJson: { juryGroupId: existing.juryGroupId, removedUserId: existing.userId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return existing }), /** * Delete a jury group entirely */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const group = await ctx.prisma.juryGroup.findUniqueOrThrow({ where: { id: input.id }, include: { _count: { select: { members: true, assignments: true, rounds: true } }, }, }) // Unlink any rounds that reference this jury group await ctx.prisma.round.updateMany({ where: { juryGroupId: input.id }, data: { juryGroupId: null }, }) // Delete all members first (cascade should handle this, but be explicit) await ctx.prisma.juryGroupMember.deleteMany({ where: { juryGroupId: input.id }, }) await ctx.prisma.juryGroup.delete({ where: { id: input.id } }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'JuryGroup', entityId: input.id, detailsJson: { name: group.name, competitionId: group.competitionId, memberCount: group._count.members, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true, name: group.name } }), /** * Update a jury group member's role/overrides */ updateMember: adminProcedure .input( z.object({ id: z.string(), role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).optional(), maxAssignmentsOverride: z.number().int().positive().nullable().optional(), capModeOverride: capModeEnum.nullable().optional(), categoryQuotasOverride: z .record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() })) .nullable() .optional(), preferredStartupRatio: z.number().min(0).max(1).nullable().optional(), availabilityNotes: z.string().nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, categoryQuotasOverride, ...rest } = input return ctx.prisma.juryGroupMember.update({ where: { id }, data: { ...rest, ...(categoryQuotasOverride !== undefined ? { categoryQuotasOverride: categoryQuotasOverride === null ? Prisma.JsonNull : (categoryQuotasOverride as Prisma.InputJsonValue), } : {}), }, include: { user: { select: { id: true, name: true, email: true, role: true } }, }, }) }), /** * Review self-service values set by jurors during onboarding. * Returns members who have self-service cap or ratio adjustments. */ reviewSelfServiceValues: adminProcedure .input(z.object({ juryGroupId: z.string() })) .query(async ({ ctx, input }) => { const group = await ctx.prisma.juryGroup.findUniqueOrThrow({ where: { id: input.juryGroupId }, select: { id: true, name: true, defaultMaxAssignments: true, allowJurorCapAdjustment: true, allowJurorRatioAdjustment: true, }, }) const members = await ctx.prisma.juryGroupMember.findMany({ where: { juryGroupId: input.juryGroupId, OR: [ { selfServiceCap: { not: null } }, { selfServiceRatio: { not: null } }, ], }, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { joinedAt: 'asc' }, }) return { group, members: members.map((m) => ({ id: m.id, userId: m.userId, userName: m.user.name, userEmail: m.user.email, role: m.role, adminCap: m.maxAssignmentsOverride ?? group.defaultMaxAssignments, selfServiceCap: m.selfServiceCap, selfServiceRatio: m.selfServiceRatio, preferredStartupRatio: m.preferredStartupRatio, })), } }), /** * Bulk invite new users as jury group members — creates accounts, assigns JURY_MEMBER role, sends invite emails */ bulkInviteMembers: adminProcedure .input( z.object({ juryGroupId: z.string(), role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'), invitees: z.array( z.object({ name: z.string().optional(), email: z.string().email(), }) ).min(1).max(50), }) ) .mutation(async ({ ctx, input }) => { const group = await ctx.prisma.juryGroup.findUniqueOrThrow({ where: { id: input.juryGroupId }, 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: 'JURY_MEMBER', 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, group.name ) } catch { // Email failure shouldn't block the invite } results.push({ email: invitee.email, status: 'created' }) } else { results.push({ email: invitee.email, status: 'existing' }) } // Add as jury group member (skip if already added) const existing = await ctx.prisma.juryGroupMember.findUnique({ where: { juryGroupId_userId: { juryGroupId: input.juryGroupId, userId: user.id }, }, }) if (!existing) { await ctx.prisma.juryGroupMember.create({ data: { juryGroupId: input.juryGroupId, userId: user.id, role: input.role, }, }) } } 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: 'JuryGroupMember', entityId: input.juryGroupId, detailsJson: { action: 'BULK_INVITE', groupName: group.name, 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, } }), })