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,
|
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)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user