Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION - Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards) - Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review) - Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone - Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields - Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish - Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors - Bulk upload page for admin project imports - File router enhanced with admin upload and submission window procedures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -925,4 +925,265 @@ export const fileRouter = router({
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// BULK UPLOAD
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List projects with their upload status for a given submission window.
|
||||
* Powers the bulk upload admin page.
|
||||
*/
|
||||
listProjectsWithUploadStatus: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
submissionWindowId: z.string(),
|
||||
search: z.string().optional(),
|
||||
status: z.enum(['all', 'missing', 'complete']).default('all'),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get the submission window with its requirements and competition
|
||||
const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({
|
||||
where: { id: input.submissionWindowId },
|
||||
include: {
|
||||
competition: { select: { id: true, programId: true, name: true } },
|
||||
fileRequirements: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
})
|
||||
|
||||
const requirements = window.fileRequirements
|
||||
|
||||
// Build project filter
|
||||
const projectWhere: Record<string, unknown> = {
|
||||
programId: window.competition.programId,
|
||||
}
|
||||
if (input.search) {
|
||||
projectWhere.OR = [
|
||||
{ title: { contains: input.search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
// Get total count first (before status filtering, which happens in-memory)
|
||||
const allProjects = await ctx.prisma.project.findMany({
|
||||
where: projectWhere,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
submittedByUserId: true,
|
||||
submittedBy: { select: { id: true, name: true, email: true } },
|
||||
files: {
|
||||
where: { submissionWindowId: input.submissionWindowId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
submissionFileRequirementId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { title: 'asc' },
|
||||
})
|
||||
|
||||
// Map projects with their requirement status
|
||||
const mapped = allProjects.map((project) => {
|
||||
const reqStatus = requirements.map((req) => {
|
||||
const file = project.files.find(
|
||||
(f) => f.submissionFileRequirementId === req.id
|
||||
)
|
||||
return {
|
||||
requirementId: req.id,
|
||||
label: req.label,
|
||||
mimeTypes: req.mimeTypes,
|
||||
required: req.required,
|
||||
file: file ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const totalRequired = reqStatus.filter((r) => r.required).length
|
||||
const filledRequired = reqStatus.filter(
|
||||
(r) => r.required && r.file
|
||||
).length
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
teamName: project.teamName,
|
||||
submittedBy: project.submittedBy,
|
||||
},
|
||||
requirements: reqStatus,
|
||||
isComplete: totalRequired > 0 ? filledRequired >= totalRequired : reqStatus.every((r) => r.file),
|
||||
filledCount: reqStatus.filter((r) => r.file).length,
|
||||
totalCount: reqStatus.length,
|
||||
}
|
||||
})
|
||||
|
||||
// Apply status filter
|
||||
const filtered =
|
||||
input.status === 'missing'
|
||||
? mapped.filter((p) => !p.isComplete)
|
||||
: input.status === 'complete'
|
||||
? mapped.filter((p) => p.isComplete)
|
||||
: mapped
|
||||
|
||||
// Paginate
|
||||
const total = filtered.length
|
||||
const totalPages = Math.ceil(total / input.pageSize)
|
||||
const page = Math.min(input.page, Math.max(totalPages, 1))
|
||||
const projects = filtered.slice(
|
||||
(page - 1) * input.pageSize,
|
||||
page * input.pageSize
|
||||
)
|
||||
|
||||
// Summary stats
|
||||
const completeCount = mapped.filter((p) => p.isComplete).length
|
||||
|
||||
return {
|
||||
projects,
|
||||
requirements,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
completeCount,
|
||||
totalProjects: mapped.length,
|
||||
competition: window.competition,
|
||||
windowName: window.name,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin upload for a specific submission file requirement.
|
||||
* Creates pre-signed PUT URL + ProjectFile record.
|
||||
*/
|
||||
adminUploadForRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().positive(),
|
||||
submissionWindowId: z.string(),
|
||||
submissionFileRequirementId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Block dangerous file extensions
|
||||
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
|
||||
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
|
||||
if (dangerousExtensions.includes(ext)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `File type "${ext}" is not allowed`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate requirement exists and belongs to the window
|
||||
const requirement = await ctx.prisma.submissionFileRequirement.findFirst({
|
||||
where: {
|
||||
id: input.submissionFileRequirementId,
|
||||
submissionWindowId: input.submissionWindowId,
|
||||
},
|
||||
})
|
||||
if (!requirement) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Requirement not found for this submission window',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate MIME type if requirement specifies allowed types
|
||||
if (requirement.mimeTypes.length > 0) {
|
||||
const isAllowed = requirement.mimeTypes.some((allowed) => {
|
||||
if (allowed.endsWith('/*')) {
|
||||
return input.mimeType.startsWith(allowed.replace('/*', '/'))
|
||||
}
|
||||
return input.mimeType === allowed
|
||||
})
|
||||
if (!isAllowed) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `File type "${input.mimeType}" is not allowed for this requirement. Accepted: ${requirement.mimeTypes.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Infer fileType from mimeType
|
||||
let fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' = 'OTHER'
|
||||
if (input.mimeType.startsWith('video/')) fileType = 'VIDEO'
|
||||
else if (input.mimeType === 'application/pdf') fileType = 'EXEC_SUMMARY'
|
||||
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
|
||||
fileType = 'PRESENTATION'
|
||||
|
||||
const bucket = BUCKET_NAME
|
||||
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
||||
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
|
||||
|
||||
// Remove any existing file for this project+requirement combo (replace)
|
||||
await ctx.prisma.projectFile.deleteMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
submissionWindowId: input.submissionWindowId,
|
||||
submissionFileRequirementId: input.submissionFileRequirementId,
|
||||
},
|
||||
})
|
||||
|
||||
// Create file record
|
||||
const file = await ctx.prisma.projectFile.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
fileType,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
bucket,
|
||||
objectKey,
|
||||
submissionWindowId: input.submissionWindowId,
|
||||
submissionFileRequirementId: input.submissionFileRequirementId,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPLOAD_FILE',
|
||||
entityType: 'ProjectFile',
|
||||
entityId: file.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
submissionWindowId: input.submissionWindowId,
|
||||
submissionFileRequirementId: input.submissionFileRequirementId,
|
||||
bulkUpload: true,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { uploadUrl, file }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List submission windows (for the bulk upload window selector)
|
||||
*/
|
||||
listSubmissionWindows: adminProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return ctx.prisma.submissionWindow.findMany({
|
||||
include: {
|
||||
competition: {
|
||||
select: { id: true, name: true, program: { select: { name: true, year: true } } },
|
||||
},
|
||||
fileRequirements: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }],
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -58,6 +58,15 @@ export const specialAwardRouter = router({
|
||||
program: {
|
||||
select: { id: true, name: true, year: true },
|
||||
},
|
||||
competition: {
|
||||
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
evaluationRound: {
|
||||
select: { id: true, name: true, roundType: true },
|
||||
},
|
||||
awardJuryGroup: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,6 +94,10 @@ export const specialAwardRouter = router({
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
autoTagRulesJson: z.record(z.unknown()).optional(),
|
||||
competitionId: z.string().optional(),
|
||||
evaluationRoundId: z.string().optional(),
|
||||
juryGroupId: z.string().optional(),
|
||||
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -104,6 +117,10 @@ export const specialAwardRouter = router({
|
||||
scoringMode: input.scoringMode,
|
||||
maxRankedPicks: input.maxRankedPicks,
|
||||
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
||||
competitionId: input.competitionId,
|
||||
evaluationRoundId: input.evaluationRoundId,
|
||||
juryGroupId: input.juryGroupId,
|
||||
eligibilityMode: input.eligibilityMode,
|
||||
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
||||
},
|
||||
})
|
||||
@@ -140,6 +157,10 @@ export const specialAwardRouter = router({
|
||||
autoTagRulesJson: z.record(z.unknown()).optional(),
|
||||
votingStartAt: z.date().optional(),
|
||||
votingEndAt: z.date().optional(),
|
||||
competitionId: z.string().nullable().optional(),
|
||||
evaluationRoundId: z.string().nullable().optional(),
|
||||
juryGroupId: z.string().nullable().optional(),
|
||||
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
Reference in New Issue
Block a user