Reopen rounds, file type buttons, checklist live-update
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m1s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m1s
- Add reopenRound() to round engine (CLOSED → ACTIVE) with auto-pause of subsequent active rounds - Add reopen endpoint to roundEngine router and UI button on round detail page - Replace free-text MIME type input with toggle-only badge buttons in file requirements editor - Enable refetchOnWindowFocus and shorter polling intervals for readiness checklist queries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
activateRound,
|
||||
closeRound,
|
||||
archiveRound,
|
||||
reopenRound,
|
||||
transitionProject,
|
||||
batchTransitionProjects,
|
||||
getProjectRoundStates,
|
||||
@@ -53,6 +54,23 @@ export const roundEngineRouter = router({
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reopen a round: ROUND_CLOSED → ROUND_ACTIVE
|
||||
* Pauses any subsequent active rounds in the same competition.
|
||||
*/
|
||||
reopen: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await reopenRound(input.roundId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to reopen round',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Archive a round: ROUND_CLOSED → ROUND_ARCHIVED
|
||||
*/
|
||||
|
||||
@@ -50,7 +50,7 @@ const BATCH_SIZE = 50
|
||||
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
||||
ROUND_DRAFT: ['ROUND_ACTIVE'],
|
||||
ROUND_ACTIVE: ['ROUND_CLOSED'],
|
||||
ROUND_CLOSED: ['ROUND_ARCHIVED'],
|
||||
ROUND_CLOSED: ['ROUND_ACTIVE', 'ROUND_ARCHIVED'],
|
||||
ROUND_ARCHIVED: [],
|
||||
}
|
||||
|
||||
@@ -321,6 +321,129 @@ export async function archiveRound(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a round: ROUND_CLOSED → ROUND_ACTIVE
|
||||
* Side effects: any subsequent rounds in the same competition that are
|
||||
* ROUND_ACTIVE will be paused (set to ROUND_CLOSED) to prevent two
|
||||
* active rounds overlapping.
|
||||
*/
|
||||
export async function reopenRound(
|
||||
roundId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
|
||||
try {
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
include: { competition: true },
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
return { success: false, errors: [`Round ${roundId} not found`] }
|
||||
}
|
||||
|
||||
if (round.status !== 'ROUND_CLOSED') {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Cannot reopen round: current status is ${round.status}, expected ROUND_CLOSED`],
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
// Pause any subsequent active rounds in the same competition
|
||||
const subsequentActiveRounds = await tx.round.findMany({
|
||||
where: {
|
||||
competitionId: round.competitionId,
|
||||
sortOrder: { gt: round.sortOrder },
|
||||
status: 'ROUND_ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
if (subsequentActiveRounds.length > 0) {
|
||||
await tx.round.updateMany({
|
||||
where: { id: { in: subsequentActiveRounds.map((r: any) => r.id) } },
|
||||
data: { status: 'ROUND_CLOSED' },
|
||||
})
|
||||
|
||||
// Audit each paused round
|
||||
for (const paused of subsequentActiveRounds) {
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'round.paused',
|
||||
entityType: 'Round',
|
||||
entityId: paused.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
roundName: paused.name,
|
||||
reason: `Paused because prior round "${round.name}" was reopened`,
|
||||
previousStatus: 'ROUND_ACTIVE',
|
||||
},
|
||||
snapshotJson: {
|
||||
timestamp: new Date().toISOString(),
|
||||
emittedBy: 'round-engine',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reopen this round
|
||||
const updated = await tx.round.update({
|
||||
where: { id: roundId },
|
||||
data: { status: 'ROUND_ACTIVE' },
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'round.reopened',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
roundName: round.name,
|
||||
previousStatus: 'ROUND_CLOSED',
|
||||
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
|
||||
},
|
||||
snapshotJson: {
|
||||
timestamp: new Date().toISOString(),
|
||||
emittedBy: 'round-engine',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'ROUND_REOPEN',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
detailsJson: {
|
||||
name: round.name,
|
||||
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
updated,
|
||||
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
round: { id: result.updated.id, status: result.updated.status },
|
||||
pausedRounds: result.pausedRounds,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RoundEngine] reopenRound failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error during round reopen'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Project-Level Transitions ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user