import type { PrismaClient, RoundStatus } from '@prisma/client' import { createNotification, NotificationTypes } from './in-app-notification' import { getPresignedUrl } from '@/lib/minio' export type FinalDocRequirement = { id: string name: string acceptedMimeTypes: string[] isRequired: boolean uploaded: boolean file: { id: string; fileName: string; mimeType: string; bucket: string; objectKey: string; createdAt: Date } | null } export type FinalDocumentStatus = { roundId: string roundName: string deadline: Date | null deadlinePassed: boolean requirements: FinalDocRequirement[] allRequiredUploaded: boolean hasRequired: boolean // any slot is marked required allUploaded: boolean // every listed slot has a file (false when no slots exist) } // A LIVE_FINAL round is "open for documents" during the lead-up — while it is // DRAFT (not yet opened for the live event) or ACTIVE — but not once CLOSED/ // finalized. This decouples document upload + judge review from the ceremony // actually starting, so the Grand Final round can stay DRAFT until event time. const OPEN_FINALE_STATUS: RoundStatus[] = ['ROUND_DRAFT', 'ROUND_ACTIVE'] /** Resolve the program's LIVE_FINAL round while it is open for documents (DRAFT or ACTIVE, not closed/finalized), or null. */ export async function getOpenFinaleRound(prisma: PrismaClient, programId: string) { return prisma.round.findFirst({ where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null }, orderBy: { sortOrder: 'desc' }, 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 } /** * Which prior-round FileRequirement ids are visible to finale judges. * null = no curation (show all prior files). Empty array = hide all prior * files (Grand Final round uploads are always shown regardless). */ export function reviewVisibleRequirementIds(configJson: unknown): string[] | null { const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null } /** * Per-project grand-final document status. Returns null unless the project is * enrolled (ProjectRoundState) in the program's active LIVE_FINAL round. */ export async function getFinalDocumentStatusForProject( prisma: PrismaClient, projectId: string, ): Promise { const project = await prisma.project.findUnique({ where: { id: projectId }, select: { id: true, programId: true }, }) if (!project) return null const round = await getOpenFinaleRound(prisma, project.programId) // 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({ where: { projectId, roundId: round.id }, select: { id: true }, }) if (!enrolled) return null const requirements = await prisma.fileRequirement.findMany({ where: { roundId: round.id }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, acceptedMimeTypes: true, isRequired: true }, }) const files = await prisma.projectFile.findMany({ where: { projectId, 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, createdAt: true }, }) const fileByReq = new Map() for (const f of files) if (f.requirementId && !fileByReq.has(f.requirementId)) fileByReq.set(f.requirementId, f) const reqStatuses: FinalDocRequirement[] = requirements.map((r) => { const f = fileByReq.get(r.id) ?? null return { id: r.id, name: r.name, acceptedMimeTypes: r.acceptedMimeTypes, isRequired: r.isRequired, uploaded: !!f, file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, bucket: f.bucket, objectKey: f.objectKey, createdAt: f.createdAt } : null, } }) const required = reqStatuses.filter((r) => r.isRequired) const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded) const hasRequired = required.length > 0 const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded) const deadline = round.windowCloseAt ?? null return { roundId: round.id, roundName: round.name, deadline, deadlinePassed: deadline ? new Date() > deadline : false, requirements: reqStatuses, allRequiredUploaded, hasRequired, allUploaded, } } /** Build the reminder notification payload for one finalist team lead. */ async function remindTeam( prisma: PrismaClient, args: { projectId: string; projectTitle: string; deadline: Date | null; missing: string[]; leadUserId: string }, ) { await createNotification({ userId: args.leadUserId, type: NotificationTypes.GRAND_FINAL_DOCS_REMINDER, title: 'Upload your Grand Final documents', message: args.missing.length ? `Still needed for "${args.projectTitle}": ${args.missing.join(', ')}.` : `Please upload the final documents for "${args.projectTitle}".`, linkUrl: `/applicant/documents`, // relative → client-side nav in-app; email renderer absolutizes metadata: { projectId: args.projectId, projectTitle: args.projectTitle, deadline: args.deadline?.toISOString(), missing: args.missing, }, }) } /** * Manual admin reminder blast. Targets `projectIds` if given, else all finalist * teams (enrolled in the active LIVE_FINAL round) with missing required docs. */ export async function sendManualFinalDocReminders( prisma: PrismaClient, opts: { programId: string; projectIds?: string[]; actorId: string }, ): Promise<{ sent: number }> { const round = await getOpenFinaleRound(prisma, opts.programId) if (!round || !finalistUploadsEnabled(round.configJson)) return { sent: 0 } const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id, ...(opts.projectIds ? { projectId: { in: opts.projectIds } } : {}) }, select: { projectId: true }, }) let sent = 0 for (const { projectId } of states) { const status = await getFinalDocumentStatusForProject(prisma, projectId) if (!status) continue const missing = status.requirements.filter((r) => r.isRequired && !r.uploaded).map((r) => r.name) // When projectIds explicitly provided, send regardless; else only if missing docs. if (!opts.projectIds && missing.length === 0) continue const project = await prisma.project.findUnique({ where: { id: projectId }, select: { title: true, teamMembers: { where: { role: 'LEAD' }, take: 1, select: { userId: true } } }, }) const leadUserId = project?.teamMembers[0]?.userId if (!project || !leadUserId) continue await remindTeam(prisma, { projectId, projectTitle: project.title, deadline: status.deadline, missing, leadUserId, }) sent++ } return { sent } } /** * Cron: remind finalist teams (enrolled in an active LIVE_FINAL round) with * missing required documents, once, when the deadline is within the configured * window. Stamps FinalistConfirmation.finalDocsReminderSentAt. */ export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{ remindersSent: number }> { const now = new Date() const rounds = await prisma.round.findMany({ where: { roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null }, select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } }, }) let remindersSent = 0 for (const round of rounds) { 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 windowMs = (cfg.finalDocsReminderHoursBeforeDeadline ?? 48) * 3_600_000 const isDue = round.windowCloseAt.getTime() <= now.getTime() + windowMs && round.windowCloseAt.getTime() > now.getTime() if (!isDue) continue const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } }) for (const { projectId } of states) { const confirmation = await prisma.finalistConfirmation.findFirst({ where: { projectId, finalDocsReminderSentAt: null }, select: { id: true }, }) if (!confirmation) continue const status = await getFinalDocumentStatusForProject(prisma, projectId) if (!status) continue const missing = status.requirements.filter((r) => r.isRequired && !r.uploaded).map((r) => r.name) if (missing.length === 0) continue const project = await prisma.project.findUnique({ where: { id: projectId }, select: { title: true, teamMembers: { where: { role: 'LEAD' }, take: 1, select: { userId: true } } }, }) const leadUserId = project?.teamMembers[0]?.userId if (!project || !leadUserId) continue try { await remindTeam(prisma, { projectId, projectTitle: project.title, deadline: status.deadline, missing, leadUserId }) await prisma.finalistConfirmation.update({ where: { id: confirmation.id }, data: { finalDocsReminderSentAt: new Date() } }) remindersSent++ } catch (e) { console.error('[final-docs] reminder failed for', projectId, e) } } } return { remindersSent } } export type ReviewFile = { id: string 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 for finale judges: every finalist team enrolled in * the program's LIVE_FINAL round, with ALL of their submitted files across every * round (pitch deck, executive summary, business plan, videos, plus any revised * finals uploads). Each 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. 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 { const round = await getOpenFinaleRound(prisma, programId) if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] } // Admin curation: which prior-round documents judges may see (null = all). const visibleIds = reviewVisibleRequirementIds(round.configJson) const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } }, }) const projectIds = states.map((s) => s.project.id) // Every file these teams have submitted, in any round. const allFiles = await prisma.projectFile.findMany({ where: { projectId: { in: projectIds } }, orderBy: { createdAt: 'desc' }, select: { id: true, projectId: true, fileName: true, mimeType: true, fileType: true, requirementId: true, bucket: true, objectKey: true, createdAt: true, roundId: true, requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } }, }, }) // Resolve round names for files attached directly to a round (no requirement). const directRoundIds = [...new Set(allFiles.filter((f) => f.roundId && !f.requirement).map((f) => f.roundId!))] const directRounds = directRoundIds.length ? await prisma.round.findMany({ where: { id: { in: directRoundIds } }, select: { id: true, name: true, sortOrder: true } }) : [] const roundById = new Map(directRounds.map((r) => [r.id, r])) const filesByProject = new Map() for (const f of allFiles) { const isFinaleUpload = f.roundId === round.id // Curated mode: prior-round files must match a selected requirement; finale uploads always pass. if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue 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, createdAt: f.createdAt, } 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, teamName: project.teamName || project.title, category: project.competitionCategory, files: (filesByProject.get(project.id) ?? []).sort( (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)) return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams } } export type ReviewDocSlot = { requirementId: string name: string roundName: string roundSort: number fileCount: number } /** * Distinct prior-round document slots (FileRequirements) that the finalist * teams have files for — the options offered in the admin "documents shown to * judges" picker. Excludes the finale round's own slots (those uploads are * always visible to judges) and files without a requirement. */ export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise { const round = await getOpenFinaleRound(prisma, programId) if (!round) return [] const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } }) const files = await prisma.projectFile.findMany({ where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } }, select: { requirementId: true, requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } }, }, }) const slots = new Map() for (const f of files) { if (!f.requirementId || !f.requirement) continue const existing = slots.get(f.requirementId) if (existing) existing.fileCount++ else slots.set(f.requirementId, { requirementId: f.requirementId, name: f.requirement.name.trim(), roundName: f.requirement.round.name, roundSort: f.requirement.round.sortOrder, fileCount: 1, }) } return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name)) } /** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */ 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: { in: OPEN_FINALE_STATUS }, finalizedAt: null }, 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 }