Files
MOPC-Portal/src/server/routers/deliberation.ts
Matt 51e18870b6
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
Admin UI audit round 2: fix 28 display bugs across 23 files
HIGH fixes (broken features / wrong data):
- H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences)
- H2: Fix deliberation results panel blank table (wrong field names)
- H3: Fix deliberation participant names blank (wrong data path)
- H4: Fix awards "Evaluated" stat duplicating "Eligible" count
- H5: Fix cross-round comparison enabled at 1 round (backend requires 2)
- H6: Fix setState during render anti-pattern (6 occurrences)
- H7: Fix round detail jury member count always showing 0
- H8: Remove 4 invalid status values from observer dashboard filter
- H9: Fix filtering progress bar always showing 100%

MEDIUM fixes (misleading display):
- M1: Filter special-award rounds from competition timeline
- M2: Exclude special-award rounds from distinct project count
- M3: Fix MENTORING pipeline node hardcoded "0 mentored"
- M4: Fix DELIB_LOCKED badge using red for success state
- M5: Add status label maps to deliberation session detail
- M6: Humanize deliberation category + tie-break method displays
- M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round"
- M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels
- M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge
- M11: Fix unsent messages showing "Scheduled" instead of "Draft"
- M12: Rename misleading totalEvaluations → totalAssignments
- M13: Rename "Stage" column to "Program" in projects page

LOW fixes (cosmetic / edge-case):
- L1: Use unfiltered rounds array for active round detection
- L2: Use all rounds length for new round sort order
- L3: Filter special-award rounds from header count
- L4: Fix single-underscore replace in award status badges
- L5: Fix score bucket boundary gaps (4.99 dropped between buckets)
- L6: Title-case LIVE_FINAL pipeline metric status
- L7: Fix roundType.replace only replacing first underscore
- L8: Remove duplicate severity sort in smart-actions component

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

351 lines
9.6 KiB
TypeScript

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
}),
})