2026-06-09 16:57:24 +02:00
|
|
|
import type { PrismaClient, RoundStatus } from '@prisma/client'
|
2026-06-09 15:26:50 +02:00
|
|
|
import { createNotification, NotificationTypes } from './in-app-notification'
|
2026-06-09 15:36:59 +02:00
|
|
|
import { getPresignedUrl } from '@/lib/minio'
|
2026-06-09 15:09:50 +02:00
|
|
|
|
|
|
|
|
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
|
2026-06-10 14:58:39 +02:00
|
|
|
hasRequired: boolean // any slot is marked required
|
|
|
|
|
allUploaded: boolean // every listed slot has a file (false when no slots exist)
|
2026-06-09 15:09:50 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 16:57:24 +02:00
|
|
|
// 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) {
|
2026-06-09 15:09:50 +02:00
|
|
|
return prisma.round.findFirst({
|
2026-06-09 16:57:24 +02:00
|
|
|
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
2026-06-09 15:09:50 +02:00
|
|
|
orderBy: { sortOrder: 'desc' },
|
2026-06-09 17:19:09 +02:00
|
|
|
select: { id: true, name: true, windowCloseAt: true, configJson: true },
|
2026-06-09 15:09:50 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:19:09 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:09:50 +02:00
|
|
|
/**
|
|
|
|
|
* 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<FinalDocumentStatus | null> {
|
|
|
|
|
const project = await prisma.project.findUnique({
|
|
|
|
|
where: { id: projectId },
|
|
|
|
|
select: { id: true, programId: true },
|
|
|
|
|
})
|
|
|
|
|
if (!project) return null
|
|
|
|
|
|
2026-06-09 16:57:24 +02:00
|
|
|
const round = await getOpenFinaleRound(prisma, project.programId)
|
2026-06-09 17:19:09 +02:00
|
|
|
// Banner / upload status only applies when the admin has enabled revised uploads.
|
|
|
|
|
if (!round || !finalistUploadsEnabled(round.configJson)) return null
|
2026-06-09 15:09:50 +02:00
|
|
|
|
|
|
|
|
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({
|
2026-06-09 15:15:22 +02:00
|
|
|
where: { projectId, roundId: round.id, requirementId: { in: requirements.map((r) => r.id) } },
|
2026-06-09 15:09:50 +02:00
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true, createdAt: true },
|
|
|
|
|
})
|
|
|
|
|
const fileByReq = new Map<string, (typeof files)[number]>()
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-09 15:15:22 +02:00
|
|
|
const required = reqStatuses.filter((r) => r.isRequired)
|
|
|
|
|
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
|
2026-06-10 14:58:39 +02:00
|
|
|
const hasRequired = required.length > 0
|
|
|
|
|
const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded)
|
2026-06-09 15:09:50 +02:00
|
|
|
const deadline = round.windowCloseAt ?? null
|
|
|
|
|
return {
|
|
|
|
|
roundId: round.id,
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
deadline,
|
|
|
|
|
deadlinePassed: deadline ? new Date() > deadline : false,
|
|
|
|
|
requirements: reqStatuses,
|
|
|
|
|
allRequiredUploaded,
|
2026-06-10 14:58:39 +02:00
|
|
|
hasRequired,
|
|
|
|
|
allUploaded,
|
2026-06-09 15:09:50 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 15:26:50 +02:00
|
|
|
|
|
|
|
|
/** 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}".`,
|
2026-06-09 16:19:16 +02:00
|
|
|
linkUrl: `/applicant/documents`, // relative → client-side nav in-app; email renderer absolutizes
|
2026-06-09 15:26:50 +02:00
|
|
|
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 }> {
|
2026-06-09 16:57:24 +02:00
|
|
|
const round = await getOpenFinaleRound(prisma, opts.programId)
|
2026-06-09 17:19:09 +02:00
|
|
|
if (!round || !finalistUploadsEnabled(round.configJson)) return { sent: 0 }
|
2026-06-09 15:26:50 +02:00
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
}
|
2026-06-09 15:36:59 +02:00
|
|
|
|
2026-06-09 16:00:42 +02:00
|
|
|
/**
|
|
|
|
|
* 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({
|
2026-06-09 16:57:24 +02:00
|
|
|
where: { roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
2026-06-09 16:00:42 +02:00
|
|
|
select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let remindersSent = 0
|
|
|
|
|
for (const round of rounds) {
|
|
|
|
|
if (!round.windowCloseAt) continue
|
2026-06-09 17:19:09 +02:00
|
|
|
// Only chase teams to upload when the admin has enabled revised uploads.
|
|
|
|
|
if (!finalistUploadsEnabled(round.configJson)) continue
|
2026-06-09 16:00:42 +02:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:19:09 +02:00
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 15:36:59 +02:00
|
|
|
|
|
|
|
|
/**
|
2026-06-09 17:19:09 +02:00
|
|
|
* 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.
|
2026-06-09 15:36:59 +02:00
|
|
|
*/
|
|
|
|
|
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
|
2026-06-09 16:57:24 +02:00
|
|
|
const round = await getOpenFinaleRound(prisma, programId)
|
2026-06-09 17:19:09 +02:00
|
|
|
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] }
|
2026-06-09 15:36:59 +02:00
|
|
|
|
|
|
|
|
const states = await prisma.projectRoundState.findMany({
|
|
|
|
|
where: { roundId: round.id },
|
|
|
|
|
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
|
|
|
|
|
})
|
2026-06-09 17:19:09 +02:00
|
|
|
const projectIds = states.map((s) => s.project.id)
|
2026-06-09 15:36:59 +02:00
|
|
|
|
2026-06-09 17:19:09 +02:00
|
|
|
// 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,
|
|
|
|
|
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<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,
|
2026-06-09 15:36:59 +02:00
|
|
|
}
|
2026-06-09 17:19:09 +02:00
|
|
|
const list = filesByProject.get(f.projectId)
|
|
|
|
|
if (list) list.push(rf)
|
|
|
|
|
else filesByProject.set(f.projectId, [rf])
|
2026-06-09 15:36:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:19:09 +02:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
}))
|
|
|
|
|
|
2026-06-09 15:36:59 +02:00
|
|
|
teams.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.teamName.localeCompare(b.teamName))
|
2026-06-09 17:19:09 +02:00
|
|
|
return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams }
|
2026-06-09 15:36:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 16:57:24 +02:00
|
|
|
/** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */
|
2026-06-09 15:36:59 +02:00
|
|
|
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({
|
2026-06-09 16:57:24 +02:00
|
|
|
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
2026-06-09 15:36:59 +02:00
|
|
|
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
|
|
|
|
|
}
|