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

154 lines
5.5 KiB
TypeScript
Raw Normal View History

import type { PrismaClient } from '@prisma/client'
import { createNotification, NotificationTypes } from './in-app-notification'
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({
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,
}
}
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 }
}