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:
716
src/server/services/deliberation.ts
Normal file
716
src/server/services/deliberation.ts
Normal file
@@ -0,0 +1,716 @@
|
||||
/**
|
||||
* 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 | 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<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 | any,
|
||||
): 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 | 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<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 | any,
|
||||
): 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: 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<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: 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<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: 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<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: 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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user