Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper - Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router - Add per-round analytics (_count, juryGroup) to competition.getById - Remove redundant acceptedCategories from intake config - Rewrite submission window manager with full CRUD, all fields, date pickers - Add round scheduling card (open/close dates) to round detail page - Add project count, assignment count, jury group to round list cards - Visual redesign: pipeline view, brand colors, progress bars, enhanced cards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,15 @@ export const competitionRouter = router({
|
||||
sortOrder: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
juryGroup: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
projectRoundStates: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
juryGroups: {
|
||||
@@ -102,6 +111,10 @@ export const competitionRouter = router({
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
isLocked: true,
|
||||
deadlinePolicy: true,
|
||||
graceHours: true,
|
||||
lockOnClose: true,
|
||||
sortOrder: true,
|
||||
_count: { select: { fileRequirements: true, projectFiles: true } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -293,6 +293,79 @@ export const roundRouter = router({
|
||||
return window
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update an existing submission window
|
||||
*/
|
||||
updateSubmissionWindow: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
||||
roundNumber: z.number().int().min(1).optional(),
|
||||
windowOpenAt: z.date().nullable().optional(),
|
||||
windowCloseAt: z.date().nullable().optional(),
|
||||
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).optional(),
|
||||
graceHours: z.number().int().min(0).nullable().optional(),
|
||||
lockOnClose: z.boolean().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
const window = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.submissionWindow.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
return updated
|
||||
})
|
||||
return window
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a submission window (only if no files uploaded)
|
||||
*/
|
||||
deleteSubmissionWindow: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if window has uploaded files
|
||||
const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { id: true, name: true, _count: { select: { projectFiles: true } } },
|
||||
})
|
||||
if (window._count.projectFiles > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`,
|
||||
})
|
||||
}
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.submissionWindow.delete({ where: { id: input.id } })
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: window.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open a submission window
|
||||
*/
|
||||
|
||||
@@ -125,14 +125,15 @@ export const specialAwardRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, scoringMode: input.scoringMode } as Prisma.InputJsonValue,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
@@ -190,13 +191,14 @@ export const specialAwardRouter = router({
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
await tx.specialAward.delete({ where: { id: input.id } })
|
||||
@@ -249,22 +251,23 @@ export const specialAwardRouter = router({
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
previousStatus: current.status,
|
||||
newStatus: input.status,
|
||||
...(votingStartAtUpdated && {
|
||||
votingStartAtUpdated: true,
|
||||
previousVotingStartAt: current.votingStartAt,
|
||||
newVotingStartAt: now,
|
||||
}),
|
||||
} as Prisma.InputJsonValue,
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
previousStatus: current.status,
|
||||
newStatus: input.status,
|
||||
...(votingStartAtUpdated && {
|
||||
votingStartAtUpdated: true,
|
||||
previousVotingStartAt: current.votingStartAt,
|
||||
newVotingStartAt: now,
|
||||
}),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
@@ -750,19 +753,20 @@ export const specialAwardRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'SET_AWARD_WINNER',
|
||||
previousWinner: previous.winnerProjectId,
|
||||
newWinner: input.projectId,
|
||||
overridden: input.overridden,
|
||||
} as Prisma.InputJsonValue,
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'SET_AWARD_WINNER',
|
||||
previousWinner: previous.winnerProjectId,
|
||||
newWinner: input.projectId,
|
||||
overridden: input.overridden,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
|
||||
Reference in New Issue
Block a user