Files
MOPC-Portal/src/server/services/deliberation.ts
Matt 1308c3ba87
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code
Phase 1 — Critical bugs:
- Fix deliberation participant selection (wire jury group query)
- Fix reports "By Round" tab (inline content instead of 404 route)
- Fix messages "Sent History" (add message.sent procedure, wire tab)
- Add missing fields to competition award form (criteriaText, maxRankedPicks)
- Wire LiveControlPanel buttons (cursor, voting, scores)
- Fix ResultLockControls empty snapshot (fetch actual data before lock)
- Fix SubmissionWindowManager losing fields on edit

Phase 2 — Backend fixes:
- Remove write-in-query from specialAward.get
- Fix award eligibility job overwriting manual shortlist overrides
- Fix filtering startJob deleting all prior results (defer cleanup to post-success)
- Tighten access control: protectedProcedure → adminProcedure on 8 procedures
- Add audit logging to deliberation mutations
- Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete

Phase 3 — Auto-refresh:
- Add refetchInterval to 15+ admin pages/components (10s–30s)
- Fix AI job polling: derive speed from job status for all viewers

Phase 4 — Dead code cleanup:
- Delete unused command-palette, pdf-report, admin-page-transition
- Remove dead subItems sidebar code, unused GripVertical import
- Replace redundant isGenerating state with mutation.isPending
- Add Role column to jury members table
- Remove misleading manual mentor assignment stub

Phase 5 — UX improvements:
- Fix rounds page single-competition assumption (add selector)
- Remove raw UUID fallback in deliberation config
- Fix programs page "Stage" → "Round" terminology

Phase 6 — Backend hardening:
- Complete logAudit calls (add prisma, ipAddress, userAgent)
- Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear)
- Batch user.bulkCreate writes (assignments, jury memberships, intents)
- Remove any casts from deliberation service (typed PrismaClient + TransactionClient)
- Fix stale DeliberationStatus enum values blocking build

40 files changed, 1010 insertions(+), 612 deletions(-)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00

717 lines
21 KiB
TypeScript

/**
* 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<string, string[]> = {
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,
) {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
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,
): Promise<SessionTransitionResult> {
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,
): Promise<SessionTransitionResult> {
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,
) {
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,
): Promise<AggregationResult> {
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<string, number>()
const projectVoteCounts = new Map<string, number>()
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,
): Promise<SessionTransitionResult> {
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: Prisma.TransactionClient) => {
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,
): Promise<SessionTransitionResult> {
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: Prisma.TransactionClient) => {
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,
): Promise<SessionTransitionResult> {
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: Prisma.TransactionClient) => {
// 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 Record<string, unknown> | null)?.reason as string ?? 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,
) {
const db = prisma ?? (await import('@/lib/prisma')).prisma
return db.$transaction(async (tx: Prisma.TransactionClient) => {
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,
) {
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,
): Promise<SessionTransitionResult> {
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: Prisma.TransactionClient) => {
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'],
}
}
}