Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
358
src/server/services/submission-manager.ts
Normal file
358
src/server/services/submission-manager.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user