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,
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)
}),
})

View File

@@ -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
}