diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 9a699a6..1330080 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -329,18 +329,26 @@ export const applicantRouter = router({ }) } - // Fetch round info and verify it's active + // Fetch round info and verify uploads are open. Normally a round must be + // ROUND_ACTIVE; the grand-final documents are collected during the lead-up + // while the LIVE_FINAL round is still DRAFT (it only "opens" at the event), + // so allow uploads for a not-yet-closed LIVE_FINAL round too. let roundName: string | undefined if (input.roundId) { const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, - select: { name: true, status: true }, + select: { name: true, status: true, roundType: true, finalizedAt: true }, }) - if (round && round.status !== 'ROUND_ACTIVE') { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'This round is closed. Documents can no longer be uploaded.', - }) + if (round) { + const uploadable = + round.status === 'ROUND_ACTIVE' || + (round.roundType === 'LIVE_FINAL' && round.status === 'ROUND_DRAFT' && !round.finalizedAt) + if (!uploadable) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'This round is closed. Documents can no longer be uploaded.', + }) + } } roundName = round?.name } @@ -555,17 +563,23 @@ export const applicantRouter = router({ }) } - // Round-specific files can only be deleted while the round is active + // Round-specific files can only be modified while the round is open. As with + // upload, a not-yet-closed LIVE_FINAL round (DRAFT, pre-event) counts as open. if (file.roundId) { const round = await ctx.prisma.round.findUnique({ where: { id: file.roundId }, - select: { status: true }, + select: { status: true, roundType: true, finalizedAt: true }, }) - if (round && round.status !== 'ROUND_ACTIVE') { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'This round is closed. Documents can no longer be modified.', - }) + if (round) { + const modifiable = + round.status === 'ROUND_ACTIVE' || + (round.roundType === 'LIVE_FINAL' && round.status === 'ROUND_DRAFT' && !round.finalizedAt) + if (!modifiable) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'This round is closed. Documents can no longer be modified.', + }) + } } } @@ -1442,7 +1456,14 @@ export const applicantRouter = router({ const allActiveRounds = await ctx.prisma.round.findMany({ where: { competition: { programId }, - status: 'ROUND_ACTIVE', + // Active rounds, plus the not-yet-opened (DRAFT) LIVE_FINAL round so + // enrolled finalists can upload their grand-final documents during the + // lead-up while the round itself stays "closed" until the event. The + // per-project membership filter below restricts this to teams in it. + OR: [ + { status: 'ROUND_ACTIVE' }, + { roundType: 'LIVE_FINAL', status: 'ROUND_DRAFT', finalizedAt: null }, + ], }, orderBy: { sortOrder: 'asc' }, select: { @@ -1469,6 +1490,8 @@ export const applicantRouter = router({ openRounds = allActiveRounds .filter((r) => { + // LIVE_FINAL (grand-final documents) only shows to enrolled finalists. + if (r.roundType === 'LIVE_FINAL' && !projectRoundIds.has(r.id)) return false // Award round project isn't in → hide if (r.specialAwardId && !projectRoundIds.has(r.id)) return false // Main round when project is in award track and has no state in this round → hide diff --git a/src/server/services/final-documents.ts b/src/server/services/final-documents.ts index 90a52cc..797bd29 100644 --- a/src/server/services/final-documents.ts +++ b/src/server/services/final-documents.ts @@ -1,4 +1,4 @@ -import type { PrismaClient } from '@prisma/client' +import type { PrismaClient, RoundStatus } from '@prisma/client' import { createNotification, NotificationTypes } from './in-app-notification' import { getPresignedUrl } from '@/lib/minio' @@ -20,10 +20,16 @@ export type FinalDocumentStatus = { allRequiredUploaded: boolean } -/** Resolve the program's active LIVE_FINAL round, or null. */ -export async function getActiveFinaleRound(prisma: PrismaClient, programId: string) { +// A LIVE_FINAL round is "open for documents" during the lead-up — while it is +// DRAFT (not yet opened for the live event) or ACTIVE — but not once CLOSED/ +// finalized. This decouples document upload + judge review from the ceremony +// actually starting, so the Grand Final round can stay DRAFT until event time. +const OPEN_FINALE_STATUS: RoundStatus[] = ['ROUND_DRAFT', 'ROUND_ACTIVE'] + +/** Resolve the program's LIVE_FINAL round while it is open for documents (DRAFT or ACTIVE, not closed/finalized), or null. */ +export async function getOpenFinaleRound(prisma: PrismaClient, programId: string) { return prisma.round.findFirst({ - where: { competition: { programId }, roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, + where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null }, orderBy: { sortOrder: 'desc' }, select: { id: true, name: true, windowCloseAt: true }, }) @@ -43,7 +49,7 @@ export async function getFinalDocumentStatusForProject( }) if (!project) return null - const round = await getActiveFinaleRound(prisma, project.programId) + const round = await getOpenFinaleRound(prisma, project.programId) if (!round) return null const enrolled = await prisma.projectRoundState.findFirst({ @@ -118,7 +124,7 @@ export async function sendManualFinalDocReminders( prisma: PrismaClient, opts: { programId: string; projectIds?: string[]; actorId: string }, ): Promise<{ sent: number }> { - const round = await getActiveFinaleRound(prisma, opts.programId) + const round = await getOpenFinaleRound(prisma, opts.programId) if (!round) return { sent: 0 } const states = await prisma.projectRoundState.findMany({ @@ -157,7 +163,7 @@ export async function sendManualFinalDocReminders( export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{ remindersSent: number }> { const now = new Date() const rounds = await prisma.round.findMany({ - where: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, + where: { roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null }, select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } }, }) @@ -212,7 +218,7 @@ export type ReviewPayload = { round: { id: string; name: string; deadline: Date * the documents directly in the browser. */ export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise { - const round = await getActiveFinaleRound(prisma, programId) + const round = await getOpenFinaleRound(prisma, programId) if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, submittedCount: 0, teams: [] } const requirements = await prisma.fileRequirement.findMany({ where: { roundId: round.id }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true } }) @@ -258,11 +264,11 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr } } -/** True if user is admin or a member of the program's active LIVE_FINAL jury group. */ +/** 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 { if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true const round = await prisma.round.findFirst({ - where: { competition: { programId }, roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, + where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null }, orderBy: { sortOrder: 'desc' }, select: { juryGroupId: true }, }) diff --git a/tests/unit/final-documents.test.ts b/tests/unit/final-documents.test.ts index 16372bf..2921437 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'; closeAt?: Date; skipRequirements?: boolean } = {}, + opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean } = {}, ) { const program = await createTestProgram() programIds.push(program.id) @@ -93,11 +93,20 @@ describe('getFinalDocumentStatusForProject', () => { expect(status!.allRequiredUploaded).toBe(true) }) - it('returns null when the LIVE_FINAL round is not active', async () => { + it('works when the LIVE_FINAL round is DRAFT (pre-event — documents open before the round opens)', 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).not.toBeNull() + expect(status!.roundId).toBe(round.id) + }) + + it('returns null when the LIVE_FINAL round is CLOSED', async () => { + const { program, round } = await makeFinaleProgram({ roundStatus: 'ROUND_CLOSED' }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + const status = await getFinalDocumentStatusForProject(prisma, project.id) expect(status).toBeNull() })