Compare commits

..

3 Commits

Author SHA1 Message Date
Matt
d38fe7887a feat(final-docs): optional-mode banner/panel + admin 'Documents shown to judges' picker
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m31s
- finalist banner/panel render an optional-uploads state (settle to green via
  hasRequired ? allRequiredUploaded : allUploaded; 'optional' copy when nothing required)
- ReviewDocsPicker admin card on the LIVE_FINAL round page to curate judge-visible docs
2026-06-10 15:02:19 +02:00
Matt
28ca7bb0a6 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)
2026-06-10 15:00:49 +02:00
Matt
d89f67ba57 feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode 2026-06-10 14:58:39 +02:00
8 changed files with 409 additions and 13 deletions

View File

@@ -98,6 +98,7 @@ import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
@@ -1543,6 +1544,7 @@ export default function RoundDetailPage() {
<FinalDocsReminderButton programId={programId} />
</div>
</div>
<ReviewDocsPicker programId={programId} roundId={roundId} />
<div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} />

View File

@@ -0,0 +1,79 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Eye } from 'lucide-react'
/**
* Admin picker: which previously-submitted documents finale judges see on the
* review page. Default (switch off) shows everything; switching to curated
* mode starts with all slots ticked, and the admin unticks what to hide.
* Grand Final round uploads are always visible regardless.
*/
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
onError: (e) => toast.error(e.message),
})
if (!data || data.options.length === 0) return null
const curated = data.selectedIds !== null
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
const toggleSlot = (id: string, on: boolean) => {
const next = new Set(selected)
if (on) next.add(id)
else next.delete(id)
set.mutate({ roundId, requirementIds: [...next] })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -8,14 +8,15 @@ import { FileText, Video, CheckCircle2, Circle, Clock, Upload } from 'lucide-rea
export function FinalDocumentsBanner() {
const { data: status } = trpc.applicant.getFinalDocumentStatus.useQuery()
if (!status) return null
if (!status || status.requirements.length === 0) return null
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
.formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value
const uploadedCount = status.requirements.filter((r) => r.uploaded).length
const total = status.requirements.length
const done = status.allRequiredUploaded
const optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
return (
<Card className={done ? 'border-emerald-200 bg-emerald-50/50' : 'border-brand-blue/30 bg-brand-blue/5'}>
@@ -24,7 +25,9 @@ export function FinalDocumentsBanner() {
<div className="flex items-center gap-2">
{done ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <Upload className="h-5 w-5 text-brand-blue" />}
<span className="font-semibold">
{done ? 'Grand Final documents submitted' : 'Upload your Grand Final documents'}
{done
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span>
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
</div>

View File

@@ -18,7 +18,8 @@ export function FinalDocumentsPanel(props: Props) {
{ enabled: props.variant === 'mentor' },
)
const status = props.variant === 'team' ? teamQuery.data : mentorQuery.data
if (!status) return null
if (!status || status.requirements.length === 0) return null
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
return (
@@ -26,8 +27,8 @@ export function FinalDocumentsPanel(props: Props) {
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="flex items-center gap-2 text-lg"><FileCheck2 className="h-5 w-5" /> Final Documents</CardTitle>
{status.allRequiredUploaded
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">Submitted</Badge>
{done
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">{status.hasRequired ? 'Submitted' : 'Uploaded'}</Badge>
: status.deadline && (
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
<Clock className="h-4 w-4" /> Due {fmt.format(new Date(status.deadline))}
@@ -36,6 +37,7 @@ export function FinalDocumentsPanel(props: Props) {
</div>
<CardDescription>
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
{!status.hasRequired && ' These uploads are optional.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
@@ -48,7 +50,7 @@ export function FinalDocumentsPanel(props: Props) {
<span className="text-xs text-muted-foreground truncate max-w-[50%]">{r.file?.fileName ?? 'Not yet uploaded'}</span>
</div>
))}
{props.variant === 'team' && !status.allRequiredUploaded && (
{props.variant === 'team' && !done && (
<Button asChild size="sm" className="mt-2 bg-brand-blue hover:bg-brand-blue-light">
<Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link>
</Button>

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

@@ -18,6 +18,8 @@ export type FinalDocumentStatus = {
deadlinePassed: boolean
requirements: FinalDocRequirement[]
allRequiredUploaded: boolean
hasRequired: boolean // any slot is marked required
allUploaded: boolean // every listed slot has a file (false when no slots exist)
}
// A LIVE_FINAL round is "open for documents" during the lead-up — while it is
@@ -45,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.
@@ -94,6 +106,8 @@ export async function getFinalDocumentStatusForProject(
const required = reqStatuses.filter((r) => r.isRequired)
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
const hasRequired = required.length > 0
const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded)
const deadline = round.windowCloseAt ?? null
return {
roundId: round.id,
@@ -102,6 +116,8 @@ export async function getFinalDocumentStatusForProject(
deadlinePassed: deadline ? new Date() > deadline : false,
requirements: reqStatuses,
allRequiredUploaded,
hasRequired,
allUploaded,
}
}
@@ -258,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 } } },
@@ -269,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 } } } },
},
@@ -284,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,
@@ -293,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)
@@ -314,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

View 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
})
})

View File

@@ -25,7 +25,7 @@ import { BUCKET_NAME, generateObjectKey } from '@/lib/minio'
const programIds: string[] = []
async function makeFinaleProgram(
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean } = {},
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
) {
const program = await createTestProgram()
programIds.push(program.id)
@@ -41,10 +41,10 @@ async function makeFinaleProgram(
return { program, comp, round, reqPlan: undefined, reqVideo: undefined }
}
const reqPlan = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: round.id, name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
data: { id: uid('req'), roundId: round.id, name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: !opts.optionalRequirements, sortOrder: 1 },
})
const reqVideo = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: round.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: true, sortOrder: 2 },
data: { id: uid('req'), roundId: round.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: !opts.optionalRequirements, sortOrder: 2 },
})
return { program, comp, round, reqPlan, reqVideo }
}
@@ -128,6 +128,49 @@ describe('getFinalDocumentStatusForProject', () => {
expect(status!.requirements).toHaveLength(0)
expect(status!.allRequiredUploaded).toBe(false)
})
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const before = await getFinalDocumentStatusForProject(prisma, project.id)
expect(before!.hasRequired).toBe(false)
expect(before!.allUploaded).toBe(false)
expect(before!.allRequiredUploaded).toBe(false)
for (const req of [reqPlan!, reqVideo!]) {
await prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
}
const after = await getFinalDocumentStatusForProject(prisma, project.id)
expect(after!.hasRequired).toBe(false)
expect(after!.allUploaded).toBe(true)
})
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
const { program, round, reqPlan } = await makeFinaleProgram()
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(true) // reqVideo still required
expect(status!.allUploaded).toBe(false)
})
it('zero slots: allUploaded false (no vacuous completeness)', async () => {
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(false)
expect(status!.allUploaded).toBe(false)
})
})
describe('applicant.getFinalDocumentStatus', () => {