Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
244
src/server/routers/deliberation.ts
Normal file
244
src/server/routers/deliberation.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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
|
||||
*/
|
||||
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' })
|
||||
}
|
||||
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,
|
||||
)
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user