diff --git a/src/server/services/final-documents.ts b/src/server/services/final-documents.ts new file mode 100644 index 0000000..1acf75f --- /dev/null +++ b/src/server/services/final-documents.ts @@ -0,0 +1,86 @@ +import type { PrismaClient } from '@prisma/client' + +export type FinalDocRequirement = { + id: string + name: string + acceptedMimeTypes: string[] + isRequired: boolean + uploaded: boolean + file: { id: string; fileName: string; mimeType: string; bucket: string; objectKey: string; createdAt: Date } | null +} + +export type FinalDocumentStatus = { + roundId: string + roundName: string + deadline: Date | null + deadlinePassed: boolean + requirements: FinalDocRequirement[] + allRequiredUploaded: boolean +} + +/** Resolve the program's active LIVE_FINAL round, or null. */ +export async function getActiveFinaleRound(prisma: PrismaClient, programId: string) { + return prisma.round.findFirst({ + where: { competition: { programId }, roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, + orderBy: { sortOrder: 'desc' }, + select: { id: true, name: true, windowCloseAt: true }, + }) +} + +/** + * Per-project grand-final document status. Returns null unless the project is + * enrolled (ProjectRoundState) in the program's active LIVE_FINAL round. + */ +export async function getFinalDocumentStatusForProject( + prisma: PrismaClient, + projectId: string, +): Promise { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true, programId: true }, + }) + if (!project) return null + + const round = await getActiveFinaleRound(prisma, project.programId) + if (!round) return null + + const enrolled = await prisma.projectRoundState.findFirst({ + where: { projectId, roundId: round.id }, + select: { id: true }, + }) + if (!enrolled) return null + + const requirements = await prisma.fileRequirement.findMany({ + where: { roundId: round.id }, + orderBy: { sortOrder: 'asc' }, + select: { id: true, name: true, acceptedMimeTypes: true, isRequired: true }, + }) + + const files = await prisma.projectFile.findMany({ + where: { projectId, requirementId: { in: requirements.map((r) => r.id) } }, + orderBy: { createdAt: 'desc' }, + select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true, createdAt: true }, + }) + const fileByReq = new Map() + for (const f of files) if (f.requirementId && !fileByReq.has(f.requirementId)) fileByReq.set(f.requirementId, f) + + const reqStatuses: FinalDocRequirement[] = requirements.map((r) => { + const f = fileByReq.get(r.id) ?? null + return { + id: r.id, name: r.name, acceptedMimeTypes: r.acceptedMimeTypes, isRequired: r.isRequired, + uploaded: !!f, + file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, bucket: f.bucket, objectKey: f.objectKey, createdAt: f.createdAt } : null, + } + }) + + const allRequiredUploaded = reqStatuses.filter((r) => r.isRequired).every((r) => r.uploaded) + const deadline = round.windowCloseAt ?? null + return { + roundId: round.id, + roundName: round.name, + deadline, + deadlinePassed: deadline ? new Date() > deadline : false, + requirements: reqStatuses, + allRequiredUploaded, + } +} diff --git a/tests/unit/final-documents.test.ts b/tests/unit/final-documents.test.ts new file mode 100644 index 0000000..3e445b0 --- /dev/null +++ b/tests/unit/final-documents.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { prisma } from '../setup' +import { + createTestProgram, + createTestCompetition, + createTestRound, + createTestProject, + createTestProjectRoundState, + cleanupTestData, + uid, +} from '../helpers' +import { getFinalDocumentStatusForProject } from '@/server/services/final-documents' + +const programIds: string[] = [] + +async function makeFinaleProgram(opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT'; closeAt?: Date } = {}) { + const program = await createTestProgram() + programIds.push(program.id) + const comp = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const round = await createTestRound(comp.id, { + roundType: 'LIVE_FINAL', + status: opts.roundStatus ?? 'ROUND_ACTIVE', + sortOrder: 6, + windowCloseAt: opts.closeAt ?? new Date(Date.now() + 86_400_000), + }) + const reqPlan = await prisma.fileRequirement.create({ + data: { id: uid('req'), roundId: round.id, name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, 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 }, + }) + return { program, comp, round, reqPlan, reqVideo } +} + +describe('getFinalDocumentStatusForProject', () => { + afterAll(async () => { + for (const id of programIds) await cleanupTestData(id) + }) + + it('returns null when the project is not enrolled in the active LIVE_FINAL round', async () => { + const { program } = await makeFinaleProgram() + const orphan = await createTestProject(program.id) + const status = await getFinalDocumentStatusForProject(prisma, orphan.id) + expect(status).toBeNull() + }) + + it('returns per-requirement status with none uploaded', async () => { + const { program, round } = await makeFinaleProgram() + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + const status = await getFinalDocumentStatusForProject(prisma, project.id) + expect(status).not.toBeNull() + expect(status!.requirements).toHaveLength(2) + expect(status!.requirements.every((r) => !r.uploaded)).toBe(true) + expect(status!.allRequiredUploaded).toBe(false) + expect(status!.deadline?.toISOString()).toBe(round.windowCloseAt!.toISOString()) + }) + + it('marks a requirement uploaded and flips allRequiredUploaded when all present', async () => { + const { program, round, reqPlan, reqVideo } = await makeFinaleProgram() + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + for (const [req, type, mime] of [ + [reqPlan, 'BUSINESS_PLAN', 'application/pdf'], + [reqVideo, 'VIDEO', 'video/mp4'], + ] as const) { + await prisma.projectFile.create({ + data: { + id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id, + fileType: type as any, fileName: `f-${req.id}`, mimeType: mime, size: 10, + bucket: 'b', objectKey: uid('key'), + }, + }) + } + const status = await getFinalDocumentStatusForProject(prisma, project.id) + expect(status!.requirements.every((r) => r.uploaded)).toBe(true) + expect(status!.allRequiredUploaded).toBe(true) + }) + + it('returns null when the LIVE_FINAL round is not active', async () => { + const { program, round } = await makeFinaleProgram({ roundStatus: 'ROUND_DRAFT' }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + const status = await getFinalDocumentStatusForProject(prisma, project.id) + expect(status).toBeNull() + }) +})