746 lines
31 KiB
Markdown
746 lines
31 KiB
Markdown
|
|
# 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.
|