import type { PrismaClient } 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 } /** Resolve the program's active LIVE_FINAL round, or null. */ export async function getActiveFinaleRound(prisma: PrismaClient, programId: string) { return prisma.round.findFirst({ where: { competition: { programId }, roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, orderBy: { sortOrder: 'desc' }, select: { id: true, name: true, windowCloseAt: true }, }) } /** * 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 getActiveFinaleRound(prisma, project.programId) if (!round) 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 deadline = round.windowCloseAt ?? null return { roundId: round.id, roundName: round.name, deadline, deadlinePassed: deadline ? new Date() > deadline : false, requirements: reqStatuses, allRequiredUploaded, } } function baseUrl(): string { return (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') } /** 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: `${baseUrl()}/applicant/documents`, 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 getActiveFinaleRound(prisma, opts.programId) if (!round) 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 } } 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 }