Files
MOPC-Portal/src/server/routers/deliberation.ts

277 lines
7.4 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, juryProcedure, protectedProcedure } from '../trpc'
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 }) => {
return createSession(input, ctx.prisma)
}),
/**
* 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 }) => {
return submitVote(input, ctx.prisma)
}),
/**
* Aggregate votes for a session
*/
aggregate: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
return aggregateVotes(input.sessionId, ctx.prisma)
}),
/**
* 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 }) => {
return updateParticipantStatus(
input.sessionId,
input.userId,
input.status,
input.replacedById,
ctx.user.id,
ctx.prisma,
)
}),
})