2026-06-09 15:09:50 +02:00
|
|
|
import type { PrismaClient } 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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<FinalDocumentStatus | null> {
|
|
|
|
|
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({
|
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-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-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 }> {
|
|
|
|
|
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 }
|
|
|
|
|
}
|
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({
|
|
|
|
|
where: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' },
|
|
|
|
|
select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let remindersSent = 0
|
|
|
|
|
for (const round of rounds) {
|
|
|
|
|
if (!round.windowCloseAt) 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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:36:59 +02:00
|
|
|
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
|
|
|
|
|
}
|