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
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