Compare commits

...

3 Commits

Author SHA1 Message Date
Matt
2c311bc65a feat(finals): prominent finalist-docs banner on jury dashboard + gate nav to finals jury
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m37s
Grand-Final jurors couldn't find the finalist documents review: the only entry
point was a top-nav text link (hidden in the hamburger on mobile), with nothing
on the dashboard. Add a prominent dashboard banner (shown only to finals-jury
members) linking to /jury/finals-documents, and gate the nav "Finalist Documents"
link to members so other jurors don't hit a dead "No access" page.

- finalist.canReviewDocuments: lightweight boolean procedure (self-resolves active
  program) so the nav can gate the link without fetching the full payload
- jury-nav: show "Finalist Documents" only when canReviewDocuments
- jury dashboard: FinalsJuryBanner server component, gated via userCanReviewFinals,
  rendered above content regardless of assignment state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:56:02 +02:00
Matt
85937ec942 docs(final-docs): implementation plan for judge-doc curation + optional uploads
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:06:20 +02:00
Matt
81352d7bd2 docs(final-docs): spec for judge-doc curation + optional revised uploads
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:59:48 +02:00
5 changed files with 932 additions and 5 deletions

View File

@@ -0,0 +1,745 @@
# Grand Final Judge-Doc Curation + Optional Uploads Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let admins curate which previously-submitted documents finale judges see, and make the "optional revised uploads" mode render correctly for finalists.
**Architecture:** Everything builds on the existing `final-documents.ts` service + `finalist` tRPC router + the LIVE_FINAL round's `configJson` (same pattern as the shipped `allowFinalistRevisedUploads` toggle). A new configJson key `reviewVisibleRequirementIds` filters the judge review payload; new status fields `hasRequired`/`allUploaded` drive optional-mode rendering in the finalist banner/panel. No schema migration.
**Tech Stack:** Next.js 15 App Router, tRPC 11 + Zod, Prisma 6, Vitest 4 (sequential, real test DB), shadcn/ui (Card/Switch/Checkbox).
**Spec:** `docs/superpowers/specs/2026-06-09-finale-doc-curation-optional-uploads-design.md`
**Conventions for every task:** TypeScript strict, `type` over `interface`. Tests use the factories in `tests/helpers.ts` (`createTestProgram`, `createTestCompetition`, `createTestRound`, `createTestProject`, `createTestProjectRoundState`, `createTestUser`, `uid`) and clean up with `cleanupTestData(programId, userIds?)` in `afterAll`. Run a single file with `npx vitest run tests/unit/<file>.test.ts`.
---
### Task 1: `hasRequired` + `allUploaded` on `FinalDocumentStatus`
**Files:**
- Modify: `src/server/services/final-documents.ts` (type at ~line 14, computation at ~line 95)
- Test: `tests/unit/final-documents.test.ts` (extend existing file)
- [ ] **Step 1: Write the failing tests**
In `tests/unit/final-documents.test.ts`, first extend the `makeFinaleProgram` factory (top of file) with an `optionalRequirements` option — change the two `fileRequirement.create` calls to use it:
```ts
async function makeFinaleProgram(
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
) {
// ... existing body unchanged, except both requirement creates:
// isRequired: !opts.optionalRequirements
}
```
Then add inside the existing `describe('getFinalDocumentStatusForProject', ...)` block:
```ts
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const before = await getFinalDocumentStatusForProject(prisma, project.id)
expect(before!.hasRequired).toBe(false)
expect(before!.allUploaded).toBe(false)
expect(before!.allRequiredUploaded).toBe(false)
for (const req of [reqPlan!, reqVideo!]) {
await prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
}
const after = await getFinalDocumentStatusForProject(prisma, project.id)
expect(after!.hasRequired).toBe(false)
expect(after!.allUploaded).toBe(true)
})
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
const { program, round, reqPlan } = await makeFinaleProgram()
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(true) // reqVideo still required
expect(status!.allUploaded).toBe(false)
})
it('zero slots: allUploaded false (no vacuous completeness)', async () => {
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(false)
expect(status!.allUploaded).toBe(false)
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents.test.ts`
Expected: the 3 new tests FAIL with TypeScript/undefined errors on `hasRequired` / `allUploaded` (fields don't exist yet); all pre-existing tests still pass.
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, extend the type (~line 14):
```ts
export type FinalDocumentStatus = {
roundId: string
roundName: string
deadline: Date | null
deadlinePassed: boolean
requirements: FinalDocRequirement[]
allRequiredUploaded: boolean
hasRequired: boolean // any slot is marked required
allUploaded: boolean // every listed slot has a file (false when no slots exist)
}
```
And in `getFinalDocumentStatusForProject` (~line 95), replace the return-value computation:
```ts
const required = reqStatuses.filter((r) => r.isRequired)
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
const hasRequired = required.length > 0
const allUploaded = reqStatuses.length > 0 && reqStatuses.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,
hasRequired,
allUploaded,
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents.test.ts`
Expected: ALL tests pass (new + pre-existing).
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents.test.ts
git commit -m "feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode"
```
---
### Task 2: Curation filter in `listFinalistDocumentsForReview`
**Files:**
- Modify: `src/server/services/final-documents.ts` (`listFinalistDocumentsForReview`, ~line 257; new helper next to `finalistUploadsEnabled` ~line 45)
- Test: Create `tests/unit/final-documents-curation.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `tests/unit/final-documents-curation.test.ts`. The review service presigns every file via MinIO, so mock `getPresignedUrl` (partial module mock — keeps `BUCKET_NAME` etc. real):
```ts
import { describe, it, expect, afterAll, vi } from 'vitest'
vi.mock('@/lib/minio', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/minio')>()
return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') }
})
import { prisma } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
cleanupTestData,
uid,
} from '../helpers'
import { listFinalistDocumentsForReview } from '@/server/services/final-documents'
const programIds: string[] = []
afterAll(async () => {
for (const id of programIds) await cleanupTestData(id)
})
/**
* One finalist team with 4 files:
* - Business Plan (prior SUBMISSION round, via requirement reqBP)
* - Pitch Deck (prior SUBMISSION round, via requirement reqDeck)
* - loose.pdf (prior SUBMISSION round, NO requirement)
* - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal)
*/
async function setupCuration() {
const program = await createTestProgram()
programIds.push(program.id)
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 })
const reqBP = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
})
const reqDeck = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 },
})
const finale = await createTestRound(comp.id, {
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
windowCloseAt: new Date(Date.now() + 86_400_000),
configJson: { allowFinalistRevisedUploads: true },
})
const reqFinal = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 },
})
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, finale.id)
const mkFile = (roundId: string, requirementId: string | null, fileName: string) =>
prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId, requirementId,
fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
await mkFile(priorRound.id, reqBP.id, 'bp.pdf')
await mkFile(priorRound.id, reqDeck.id, 'deck.pdf')
await mkFile(priorRound.id, null, 'loose.pdf')
await mkFile(finale.id, reqFinal.id, 'final.mp4')
return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project }
}
async function setSelection(roundId: string, ids: string[] | null) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } })
const cfg = (round.configJson ?? {}) as Record<string, unknown>
if (ids === null) delete cfg.reviewVisibleRequirementIds
else cfg.reviewVisibleRequirementIds = ids
await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } })
}
describe('listFinalistDocumentsForReview curation', () => {
it('no selection key → all files visible (current behavior)', async () => {
const { program } = await setupCuration()
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams).toHaveLength(1)
expect(result.teams[0].files).toHaveLength(4)
})
it('selection → only matching prior files, finale uploads always visible', async () => {
const { program, finale, reqBP } = await setupCuration()
await setSelection(finale.id, [reqBP.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
const names = result.teams[0].files.map((f) => f.fileName).sort()
expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden
})
it('empty selection → only finale uploads visible', async () => {
const { program, finale } = await setupCuration()
await setSelection(finale.id, [])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4'])
})
it('prior file without a requirement is excluded under any selection', async () => {
const { program, finale, reqBP, reqDeck } = await setupCuration()
await setSelection(finale.id, [reqBP.id, reqDeck.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: the first test PASSES (current behavior), the other three FAIL (filter not implemented — they see all 4 files).
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, add a config reader next to `finalistUploadsEnabled` (~line 45):
```ts
/**
* Which prior-round FileRequirement ids are visible to finale judges.
* null = no curation (show all prior files). Empty array = hide all prior
* files (Grand Final round uploads are always shown regardless).
*/
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
}
```
In `listFinalistDocumentsForReview`:
1. After the `if (!round) return ...` guard, read the selection: `const visibleIds = reviewVisibleRequirementIds(round.configJson)`
2. Add `requirementId: true` to the `allFiles` select.
3. At the top of the `for (const f of allFiles)` loop, before building `rf`:
```ts
const isFinaleUpload = f.roundId === round.id
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
```
4. Use the `isFinaleUpload` const in the `rf` object (replacing the inline `f.roundId === round.id`).
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all 4 PASS.
Also run the neighbors to catch regressions: `npx vitest run tests/unit/final-documents.test.ts`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): filter judge review by reviewVisibleRequirementIds (finale uploads always shown)"
```
---
### Task 3: Picker options helper `listReviewVisibilityOptions`
**Files:**
- Modify: `src/server/services/final-documents.ts` (new exported function + type, after `listFinalistDocumentsForReview`)
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
- [ ] **Step 1: Write the failing tests**
Add to `tests/unit/final-documents-curation.test.ts` (import `listReviewVisibilityOptions` from the same service module):
```ts
describe('listReviewVisibilityOptions', () => {
it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => {
const { program, reqBP, reqDeck } = await setupCuration()
const options = await listReviewVisibilityOptions(prisma, program.id)
expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort())
const bp = options.find((o) => o.requirementId === reqBP.id)!
expect(bp.name).toBe('Business Plan')
expect(bp.fileCount).toBe(1)
expect(bp.roundName).toBeTruthy()
})
it('returns [] when there is no open finale round', async () => {
const program = await createTestProgram()
programIds.push(program.id)
expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([])
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: FAIL — `listReviewVisibilityOptions` is not exported.
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, after `listFinalistDocumentsForReview`:
```ts
export type ReviewDocSlot = {
requirementId: string
name: string
roundName: string
roundSort: number
fileCount: number
}
/**
* Distinct prior-round document slots (FileRequirements) that the finalist
* teams have files for — the options offered in the admin "documents shown to
* judges" picker. Excludes the finale round's own slots (those uploads are
* always visible to judges) and files without a requirement.
*/
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return []
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
const files = await prisma.projectFile.findMany({
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
select: {
requirementId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
})
const slots = new Map<string, ReviewDocSlot>()
for (const f of files) {
if (!f.requirementId || !f.requirement) continue
const existing = slots.get(f.requirementId)
if (existing) existing.fileCount++
else slots.set(f.requirementId, {
requirementId: f.requirementId,
name: f.requirement.name.trim(),
roundName: f.requirement.round.name,
roundSort: f.requirement.round.sortOrder,
fileCount: 1,
})
}
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): listReviewVisibilityOptions — distinct prior-round doc slots for the curation picker"
```
---
### Task 4: tRPC procedures `getReviewDocSettings` / `setReviewVisibleRequirements`
**Files:**
- Modify: `src/server/routers/finalist.ts` (add two procedures next to `getRevisedUploadSetting`/`setRevisedUploadSetting`, ~line 1695; extend the existing `@/server/services/final-documents` import with `listReviewVisibilityOptions` and `reviewVisibleRequirementIds`)
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
- [ ] **Step 1: Write the failing tests**
Add to `tests/unit/final-documents-curation.test.ts`:
```ts
import * as finalistRouter from '@/server/routers/finalist'
import { createCaller } from '../setup'
import { createTestUser } from '../helpers' // merge into the existing helpers import
describe('finalist review-doc settings procedures', () => {
const userIds: string[] = []
afterAll(async () => {
// cleanupTestData of programIds already runs in the file-level afterAll;
// pass userIds through an extra cleanup for the admin users:
for (const id of programIds) await cleanupTestData(id, userIds)
})
it('round-trips a selection and preserves sibling configJson keys', async () => {
const { program, finale, reqBP } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const caller = createCaller(finalistRouter.finalistRouter, admin)
const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(initial.selectedIds).toBeNull()
expect(initial.options.length).toBe(2)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] })
const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(curated.selectedIds).toEqual([reqBP.id])
// sibling key from setupCuration must survive
const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } })
expect((round.configJson as Record<string, unknown>).allowFinalistRevisedUploads).toBe(true)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null })
const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(cleared.selectedIds).toBeNull()
})
it('rejects a non-LIVE_FINAL round', async () => {
const { program, priorRound } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const caller = createCaller(finalistRouter.finalistRouter, admin)
await expect(
caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }),
).rejects.toThrow()
void program
})
})
```
(Adjust the top-of-file `../helpers` import to include `createTestUser` rather than re-importing.)
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: FAIL — `getReviewDocSettings` does not exist on the router.
- [ ] **Step 3: Implement**
In `src/server/routers/finalist.ts`, extend the existing service import with `listReviewVisibilityOptions, reviewVisibleRequirementIds`, then add after `setRevisedUploadSetting`:
```ts
/** Options + current selection for the "documents shown to judges" picker. */
getReviewDocSettings: adminProcedure
.input(z.object({ programId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
return {
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
}
}),
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
setReviewVisibleRequirements: adminProcedure
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
if (!round || round.roundType !== 'LIVE_FINAL') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
}
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_REVIEW_DOCS_CURATED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { requirementIds: input.requirementIds },
})
return { ok: true }
}),
```
Note: `data: { configJson: next }` may need `next as Prisma.InputJsonValue` depending on inference — match how the file already imports/uses Prisma types if the typechecker complains.
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all PASS. Then `npm run typecheck` — clean.
- [ ] **Step 5: Commit**
```bash
git add src/server/routers/finalist.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): admin procedures to read/set judge-visible document curation"
```
---
### Task 5: Optional-mode rendering — banner + panel
No component-test infrastructure exists in this repo (vitest covers server code only) — verify via typecheck + the manual smoke in Task 7.
**Files:**
- Modify: `src/components/applicant/final-documents-banner.tsx`
- Modify: `src/components/applicant/final-documents-panel.tsx`
- [ ] **Step 1: Update the banner**
In `final-documents-banner.tsx`:
1. Guard (line 11): `if (!status || status.requirements.length === 0) return null`
2. Replace the `done` computation (line 18) and add the mode flag:
```ts
const optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
```
3. Replace the title span (lines 26-28):
```tsx
<span className="font-semibold">
{done
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span>
```
Everything else (styling, checklist, deadline, button gated on `!done`) stays as is.
- [ ] **Step 2: Update the panel**
In `final-documents-panel.tsx`:
1. Guard (line 21): `if (!status || status.requirements.length === 0) return null`
2. Add after the guard: `const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded`
3. Replace both `status.allRequiredUploaded` usages (badge at line 29, team upload-button gate at line 51) with `done`.
4. Badge label: `{status.hasRequired ? 'Submitted' : 'Uploaded'}`
5. Description (line 38) — append the optional hint:
```tsx
<CardDescription>
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
{!status.hasRequired && ' These uploads are optional.'}
</CardDescription>
```
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: clean. (The tRPC client types pick up `hasRequired`/`allUploaded` from Task 1 automatically.)
- [ ] **Step 4: Commit**
```bash
git add src/components/applicant/final-documents-banner.tsx src/components/applicant/final-documents-panel.tsx
git commit -m "feat(final-docs): optional-mode rendering for finalist banner + panel"
```
---
### Task 6: Admin "Documents shown to judges" card
**Files:**
- Create: `src/components/admin/grand-finale/review-docs-picker.tsx`
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (import near line 100; render near line 1535)
- [ ] **Step 1: Create the picker component**
`src/components/admin/grand-finale/review-docs-picker.tsx` (full file):
```tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Eye } from 'lucide-react'
/**
* Admin picker: which previously-submitted documents finale judges see on the
* review page. Default (switch off) shows everything; switching to curated
* mode starts with all slots ticked, and the admin unticks what to hide.
* Grand Final round uploads are always visible regardless.
*/
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
onError: (e) => toast.error(e.message),
})
if (!data || data.options.length === 0) return null
const curated = data.selectedIds !== null
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
const toggleSlot = (id: string, on: boolean) => {
const next = new Set(selected)
if (on) next.add(id)
else next.delete(id)
set.mutate({ roundId, requirementIds: [...next] })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Wire into the round admin page**
In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`:
Import (next to the other grand-finale imports, ~line 100):
```tsx
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
```
Render inside the existing `isGrandFinale && programId` block, directly after the flex row containing `<FinalDocsUploadsToggle …>` (the `</div>` around line 1545):
```tsx
<ReviewDocsPicker programId={programId} roundId={roundId} />
```
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/grand-finale/review-docs-picker.tsx "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
git commit -m "feat(final-docs): admin card to curate documents shown to finale judges"
```
---
### Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Full test suite**
Run: `npx vitest run`
Expected: all tests pass (was 321 before this work; now more).
- [ ] **Step 2: Lint + typecheck + build**
Run: `npm run lint && npm run typecheck && npm run build`
Expected: all clean. (CLAUDE.md: always build before push.)
- [ ] **Step 3: Manual smoke (dev server + Playwright or browser)**
1. As admin, open the Grand Final round page → the "Documents shown to judges" card lists the prior-round slots with counts; flip to curated, untick one slot.
2. Open `/admin/finals-documents` → the unticked document type disappears from every team; any Grand Final uploads remain.
3. Flip the curation switch off → all documents reappear.
4. With `allowFinalistRevisedUploads` ON and all finale slots optional (set in dev data), check the applicant dashboard banner shows "Upload updated Grand Final documents (optional)" and turns green only when every slot is filled.
- [ ] **Step 4: Commit anything outstanding**
```bash
git status --short # should be clean except untracked screenshots/docs
```
---
## Self-Review (completed at planning time)
- **Spec coverage:** configJson key + semantics → Tasks 2/4; always-visible finale uploads → Task 2; requirement-less files excluded under selection → Task 2; picker options with counts → Task 3; admin UI next to toggle → Task 6; `hasRequired`/`allUploaded` + zero-slot edge → Tasks 1/5; sibling-key preservation + audit → Task 4; reminders unchanged → verified in spec, no task needed.
- **Placeholder scan:** none.
- **Type consistency:** `ReviewDocSlot`, `reviewVisibleRequirementIds(configJson)`, `getReviewDocSettings`/`setReviewVisibleRequirements`, `hasRequired`/`allUploaded` used consistently across tasks.

View File

@@ -0,0 +1,93 @@
# Grand Final: judge-visible document curation + optional revised uploads
**Date:** 2026-06-09
**Status:** Approved (Matt, this session)
**Builds on:** `2026-06-09-grand-final-documents-design.md` and the same-day pivot (commits `f8f2d77`, `8a4184d`)
## Problem
Feedback from the other program admin:
> Jury actually need to see BP + Exec summary + 1min video — the ones they uploaded already. And candidates should be able to upload their PDF pres + video — optional, as some sent it another way.
Two gaps against what is deployed:
1. **Judges see too much.** `listFinalistDocumentsForReview` returns *every* file each finalist team ever submitted (57 per team on prod: Pitch Deck, Intro Video, Executive Summary, Business Plan, Promotional Video, plus any Grand Final uploads). The admin wants judges to see a curated subset (BP + exec summary + 1-min video). There is no way to choose which prior documents are surfaced.
2. **"Optional uploads" mode renders wrong.** The three modes the admin wants are: no new uploads (toggle OFF — works), mandatory uploads (toggle ON + slots required — works), and optional uploads (toggle ON + slots marked not-required). In the all-optional case, `FinalDocumentStatus.allRequiredUploaded` is hardcoded `false` when zero slots are required, so the finalist banner/panel never reach a settled state and the copy implies the docs are mandatory.
Prod facts (verified 2026-06-09 via read-only query): 9 finalist teams, 48 prior files + 3 Grand Final uploads. Every team has all 5 prior doc types. The Business Plan and the 1-minute promo video live under *two different* `FileRequirement` rows depending on the team's path (Semi-Finals Document Submission for 8 teams, Spotlight on Africa Submission Round for 1 team — Blue Fields Company).
## Part 1 — Admin curation of judge-visible documents
### Storage
New optional key on the LIVE_FINAL round's `configJson`:
```ts
reviewVisibleRequirementIds?: string[] // FileRequirement ids from prior rounds
```
Semantics:
- **absent / null** → show all prior files (current behavior; safe default, no migration needed)
- **non-empty array** → show only prior files whose `requirementId` is in the list
- **empty array** → hide all prior files (only Grand Final uploads remain visible)
- **Grand Final round uploads are always shown**, regardless of the selection — they are what the team explicitly submitted for the finale
- Prior files with no `requirementId` (fileType-only) are excluded whenever a selection is active. (All 48 prod files have a requirement, so nothing is lost in practice.)
### Service (`src/server/services/final-documents.ts`)
`listFinalistDocumentsForReview` adds `requirementId` to its file select and applies the filter above using the finale round's `configJson`. No signature change; `ReviewPayload` unchanged.
New helper to power the admin picker: list the distinct prior-round requirement slots referenced by the finalist teams' files — `{ requirementId, name, roundName, fileCount }`, ordered by round sort then name. Derived from the same file query, so the picker only offers slots that actually have files.
### tRPC (`src/server/routers/finalist.ts`, adminProcedure)
- `getReviewDocSettings``{ options: Slot[], selectedIds: string[] | null }` (null = "all" mode)
- `setReviewVisibleRequirements({ requirementIds: string[] | null })` → writes/clears the configJson key (null clears back to "show all"). Audited like `setRevisedUploadSetting`.
### Admin UI
New card "Documents shown to judges" placed next to the existing revised-uploads toggle (`src/components/admin/grand-finale/final-docs-uploads-toggle.tsx`, rendered on the LIVE_FINAL round admin page `src/app/(admin)/admin/rounds/[roundId]/page.tsx`):
- A "Show all submitted documents" master state (the default), and beneath it a checkbox per slot labeled `"<requirement name> — <round name>"` with the file count (e.g. "Business Plan — Semi-Finals Document Submission (8 files)").
- Unchecking the master switches to curated mode with all boxes ticked; the admin then unticks what judges shouldn't see. Re-checking the master clears the selection (back to null/"all").
- Copy notes that Grand Final uploads are always visible to judges.
For the admin's stated goal, they'd switch to curated mode and leave 5 boxes ticked: Executive Summary (Intake), Business Plan (Semi-Finals + Spotlight), Promotional Video (Semi-Finals) and 1 Minute Promotional Video (Spotlight) — judges then see exactly BP + exec summary + 1-min video per team, plus any finale uploads.
## Part 2 — All-optional upload mode fix
`FinalDocumentStatus` (in `final-documents.ts`) gains:
```ts
hasRequired: boolean // any slot with isRequired
allUploaded: boolean // requirements.length > 0 && every slot has a file, required or not
```
`allRequiredUploaded` keeps its current semantics (meaningful only when `hasRequired`). Edge case: if the toggle is ON but no slots are defined at all (`requirements.length === 0`), the banner and panel render nothing — no vacuous "(0 of 0)" complete state.
UI changes:
- **Banner** (`src/components/applicant/final-documents-banner.tsx`): when `hasRequired` is false — title "Upload updated Grand Final documents (optional)", same neutral blue styling, keep per-doc checklist/count/deadline/upload button; green settled state ("Grand Final documents uploaded") only when `allUploaded`.
- **Panel** (`src/components/applicant/final-documents-panel.tsx`, team + mentor variants): "Submitted" badge driven by `hasRequired ? allRequiredUploaded : allUploaded`; description gains "(optional)" when nothing is required.
Reminders need **no change** — verified: the cron and the untargeted manual blast already skip teams with no missing *required* docs, so all-optional mode never nags; an explicitly targeted manual reminder still sends (intentional admin override).
## Out of scope (admin actions in existing UI, not code)
- Flipping `allowFinalistRevisedUploads` ON
- Creating/adjusting the finale upload slots (PDF presentation + 1-min video, "Required" off) in the round's file-requirements editor
- Ticking the curation checkboxes
- Populating the Finals Jury group (still open from the previous ship)
## Testing
Vitest service tests (extend `tests/` final-documents coverage):
- Curation: null selection → all files; selection → only matching prior files + finale uploads always; empty array → finale uploads only; file without requirementId excluded under a selection.
- Picker helper returns distinct slots with correct counts.
- `setReviewVisibleRequirements` round-trips null/array through configJson without clobbering other keys (`allowFinalistRevisedUploads`).
- Status: `hasRequired`/`allUploaded` across mixed, all-optional (0 required), and fully-uploaded fixtures.
## Risks
- **configJson clobbering:** both toggles write the same JSON column — read-modify-write must preserve sibling keys (existing `setRevisedUploadSetting` pattern already does this; reuse it).
- **Stale selection:** if a selected requirement is later deleted, its files simply stop matching; "all" fallback never breaks. No cleanup needed.

View File

@@ -28,7 +28,9 @@ import {
Waves,
Send,
Trophy,
FileText,
} from 'lucide-react'
import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents'
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -42,6 +44,70 @@ function getGreeting(): string {
return 'Good evening'
}
/**
* Prominent entry point to the finalist documents review, shown only to
* Grand-Final jury members (and admins). Rendered at the top of the dashboard
* regardless of whether the juror has individual assignments, so finals jurors
* can always find the teams' files in one obvious place.
*/
async function FinalsJuryBanner() {
const session = await auth()
const userId = session?.user?.id
if (!userId) return null
const program = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
if (!program) return null
const canReview = await userCanReviewFinals(prisma, userId, session.user.role, program.id)
if (!canReview) return null
const round = await getOpenFinaleRound(prisma, program.id)
const teamCount = round
? await prisma.projectRoundState.count({ where: { roundId: round.id } })
: 0
return (
<AnimatedCard index={0}>
<Card className="overflow-hidden border-0 shadow-lg">
<div className="rounded-lg bg-gradient-to-r from-brand-blue to-brand-teal p-[1px]">
<CardContent className="flex flex-col gap-4 rounded-[7px] bg-background p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className="shrink-0 rounded-xl bg-gradient-to-br from-brand-blue to-brand-teal p-3 shadow-sm">
<Trophy className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-teal">
Grand Final
</p>
<h2 className="text-lg font-bold text-brand-blue">Finalist Documents</h2>
<p className="mt-0.5 max-w-md text-sm text-muted-foreground">
{teamCount > 0 ? `All ${teamCount} finalist teams ` : 'Every finalist teams '}
pitch decks, business plans, executive summaries and videos in one place.
</p>
</div>
</div>
<Button
asChild
size="lg"
className="w-full shrink-0 bg-brand-blue shadow-md hover:bg-brand-blue-light sm:w-auto"
>
<Link href="/jury/finals-documents">
<FileText className="mr-2 h-4 w-4" />
Review Finalist Documents
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardContent>
</div>
</Card>
</AnimatedCard>
)
}
async function JuryDashboardContent() {
const session = await auth()
const userId = session?.user?.id
@@ -863,6 +929,11 @@ export default async function JuryDashboardPage() {
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
<JuryPreferencesBanner />
{/* Grand-Final finalist documents — prominent entry for finals jurors */}
<Suspense fallback={null}>
<FinalsJuryBanner />
</Suspense>
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent />

View File

@@ -47,6 +47,9 @@ export function JuryNav({ user }: JuryNavProps) {
{ refetchInterval: 60000 }
)
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
// Only Grand-Final jury members (and admins) can open the finalist documents
// review — hide the link from everyone else so they don't hit a dead "No access" page.
const { data: canReviewFinals } = trpc.finalist.canReviewDocuments.useQuery()
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
@@ -61,11 +64,15 @@ export function JuryNav({ user }: JuryNavProps) {
href: '/jury/competitions',
icon: ClipboardList,
},
...(canReviewFinals
? [
{
name: 'Finalist Documents',
href: '/jury/finals-documents',
icon: FileText,
},
]
: []),
...(myAwards && myAwards.length > 0
? [
{

View File

@@ -1683,6 +1683,17 @@ export const finalistRouter = router({
}),
/** Read-only review of all finalists' grand-final documents (admins + finale jury). */
/** Lightweight boolean — may the current user open the finalist documents review? Self-resolves the active program so the nav can gate the link without fetching the full payload. */
canReviewDocuments: protectedProcedure.query(async ({ ctx }) => {
const program = await ctx.prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
if (!program) return false
return userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, program.id)
}),
listReviewDocuments: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {