From 28ca7bb0a601db2234367341e5a779b4497b434d Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 15:00:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(final-docs):=20judge-doc=20curation=20?= =?UTF-8?q?=E2=80=94=20reviewVisibleRequirementIds=20filter,=20picker=20he?= =?UTF-8?q?lper,=20admin=20procedures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/server/routers/finalist.ts | 35 +++- src/server/services/final-documents.ts | 61 ++++++- tests/unit/final-documents-curation.test.ts | 171 ++++++++++++++++++++ 3 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 tests/unit/final-documents-curation.test.ts diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 253ef6a..23819c1 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -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 + 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 } + }), }) diff --git a/src/server/services/final-documents.ts b/src/server/services/final-documents.ts index 8eabf3d..41890a8 100644 --- a/src/server/services/final-documents.ts +++ b/src/server/services/final-documents.ts @@ -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() 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 { + 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() + 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 { if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true diff --git a/tests/unit/final-documents-curation.test.ts b/tests/unit/final-documents-curation.test.ts new file mode 100644 index 0000000..da89b49 --- /dev/null +++ b/tests/unit/final-documents-curation.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, afterAll, vi } from 'vitest' + +vi.mock('@/lib/minio', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') } +}) + +import { prisma } from '../setup' +import { + createTestProgram, + createTestCompetition, + createTestRound, + createTestProject, + createTestProjectRoundState, + createTestUser, + cleanupTestData, + uid, +} from '../helpers' +import { listFinalistDocumentsForReview, listReviewVisibilityOptions } from '@/server/services/final-documents' + +const programIds: string[] = [] +afterAll(async () => { + for (const id of programIds) await cleanupTestData(id) +}) + +/** + * One finalist team with 4 files: + * - Business Plan (prior SUBMISSION round, via requirement reqBP) + * - Pitch Deck (prior SUBMISSION round, via requirement reqDeck) + * - loose.pdf (prior SUBMISSION round, NO requirement) + * - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal) + */ +async function setupCuration() { + const program = await createTestProgram() + programIds.push(program.id) + const comp = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 }) + const reqBP = await prisma.fileRequirement.create({ + data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 }, + }) + const reqDeck = await prisma.fileRequirement.create({ + data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 }, + }) + const finale = await createTestRound(comp.id, { + roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, + windowCloseAt: new Date(Date.now() + 86_400_000), + configJson: { allowFinalistRevisedUploads: true }, + }) + const reqFinal = await prisma.fileRequirement.create({ + data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 }, + }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, finale.id) + + const mkFile = (roundId: string, requirementId: string | null, fileName: string) => + prisma.projectFile.create({ + data: { + id: uid('file'), projectId: project.id, roundId, requirementId, + fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10, + bucket: 'b', objectKey: uid('key'), + }, + }) + await mkFile(priorRound.id, reqBP.id, 'bp.pdf') + await mkFile(priorRound.id, reqDeck.id, 'deck.pdf') + await mkFile(priorRound.id, null, 'loose.pdf') + await mkFile(finale.id, reqFinal.id, 'final.mp4') + + return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project } +} + +async function setSelection(roundId: string, ids: string[] | null) { + const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } }) + const cfg = (round.configJson ?? {}) as Record + if (ids === null) delete cfg.reviewVisibleRequirementIds + else cfg.reviewVisibleRequirementIds = ids + await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } }) +} + +describe('listFinalistDocumentsForReview curation', () => { + it('no selection key → all files visible (current behavior)', async () => { + const { program } = await setupCuration() + const result = await listFinalistDocumentsForReview(prisma, program.id) + expect(result.teams).toHaveLength(1) + expect(result.teams[0].files).toHaveLength(4) + }) + + it('selection → only matching prior files, finale uploads always visible', async () => { + const { program, finale, reqBP } = await setupCuration() + await setSelection(finale.id, [reqBP.id]) + const result = await listFinalistDocumentsForReview(prisma, program.id) + const names = result.teams[0].files.map((f) => f.fileName).sort() + expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden + }) + + it('empty selection → only finale uploads visible', async () => { + const { program, finale } = await setupCuration() + await setSelection(finale.id, []) + const result = await listFinalistDocumentsForReview(prisma, program.id) + expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4']) + }) + + it('prior file without a requirement is excluded under any selection', async () => { + const { program, finale, reqBP, reqDeck } = await setupCuration() + await setSelection(finale.id, [reqBP.id, reqDeck.id]) + const result = await listFinalistDocumentsForReview(prisma, program.id) + expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf') + }) +}) + +describe('listReviewVisibilityOptions', () => { + it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => { + const { program, reqBP, reqDeck } = await setupCuration() + const options = await listReviewVisibilityOptions(prisma, program.id) + expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort()) + const bp = options.find((o) => o.requirementId === reqBP.id)! + expect(bp.name).toBe('Business Plan') + expect(bp.fileCount).toBe(1) + expect(bp.roundName).toBeTruthy() + }) + + it('returns [] when there is no open finale round', async () => { + const program = await createTestProgram() + programIds.push(program.id) + expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([]) + }) +}) + +describe('finalist review-doc settings procedures', () => { + const userIds: string[] = [] + afterAll(async () => { + for (const id of programIds) await cleanupTestData(id, userIds) + }) + + it('round-trips a selection and preserves sibling configJson keys', async () => { + const { program, finale, reqBP } = await setupCuration() + const admin = await createTestUser('PROGRAM_ADMIN') + userIds.push(admin.id) + const { finalistRouter } = await import('@/server/routers/finalist') + const { createCaller } = await import('../setup') + const caller = createCaller(finalistRouter, admin) + + const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id }) + expect(initial.selectedIds).toBeNull() + expect(initial.options.length).toBe(2) + + await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] }) + const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id }) + expect(curated.selectedIds).toEqual([reqBP.id]) + + // sibling key from setupCuration must survive + const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } }) + expect((round.configJson as Record).allowFinalistRevisedUploads).toBe(true) + + await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null }) + const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id }) + expect(cleared.selectedIds).toBeNull() + }) + + it('rejects a non-LIVE_FINAL round', async () => { + const { program, priorRound } = await setupCuration() + const admin = await createTestUser('PROGRAM_ADMIN') + userIds.push(admin.id) + const { finalistRouter } = await import('@/server/routers/finalist') + const { createCaller } = await import('../setup') + const caller = createCaller(finalistRouter, admin) + await expect( + caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }), + ).rejects.toThrow() + void program + }) +})