Reopen rounds, file type buttons, checklist live-update
- 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:
parent
de73a6f080
commit
079468d2ca
|
|
@ -76,6 +76,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||||
|
|
@ -159,22 +160,22 @@ export default function RoundDetailPage() {
|
||||||
// ── Core data queries ──────────────────────────────────────────────────
|
// ── Core data queries ──────────────────────────────────────────────────
|
||||||
const { data: round, isLoading } = trpc.round.getById.useQuery(
|
const { data: round, isLoading } = trpc.round.getById.useQuery(
|
||||||
{ id: roundId },
|
{ id: roundId },
|
||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 15_000, refetchOnWindowFocus: true },
|
||||||
)
|
)
|
||||||
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
|
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ refetchInterval: 15_000 },
|
{ refetchInterval: 10_000, refetchOnWindowFocus: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const competitionId = round?.competitionId ?? ''
|
const competitionId = round?.competitionId ?? ''
|
||||||
|
|
||||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||||
{ competitionId },
|
{ competitionId },
|
||||||
{ enabled: !!competitionId, refetchInterval: 30_000 },
|
{ enabled: !!competitionId, refetchInterval: 30_000, refetchOnWindowFocus: true },
|
||||||
)
|
)
|
||||||
const { data: fileRequirements } = trpc.file.listRequirements.useQuery(
|
const { data: fileRequirements } = trpc.file.listRequirements.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 15_000, refetchOnWindowFocus: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch awards linked to this round
|
// Fetch awards linked to this round
|
||||||
|
|
@ -223,6 +224,18 @@ export default function RoundDetailPage() {
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const reopenMutation = trpc.roundEngine.reopen.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
const msg = data.pausedRounds?.length
|
||||||
|
? `Round reopened. Paused: ${data.pausedRounds.join(', ')}`
|
||||||
|
: 'Round reopened'
|
||||||
|
toast.success(msg)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const archiveMutation = trpc.roundEngine.archive.useMutation({
|
const archiveMutation = trpc.roundEngine.archive.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
|
|
@ -268,7 +281,7 @@ export default function RoundDetailPage() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
|
const isTransitioning = activateMutation.isPending || closeMutation.isPending || reopenMutation.isPending || archiveMutation.isPending
|
||||||
|
|
||||||
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
|
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
|
||||||
setConfig(newConfig)
|
setConfig(newConfig)
|
||||||
|
|
@ -437,11 +450,11 @@ export default function RoundDetailPage() {
|
||||||
{status === 'ROUND_CLOSED' && (
|
{status === 'ROUND_CLOSED' && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => activateMutation.mutate({ roundId })}
|
onClick={() => reopenMutation.mutate({ roundId })}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
>
|
>
|
||||||
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
||||||
Reactivate Round
|
Reopen Round
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
@ -775,6 +788,36 @@ export default function RoundDetailPage() {
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status === 'ROUND_CLOSED' && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-amber-200 bg-amber-50/50">
|
||||||
|
<RotateCcw className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Reopen Round</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Reactivate this round. Any subsequent active rounds will be paused.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reopen this round?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
The round will become active again. Any rounds after this one that are currently active will be paused (closed) automatically.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => reopenMutation.mutate({ roundId })}>
|
||||||
|
Reopen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Assign projects */}
|
{/* Assign projects */}
|
||||||
<Link href={poolLink}>
|
<Link href={poolLink}>
|
||||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
|
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ type FileRequirementsEditorProps = {
|
||||||
type FormState = {
|
type FormState = {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
acceptedMimeTypes: string
|
acceptedMimeTypes: string[]
|
||||||
maxSizeMB: string
|
maxSizeMB: string
|
||||||
isRequired: boolean
|
isRequired: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -57,21 +57,27 @@ type FormState = {
|
||||||
const emptyForm: FormState = {
|
const emptyForm: FormState = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
acceptedMimeTypes: '',
|
acceptedMimeTypes: [],
|
||||||
maxSizeMB: '',
|
maxSizeMB: '',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_MIME_PRESETS: { label: string; value: string }[] = [
|
const MIME_TYPE_OPTIONS: { label: string; value: string }[] = [
|
||||||
{ label: 'PDF only', value: 'application/pdf' },
|
{ label: 'PDF', value: 'application/pdf' },
|
||||||
{ label: 'Images', value: 'image/png, image/jpeg, image/webp' },
|
{ label: 'Images', value: 'image/*' },
|
||||||
{ label: 'Video', value: 'video/mp4, video/quicktime, video/webm' },
|
{ label: 'Video', value: 'video/*' },
|
||||||
{ label: 'Documents', value: 'application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
|
{ label: 'Word', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
|
||||||
{ label: 'Spreadsheets', value: 'application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/csv' },
|
{ label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||||
{ label: 'Presentations', value: 'application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation' },
|
{ label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
|
||||||
{ label: 'Any file', value: '' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function getMimeLabel(mime: string): string {
|
||||||
|
const preset = MIME_TYPE_OPTIONS.find((p) => p.value === mime)
|
||||||
|
if (preset) return preset.label
|
||||||
|
if (mime.endsWith('/*')) return mime.replace('/*', '')
|
||||||
|
return mime
|
||||||
|
}
|
||||||
|
|
||||||
export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) {
|
export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
@ -123,7 +129,7 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
|
||||||
setForm({
|
setForm({
|
||||||
name: req.name,
|
name: req.name,
|
||||||
description: req.description || '',
|
description: req.description || '',
|
||||||
acceptedMimeTypes: (req.acceptedMimeTypes || []).join(', '),
|
acceptedMimeTypes: req.acceptedMimeTypes || [],
|
||||||
maxSizeMB: req.maxSizeMB?.toString() || '',
|
maxSizeMB: req.maxSizeMB?.toString() || '',
|
||||||
isRequired: req.isRequired ?? true,
|
isRequired: req.isRequired ?? true,
|
||||||
})
|
})
|
||||||
|
|
@ -131,12 +137,16 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const toggleMimeType = (mime: string) => {
|
||||||
const mimeTypes = form.acceptedMimeTypes
|
setForm((prev) => ({
|
||||||
.split(',')
|
...prev,
|
||||||
.map((s) => s.trim())
|
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
|
||||||
.filter(Boolean)
|
? prev.acceptedMimeTypes.filter((m) => m !== mime)
|
||||||
|
: [...prev.acceptedMimeTypes, mime],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
|
const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
|
|
@ -144,7 +154,7 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
|
||||||
id: editingId,
|
id: editingId,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description || null,
|
description: form.description || null,
|
||||||
acceptedMimeTypes: mimeTypes,
|
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||||
maxSizeMB: maxSize ?? null,
|
maxSizeMB: maxSize ?? null,
|
||||||
isRequired: form.isRequired,
|
isRequired: form.isRequired,
|
||||||
})
|
})
|
||||||
|
|
@ -153,7 +163,7 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
|
||||||
roundId,
|
roundId,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description || undefined,
|
description: form.description || undefined,
|
||||||
acceptedMimeTypes: mimeTypes,
|
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||||
maxSizeMB: maxSize,
|
maxSizeMB: maxSize,
|
||||||
isRequired: form.isRequired,
|
isRequired: form.isRequired,
|
||||||
sortOrder: (requirements?.length ?? 0),
|
sortOrder: (requirements?.length ?? 0),
|
||||||
|
|
@ -258,25 +268,22 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
|
||||||
{req.description && (
|
{req.description && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{req.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{req.description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
{req.acceptedMimeTypes?.length > 0 ? (
|
{req.acceptedMimeTypes?.length > 0 ? (
|
||||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
req.acceptedMimeTypes.map((t: string) => (
|
||||||
{req.acceptedMimeTypes.map((t: string) => {
|
<Badge key={t} variant="outline" className="text-[10px]">
|
||||||
if (t === 'application/pdf') return 'PDF'
|
{getMimeLabel(t)}
|
||||||
if (t.startsWith('image/')) return t.replace('image/', '').toUpperCase()
|
</Badge>
|
||||||
if (t.startsWith('video/')) return t.replace('video/', '').toUpperCase()
|
))
|
||||||
return t.split('/').pop()?.toUpperCase() || t
|
|
||||||
}).join(', ')}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
Any file type
|
Any file type
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{req.maxSizeMB && (
|
{req.maxSizeMB && (
|
||||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
Max {req.maxSizeMB} MB
|
Max {req.maxSizeMB} MB
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -347,23 +354,21 @@ export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }:
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Accepted File Types</label>
|
<label className="text-sm font-medium">Accepted File Types</label>
|
||||||
<Input
|
<div className="flex flex-wrap gap-2">
|
||||||
placeholder="application/pdf, image/png (leave empty for any)"
|
{MIME_TYPE_OPTIONS.map((opt) => (
|
||||||
value={form.acceptedMimeTypes}
|
<Badge
|
||||||
onChange={(e) => setForm((f) => ({ ...f, acceptedMimeTypes: e.target.value }))}
|
key={opt.value}
|
||||||
/>
|
variant={form.acceptedMimeTypes.includes(opt.value) ? 'default' : 'outline'}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
className="cursor-pointer select-none"
|
||||||
{COMMON_MIME_PRESETS.map((preset) => (
|
onClick={() => toggleMimeType(opt.value)}
|
||||||
<button
|
|
||||||
key={preset.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setForm((f) => ({ ...f, acceptedMimeTypes: preset.value }))}
|
|
||||||
className="text-[10px] px-2 py-1 rounded-full border hover:bg-muted transition-colors"
|
|
||||||
>
|
>
|
||||||
{preset.label}
|
{opt.label}
|
||||||
</button>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select one or more file types. Leave empty to accept any file type.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Max File Size (MB)</label>
|
<label className="text-sm font-medium">Max File Size (MB)</label>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
activateRound,
|
activateRound,
|
||||||
closeRound,
|
closeRound,
|
||||||
archiveRound,
|
archiveRound,
|
||||||
|
reopenRound,
|
||||||
transitionProject,
|
transitionProject,
|
||||||
batchTransitionProjects,
|
batchTransitionProjects,
|
||||||
getProjectRoundStates,
|
getProjectRoundStates,
|
||||||
|
|
@ -53,6 +54,23 @@ export const roundEngineRouter = router({
|
||||||
return result
|
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
|
* Archive a round: ROUND_CLOSED → ROUND_ARCHIVED
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const BATCH_SIZE = 50
|
||||||
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
||||||
ROUND_DRAFT: ['ROUND_ACTIVE'],
|
ROUND_DRAFT: ['ROUND_ACTIVE'],
|
||||||
ROUND_ACTIVE: ['ROUND_CLOSED'],
|
ROUND_ACTIVE: ['ROUND_CLOSED'],
|
||||||
ROUND_CLOSED: ['ROUND_ARCHIVED'],
|
ROUND_CLOSED: ['ROUND_ACTIVE', 'ROUND_ARCHIVED'],
|
||||||
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 ──────────────────────────────────────────────
|
// ─── Project-Level Transitions ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue