/** * Submission Round Manager Service * * Manages SubmissionWindow lifecycle, file requirement enforcement, * and deadline policies. */ import type { PrismaClient, DeadlinePolicy, Prisma } from '@prisma/client' import { logAudit } from '@/server/utils/audit' // ─── Types ────────────────────────────────────────────────────────────────── export type WindowLifecycleResult = { success: boolean errors?: string[] } export type DeadlineStatus = { status: 'OPEN' | 'GRACE' | 'CLOSED' | 'LOCKED' graceExpiresAt?: Date deadlinePolicy: DeadlinePolicy } export type SubmissionValidationResult = { valid: boolean errors: string[] } // ─── Window Lifecycle ─────────────────────────────────────────────────────── /** * Open a submission window for accepting files. */ export async function openWindow( windowId: string, actorId: string, prisma: PrismaClient | any, ): Promise { try { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) if (!window) { return { success: false, errors: [`Submission window ${windowId} not found`] } } if (window.isLocked) { return { success: false, errors: ['Cannot open a locked window'] } } await prisma.$transaction(async (tx: any) => { await tx.submissionWindow.update({ where: { id: windowId }, data: { windowOpenAt: new Date(), isLocked: false, }, }) await tx.decisionAuditLog.create({ data: { eventType: 'submission_window.opened', entityType: 'SubmissionWindow', entityId: windowId, actorId, detailsJson: { windowName: window.name }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'submission-manager' }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'SUBMISSION_WINDOW_OPEN', entityType: 'SubmissionWindow', entityId: windowId, detailsJson: { name: window.name }, }) }) return { success: true } } catch (error) { console.error('[SubmissionManager] openWindow failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error'], } } } /** * Close a submission window (respects deadline policy). */ export async function closeWindow( windowId: string, actorId: string, prisma: PrismaClient | any, ): Promise { try { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) if (!window) { return { success: false, errors: [`Submission window ${windowId} not found`] } } await prisma.$transaction(async (tx: any) => { const data: Record = { windowCloseAt: new Date(), } // Auto-lock on close if configured if (window.lockOnClose && window.deadlinePolicy === 'HARD_DEADLINE') { data.isLocked = true } await tx.submissionWindow.update({ where: { id: windowId }, data }) await tx.decisionAuditLog.create({ data: { eventType: 'submission_window.closed', entityType: 'SubmissionWindow', entityId: windowId, actorId, detailsJson: { windowName: window.name, deadlinePolicy: window.deadlinePolicy, autoLocked: data.isLocked === true, }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'submission-manager' }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'SUBMISSION_WINDOW_CLOSE', entityType: 'SubmissionWindow', entityId: windowId, detailsJson: { name: window.name }, }) }) return { success: true } } catch (error) { console.error('[SubmissionManager] closeWindow failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error'], } } } /** * Lock a submission window (no further uploads allowed). */ export async function lockWindow( windowId: string, actorId: string, prisma: PrismaClient | any, ): Promise { try { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) if (!window) { return { success: false, errors: [`Submission window ${windowId} not found`] } } if (window.isLocked) { return { success: false, errors: ['Window is already locked'] } } await prisma.$transaction(async (tx: any) => { await tx.submissionWindow.update({ where: { id: windowId }, data: { isLocked: true }, }) await tx.decisionAuditLog.create({ data: { eventType: 'submission_window.locked', entityType: 'SubmissionWindow', entityId: windowId, actorId, detailsJson: { windowName: window.name }, snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'submission-manager' }, }, }) await logAudit({ prisma: tx, userId: actorId, action: 'SUBMISSION_WINDOW_LOCK', entityType: 'SubmissionWindow', entityId: windowId, detailsJson: { name: window.name }, }) }) return { success: true } } catch (error) { console.error('[SubmissionManager] lockWindow failed:', error) return { success: false, errors: [error instanceof Error ? error.message : 'Unknown error'], } } } // ─── Deadline Enforcement ─────────────────────────────────────────────────── /** * Check the current deadline status of a submission window. */ export async function checkDeadlinePolicy( windowId: string, prisma: PrismaClient | any, ): Promise { const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } }) if (!window) { return { status: 'LOCKED', deadlinePolicy: 'HARD_DEADLINE' } } if (window.isLocked) { return { status: 'LOCKED', deadlinePolicy: window.deadlinePolicy } } const now = new Date() // Not yet open if (window.windowOpenAt && now < window.windowOpenAt) { return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy } } // No close time or still before close if (!window.windowCloseAt || now < window.windowCloseAt) { return { status: 'OPEN', deadlinePolicy: window.deadlinePolicy } } // Past the close time — policy determines behavior switch (window.deadlinePolicy) { case 'HARD_DEADLINE': return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy } case 'FLAG': // Allow uploads but flag them return { status: 'OPEN', deadlinePolicy: window.deadlinePolicy } case 'GRACE': { if (window.graceHours) { const graceEnd = new Date(window.windowCloseAt.getTime() + window.graceHours * 60 * 60 * 1000) if (now < graceEnd) { return { status: 'GRACE', graceExpiresAt: graceEnd, deadlinePolicy: window.deadlinePolicy, } } } return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy } } default: return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy } } } // ─── File Requirement Validation ──────────────────────────────────────────── /** * Validate a project's submission against the window's file requirements. */ export async function validateSubmission( projectId: string, windowId: string, files: Array<{ mimeType: string; size: number; requirementId?: string }>, prisma: PrismaClient | any, ): Promise { const errors: string[] = [] const requirements = await prisma.submissionFileRequirement.findMany({ where: { submissionWindowId: windowId }, orderBy: { sortOrder: 'asc' }, }) // Check required files are present for (const req of requirements) { if (!req.required) continue const matchingFiles = files.filter((f) => f.requirementId === req.id) if (matchingFiles.length === 0) { errors.push(`Missing required file: ${req.label}`) } } // Validate each file against its requirement for (const file of files) { if (!file.requirementId) continue const req = requirements.find((r: any) => r.id === file.requirementId) if (!req) { errors.push(`Unknown file requirement: ${file.requirementId}`) continue } // Check mime type if (req.mimeTypes.length > 0 && !req.mimeTypes.includes(file.mimeType)) { errors.push( `File for "${req.label}" has invalid type ${file.mimeType}. Allowed: ${req.mimeTypes.join(', ')}`, ) } // Check size if (req.maxSizeMb && file.size > req.maxSizeMb * 1024 * 1024) { errors.push( `File for "${req.label}" exceeds max size of ${req.maxSizeMb}MB`, ) } } return { valid: errors.length === 0, errors } } // ─── Read-Only Enforcement ────────────────────────────────────────────────── /** * Check if a window is read-only (locked or hard-closed). */ export async function isWindowReadOnly( windowId: string, prisma: PrismaClient | any, ): Promise { const status = await checkDeadlinePolicy(windowId, prisma) return status.status === 'LOCKED' || status.status === 'CLOSED' } // ─── Visibility Helpers ───────────────────────────────────────────────────── /** * Get visible submission windows for a round. */ export async function getVisibleWindows( roundId: string, prisma: PrismaClient | any, ) { const visibility = await prisma.roundSubmissionVisibility.findMany({ where: { roundId, canView: true }, include: { submissionWindow: { include: { fileRequirements: { orderBy: { sortOrder: 'asc' } } }, }, }, }) return visibility.map((v: any) => ({ ...v.submissionWindow, displayLabel: v.displayLabel, })) }