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' 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 } }, }, }) }), /** * 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 }), /** * 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, })), } }), })