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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
171
tests/unit/final-documents-curation.test.ts
Normal file
171
tests/unit/final-documents-curation.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, afterAll, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/minio', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/minio')>()
|
||||
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<string, unknown>
|
||||
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<string, unknown>).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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user