feat(final-docs): judges see all teams' prior-round files; revised uploads behind admin toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m47s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m47s
The finals jury needs the teams' EXISTING submissions (pitch deck, exec summary, business plan, videos from prior rounds) — which all 9 teams already have. So: - listFinalistDocumentsForReview now returns ALL of each finalist team's files across every round (labeled by doc type + round; finale uploads flagged 'Revised for finals'), with presigned URLs. NOT gated — judges always see. - Revised re-uploads are now an admin toggle (Round.configJson.allowFinalistRevisedUploads, default OFF): gates the banner/panel (getFinalDocumentStatusForProject), the upload guard (getUploadUrl/deleteFile), the documents-page round, and the reminders (manual + cron). When off, teams aren't prompted/able to upload. - finalist.get/setRevisedUploadSetting + a Switch on the admin finale overview. - judge review component rewritten to a per-team labeled file list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,10 +31,20 @@ export async function getOpenFinaleRound(prisma: PrismaClient, programId: string
|
||||
return prisma.round.findFirst({
|
||||
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
select: { id: true, name: true, windowCloseAt: true },
|
||||
select: { id: true, name: true, windowCloseAt: true, configJson: true },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether finalist teams are allowed to upload *revised* documents for the
|
||||
* grand final. This is an admin toggle on the LIVE_FINAL round's configJson.
|
||||
* When false (default), judges still see the teams' existing prior-round
|
||||
* submissions, but teams are not prompted/able to upload anything new.
|
||||
*/
|
||||
export function finalistUploadsEnabled(configJson: unknown): boolean {
|
||||
return !!(configJson as { allowFinalistRevisedUploads?: boolean } | null)?.allowFinalistRevisedUploads
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project grand-final document status. Returns null unless the project is
|
||||
* enrolled (ProjectRoundState) in the program's active LIVE_FINAL round.
|
||||
@@ -50,7 +60,8 @@ export async function getFinalDocumentStatusForProject(
|
||||
if (!project) return null
|
||||
|
||||
const round = await getOpenFinaleRound(prisma, project.programId)
|
||||
if (!round) return null
|
||||
// Banner / upload status only applies when the admin has enabled revised uploads.
|
||||
if (!round || !finalistUploadsEnabled(round.configJson)) return null
|
||||
|
||||
const enrolled = await prisma.projectRoundState.findFirst({
|
||||
where: { projectId, roundId: round.id },
|
||||
@@ -125,7 +136,7 @@ export async function sendManualFinalDocReminders(
|
||||
opts: { programId: string; projectIds?: string[]; actorId: string },
|
||||
): Promise<{ sent: number }> {
|
||||
const round = await getOpenFinaleRound(prisma, opts.programId)
|
||||
if (!round) return { sent: 0 }
|
||||
if (!round || !finalistUploadsEnabled(round.configJson)) return { sent: 0 }
|
||||
|
||||
const states = await prisma.projectRoundState.findMany({
|
||||
where: { roundId: round.id, ...(opts.projectIds ? { projectId: { in: opts.projectIds } } : {}) },
|
||||
@@ -170,6 +181,8 @@ export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{
|
||||
let remindersSent = 0
|
||||
for (const round of rounds) {
|
||||
if (!round.windowCloseAt) continue
|
||||
// Only chase teams to upload when the admin has enabled revised uploads.
|
||||
if (!finalistUploadsEnabled(round.configJson)) continue
|
||||
const cfg = (round.configJson ?? {}) as { finalDocsReminderHoursBeforeDeadline?: number }
|
||||
const windowMs = (cfg.finalDocsReminderHoursBeforeDeadline ?? 48) * 3_600_000
|
||||
const isDue = round.windowCloseAt.getTime() <= now.getTime() + windowMs && round.windowCloseAt.getTime() > now.getTime()
|
||||
@@ -206,62 +219,99 @@ export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{
|
||||
return { remindersSent }
|
||||
}
|
||||
|
||||
export type ReviewDocument = { requirementId: string; requirementName: string; file: { id: string; fileName: string; mimeType: string; url: string } | null }
|
||||
export type ReviewTeam = { projectId: string; teamName: string; category: string | null; documents: ReviewDocument[]; submitted: boolean }
|
||||
export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; submittedCount: number; teams: ReviewTeam[] }
|
||||
export type ReviewFile = {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
url: string
|
||||
docLabel: string // requirement name if known, else a humanized file type
|
||||
roundLabel: string // which round this was submitted in
|
||||
roundSort: number
|
||||
isFinaleUpload: boolean // uploaded directly to the LIVE_FINAL round (a revised "final")
|
||||
createdAt: Date
|
||||
}
|
||||
export type ReviewTeam = { projectId: string; teamName: string; category: string | null; files: ReviewFile[] }
|
||||
export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; teams: ReviewTeam[] }
|
||||
|
||||
function humanizeFileType(t: string | null | undefined): string {
|
||||
switch (t) {
|
||||
case 'EXEC_SUMMARY': return 'Executive Summary'
|
||||
case 'BUSINESS_PLAN': return 'Business Plan'
|
||||
case 'PRESENTATION': return 'Presentation'
|
||||
case 'VIDEO_PITCH': return 'Video Pitch'
|
||||
case 'VIDEO': return 'Video'
|
||||
case 'SUPPORTING_DOC': return 'Supporting Document'
|
||||
default: return 'Document'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only review payload of every finalist team enrolled in the program's
|
||||
* active LIVE_FINAL round, with their uploaded grand-final documents. Each
|
||||
* present file carries a server-generated GET presigned URL (1h) so finale
|
||||
* judges — who are not assignment-gated through file.getDownloadUrl — can open
|
||||
* the documents directly in the browser.
|
||||
* Read-only review payload for finale judges: every finalist team enrolled in
|
||||
* the program's LIVE_FINAL round, with ALL of their submitted files across every
|
||||
* round (pitch deck, executive summary, business plan, videos, plus any revised
|
||||
* finals uploads). Each file carries a server-generated GET presigned URL (1h)
|
||||
* so finale judges — who are not assignment-gated through file.getDownloadUrl —
|
||||
* can open the documents directly. This is NOT gated on the upload toggle:
|
||||
* judges can always review the teams' existing submissions.
|
||||
*/
|
||||
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
|
||||
const round = await getOpenFinaleRound(prisma, programId)
|
||||
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, submittedCount: 0, teams: [] }
|
||||
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] }
|
||||
|
||||
const requirements = await prisma.fileRequirement.findMany({ where: { roundId: round.id }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true } })
|
||||
const states = await prisma.projectRoundState.findMany({
|
||||
where: { roundId: round.id },
|
||||
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
|
||||
})
|
||||
const projectIds = states.map((s) => s.project.id)
|
||||
|
||||
const teams: ReviewTeam[] = []
|
||||
for (const { project } of states) {
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: { projectId: project.id, roundId: round.id, requirementId: { in: requirements.map((r) => r.id) } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true },
|
||||
})
|
||||
const byReq = new Map<string, (typeof files)[number]>()
|
||||
for (const f of files) if (f.requirementId && !byReq.has(f.requirementId)) byReq.set(f.requirementId, f)
|
||||
// Every file these teams have submitted, in any round.
|
||||
const allFiles = await prisma.projectFile.findMany({
|
||||
where: { projectId: { in: projectIds } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, projectId: true, fileName: true, mimeType: true, fileType: true,
|
||||
bucket: true, objectKey: true, createdAt: true, roundId: true,
|
||||
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
|
||||
},
|
||||
})
|
||||
|
||||
const documents: ReviewDocument[] = []
|
||||
for (const r of requirements) {
|
||||
const f = byReq.get(r.id)
|
||||
documents.push({
|
||||
requirementId: r.id,
|
||||
requirementName: r.name,
|
||||
file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600) } : null,
|
||||
})
|
||||
// Resolve round names for files attached directly to a round (no requirement).
|
||||
const directRoundIds = [...new Set(allFiles.filter((f) => f.roundId && !f.requirement).map((f) => f.roundId!))]
|
||||
const directRounds = directRoundIds.length
|
||||
? await prisma.round.findMany({ where: { id: { in: directRoundIds } }, select: { id: true, name: true, sortOrder: true } })
|
||||
: []
|
||||
const roundById = new Map(directRounds.map((r) => [r.id, r]))
|
||||
|
||||
const filesByProject = new Map<string, ReviewFile[]>()
|
||||
for (const f of allFiles) {
|
||||
const r = f.requirement?.round ?? (f.roundId ? roundById.get(f.roundId) : null)
|
||||
const rf: ReviewFile = {
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
mimeType: f.mimeType,
|
||||
url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600),
|
||||
docLabel: f.requirement?.name?.trim() || humanizeFileType(f.fileType),
|
||||
roundLabel: r?.name ?? '—',
|
||||
roundSort: r?.sortOrder ?? -1,
|
||||
isFinaleUpload: f.roundId === round.id,
|
||||
createdAt: f.createdAt,
|
||||
}
|
||||
teams.push({
|
||||
projectId: project.id,
|
||||
teamName: project.teamName || project.title,
|
||||
category: project.competitionCategory,
|
||||
documents,
|
||||
submitted: documents.every((d) => d.file !== null),
|
||||
})
|
||||
const list = filesByProject.get(f.projectId)
|
||||
if (list) list.push(rf)
|
||||
else filesByProject.set(f.projectId, [rf])
|
||||
}
|
||||
|
||||
const teams: ReviewTeam[] = states.map(({ project }) => ({
|
||||
projectId: project.id,
|
||||
teamName: project.teamName || project.title,
|
||||
category: project.competitionCategory,
|
||||
files: (filesByProject.get(project.id) ?? []).sort(
|
||||
(a, b) => b.roundSort - a.roundSort || a.docLabel.localeCompare(b.docLabel),
|
||||
),
|
||||
}))
|
||||
|
||||
teams.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.teamName.localeCompare(b.teamName))
|
||||
return {
|
||||
round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null },
|
||||
totalCount: teams.length,
|
||||
submittedCount: teams.filter((t) => t.submitted).length,
|
||||
teams,
|
||||
}
|
||||
return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams }
|
||||
}
|
||||
|
||||
/** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */
|
||||
|
||||
Reference in New Issue
Block a user