import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure, juryProcedure, protectedProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { createSession, openVoting, closeVoting, submitVote, aggregateVotes, initRunoff, adminDecide, finalizeResults, updateParticipantStatus, getSessionWithVotes, } from '../services/deliberation' const categoryEnum = z.enum([ 'STARTUP', 'BUSINESS_CONCEPT', ]) const deliberationModeEnum = z.enum(['SINGLE_WINNER_VOTE', 'FULL_RANKING']) const tieBreakMethodEnum = z.enum(['TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK']) const participantStatusEnum = z.enum([ 'REQUIRED', 'ABSENT_EXCUSED', 'REPLACED', 'REPLACEMENT_ACTIVE', ]) export const deliberationRouter = router({ /** * Create a new deliberation session with participants */ createSession: adminProcedure .input( z.object({ competitionId: z.string(), roundId: z.string(), category: categoryEnum, mode: deliberationModeEnum, tieBreakMethod: tieBreakMethodEnum, showCollectiveRankings: z.boolean().default(false), showPriorJuryData: z.boolean().default(false), participantUserIds: z.array(z.string()).min(1), }) ) .mutation(async ({ ctx, input }) => { const session = await createSession(input, ctx.prisma) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'DeliberationSession', entityId: session.id, detailsJson: { competitionId: input.competitionId, roundId: input.roundId, category: input.category, mode: input.mode, participantCount: input.participantUserIds.length, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return session }), /** * Open voting: DELIB_OPEN → VOTING */ openVoting: adminProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await openVoting(input.sessionId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to open voting', }) } return result }), /** * Close voting: VOTING → TALLYING */ closeVoting: adminProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await closeVoting(input.sessionId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to close voting', }) } return result }), /** * Submit a vote (jury member) */ submitVote: juryProcedure .input( z.object({ sessionId: z.string(), juryMemberId: z.string(), projectId: z.string(), rank: z.number().int().min(1).optional(), isWinnerPick: z.boolean().optional(), runoffRound: z.number().int().min(0).optional(), }) ) .mutation(async ({ ctx, input }) => { const vote = await submitVote(input, ctx.prisma) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'DeliberationVote', entityId: input.sessionId, detailsJson: { sessionId: input.sessionId, projectId: input.projectId, rank: input.rank, isWinnerPick: input.isWinnerPick, runoffRound: input.runoffRound, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return vote }), /** * Aggregate votes for a session */ aggregate: adminProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const result = await aggregateVotes(input.sessionId, ctx.prisma) // Enrich rankings with project titles const projectIds = result.rankings.map((r) => r.projectId) const projects = projectIds.length > 0 ? await ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, teamName: true }, }) : [] const projectMap = new Map(projects.map((p) => [p.id, p])) return { ...result, rankings: result.rankings.map((r) => ({ ...r, projectTitle: projectMap.get(r.projectId)?.title ?? 'Unknown Project', teamName: projectMap.get(r.projectId)?.teamName ?? '', })), } }), /** * Initiate a runoff: TALLYING → RUNOFF */ initRunoff: adminProcedure .input( z.object({ sessionId: z.string(), tiedProjectIds: z.array(z.string()).min(2), }) ) .mutation(async ({ ctx, input }) => { const result = await initRunoff( input.sessionId, input.tiedProjectIds, ctx.user.id, ctx.prisma, ) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to initiate runoff', }) } return result }), /** * Admin override: directly set final rankings */ adminDecide: adminProcedure .input( z.object({ sessionId: z.string(), rankings: z.array( z.object({ projectId: z.string(), rank: z.number().int().min(1), }) ).min(1), reason: z.string().min(1).max(2000), }) ) .mutation(async ({ ctx, input }) => { const result = await adminDecide( input.sessionId, input.rankings, input.reason, ctx.user.id, ctx.prisma, ) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to admin-decide', }) } return result }), /** * Finalize results: TALLYING → DELIB_LOCKED */ finalize: adminProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await finalizeResults(input.sessionId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to finalize results', }) } return result }), /** * Get session with votes, results, and participants. * Redacts juror identities for non-admin users when session flags are off. */ getSession: protectedProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const session = await getSessionWithVotes(input.sessionId, ctx.prisma) if (!session) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' }) } const isAdmin = ctx.user.role === 'SUPER_ADMIN' || ctx.user.role === 'PROGRAM_ADMIN' if (isAdmin) return session // Non-admin: enforce visibility flags if (!session.showCollectiveRankings) { // Anonymize juror identity on votes — only show own votes with identity session.votes = session.votes.map((v: any, i: number) => { const isOwn = v.juryMember?.user?.id === ctx.user.id if (isOwn) return v return { ...v, juryMember: { ...v.juryMember, user: { id: `anon-${i}`, name: `Juror ${i + 1}`, email: '' }, }, } }) // Anonymize participants session.participants = session.participants.map((p: any, i: number) => { const isOwn = p.user?.user?.id === ctx.user.id if (isOwn) return p return { ...p, user: p.user ? { ...p.user, user: { id: `anon-${i}`, name: `Juror ${i + 1}`, email: '' } } : p.user, } }) } return session }), /** * List deliberation sessions for a competition */ listSessions: adminProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.deliberationSession.findMany({ where: { round: { competitionId: input.competitionId }, }, include: { round: { select: { id: true, name: true, roundType: true } }, _count: { select: { votes: true, participants: true } }, participants: { select: { userId: true }, }, }, orderBy: { createdAt: 'desc' }, }) }), /** * Update participant status (mark absent, replace, etc.) */ updateParticipant: adminProcedure .input( z.object({ sessionId: z.string(), userId: z.string(), status: participantStatusEnum, replacedById: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const result = await updateParticipantStatus( input.sessionId, input.userId, input.status, input.replacedById, ctx.user.id, ctx.prisma, ) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'DeliberationParticipant', entityId: input.sessionId, detailsJson: { sessionId: input.sessionId, targetUserId: input.userId, status: input.status, replacedById: input.replacedById, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return result }), })