feat(final-docs): judge-doc curation — reviewVisibleRequirementIds filter, picker helper, admin procedures

- listFinalistDocumentsForReview filters prior-round files by the round's
  reviewVisibleRequirementIds (finale uploads always shown; null = show all)
- listReviewVisibilityOptions: distinct prior-round slots + file counts for the picker
- finalist.getReviewDocSettings / setReviewVisibleRequirements (adminProcedure, audited,
  preserves sibling configJson keys)
This commit is contained in:
Matt
2026-06-10 15:00:49 +02:00
parent d89f67ba57
commit 28ca7bb0a6
3 changed files with 264 additions and 3 deletions

View File

@@ -19,7 +19,7 @@ import {
resetOrCreatePendingConfirmation,
confirmAttendanceInTx,
} from '../services/finalist-enrollment'
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents'
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals, listReviewVisibilityOptions, reviewVisibleRequirementIds } from '../services/final-documents'
export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */
@@ -1734,4 +1734,37 @@ export const finalistRouter = router({
})
return { ok: true, enabled: input.enabled }
}),
/** Options + current selection for the "documents shown to judges" picker. */
getReviewDocSettings: adminProcedure
.input(z.object({ programId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
return {
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
}
}),
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
setReviewVisibleRequirements: adminProcedure
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
if (!round || round.roundType !== 'LIVE_FINAL') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
}
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next as object } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_REVIEW_DOCS_CURATED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { requirementIds: input.requirementIds },
})
return { ok: true }
}),
})

View File

@@ -47,6 +47,16 @@ export function finalistUploadsEnabled(configJson: unknown): boolean {
return !!(configJson as { allowFinalistRevisedUploads?: boolean } | null)?.allowFinalistRevisedUploads
}
/**
* Which prior-round FileRequirement ids are visible to finale judges.
* null = no curation (show all prior files). Empty array = hide all prior
* files (Grand Final round uploads are always shown regardless).
*/
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
}
/**
* Per-project grand-final document status. Returns null unless the project is
* enrolled (ProjectRoundState) in the program's active LIVE_FINAL round.
@@ -264,6 +274,9 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] }
// Admin curation: which prior-round documents judges may see (null = all).
const visibleIds = reviewVisibleRequirementIds(round.configJson)
const states = await prisma.projectRoundState.findMany({
where: { roundId: round.id },
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
@@ -275,7 +288,7 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
where: { projectId: { in: projectIds } },
orderBy: { createdAt: 'desc' },
select: {
id: true, projectId: true, fileName: true, mimeType: true, fileType: true,
id: true, projectId: true, fileName: true, mimeType: true, fileType: true, requirementId: true,
bucket: true, objectKey: true, createdAt: true, roundId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
@@ -290,6 +303,9 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
const filesByProject = new Map<string, ReviewFile[]>()
for (const f of allFiles) {
const isFinaleUpload = f.roundId === round.id
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
const r = f.requirement?.round ?? (f.roundId ? roundById.get(f.roundId) : null)
const rf: ReviewFile = {
id: f.id,
@@ -299,7 +315,7 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
docLabel: f.requirement?.name?.trim() || humanizeFileType(f.fileType),
roundLabel: r?.name ?? '—',
roundSort: r?.sortOrder ?? -1,
isFinaleUpload: f.roundId === round.id,
isFinaleUpload,
createdAt: f.createdAt,
}
const list = filesByProject.get(f.projectId)
@@ -320,6 +336,47 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams }
}
export type ReviewDocSlot = {
requirementId: string
name: string
roundName: string
roundSort: number
fileCount: number
}
/**
* Distinct prior-round document slots (FileRequirements) that the finalist
* teams have files for — the options offered in the admin "documents shown to
* judges" picker. Excludes the finale round's own slots (those uploads are
* always visible to judges) and files without a requirement.
*/
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return []
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
const files = await prisma.projectFile.findMany({
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
select: {
requirementId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
})
const slots = new Map<string, ReviewDocSlot>()
for (const f of files) {
if (!f.requirementId || !f.requirement) continue
const existing = slots.get(f.requirementId)
if (existing) existing.fileCount++
else slots.set(f.requirementId, {
requirementId: f.requirementId,
name: f.requirement.name.trim(),
roundName: f.requirement.round.name,
roundSort: f.requirement.round.sortOrder,
fileCount: 1,
})
}
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
}
/** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */
export async function userCanReviewFinals(prisma: PrismaClient, userId: string, userRole: string, programId: string): Promise<boolean> {
if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true