/** * Deliberation Service * * Full deliberation lifecycle: session management, voting, aggregation, * tie-breaking, and finalization. * * Session transitions: DELIB_OPEN → VOTING → TALLYING → DELIB_LOCKED * → RUNOFF → TALLYING (max 3 runoff rounds) */ import type { PrismaClient, DeliberationMode, DeliberationStatus, TieBreakMethod, CompetitionCategory, DeliberationParticipantStatus, Prisma, } from '@prisma/client' import { logAudit } from '@/server/utils/audit' // ─── Types ────────────────────────────────────────────────────────────────── export type SessionTransitionResult = { success: boolean session?: { id: string; status: DeliberationStatus } errors?: string[] } export type AggregationResult = { rankings: Array<{ projectId: string rank: number voteCount: number score: number }> hasTies: boolean tiedProjectIds: string[] } const MAX_RUNOFF_ROUNDS = 3 // ─── Valid Transitions ────────────────────────────────────────────────────── const VALID_SESSION_TRANSITIONS: Record = { DELIB_OPEN: ['VOTING'], VOTING: ['TALLYING'], TALLYING: ['DELIB_LOCKED', 'RUNOFF'], RUNOFF: ['TALLYING'], DELIB_LOCKED: [], } // ─── Session Lifecycle ────────────────────────────────────────────────────── /** * Create a new deliberation session with participants. */ export async function createSession( params: { competitionId: string roundId: string category: CompetitionCategory mode: DeliberationMode tieBreakMethod: TieBreakMethod showCollectiveRankings?: boolean showPriorJuryData?: boolean participantUserIds: string[] // JuryGroupMember IDs }, prisma: PrismaClient | any, ) { return prisma.$transaction(async (tx: any) => { const session = await tx.deliberationSession.create({ data: { competitionId: params.competitionId, roundId: params.roundId, category: params.category, mode: params.mode, tieBreakMethod: params.tieBreakMethod, showCollectiveRankings: params.showCollectiveRankings ?? false, showPriorJuryData: params.showPriorJuryData ?? false, status: 'DELIB_OPEN', }, }) // Create participant records for (const userId of params.participantUserIds) { await tx.deliberationParticipant.create({ data: { sessionId: session.id, userId, status: 'REQUIRED', }, }) } await tx.decisionAuditLog.create({ data: { eventType: 'deliberation.created', entityType: 'DeliberationSession', entityId: session.id, actorId: null, detailsJson: { competitionId: params.competitionId, roundId: params.roundId, category: params.category, mode: params.mode, participantCount: params.participantUserIds.length, } as Prisma.InputJsonValue, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' }, }, }) return session }) } /** * Open voting: DELIB_OPEN → VOTING */ export async function openVoting( sessionId: string, actorId: string, prisma: PrismaClient | any, ): Promise { return transitionSession(sessionId, 'DELIB_OPEN', 'VOTING', actorId, prisma) } /** * Close voting: VOTING → TALLYING * Triggers vote aggregation. */ export async function closeVoting( sessionId: string, actorId: string, prisma: PrismaClient | any, ): Promise { return transitionSession(sessionId, 'VOTING', 'TALLYING', actorId, prisma) } // ─── Vote Submission ──────────────────────────────────────────────────────── /** * Submit a vote in a deliberation session. * Validates: session is VOTING (or RUNOFF), juryMember is active participant. */ export async function submitVote( params: { sessionId: string juryMemberId: string // JuryGroupMember ID projectId: string rank?: number isWinnerPick?: boolean runoffRound?: number }, prisma: PrismaClient | any, ) { const session = await prisma.deliberationSession.findUnique({ where: { id: params.sessionId }, }) if (!session) { throw new Error('Deliberation session not found') } if (session.status !== 'VOTING' && session.status !== 'RUNOFF') { throw new Error(`Cannot vote: session status is ${session.status}`) } // Verify participant is active const participant = await prisma.deliberationParticipant.findUnique({ where: { sessionId_userId: { sessionId: params.sessionId, userId: params.juryMemberId, }, }, }) if (!participant) { throw new Error('Juror is not a participant in this deliberation') } if (participant.status !== 'REQUIRED' && participant.status !== 'REPLACEMENT_ACTIVE') { throw new Error(`Participant status ${participant.status} does not allow voting`) } const runoffRound = params.runoffRound ?? 0 return prisma.deliberationVote.upsert({ where: { sessionId_juryMemberId_projectId_runoffRound: { sessionId: params.sessionId, juryMemberId: params.juryMemberId, projectId: params.projectId, runoffRound, }, }, create: { sessionId: params.sessionId, juryMemberId: params.juryMemberId, projectId: params.projectId, rank: params.rank, isWinnerPick: params.isWinnerPick ?? false, runoffRound, }, update: { rank: params.rank, isWinnerPick: params.isWinnerPick ?? false, }, }) } // ─── Aggregation ──────────────────────────────────────────────────────────── /** * Aggregate votes for a session. * - SINGLE_WINNER_VOTE: count isWinnerPick=true per project * - FULL_RANKING: Borda count (N points for rank 1, N-1 for rank 2, etc.) */ export async function aggregateVotes( sessionId: string, prisma: PrismaClient | any, ): Promise { const session = await prisma.deliberationSession.findUnique({ where: { id: sessionId }, }) if (!session) { throw new Error('Deliberation session not found') } // Get the latest runoff round const latestVote = await prisma.deliberationVote.findFirst({ where: { sessionId }, orderBy: { runoffRound: 'desc' }, select: { runoffRound: true }, }) const currentRound = latestVote?.runoffRound ?? 0 const votes = await prisma.deliberationVote.findMany({ where: { sessionId, runoffRound: currentRound }, }) const projectScores = new Map() const projectVoteCounts = new Map() if (session.mode === 'SINGLE_WINNER_VOTE') { // Count isWinnerPick=true per project for (const vote of votes) { if (vote.isWinnerPick) { projectScores.set(vote.projectId, (projectScores.get(vote.projectId) ?? 0) + 1) projectVoteCounts.set(vote.projectId, (projectVoteCounts.get(vote.projectId) ?? 0) + 1) } } } else { // FULL_RANKING: Borda count // First, find N = total unique projects being ranked const uniqueProjects = new Set(votes.map((v: any) => v.projectId)) const n = uniqueProjects.size for (const vote of votes) { if (vote.rank != null) { // Borda: rank 1 gets N points, rank 2 gets N-1, etc. const score = Math.max(0, n + 1 - vote.rank) projectScores.set(vote.projectId, (projectScores.get(vote.projectId) ?? 0) + score) projectVoteCounts.set(vote.projectId, (projectVoteCounts.get(vote.projectId) ?? 0) + 1) } } } // Sort by score descending const sorted = [...projectScores.entries()] .sort(([, a], [, b]) => b - a) .map(([projectId, score], index) => ({ projectId, rank: index + 1, voteCount: projectVoteCounts.get(projectId) ?? 0, score, })) // Detect ties: projects with same score get same rank const rankings: typeof sorted = [] let currentRank = 1 for (let i = 0; i < sorted.length; i++) { if (i > 0 && sorted[i].score === sorted[i - 1].score) { rankings.push({ ...sorted[i], rank: rankings[i - 1].rank }) } else { rankings.push({ ...sorted[i], rank: currentRank }) } currentRank = rankings[i].rank + 1 } // Find tied projects (projects sharing rank 1, or if no clear winner) const topScore = rankings.length > 0 ? rankings[0].score : 0 const tiedProjectIds = rankings.filter((r) => r.score === topScore && topScore > 0).length > 1 ? rankings.filter((r) => r.score === topScore).map((r) => r.projectId) : [] return { rankings, hasTies: tiedProjectIds.length > 1, tiedProjectIds, } } // ─── Tie-Breaking ─────────────────────────────────────────────────────────── /** * Initiate a runoff vote for tied projects. * TALLYING → RUNOFF */ export async function initRunoff( sessionId: string, tiedProjectIds: string[], actorId: string, prisma: PrismaClient | any, ): Promise { const session = await prisma.deliberationSession.findUnique({ where: { id: sessionId }, }) if (!session) { return { success: false, errors: ['Session not found'] } } if (session.status !== 'TALLYING') { return { success: false, errors: [`Cannot init runoff: status is ${session.status}`] } } // Check max runoff rounds const latestVote = await prisma.deliberationVote.findFirst({ where: { sessionId }, orderBy: { runoffRound: 'desc' }, select: { runoffRound: true }, }) const nextRound = (latestVote?.runoffRound ?? 0) + 1 if (nextRound > MAX_RUNOFF_ROUNDS) { return { success: false, errors: [`Maximum runoff rounds (${MAX_RUNOFF_ROUNDS}) exceeded`] } } return prisma.$transaction(async (tx: any) => { const updated = await tx.deliberationSession.update({ where: { id: sessionId }, data: { status: 'RUNOFF' }, }) await tx.decisionAuditLog.create({ data: { eventType: 'deliberation.runoff_initiated', entityType: 'DeliberationSession', entityId: sessionId, actorId, detailsJson: { runoffRound: nextRound, tiedProjectIds, } as Prisma.InputJsonValue, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' }, }, }) return { success: true, session: { id: updated.id, status: updated.status }, } }) } /** * Admin override: directly set final rankings. */ export async function adminDecide( sessionId: string, rankings: Array<{ projectId: string; rank: number }>, reason: string, actorId: string, prisma: PrismaClient | any, ): Promise { const session = await prisma.deliberationSession.findUnique({ where: { id: sessionId }, }) if (!session) { return { success: false, errors: ['Session not found'] } } if (session.status !== 'TALLYING') { return { success: false, errors: [`Cannot admin-decide: status is ${session.status}`] } } return prisma.$transaction(async (tx: any) => { const updated = await tx.deliberationSession.update({ where: { id: sessionId }, data: { adminOverrideResult: { rankings, reason, decidedBy: actorId, decidedAt: new Date().toISOString(), } as Prisma.InputJsonValue, }, }) await tx.decisionAuditLog.create({ data: { eventType: 'deliberation.admin_override', entityType: 'DeliberationSession', entityId: sessionId, actorId, detailsJson: { rankings, reason, } as Prisma.InputJsonValue, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' }, }, }) return { success: true, session: { id: updated.id, status: updated.status }, } }) } // ─── Finalization ─────────────────────────────────────────────────────────── /** * Finalize deliberation results: TALLYING → DELIB_LOCKED * Creates DeliberationResult records. */ export async function finalizeResults( sessionId: string, actorId: string, prisma: PrismaClient | any, ): Promise { const session = await prisma.deliberationSession.findUnique({ where: { id: sessionId }, }) if (!session) { return { success: false, errors: ['Session not found'] } } if (session.status !== 'TALLYING') { return { success: false, errors: [`Cannot finalize: status is ${session.status}`] } } // If admin override exists, use those rankings const override = session.adminOverrideResult as { rankings: Array<{ projectId: string; rank: number }> } | null let finalRankings: Array<{ projectId: string; rank: number; voteCount: number; isAdminOverridden: boolean }> if (override?.rankings) { finalRankings = override.rankings.map((r) => ({ projectId: r.projectId, rank: r.rank, voteCount: 0, isAdminOverridden: true, })) } else { // Use aggregated votes const agg = await aggregateVotes(sessionId, prisma) finalRankings = agg.rankings.map((r) => ({ projectId: r.projectId, rank: r.rank, voteCount: r.voteCount, isAdminOverridden: false, })) } return prisma.$transaction(async (tx: any) => { // Create result records for (const ranking of finalRankings) { await tx.deliberationResult.upsert({ where: { sessionId_projectId: { sessionId, projectId: ranking.projectId, }, }, create: { sessionId, projectId: ranking.projectId, finalRank: ranking.rank, voteCount: ranking.voteCount, isAdminOverridden: ranking.isAdminOverridden, overrideReason: ranking.isAdminOverridden ? (session.adminOverrideResult as any)?.reason ?? null : null, }, update: { finalRank: ranking.rank, voteCount: ranking.voteCount, isAdminOverridden: ranking.isAdminOverridden, }, }) } // Transition to DELIB_LOCKED const updated = await tx.deliberationSession.update({ where: { id: sessionId }, data: { status: 'DELIB_LOCKED' }, }) await tx.decisionAuditLog.create({ data: { eventType: 'deliberation.finalized', entityType: 'DeliberationSession', entityId: sessionId, actorId, detailsJson: { resultCount: finalRankings.length, isAdminOverride: finalRankings.some((r) => r.isAdminOverridden), } as Prisma.InputJsonValue, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation', rankings: finalRankings, }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'DELIBERATION_FINALIZE', entityType: 'DeliberationSession', entityId: sessionId, detailsJson: { resultCount: finalRankings.length }, }) return { success: true, session: { id: updated.id, status: updated.status }, } }) } // ─── Participant Management ───────────────────────────────────────────────── /** * Update a participant's status (e.g. mark absent, replace). */ export async function updateParticipantStatus( sessionId: string, userId: string, status: DeliberationParticipantStatus, replacedById?: string, actorId?: string, prisma?: PrismaClient | any, ) { const db = prisma ?? (await import('@/lib/prisma')).prisma return db.$transaction(async (tx: any) => { const updated = await tx.deliberationParticipant.update({ where: { sessionId_userId: { sessionId, userId } }, data: { status, replacedById: replacedById ?? null, }, }) // If replacing, create participant record for replacement if (status === 'REPLACED' && replacedById) { await tx.deliberationParticipant.upsert({ where: { sessionId_userId: { sessionId, userId: replacedById } }, create: { sessionId, userId: replacedById, status: 'REPLACEMENT_ACTIVE', }, update: { status: 'REPLACEMENT_ACTIVE', }, }) } if (actorId) { await tx.decisionAuditLog.create({ data: { eventType: 'deliberation.participant_updated', entityType: 'DeliberationParticipant', entityId: updated.id, actorId, detailsJson: { userId, newStatus: status, replacedById } as Prisma.InputJsonValue, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' }, }, }) } return updated }) } // ─── Queries ──────────────────────────────────────────────────────────────── /** * Get a deliberation session with votes, results, and participants. */ export async function getSessionWithVotes( sessionId: string, prisma: PrismaClient | any, ) { return prisma.deliberationSession.findUnique({ where: { id: sessionId }, include: { votes: { include: { project: { select: { id: true, title: true, teamName: true } }, juryMember: { include: { user: { select: { id: true, name: true, email: true } }, }, }, }, orderBy: [{ runoffRound: 'desc' }, { rank: 'asc' }], }, results: { include: { project: { select: { id: true, title: true, teamName: true } }, }, orderBy: { finalRank: 'asc' }, }, participants: { include: { user: { include: { user: { select: { id: true, name: true, email: true } }, }, }, }, }, competition: { select: { id: true, name: true } }, round: { select: { id: true, name: true, roundType: true } }, }, }) } // ─── Internal Helpers ─────────────────────────────────────────────────────── async function transitionSession( sessionId: string, expectedStatus: DeliberationStatus, newStatus: DeliberationStatus, actorId: string, prisma: PrismaClient | any, ): Promise { try { const session = await prisma.deliberationSession.findUnique({ where: { id: sessionId }, }) if (!session) { return { success: false, errors: ['Session not found'] } } if (session.status !== expectedStatus) { return { success: false, errors: [`Cannot transition: status is ${session.status}, expected ${expectedStatus}`], } } const valid = VALID_SESSION_TRANSITIONS[expectedStatus] ?? [] if (!valid.includes(newStatus)) { return { success: false, errors: [`Invalid transition: ${expectedStatus} → ${newStatus}`], } } const updated = await prisma.$transaction(async (tx: any) => { const result = await tx.deliberationSession.update({ where: { id: sessionId }, data: { status: newStatus }, }) await tx.decisionAuditLog.create({ data: { eventType: `deliberation.${newStatus.toLowerCase()}`, entityType: 'DeliberationSession', entityId: sessionId, actorId, detailsJson: { previousStatus: expectedStatus, newStatus, } as Prisma.InputJsonValue, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' }, }, }) await logAudit({ prisma: tx, userId: actorId, action: `DELIBERATION_${newStatus}`, entityType: 'DeliberationSession', entityId: sessionId, }) return result }) return { success: true, session: { id: updated.id, status: updated.status }, } } catch (error) { console.error('[Deliberation] Session transition failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error'], } } }