feat(final-docs): finalist document review service + procedure with finale-access gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-09 15:36:59 +02:00
parent b0a0a71cfe
commit e9e072dda7
3 changed files with 128 additions and 1 deletions

View File

@@ -19,7 +19,7 @@ import {
resetOrCreatePendingConfirmation, resetOrCreatePendingConfirmation,
confirmAttendanceInTx, confirmAttendanceInTx,
} from '../services/finalist-enrollment' } from '../services/finalist-enrollment'
import { sendManualFinalDocReminders } from '../services/final-documents' import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents'
export const finalistRouter = router({ export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */ /** List all per-category finalist slot quotas for a program. */
@@ -1681,4 +1681,13 @@ export const finalistRouter = router({
}) })
return result return result
}), }),
/** Read-only review of all finalists' grand-final documents (admins + finale jury). */
listReviewDocuments: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const allowed = await userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, input.programId)
if (!allowed) throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to the finalist documents review.' })
return listFinalistDocumentsForReview(ctx.prisma, input.programId)
}),
}) })

View File

@@ -1,5 +1,6 @@
import type { PrismaClient } from '@prisma/client' import type { PrismaClient } from '@prisma/client'
import { createNotification, NotificationTypes } from './in-app-notification' import { createNotification, NotificationTypes } from './in-app-notification'
import { getPresignedUrl } from '@/lib/minio'
export type FinalDocRequirement = { export type FinalDocRequirement = {
id: string id: string
@@ -151,3 +152,74 @@ export async function sendManualFinalDocReminders(
} }
return { sent } return { sent }
} }
export type ReviewDocument = { requirementId: string; requirementName: string; file: { id: string; fileName: string; mimeType: string; url: string } | null }
export type ReviewTeam = { projectId: string; teamName: string; category: string | null; documents: ReviewDocument[]; submitted: boolean }
export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; submittedCount: number; teams: ReviewTeam[] }
/**
* Read-only review payload of every finalist team enrolled in the program's
* active LIVE_FINAL round, with their uploaded grand-final documents. Each
* present file carries a server-generated GET presigned URL (1h) so finale
* judges — who are not assignment-gated through file.getDownloadUrl — can open
* the documents directly in the browser.
*/
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
const round = await getActiveFinaleRound(prisma, programId)
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, submittedCount: 0, teams: [] }
const requirements = await prisma.fileRequirement.findMany({ where: { roundId: round.id }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true } })
const states = await prisma.projectRoundState.findMany({
where: { roundId: round.id },
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
})
const teams: ReviewTeam[] = []
for (const { project } of states) {
const files = await prisma.projectFile.findMany({
where: { projectId: project.id, roundId: round.id, requirementId: { in: requirements.map((r) => r.id) } },
orderBy: { createdAt: 'desc' },
select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true },
})
const byReq = new Map<string, (typeof files)[number]>()
for (const f of files) if (f.requirementId && !byReq.has(f.requirementId)) byReq.set(f.requirementId, f)
const documents: ReviewDocument[] = []
for (const r of requirements) {
const f = byReq.get(r.id)
documents.push({
requirementId: r.id,
requirementName: r.name,
file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600) } : null,
})
}
teams.push({
projectId: project.id,
teamName: project.teamName || project.title,
category: project.competitionCategory,
documents,
submitted: documents.every((d) => d.file !== null),
})
}
teams.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.teamName.localeCompare(b.teamName))
return {
round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null },
totalCount: teams.length,
submittedCount: teams.filter((t) => t.submitted).length,
teams,
}
}
/** True if user is admin or a member of the program's active LIVE_FINAL jury group. */
export async function userCanReviewFinals(prisma: PrismaClient, userId: string, userRole: string, programId: string): Promise<boolean> {
if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true
const round = await prisma.round.findFirst({
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' },
orderBy: { sortOrder: 'desc' },
select: { juryGroupId: true },
})
if (!round?.juryGroupId) return false
const member = await prisma.juryGroupMember.findFirst({ where: { juryGroupId: round.juryGroupId, userId }, select: { id: true } })
return !!member
}

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, afterAll } from 'vitest'
import { TRPCError } from '@trpc/server'
import { prisma } from '../setup' import { prisma } from '../setup'
import { import {
createTestProgram, createTestProgram,
@@ -15,6 +16,7 @@ import {
sendManualFinalDocReminders, sendManualFinalDocReminders,
} from '@/server/services/final-documents' } from '@/server/services/final-documents'
import * as applicantRouter from '@/server/routers/applicant' import * as applicantRouter from '@/server/routers/applicant'
import * as finalistRouter from '@/server/routers/finalist'
import { createCaller } from '../setup' import { createCaller } from '../setup'
const programIds: string[] = [] const programIds: string[] = []
@@ -163,3 +165,47 @@ describe('sendManualFinalDocReminders', () => {
expect(notif).not.toBeNull() expect(notif).not.toBeNull()
}) })
}) })
describe('finalist.listReviewDocuments', () => {
const localPrograms: string[] = []
const localUsers: string[] = []
afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) })
async function setup() {
const program = await createTestProgram()
localPrograms.push(program.id)
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
const jg = await prisma.juryGroup.create({ data: { id: uid('jg'), competitionId: comp.id, name: 'Finals Jury', slug: uid('jg') } })
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000) })
await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: jg.id } })
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
await createTestProjectRoundState(project.id, round.id)
return { program, comp, jg, round, project }
}
it('admin sees all finalist teams', async () => {
const { program } = await setup()
const admin = await createTestUser('PROGRAM_ADMIN'); localUsers.push(admin.id)
const caller = createCaller(finalistRouter.finalistRouter, admin)
const result = await caller.listReviewDocuments({ programId: program.id })
expect(result.teams).toHaveLength(1)
expect(result.totalCount).toBe(1)
})
it('a finals jury-group member is allowed', async () => {
const { program, jg } = await setup()
const juror = await createTestUser('JURY_MEMBER'); localUsers.push(juror.id)
await prisma.juryGroupMember.create({ data: { juryGroupId: jg.id, userId: juror.id, role: 'MEMBER' } })
const caller = createCaller(finalistRouter.finalistRouter, juror)
const result = await caller.listReviewDocuments({ programId: program.id })
expect(result.teams).toHaveLength(1)
})
it('a non-finals jury member is forbidden', async () => {
const { program } = await setup()
const juror = await createTestUser('JURY_MEMBER'); localUsers.push(juror.id)
const caller = createCaller(finalistRouter.finalistRouter, juror)
await expect(caller.listReviewDocuments({ programId: program.id })).rejects.toThrow(TRPCError)
})
})