feat(final-docs): judges see all teams' prior-round files; revised uploads behind admin toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m47s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m47s
The finals jury needs the teams' EXISTING submissions (pitch deck, exec summary, business plan, videos from prior rounds) — which all 9 teams already have. So: - listFinalistDocumentsForReview now returns ALL of each finalist team's files across every round (labeled by doc type + round; finale uploads flagged 'Revised for finals'), with presigned URLs. NOT gated — judges always see. - Revised re-uploads are now an admin toggle (Round.configJson.allowFinalistRevisedUploads, default OFF): gates the banner/panel (getFinalDocumentStatusForProject), the upload guard (getUploadUrl/deleteFile), the documents-page round, and the reminders (manual + cron). When off, teams aren't prompted/able to upload. - finalist.get/setRevisedUploadSetting + a Switch on the admin finale overview. - judge review component rewritten to a per-team labeled file list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,7 @@ import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slot
|
|||||||
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
||||||
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
|
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
|
||||||
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
|
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
|
||||||
|
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
|
||||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
@@ -1530,7 +1531,9 @@ export default function RoundDetailPage() {
|
|||||||
{isGrandFinale && programId && (
|
{isGrandFinale && programId && (
|
||||||
<>
|
<>
|
||||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<FinalDocsUploadsToggle roundId={roundId} />
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/admin/finals-documents">
|
<Link href="/admin/finals-documents">
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
@@ -1539,6 +1542,7 @@ export default function RoundDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<FinalDocsReminderButton programId={programId} />
|
<FinalDocsReminderButton programId={programId} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FinalistSlotsCard programId={programId} />
|
<FinalistSlotsCard programId={programId} />
|
||||||
<WaitlistCard programId={programId} />
|
<WaitlistCard programId={programId} />
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin toggle: whether finalist teams may upload *revised* grand-final documents.
|
||||||
|
* Off by default — judges always see the teams' existing prior-round submissions
|
||||||
|
* regardless; this only controls whether teams are prompted/allowed to upload new
|
||||||
|
* revised versions (and whether the upload reminder cron runs).
|
||||||
|
*/
|
||||||
|
export function FinalDocsUploadsToggle({ roundId }: { roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data } = trpc.finalist.getRevisedUploadSetting.useQuery({ roundId })
|
||||||
|
const set = trpc.finalist.setRevisedUploadSetting.useMutation({
|
||||||
|
onSuccess: (r) => {
|
||||||
|
toast.success(r.enabled ? 'Finalist revised uploads enabled' : 'Finalist revised uploads disabled')
|
||||||
|
utils.finalist.getRevisedUploadSetting.invalidate({ roundId })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="finalist-revised-uploads"
|
||||||
|
checked={!!data?.enabled}
|
||||||
|
disabled={set.isPending}
|
||||||
|
onCheckedChange={(v) => set.mutate({ roundId, enabled: v })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="finalist-revised-uploads" className="text-sm text-muted-foreground cursor-pointer">
|
||||||
|
Allow finalists to upload revised documents
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -60,10 +60,6 @@ export function FinalsDocumentsReview() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: 'long',
|
|
||||||
timeStyle: 'short',
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -71,10 +67,8 @@ export function FinalsDocumentsReview() {
|
|||||||
Finalist Documents
|
Finalist Documents
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{data.submittedCount} of {data.totalCount} teams complete
|
{data.totalCount} finalist team{data.totalCount === 1 ? '' : 's'} · every file each team
|
||||||
{data.round.deadline
|
has submitted across all rounds
|
||||||
? ` · due ${fmt.format(new Date(data.round.deadline))}`
|
|
||||||
: ''}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{data.teams.map((team) => (
|
{data.teams.map((team) => (
|
||||||
@@ -82,61 +76,45 @@ export function FinalsDocumentsReview() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-lg">{team.teamName}</CardTitle>
|
<CardTitle className="text-lg">{team.teamName}</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{team.category && (
|
{team.category && <Badge variant="secondary">{team.category}</Badge>}
|
||||||
<Badge variant="secondary">{team.category}</Badge>
|
<Badge variant="outline">
|
||||||
)}
|
{team.files.length} file{team.files.length === 1 ? '' : 's'}
|
||||||
<Badge
|
|
||||||
variant={team.submitted ? 'default' : 'outline'}
|
|
||||||
className={
|
|
||||||
team.submitted
|
|
||||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{team.submitted ? 'Complete' : 'Incomplete'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
{team.documents.map((doc) => (
|
{team.files.length === 0 && (
|
||||||
<div
|
<p className="text-sm text-muted-foreground py-4 text-center md:col-span-2">
|
||||||
key={doc.requirementId}
|
No files submitted.
|
||||||
className="rounded-lg border p-3 space-y-2"
|
</p>
|
||||||
>
|
)}
|
||||||
<div className="flex items-center justify-between">
|
{team.files.map((f) => (
|
||||||
<span className="font-medium text-sm flex items-center gap-2">
|
<div key={f.id} className="rounded-lg border p-3 space-y-2">
|
||||||
<FileText className="h-4 w-4" /> {doc.requirementName}
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm flex items-center gap-2 min-w-0">
|
||||||
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{f.docLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
{doc.file && (
|
<Button asChild variant="ghost" size="sm" className="h-7 px-2 text-xs shrink-0">
|
||||||
<Button
|
<a href={f.url} target="_blank" rel="noreferrer">
|
||||||
asChild
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={doc.file.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<Download className="h-3 w-3 mr-1" /> Open
|
<Download className="h-3 w-3 mr-1" /> Open
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{f.roundLabel}
|
||||||
|
</Badge>
|
||||||
|
{f.isFinaleUpload && (
|
||||||
|
<Badge className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
|
||||||
|
Revised for finals
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{doc.file ? (
|
|
||||||
<FilePreview
|
<FilePreview
|
||||||
file={{
|
file={{ mimeType: f.mimeType, fileName: f.fileName }}
|
||||||
mimeType: doc.file.mimeType,
|
url={f.url}
|
||||||
fileName: doc.file.fileName,
|
|
||||||
}}
|
|
||||||
url={doc.file.url}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
|
||||||
Not yet uploaded
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/em
|
|||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { createNotification, notifyProjectMentors, NotificationTypes } from '../services/in-app-notification'
|
import { createNotification, notifyProjectMentors, NotificationTypes } from '../services/in-app-notification'
|
||||||
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
||||||
import { getFinalDocumentStatusForProject } from '../services/final-documents'
|
import { getFinalDocumentStatusForProject, finalistUploadsEnabled } from '../services/final-documents'
|
||||||
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
||||||
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
||||||
|
|
||||||
@@ -337,12 +337,15 @@ export const applicantRouter = router({
|
|||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
const round = await ctx.prisma.round.findUnique({
|
const round = await ctx.prisma.round.findUnique({
|
||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
select: { name: true, status: true, roundType: true, finalizedAt: true },
|
select: { name: true, status: true, roundType: true, finalizedAt: true, configJson: true },
|
||||||
})
|
})
|
||||||
if (round) {
|
if (round) {
|
||||||
const uploadable =
|
const uploadable =
|
||||||
round.status === 'ROUND_ACTIVE' ||
|
round.roundType === 'LIVE_FINAL'
|
||||||
(round.roundType === 'LIVE_FINAL' && round.status === 'ROUND_DRAFT' && !round.finalizedAt)
|
? !round.finalizedAt &&
|
||||||
|
(round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
|
||||||
|
finalistUploadsEnabled(round.configJson)
|
||||||
|
: round.status === 'ROUND_ACTIVE'
|
||||||
if (!uploadable) {
|
if (!uploadable) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -568,12 +571,15 @@ export const applicantRouter = router({
|
|||||||
if (file.roundId) {
|
if (file.roundId) {
|
||||||
const round = await ctx.prisma.round.findUnique({
|
const round = await ctx.prisma.round.findUnique({
|
||||||
where: { id: file.roundId },
|
where: { id: file.roundId },
|
||||||
select: { status: true, roundType: true, finalizedAt: true },
|
select: { status: true, roundType: true, finalizedAt: true, configJson: true },
|
||||||
})
|
})
|
||||||
if (round) {
|
if (round) {
|
||||||
const modifiable =
|
const modifiable =
|
||||||
round.status === 'ROUND_ACTIVE' ||
|
round.roundType === 'LIVE_FINAL'
|
||||||
(round.roundType === 'LIVE_FINAL' && round.status === 'ROUND_DRAFT' && !round.finalizedAt)
|
? !round.finalizedAt &&
|
||||||
|
(round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
|
||||||
|
finalistUploadsEnabled(round.configJson)
|
||||||
|
: round.status === 'ROUND_ACTIVE'
|
||||||
if (!modifiable) {
|
if (!modifiable) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -1472,6 +1478,7 @@ export const applicantRouter = router({
|
|||||||
slug: true,
|
slug: true,
|
||||||
roundType: true,
|
roundType: true,
|
||||||
windowCloseAt: true,
|
windowCloseAt: true,
|
||||||
|
configJson: true,
|
||||||
specialAwardId: true,
|
specialAwardId: true,
|
||||||
specialAward: { select: { name: true } },
|
specialAward: { select: { name: true } },
|
||||||
},
|
},
|
||||||
@@ -1490,8 +1497,9 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
openRounds = allActiveRounds
|
openRounds = allActiveRounds
|
||||||
.filter((r) => {
|
.filter((r) => {
|
||||||
// LIVE_FINAL (grand-final documents) only shows to enrolled finalists.
|
// LIVE_FINAL (grand-final documents) only shows to enrolled finalists,
|
||||||
if (r.roundType === 'LIVE_FINAL' && !projectRoundIds.has(r.id)) return false
|
// and only when the admin has enabled revised uploads.
|
||||||
|
if (r.roundType === 'LIVE_FINAL' && (!projectRoundIds.has(r.id) || !finalistUploadsEnabled(r.configJson))) return false
|
||||||
// Award round project isn't in → hide
|
// Award round project isn't in → hide
|
||||||
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
|
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
|
||||||
// Main round when project is in award track and has no state in this round → hide
|
// Main round when project is in award track and has no state in this round → hide
|
||||||
|
|||||||
@@ -1690,4 +1690,37 @@ export const finalistRouter = router({
|
|||||||
if (!allowed) throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to the finalist documents review.' })
|
if (!allowed) throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to the finalist documents review.' })
|
||||||
return listFinalistDocumentsForReview(ctx.prisma, input.programId)
|
return listFinalistDocumentsForReview(ctx.prisma, input.programId)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Read whether finalists may upload revised grand-final documents (admin toggle). */
|
||||||
|
getRevisedUploadSetting: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
|
||||||
|
const cfg = (round?.configJson ?? {}) as { allowFinalistRevisedUploads?: boolean }
|
||||||
|
return { enabled: !!cfg.allowFinalistRevisedUploads }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Toggle whether finalists may upload revised grand-final documents (admin setting on the LIVE_FINAL round). */
|
||||||
|
setRevisedUploadSetting: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), enabled: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
|
||||||
|
if (!round || round.roundType !== 'LIVE_FINAL') {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
|
||||||
|
}
|
||||||
|
const cfg = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
await ctx.prisma.round.update({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
data: { configJson: { ...cfg, allowFinalistRevisedUploads: input.enabled } },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'FINALIST_REVISED_UPLOADS_TOGGLED',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: { enabled: input.enabled },
|
||||||
|
})
|
||||||
|
return { ok: true, enabled: input.enabled }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,10 +31,20 @@ export async function getOpenFinaleRound(prisma: PrismaClient, programId: string
|
|||||||
return prisma.round.findFirst({
|
return prisma.round.findFirst({
|
||||||
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
||||||
orderBy: { sortOrder: 'desc' },
|
orderBy: { sortOrder: 'desc' },
|
||||||
select: { id: true, name: true, windowCloseAt: true },
|
select: { id: true, name: true, windowCloseAt: true, configJson: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether finalist teams are allowed to upload *revised* documents for the
|
||||||
|
* grand final. This is an admin toggle on the LIVE_FINAL round's configJson.
|
||||||
|
* When false (default), judges still see the teams' existing prior-round
|
||||||
|
* submissions, but teams are not prompted/able to upload anything new.
|
||||||
|
*/
|
||||||
|
export function finalistUploadsEnabled(configJson: unknown): boolean {
|
||||||
|
return !!(configJson as { allowFinalistRevisedUploads?: boolean } | null)?.allowFinalistRevisedUploads
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-project grand-final document status. Returns null unless the project is
|
* Per-project grand-final document status. Returns null unless the project is
|
||||||
* enrolled (ProjectRoundState) in the program's active LIVE_FINAL round.
|
* enrolled (ProjectRoundState) in the program's active LIVE_FINAL round.
|
||||||
@@ -50,7 +60,8 @@ export async function getFinalDocumentStatusForProject(
|
|||||||
if (!project) return null
|
if (!project) return null
|
||||||
|
|
||||||
const round = await getOpenFinaleRound(prisma, project.programId)
|
const round = await getOpenFinaleRound(prisma, project.programId)
|
||||||
if (!round) return null
|
// Banner / upload status only applies when the admin has enabled revised uploads.
|
||||||
|
if (!round || !finalistUploadsEnabled(round.configJson)) return null
|
||||||
|
|
||||||
const enrolled = await prisma.projectRoundState.findFirst({
|
const enrolled = await prisma.projectRoundState.findFirst({
|
||||||
where: { projectId, roundId: round.id },
|
where: { projectId, roundId: round.id },
|
||||||
@@ -125,7 +136,7 @@ export async function sendManualFinalDocReminders(
|
|||||||
opts: { programId: string; projectIds?: string[]; actorId: string },
|
opts: { programId: string; projectIds?: string[]; actorId: string },
|
||||||
): Promise<{ sent: number }> {
|
): Promise<{ sent: number }> {
|
||||||
const round = await getOpenFinaleRound(prisma, opts.programId)
|
const round = await getOpenFinaleRound(prisma, opts.programId)
|
||||||
if (!round) return { sent: 0 }
|
if (!round || !finalistUploadsEnabled(round.configJson)) return { sent: 0 }
|
||||||
|
|
||||||
const states = await prisma.projectRoundState.findMany({
|
const states = await prisma.projectRoundState.findMany({
|
||||||
where: { roundId: round.id, ...(opts.projectIds ? { projectId: { in: opts.projectIds } } : {}) },
|
where: { roundId: round.id, ...(opts.projectIds ? { projectId: { in: opts.projectIds } } : {}) },
|
||||||
@@ -170,6 +181,8 @@ export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{
|
|||||||
let remindersSent = 0
|
let remindersSent = 0
|
||||||
for (const round of rounds) {
|
for (const round of rounds) {
|
||||||
if (!round.windowCloseAt) continue
|
if (!round.windowCloseAt) continue
|
||||||
|
// Only chase teams to upload when the admin has enabled revised uploads.
|
||||||
|
if (!finalistUploadsEnabled(round.configJson)) continue
|
||||||
const cfg = (round.configJson ?? {}) as { finalDocsReminderHoursBeforeDeadline?: number }
|
const cfg = (round.configJson ?? {}) as { finalDocsReminderHoursBeforeDeadline?: number }
|
||||||
const windowMs = (cfg.finalDocsReminderHoursBeforeDeadline ?? 48) * 3_600_000
|
const windowMs = (cfg.finalDocsReminderHoursBeforeDeadline ?? 48) * 3_600_000
|
||||||
const isDue = round.windowCloseAt.getTime() <= now.getTime() + windowMs && round.windowCloseAt.getTime() > now.getTime()
|
const isDue = round.windowCloseAt.getTime() <= now.getTime() + windowMs && round.windowCloseAt.getTime() > now.getTime()
|
||||||
@@ -206,62 +219,99 @@ export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{
|
|||||||
return { remindersSent }
|
return { remindersSent }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReviewDocument = { requirementId: string; requirementName: string; file: { id: string; fileName: string; mimeType: string; url: string } | null }
|
export type ReviewFile = {
|
||||||
export type ReviewTeam = { projectId: string; teamName: string; category: string | null; documents: ReviewDocument[]; submitted: boolean }
|
id: string
|
||||||
export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; submittedCount: number; teams: ReviewTeam[] }
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
url: string
|
||||||
|
docLabel: string // requirement name if known, else a humanized file type
|
||||||
|
roundLabel: string // which round this was submitted in
|
||||||
|
roundSort: number
|
||||||
|
isFinaleUpload: boolean // uploaded directly to the LIVE_FINAL round (a revised "final")
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
export type ReviewTeam = { projectId: string; teamName: string; category: string | null; files: ReviewFile[] }
|
||||||
|
export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; teams: ReviewTeam[] }
|
||||||
|
|
||||||
|
function humanizeFileType(t: string | null | undefined): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'EXEC_SUMMARY': return 'Executive Summary'
|
||||||
|
case 'BUSINESS_PLAN': return 'Business Plan'
|
||||||
|
case 'PRESENTATION': return 'Presentation'
|
||||||
|
case 'VIDEO_PITCH': return 'Video Pitch'
|
||||||
|
case 'VIDEO': return 'Video'
|
||||||
|
case 'SUPPORTING_DOC': return 'Supporting Document'
|
||||||
|
default: return 'Document'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only review payload of every finalist team enrolled in the program's
|
* Read-only review payload for finale judges: every finalist team enrolled in
|
||||||
* active LIVE_FINAL round, with their uploaded grand-final documents. Each
|
* the program's LIVE_FINAL round, with ALL of their submitted files across every
|
||||||
* present file carries a server-generated GET presigned URL (1h) so finale
|
* round (pitch deck, executive summary, business plan, videos, plus any revised
|
||||||
* judges — who are not assignment-gated through file.getDownloadUrl — can open
|
* finals uploads). Each file carries a server-generated GET presigned URL (1h)
|
||||||
* the documents directly in the browser.
|
* so finale judges — who are not assignment-gated through file.getDownloadUrl —
|
||||||
|
* can open the documents directly. This is NOT gated on the upload toggle:
|
||||||
|
* judges can always review the teams' existing submissions.
|
||||||
*/
|
*/
|
||||||
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
|
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
|
||||||
const round = await getOpenFinaleRound(prisma, programId)
|
const round = await getOpenFinaleRound(prisma, programId)
|
||||||
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, submittedCount: 0, teams: [] }
|
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 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({
|
const states = await prisma.projectRoundState.findMany({
|
||||||
where: { roundId: round.id },
|
where: { roundId: round.id },
|
||||||
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
|
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
|
||||||
})
|
})
|
||||||
|
const projectIds = states.map((s) => s.project.id)
|
||||||
|
|
||||||
const teams: ReviewTeam[] = []
|
// Every file these teams have submitted, in any round.
|
||||||
for (const { project } of states) {
|
const allFiles = await prisma.projectFile.findMany({
|
||||||
const files = await prisma.projectFile.findMany({
|
where: { projectId: { in: projectIds } },
|
||||||
where: { projectId: project.id, roundId: round.id, requirementId: { in: requirements.map((r) => r.id) } },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true },
|
select: {
|
||||||
|
id: true, projectId: true, fileName: true, mimeType: true, fileType: true,
|
||||||
|
bucket: true, objectKey: true, createdAt: true, roundId: true,
|
||||||
|
requirement: { select: { name: true, round: { select: { name: true, sortOrder: 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[] = []
|
// Resolve round names for files attached directly to a round (no requirement).
|
||||||
for (const r of requirements) {
|
const directRoundIds = [...new Set(allFiles.filter((f) => f.roundId && !f.requirement).map((f) => f.roundId!))]
|
||||||
const f = byReq.get(r.id)
|
const directRounds = directRoundIds.length
|
||||||
documents.push({
|
? await prisma.round.findMany({ where: { id: { in: directRoundIds } }, select: { id: true, name: true, sortOrder: true } })
|
||||||
requirementId: r.id,
|
: []
|
||||||
requirementName: r.name,
|
const roundById = new Map(directRounds.map((r) => [r.id, r]))
|
||||||
file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600) } : null,
|
|
||||||
})
|
const filesByProject = new Map<string, ReviewFile[]>()
|
||||||
|
for (const f of allFiles) {
|
||||||
|
const r = f.requirement?.round ?? (f.roundId ? roundById.get(f.roundId) : null)
|
||||||
|
const rf: ReviewFile = {
|
||||||
|
id: f.id,
|
||||||
|
fileName: f.fileName,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600),
|
||||||
|
docLabel: f.requirement?.name?.trim() || humanizeFileType(f.fileType),
|
||||||
|
roundLabel: r?.name ?? '—',
|
||||||
|
roundSort: r?.sortOrder ?? -1,
|
||||||
|
isFinaleUpload: f.roundId === round.id,
|
||||||
|
createdAt: f.createdAt,
|
||||||
}
|
}
|
||||||
teams.push({
|
const list = filesByProject.get(f.projectId)
|
||||||
|
if (list) list.push(rf)
|
||||||
|
else filesByProject.set(f.projectId, [rf])
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams: ReviewTeam[] = states.map(({ project }) => ({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
teamName: project.teamName || project.title,
|
teamName: project.teamName || project.title,
|
||||||
category: project.competitionCategory,
|
category: project.competitionCategory,
|
||||||
documents,
|
files: (filesByProject.get(project.id) ?? []).sort(
|
||||||
submitted: documents.every((d) => d.file !== null),
|
(a, b) => b.roundSort - a.roundSort || a.docLabel.localeCompare(b.docLabel),
|
||||||
})
|
),
|
||||||
}
|
}))
|
||||||
|
|
||||||
teams.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.teamName.localeCompare(b.teamName))
|
teams.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.teamName.localeCompare(b.teamName))
|
||||||
return {
|
return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams }
|
||||||
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 open LIVE_FINAL jury group (DRAFT or ACTIVE). */
|
/** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { BUCKET_NAME, generateObjectKey } from '@/lib/minio'
|
|||||||
const programIds: string[] = []
|
const programIds: string[] = []
|
||||||
|
|
||||||
async function makeFinaleProgram(
|
async function makeFinaleProgram(
|
||||||
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean } = {},
|
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const program = await createTestProgram()
|
const program = await createTestProgram()
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
@@ -35,6 +35,7 @@ async function makeFinaleProgram(
|
|||||||
status: opts.roundStatus ?? 'ROUND_ACTIVE',
|
status: opts.roundStatus ?? 'ROUND_ACTIVE',
|
||||||
sortOrder: 6,
|
sortOrder: 6,
|
||||||
windowCloseAt: opts.closeAt ?? new Date(Date.now() + 86_400_000),
|
windowCloseAt: opts.closeAt ?? new Date(Date.now() + 86_400_000),
|
||||||
|
configJson: { allowFinalistRevisedUploads: opts.uploadsEnabled ?? true },
|
||||||
})
|
})
|
||||||
if (opts.skipRequirements) {
|
if (opts.skipRequirements) {
|
||||||
return { program, comp, round, reqPlan: undefined, reqVideo: undefined }
|
return { program, comp, round, reqPlan: undefined, reqVideo: undefined }
|
||||||
@@ -110,6 +111,14 @@ describe('getFinalDocumentStatusForProject', () => {
|
|||||||
expect(status).toBeNull()
|
expect(status).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns null when the admin has NOT enabled revised uploads (toggle off)', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram({ uploadsEnabled: false })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('reports allRequiredUploaded false when the round has no required requirements', async () => {
|
it('reports allRequiredUploaded false when the round has no required requirements', async () => {
|
||||||
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
|
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
|
||||||
const project = await createTestProject(program.id)
|
const project = await createTestProject(program.id)
|
||||||
@@ -132,7 +141,7 @@ describe('applicant.getFinalDocumentStatus', () => {
|
|||||||
const program = await createTestProgram()
|
const program = await createTestProgram()
|
||||||
localPrograms.push(program.id)
|
localPrograms.push(program.id)
|
||||||
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000) })
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
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)
|
const project = await createTestProject(program.id)
|
||||||
await createTestProjectRoundState(project.id, round.id)
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
@@ -163,7 +172,7 @@ describe('sendManualFinalDocReminders', () => {
|
|||||||
const program = await createTestProgram()
|
const program = await createTestProgram()
|
||||||
localPrograms.push(program.id)
|
localPrograms.push(program.id)
|
||||||
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000) })
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
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)
|
const project = await createTestProject(program.id)
|
||||||
await createTestProjectRoundState(project.id, round.id)
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
@@ -189,7 +198,7 @@ describe('sendDueFinalDocReminders', () => {
|
|||||||
const round = await createTestRound(comp.id, {
|
const round = await createTestRound(comp.id, {
|
||||||
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
|
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
|
||||||
windowCloseAt: new Date(Date.now() + 3_600_000), // 1h out → within 48h window
|
windowCloseAt: new Date(Date.now() + 3_600_000), // 1h out → within 48h window
|
||||||
configJson: { finalDocsReminderHoursBeforeDeadline: 48 },
|
configJson: { finalDocsReminderHoursBeforeDeadline: 48, allowFinalistRevisedUploads: true },
|
||||||
})
|
})
|
||||||
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
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)
|
const project = await createTestProject(program.id)
|
||||||
@@ -215,7 +224,7 @@ describe('finalist.listReviewDocuments', () => {
|
|||||||
localPrograms.push(program.id)
|
localPrograms.push(program.id)
|
||||||
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
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 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) })
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: jg.id } })
|
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 } })
|
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' })
|
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
@@ -257,7 +266,7 @@ describe('mentor.getProjectFinalDocuments', () => {
|
|||||||
it('returns status for a project the mentor is assigned to', async () => {
|
it('returns status for a project the mentor is assigned to', async () => {
|
||||||
const program = await createTestProgram(); localPrograms.push(program.id)
|
const program = await createTestProgram(); localPrograms.push(program.id)
|
||||||
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000) })
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
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)
|
const project = await createTestProject(program.id)
|
||||||
await createTestProjectRoundState(project.id, round.id)
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user