359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<WindowLifecycleResult> {
|
||
|
|
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<WindowLifecycleResult> {
|
||
|
|
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<string, unknown> = {
|
||
|
|
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<WindowLifecycleResult> {
|
||
|
|
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<DeadlineStatus> {
|
||
|
|
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<SubmissionValidationResult> {
|
||
|
|
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<boolean> {
|
||
|
|
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,
|
||
|
|
}))
|
||
|
|
}
|