diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index ff92d71..1c6cec8 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -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) + }), }) diff --git a/src/server/services/final-documents.ts b/src/server/services/final-documents.ts index a60a9b2..49c381a 100644 --- a/src/server/services/final-documents.ts +++ b/src/server/services/final-documents.ts @@ -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 { + 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() + 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 { + 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 +} diff --git a/tests/unit/final-documents.test.ts b/tests/unit/final-documents.test.ts index f8ba5f7..acffe82 100644 --- a/tests/unit/final-documents.test.ts +++ b/tests/unit/final-documents.test.ts @@ -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) + }) +})