Files
MOPC-Portal/src/server/services/final-documents.ts

329 lines
14 KiB
TypeScript
Raw Normal View History

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
}
// 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
}
/**
* 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 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<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,
}
})
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,
}
}
/** 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<ReviewPayload> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] }
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,
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,
}
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 }
}
/** 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<boolean> {
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
}