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:
@@ -19,7 +19,7 @@ import {
|
||||
resetOrCreatePendingConfirmation,
|
||||
confirmAttendanceInTx,
|
||||
} from '../services/finalist-enrollment'
|
||||
import { sendManualFinalDocReminders } from '../services/final-documents'
|
||||
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents'
|
||||
|
||||
export const finalistRouter = router({
|
||||
/** List all per-category finalist slot quotas for a program. */
|
||||
@@ -1681,4 +1681,13 @@ export const finalistRouter = router({
|
||||
})
|
||||
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)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { createNotification, NotificationTypes } from './in-app-notification'
|
||||
import { getPresignedUrl } from '@/lib/minio'
|
||||
|
||||
export type FinalDocRequirement = {
|
||||
id: string
|
||||
@@ -151,3 +152,74 @@ export async function sendManualFinalDocReminders(
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestProgram,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
sendManualFinalDocReminders,
|
||||
} from '@/server/services/final-documents'
|
||||
import * as applicantRouter from '@/server/routers/applicant'
|
||||
import * as finalistRouter from '@/server/routers/finalist'
|
||||
import { createCaller } from '../setup'
|
||||
|
||||
const programIds: string[] = []
|
||||
@@ -163,3 +165,47 @@ describe('sendManualFinalDocReminders', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user