feat(final-docs): grand-final document status service
This commit is contained in:
86
src/server/services/final-documents.ts
Normal file
86
src/server/services/final-documents.ts
Normal file
@@ -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<FinalDocumentStatus | null> {
|
||||||
|
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<string, (typeof files)[number]>()
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/unit/final-documents.test.ts
Normal file
87
tests/unit/final-documents.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user