Reopen rounds, file type buttons, checklist live-update
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:
Matt
2026-02-16 12:06:07 +01:00
parent de73a6f080
commit 079468d2ca
4 changed files with 242 additions and 53 deletions

View File

@@ -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
*/

View File

@@ -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 ──────────────────────────────────────────────
/**