feat(final-docs): decouple grand-final docs from LIVE_FINAL being ROUND_ACTIVE
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m44s

The Grand Final round = the live event; document upload + judge review happen
in the lead-up BEFORE it opens. So gate them on finalist enrollment + the round
being open-for-docs (DRAFT or ACTIVE, not closed/finalized) instead of requiring
ROUND_ACTIVE. Lets the round stay DRAFT until event time.

- getOpenFinaleRound (was getActiveFinaleRound): status in {DRAFT,ACTIVE}, not finalized
- cron + userCanReviewFinals use the same open-status condition
- getUploadUrl + deleteFile allow a not-yet-closed LIVE_FINAL round
- getMyDashboard openRounds includes the enrolled DRAFT LIVE_FINAL round (finalists only)
- tests: DRAFT now works; CLOSED returns null

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-09 16:57:24 +02:00
parent 696d7e9041
commit f8f2d77e3b
3 changed files with 65 additions and 27 deletions

View File

@@ -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 let roundName: string | undefined
if (input.roundId) { if (input.roundId) {
const round = await ctx.prisma.round.findUnique({ const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId }, where: { id: input.roundId },
select: { name: true, status: true }, select: { name: true, status: true, roundType: true, finalizedAt: true },
}) })
if (round && round.status !== 'ROUND_ACTIVE') { if (round) {
throw new TRPCError({ const uploadable =
code: 'BAD_REQUEST', round.status === 'ROUND_ACTIVE' ||
message: 'This round is closed. Documents can no longer be uploaded.', (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 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) { if (file.roundId) {
const round = await ctx.prisma.round.findUnique({ const round = await ctx.prisma.round.findUnique({
where: { id: file.roundId }, where: { id: file.roundId },
select: { status: true }, select: { status: true, roundType: true, finalizedAt: true },
}) })
if (round && round.status !== 'ROUND_ACTIVE') { if (round) {
throw new TRPCError({ const modifiable =
code: 'BAD_REQUEST', round.status === 'ROUND_ACTIVE' ||
message: 'This round is closed. Documents can no longer be modified.', (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({ const allActiveRounds = await ctx.prisma.round.findMany({
where: { where: {
competition: { programId }, 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' }, orderBy: { sortOrder: 'asc' },
select: { select: {
@@ -1469,6 +1490,8 @@ export const applicantRouter = router({
openRounds = allActiveRounds openRounds = allActiveRounds
.filter((r) => { .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 // Award round project isn't in → hide
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false 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 // Main round when project is in award track and has no state in this round → hide

View File

@@ -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 { createNotification, NotificationTypes } from './in-app-notification'
import { getPresignedUrl } from '@/lib/minio' import { getPresignedUrl } from '@/lib/minio'
@@ -20,10 +20,16 @@ export type FinalDocumentStatus = {
allRequiredUploaded: boolean allRequiredUploaded: boolean
} }
/** Resolve the program's active LIVE_FINAL round, or null. */ // A LIVE_FINAL round is "open for documents" during the lead-up — while it is
export async function getActiveFinaleRound(prisma: PrismaClient, programId: string) { // 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({ 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' }, orderBy: { sortOrder: 'desc' },
select: { id: true, name: true, windowCloseAt: true }, select: { id: true, name: true, windowCloseAt: true },
}) })
@@ -43,7 +49,7 @@ export async function getFinalDocumentStatusForProject(
}) })
if (!project) return null if (!project) return null
const round = await getActiveFinaleRound(prisma, project.programId) const round = await getOpenFinaleRound(prisma, project.programId)
if (!round) return null if (!round) return null
const enrolled = await prisma.projectRoundState.findFirst({ const enrolled = await prisma.projectRoundState.findFirst({
@@ -118,7 +124,7 @@ export async function sendManualFinalDocReminders(
prisma: PrismaClient, prisma: PrismaClient,
opts: { programId: string; projectIds?: string[]; actorId: string }, opts: { programId: string; projectIds?: string[]; actorId: string },
): Promise<{ sent: number }> { ): Promise<{ sent: number }> {
const round = await getActiveFinaleRound(prisma, opts.programId) const round = await getOpenFinaleRound(prisma, opts.programId)
if (!round) return { sent: 0 } if (!round) return { sent: 0 }
const states = await prisma.projectRoundState.findMany({ const states = await prisma.projectRoundState.findMany({
@@ -157,7 +163,7 @@ export async function sendManualFinalDocReminders(
export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{ remindersSent: number }> { export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{ remindersSent: number }> {
const now = new Date() const now = new Date()
const rounds = await prisma.round.findMany({ 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 } } }, 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. * the documents directly in the browser.
*/ */
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> { export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
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: [] } 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 } }) 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<boolean> { export async function userCanReviewFinals(prisma: PrismaClient, userId: string, userRole: string, programId: string): Promise<boolean> {
if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true
const round = await prisma.round.findFirst({ 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' }, orderBy: { sortOrder: 'desc' },
select: { juryGroupId: true }, select: { juryGroupId: true },
}) })

View File

@@ -25,7 +25,7 @@ import { BUCKET_NAME, generateObjectKey } from '@/lib/minio'
const programIds: string[] = [] const programIds: string[] = []
async function makeFinaleProgram( 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() const program = await createTestProgram()
programIds.push(program.id) programIds.push(program.id)
@@ -93,11 +93,20 @@ describe('getFinalDocumentStatusForProject', () => {
expect(status!.allRequiredUploaded).toBe(true) 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 { program, round } = await makeFinaleProgram({ roundStatus: 'ROUND_DRAFT' })
const project = await createTestProject(program.id) const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id) await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.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() expect(status).toBeNull()
}) })