From d89f67ba576c8868560e8ee8b9afda42e364ed6f Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 14:58:39 +0200 Subject: [PATCH] feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode --- src/server/services/final-documents.ts | 6 ++++ tests/unit/final-documents.test.ts | 49 ++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/server/services/final-documents.ts b/src/server/services/final-documents.ts index e0a9078..8eabf3d 100644 --- a/src/server/services/final-documents.ts +++ b/src/server/services/final-documents.ts @@ -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 @@ -94,6 +96,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 +106,8 @@ export async function getFinalDocumentStatusForProject( deadlinePassed: deadline ? new Date() > deadline : false, requirements: reqStatuses, allRequiredUploaded, + hasRequired, + allUploaded, } } diff --git a/tests/unit/final-documents.test.ts b/tests/unit/final-documents.test.ts index 5829448..18484ad 100644 --- a/tests/unit/final-documents.test.ts +++ b/tests/unit/final-documents.test.ts @@ -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', () => {