Competition/Round architecture: full platform rewrite (Phases 1-9)
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:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -31,10 +31,39 @@ import {
const ASSIGNMENT_BATCH_SIZE = 15
// Optimized system prompt
const ASSIGNMENT_SYSTEM_PROMPT = `Match jurors to projects by expertise. Return JSON assignments.
Each: {juror_id, project_id, confidence_score: 0-1, expertise_match_score: 0-1, reasoning: str (1-2 sentences)}
Distribute workload fairly. Avoid assigning jurors at capacity.`
// Structured system prompt for assignment
const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer for an ocean conservation competition.
## Your Role
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
## Matching Criteria (Weighted)
- Expertise Match (50%): How well juror tags/expertise align with project topics
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
## Output Format
Return a JSON object:
{
"assignments": [
{
"juror_id": "JUROR_001",
"project_id": "PROJECT_001",
"confidence_score": 0.0-1.0,
"expertise_match_score": 0.0-1.0,
"reasoning": "1-2 sentence justification"
}
]
}
## Guidelines
- Each project should receive the required number of reviews
- Do not assign jurors who are at or above their capacity
- Favor geographic and disciplinary diversity in assignments
- confidence_score reflects overall assignment quality; expertise_match_score reflects tag overlap only
- A strong match: shared expertise tags + available capacity + under minimum target
- An acceptable match: related domain + available capacity
- A poor match: no expertise overlap, only assigned for coverage`
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -126,6 +155,10 @@ async function processAssignmentBatch(
batchMappings
)
const MAX_PARSE_RETRIES = 2
let parseAttempts = 0
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
try {
const params = buildCompletionParams(model, {
messages: [
@@ -133,11 +166,10 @@ async function processAssignmentBatch(
{ role: 'user', content: userPrompt },
],
jsonMode: true,
temperature: 0.3,
temperature: 0.1,
maxTokens: 4000,
})
let response
try {
response = await openai.chat.completions.create(params)
} catch (apiError) {
@@ -167,20 +199,8 @@ async function processAssignmentBatch(
status: 'SUCCESS',
})
const content = response.choices[0]?.message?.content
if (!content) {
// Check if response indicates an issue
const finishReason = response.choices[0]?.finish_reason
if (finishReason === 'content_filter') {
throw new Error('AI response was filtered. Try a different model or simplify the project descriptions.')
}
if (!response.choices || response.choices.length === 0) {
throw new Error(`No response from model "${model}". This model may not exist or may not be available. Please verify the model name.`)
}
throw new Error(`Empty response from AI model "${model}". The model may not support this type of request.`)
}
const parsed = JSON.parse(content) as {
// Parse with retry logic
let parsed: {
assignments: Array<{
juror_id: string
project_id: string
@@ -190,6 +210,46 @@ async function processAssignmentBatch(
}>
}
while (true) {
try {
const content = response.choices[0]?.message?.content
if (!content) {
// Check if response indicates an issue
const finishReason = response.choices[0]?.finish_reason
if (finishReason === 'content_filter') {
throw new Error('AI response was filtered. Try a different model or simplify the project descriptions.')
}
if (!response.choices || response.choices.length === 0) {
throw new Error(`No response from model "${model}". This model may not exist or may not be available. Please verify the model name.`)
}
throw new Error(`Empty response from AI model "${model}". The model may not support this type of request.`)
}
parsed = JSON.parse(content)
break
} catch (parseError) {
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
parseAttempts++
console.warn(`[AI Assignment] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
// Retry the API call with hint
const retryParams = buildCompletionParams(model, {
messages: [
{ role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
],
jsonMode: true,
temperature: 0.1,
maxTokens: 4000,
})
response = await openai.chat.completions.create(retryParams)
const retryUsage = extractTokenUsage(response)
tokensUsed += retryUsage.totalTokens
continue
}
throw parseError
}
}
// De-anonymize and add to suggestions
const deanonymized = deanonymizeResults(
(parsed.assignments || []).map((a) => ({

View File

@@ -14,6 +14,7 @@
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
import {
anonymizeProjectsForAI,
validateAnonymizedProjects,
@@ -27,10 +28,42 @@ import type { SubmissionSource } from '@prisma/client'
const BATCH_SIZE = 20
// Optimized system prompt
const AI_ELIGIBILITY_SYSTEM_PROMPT = `Award eligibility evaluator. Evaluate projects against criteria, return JSON.
Format: {"evaluations": [{project_id, eligible: bool, confidence: 0-1, reasoning: str}]}
Be objective. Base evaluation only on provided data. No personal identifiers in reasoning.`
// Structured system prompt for award eligibility
const AI_ELIGIBILITY_SYSTEM_PROMPT = `You are an award eligibility evaluator for an ocean conservation competition.
## Your Role
Determine whether each project meets the criteria for a specific award category.
## Evaluation Dimensions
- Geographic Relevance: Does the project's location/focus match the award's geographic requirements?
- Category Fit: Does the project category align with the award criteria?
- Topic Alignment: Does the project's ocean issue focus match the award's thematic area?
- Maturity Level: Is the project at the right stage for this award?
## Output Format
Return a JSON object:
{
"evaluations": [
{
"project_id": "PROJECT_001",
"eligible": true/false,
"confidence": 0.0-1.0,
"reasoning": "2-3 sentence explanation covering key dimensions",
"dimensionScores": {
"geographic": 0.0-1.0,
"category": 0.0-1.0,
"topic": 0.0-1.0,
"maturity": 0.0-1.0
}
}
]
}
## Guidelines
- Base evaluation only on provided data — do not infer missing information
- eligible=true only when ALL required dimensions score above 0.5
- confidence reflects how clearly the data supports the determination
- No personal identifiers in reasoning`
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -149,10 +182,17 @@ async function processEligibilityBatch(
const results: EligibilityResult[] = []
let tokensUsed = 0
const userPrompt = `CRITERIA: ${criteriaText}
// Sanitize user-supplied criteria
const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText)
const userPrompt = `CRITERIA: ${safeCriteria}
PROJECTS: ${JSON.stringify(anonymized)}
Evaluate eligibility for each project.`
const MAX_PARSE_RETRIES = 2
let parseAttempts = 0
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
try {
const params = buildCompletionParams(model, {
messages: [
@@ -160,11 +200,11 @@ Evaluate eligibility for each project.`
{ role: 'user', content: userPrompt },
],
jsonMode: true,
temperature: 0.3,
temperature: 0.1,
maxTokens: 4000,
})
const response = await openai.chat.completions.create(params)
response = await openai.chat.completions.create(params)
const usage = extractTokenUsage(response)
tokensUsed = usage.totalTokens
@@ -183,12 +223,8 @@ Evaluate eligibility for each project.`
status: 'SUCCESS',
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
const parsed = JSON.parse(content) as {
// Parse with retry logic
let parsed: {
evaluations: Array<{
project_id: string
eligible: boolean
@@ -197,6 +233,38 @@ Evaluate eligibility for each project.`
}>
}
while (true) {
try {
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
parsed = JSON.parse(content)
break
} catch (parseError) {
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
parseAttempts++
console.warn(`[AI Eligibility] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
// Retry the API call with hint
const retryParams = buildCompletionParams(model, {
messages: [
{ role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
],
jsonMode: true,
temperature: 0.1,
maxTokens: 4000,
})
response = await openai.chat.completions.create(retryParams)
const retryUsage = extractTokenUsage(response)
tokensUsed += retryUsage.totalTokens
continue
}
throw parseError
}
}
// Map results back to real IDs
for (const eval_ of parsed.evaluations || []) {
const mapping = mappings.find((m) => m.anonymousId === eval_.project_id)

View File

@@ -68,7 +68,7 @@ interface ScoringPatterns {
export interface EvaluationSummaryResult {
id: string
projectId: string
stageId: string
roundId: string
summaryJson: AIResponsePayload & { scoringPatterns: ScoringPatterns }
generatedAt: Date
model: string
@@ -123,6 +123,15 @@ Analyze these evaluations and return a JSON object with this exact structure:
"recommendation": "A brief recommendation based on the evaluation consensus"
}
Example output:
{
"overallAssessment": "The project received strong scores (avg 7.8/10) with high consensus among evaluators. Key strengths in innovation were balanced by concerns about scalability.",
"strengths": ["Innovative approach to coral reef monitoring", "Strong team expertise in marine biology"],
"weaknesses": ["Limited scalability plan", "Budget projections need more detail"],
"themes": [{"theme": "Innovation", "sentiment": "positive", "frequency": 3}, {"theme": "Scalability", "sentiment": "negative", "frequency": 2}],
"recommendation": "Recommended for advancement with condition to address scalability concerns in next round."
}
Guidelines:
- Base your analysis only on the provided evaluation data
- Identify common themes across evaluator feedback
@@ -194,12 +203,12 @@ export function computeScoringPatterns(
*/
export async function generateSummary({
projectId,
stageId,
roundId,
userId,
prisma,
}: {
projectId: string
stageId: string
roundId: string
userId: string
prisma: PrismaClient
}): Promise<EvaluationSummaryResult> {
@@ -216,13 +225,13 @@ export async function generateSummary({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
// Fetch submitted evaluations for this project in this stage
// Fetch submitted evaluations for this project in this round
const evaluations = await prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
projectId,
stageId,
roundId,
},
},
select: {
@@ -244,13 +253,13 @@ export async function generateSummary({
if (evaluations.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No submitted evaluations found for this project in this stage',
message: 'No submitted evaluations found for this project in this round',
})
}
// Get evaluation form criteria for this stage
// Get evaluation form criteria for this round
const form = await prisma.evaluationForm.findFirst({
where: { stageId, isActive: true },
where: { roundId, isActive: true },
select: { criteriaJson: true },
})
@@ -278,49 +287,83 @@ export async function generateSummary({
let aiResponse: AIResponsePayload
let tokensUsed = 0
const MAX_PARSE_RETRIES = 2
let parseAttempts = 0
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
try {
const params = buildCompletionParams(model, {
messages: [
{ role: 'user', content: prompt },
],
jsonMode: true,
temperature: 0.3,
temperature: 0.1,
maxTokens: 2000,
})
const response = await openai.chat.completions.create(params)
const usage = extractTokenUsage(response)
response = await openai.chat.completions.create(params)
let usage = extractTokenUsage(response)
tokensUsed = usage.totalTokens
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
// Parse with retry logic
while (true) {
try {
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
aiResponse = JSON.parse(content) as AIResponsePayload
break
} catch (parseError) {
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
parseAttempts++
console.warn(`[AI Evaluation Summary] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
// Retry the API call with hint
const retryParams = buildCompletionParams(model, {
messages: [
{ role: 'user', content: prompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
],
jsonMode: true,
temperature: 0.1,
maxTokens: 2000,
})
response = await openai.chat.completions.create(retryParams)
const retryUsage = extractTokenUsage(response)
tokensUsed += retryUsage.totalTokens
continue
}
// If retry limit reached or non-syntax error
if (parseError instanceof SyntaxError) {
const parseErrorObj = createParseError((parseError as Error).message)
logAIError('EvaluationSummary', 'generateSummary', parseErrorObj)
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0,
completionTokens: 0,
totalTokens: tokensUsed,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: parseErrorObj.message,
})
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse AI response. Please try again.',
})
}
throw parseError
}
}
aiResponse = JSON.parse(content) as AIResponsePayload
} catch (error) {
if (error instanceof SyntaxError) {
const parseError = createParseError(error.message)
logAIError('EvaluationSummary', 'generateSummary', parseError)
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0,
completionTokens: 0,
totalTokens: tokensUsed,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: parseError.message,
})
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse AI response. Please try again.',
})
if (error instanceof TRPCError) {
throw error
}
const classified = classifyAIError(error)
@@ -359,11 +402,11 @@ export async function generateSummary({
const summary = await prisma.evaluationSummary.upsert({
where: {
projectId_stageId: { projectId, stageId },
projectId_roundId: { projectId, roundId },
},
create: {
projectId,
stageId,
roundId,
summaryJson: summaryJsonValue,
generatedById: userId,
model,
@@ -395,7 +438,7 @@ export async function generateSummary({
return {
id: summary.id,
projectId: summary.projectId,
stageId: summary.stageId,
roundId: summary.roundId,
summaryJson: summaryJson as AIResponsePayload & { scoringPatterns: ScoringPatterns },
generatedAt: summary.generatedAt,
model: summary.model,

View File

@@ -15,6 +15,7 @@
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
import {
anonymizeProjectsForAI,
validateAnonymizedProjects,
@@ -133,10 +134,40 @@ const MIN_BATCH_SIZE = 1
const DEFAULT_PARALLEL_BATCHES = 1
const MAX_PARALLEL_BATCHES = 10
// Optimized system prompt (compressed for token efficiency)
const AI_SCREENING_SYSTEM_PROMPT = `Project screening assistant. Evaluate against criteria, return JSON.
Format: {"projects": [{project_id, meets_criteria: bool, confidence: 0-1, reasoning: str, quality_score: 1-10, spam_risk: bool}]}
Be objective. Base evaluation only on provided data. No personal identifiers in reasoning.`
// Structured system prompt for AI screening
const AI_SCREENING_SYSTEM_PROMPT = `You are an expert project screening assistant for an ocean conservation competition.
## Your Role
Evaluate each project against the provided screening criteria. Be objective and base evaluation only on provided data.
## Output Format
Return a JSON object with this exact structure:
{
"projects": [
{
"project_id": "PROJECT_001",
"meets_criteria": true/false,
"confidence": 0.0-1.0,
"reasoning": "2-3 sentence explanation",
"quality_score": 1-10,
"spam_risk": true/false
}
]
}
## Scoring Rubric for quality_score
- 9-10: Exceptional — clearly meets all criteria, well-documented, innovative
- 7-8: Strong — meets most criteria, minor gaps acceptable
- 5-6: Adequate — partially meets criteria, notable gaps
- 3-4: Weak — significant shortcomings against criteria
- 1-2: Poor — does not meet criteria or appears low-quality/spam
## Guidelines
- Evaluate ONLY against the provided criteria, not your own standards
- A confidence of 1.0 means absolute certainty; 0.5 means borderline
- Flag spam_risk=true for: AI-generated filler text, copied content, or irrelevant submissions
- Do not include any personal identifiers in reasoning
- If project data is insufficient to evaluate, set confidence below 0.3`
// ─── Field-Based Rule Evaluation ────────────────────────────────────────────
@@ -293,11 +324,18 @@ async function processAIBatch(
const results = new Map<string, AIScreeningResult>()
let tokensUsed = 0
// Sanitize user-supplied criteria
const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText)
// Build optimized prompt
const userPrompt = `CRITERIA: ${criteriaText}
const userPrompt = `CRITERIA: ${safeCriteria}
PROJECTS: ${JSON.stringify(anonymized)}
Evaluate and return JSON.`
const MAX_PARSE_RETRIES = 2
let parseAttempts = 0
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
try {
const params = buildCompletionParams(model, {
messages: [
@@ -305,11 +343,11 @@ Evaluate and return JSON.`
{ role: 'user', content: userPrompt },
],
jsonMode: true,
temperature: 0.3,
temperature: 0.1,
maxTokens: 4000,
})
const response = await openai.chat.completions.create(params)
response = await openai.chat.completions.create(params)
const usage = extractTokenUsage(response)
tokensUsed = usage.totalTokens
@@ -327,12 +365,8 @@ Evaluate and return JSON.`
status: 'SUCCESS',
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
const parsed = JSON.parse(content) as {
// Parse with retry logic
let parsed: {
projects: Array<{
project_id: string
meets_criteria: boolean
@@ -343,6 +377,38 @@ Evaluate and return JSON.`
}>
}
while (true) {
try {
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
parsed = JSON.parse(content)
break
} catch (parseError) {
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
parseAttempts++
console.warn(`[AI Filtering] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
// Retry the API call with hint
const retryParams = buildCompletionParams(model, {
messages: [
{ role: 'system', content: AI_SCREENING_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
],
jsonMode: true,
temperature: 0.1,
maxTokens: 4000,
})
response = await openai.chat.completions.create(retryParams)
const retryUsage = extractTokenUsage(response)
tokensUsed += retryUsage.totalTokens
continue
}
throw parseError
}
}
// Map results back to real IDs
for (const result of parsed.projects || []) {
const mapping = mappings.find((m) => m.anonymousId === result.project_id)
@@ -542,7 +608,7 @@ export async function executeFilteringRules(
rules: FilteringRuleInput[],
projects: ProjectForFiltering[],
userId?: string,
stageId?: string,
roundId?: string,
onProgress?: ProgressCallback
): Promise<ProjectFilteringResult[]> {
const activeRules = rules
@@ -558,7 +624,7 @@ export async function executeFilteringRules(
for (const aiRule of aiRules) {
const config = aiRule.configJson as unknown as AIScreeningConfig
const screeningResults = await executeAIScreening(config, projects, userId, stageId, onProgress)
const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
aiResults.set(aiRule.id, screeningResults)
}

View File

@@ -0,0 +1,167 @@
/**
* AI Prompt Injection Guard
*
* Detects and strips common prompt injection patterns from user-supplied text
* before passing to AI services. Called before every AI service that uses
* user-supplied criteria, descriptions, or free-text fields.
*
* Patterns detected:
* - ChatML tags (<|im_start|>, <|im_end|>, <|endoftext|>)
* - Role impersonation ("system:", "assistant:")
* - Instruction override ("ignore previous instructions", "disregard above")
* - Encoded injection attempts (base64 encoded instructions, unicode tricks)
*/
// ─── Injection Patterns ─────────────────────────────────────────────────────
const CHATML_PATTERN = /<\|(?:im_start|im_end|endoftext|system|user|assistant)\|>/gi
const ROLE_IMPERSONATION_PATTERN =
/^\s*(?:system|assistant|user|human|ai|bot)\s*:/gim
const INSTRUCTION_OVERRIDE_PATTERNS = [
/ignore\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+instructions?/gi,
/disregard\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|context)/gi,
/forget\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|context)/gi,
/override\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?)/gi,
/you\s+are\s+now\s+(?:a|an)\s+/gi,
/new\s+instructions?\s*:/gi,
/begin\s+(?:new\s+)?(?:prompt|instructions?|session)/gi,
/\[INST\]/gi,
/\[\/INST\]/gi,
/<<SYS>>/gi,
/<\/SYS>>/gi,
]
const ENCODED_INJECTION_PATTERNS = [
// Base64 encoded common injection phrases
/aWdub3JlIHByZXZpb3Vz/gi, // "ignore previous" in base64
/ZGlzcmVnYXJkIGFib3Zl/gi, // "disregard above" in base64
]
// ─── Types ──────────────────────────────────────────────────────────────────
export type SanitizationResult = {
sanitized: string
wasModified: boolean
detectedPatterns: string[]
}
// ─── Core Functions ─────────────────────────────────────────────────────────
/**
* Sanitize user-supplied text by stripping injection patterns.
* Returns the sanitized text and metadata about what was detected/removed.
*/
export function sanitizeUserInput(text: string): SanitizationResult {
if (!text || typeof text !== 'string') {
return { sanitized: '', wasModified: false, detectedPatterns: [] }
}
let sanitized = text
const detectedPatterns: string[] = []
// Strip ChatML tags
if (CHATML_PATTERN.test(sanitized)) {
detectedPatterns.push('ChatML tags')
sanitized = sanitized.replace(CHATML_PATTERN, '')
}
// Strip role impersonation
if (ROLE_IMPERSONATION_PATTERN.test(sanitized)) {
detectedPatterns.push('Role impersonation')
sanitized = sanitized.replace(ROLE_IMPERSONATION_PATTERN, '')
}
// Strip instruction overrides
for (const pattern of INSTRUCTION_OVERRIDE_PATTERNS) {
// Reset lastIndex for global patterns
pattern.lastIndex = 0
if (pattern.test(sanitized)) {
detectedPatterns.push('Instruction override attempt')
pattern.lastIndex = 0
sanitized = sanitized.replace(pattern, '[FILTERED]')
}
}
// Strip encoded injections
for (const pattern of ENCODED_INJECTION_PATTERNS) {
pattern.lastIndex = 0
if (pattern.test(sanitized)) {
detectedPatterns.push('Encoded injection')
pattern.lastIndex = 0
sanitized = sanitized.replace(pattern, '[FILTERED]')
}
}
// Trim excessive whitespace left by removals
sanitized = sanitized.replace(/\n{3,}/g, '\n\n').trim()
const wasModified = sanitized !== text.trim()
if (wasModified && detectedPatterns.length > 0) {
console.warn(
`[PromptGuard] Detected injection patterns in user input: ${detectedPatterns.join(', ')}`
)
}
return { sanitized, wasModified, detectedPatterns }
}
/**
* Quick check: does the text contain any injection patterns?
* Faster than full sanitization when you only need a boolean check.
*/
export function containsInjectionPatterns(text: string): boolean {
if (!text) return false
if (CHATML_PATTERN.test(text)) return true
CHATML_PATTERN.lastIndex = 0
if (ROLE_IMPERSONATION_PATTERN.test(text)) return true
ROLE_IMPERSONATION_PATTERN.lastIndex = 0
for (const pattern of INSTRUCTION_OVERRIDE_PATTERNS) {
pattern.lastIndex = 0
if (pattern.test(text)) return true
}
for (const pattern of ENCODED_INJECTION_PATTERNS) {
pattern.lastIndex = 0
if (pattern.test(text)) return true
}
return false
}
/**
* Sanitize all string values in a criteria/config object.
* Recursively processes nested objects and arrays.
*/
export function sanitizeCriteriaObject(
obj: Record<string, unknown>
): { sanitized: Record<string, unknown>; detectedPatterns: string[] } {
const allDetected: string[] = []
function processValue(value: unknown): unknown {
if (typeof value === 'string') {
const result = sanitizeUserInput(value)
allDetected.push(...result.detectedPatterns)
return result.sanitized
}
if (Array.isArray(value)) {
return value.map(processValue)
}
if (value && typeof value === 'object') {
const processed: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
processed[k] = processValue(v)
}
return processed
}
return value
}
const sanitized = processValue(obj) as Record<string, unknown>
return { sanitized, detectedPatterns: [...new Set(allDetected)] }
}

View File

@@ -0,0 +1,284 @@
/**
* AI Shortlist Service
*
* Generates ranked recommendations at end of evaluation rounds.
* Follows patterns from ai-filtering.ts and ai-evaluation-summary.ts.
*
* GDPR Compliance:
* - All project data is anonymized before AI processing
* - No personal identifiers in prompts or responses
*/
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, logAIError } from './ai-errors'
import type { PrismaClient } from '@prisma/client'
// ─── Types ──────────────────────────────────────────────────────────────────
export type ShortlistResult = {
success: boolean
recommendations: ShortlistRecommendation[]
errors?: string[]
tokensUsed?: number
}
export type ShortlistRecommendation = {
projectId: string
rank: number
score: number
strengths: string[]
concerns: string[]
recommendation: string
}
// ─── Main Function ──────────────────────────────────────────────────────────
/**
* Generate an AI shortlist for projects in a round.
* Only runs if EvaluationConfig.generateAiShortlist is true.
*/
export async function generateShortlist(
params: {
roundId: string
competitionId: string
category?: string
topN?: number
rubric?: string
},
prisma: PrismaClient | any,
): Promise<ShortlistResult> {
const { roundId, competitionId, category, topN = 10, rubric } = params
try {
// Load projects with evaluations
const where: Record<string, unknown> = {
assignments: { some: { roundId } },
}
if (category) {
where.competitionCategory = category
}
const projects = await prisma.project.findMany({
where,
include: {
assignments: {
where: { roundId },
include: {
evaluation: true,
},
},
projectTags: { include: { tag: true } },
files: { select: { id: true, type: true } },
teamMembers: { select: { user: { select: { name: true } } } },
},
})
if (projects.length === 0) {
return {
success: true,
recommendations: [],
errors: ['No projects found for this round'],
}
}
// Aggregate scores per project
const projectSummaries = projects.map((project: any) => {
const evaluations = project.assignments
.map((a: any) => a.evaluation)
.filter(Boolean)
.filter((e: any) => e.status === 'SUBMITTED')
const scores = evaluations.map((e: any) => e.globalScore ?? 0)
const avgScore = scores.length > 0
? scores.reduce((sum: number, s: number) => sum + s, 0) / scores.length
: 0
const feedbacks = evaluations
.map((e: any) => e.feedbackGeneral)
.filter(Boolean)
return {
id: project.id,
title: project.title,
description: project.description,
category: project.competitionCategory,
tags: project.projectTags.map((pt: any) => pt.tag.name),
avgScore,
evaluationCount: evaluations.length,
feedbackSamples: feedbacks.slice(0, 3), // Max 3 feedback samples
}
})
// Anonymize for AI
const anonymized = projectSummaries.map((p: any, index: number) => ({
anonymousId: `PROJECT_${String(index + 1).padStart(3, '0')}`,
...p,
// Strip identifying info
title: undefined,
id: undefined,
}))
// Build idMap for de-anonymization
const idMap = new Map<string, string>()
projectSummaries.forEach((p: any, index: number) => {
idMap.set(`PROJECT_${String(index + 1).padStart(3, '0')}`, p.id)
})
// Build prompt
const systemPrompt = `You are a senior jury advisor for the Monaco Ocean Protection Challenge.
## Your Role
Analyze aggregated evaluation data to produce a ranked shortlist of top projects.
## Ranking Criteria (Weighted)
- Evaluation Scores (40%): Average scores across all jury evaluations
- Innovation & Impact (25%): Novelty of approach and potential environmental impact
- Feasibility (20%): Likelihood of successful implementation
- Alignment (15%): Fit with ocean protection mission and competition goals
## Output Format
Return a JSON array:
[
{
"anonymousId": "PROJECT_001",
"rank": 1,
"score": 0-100,
"strengths": ["strength 1", "strength 2"],
"concerns": ["concern 1"],
"recommendation": "1-2 sentence recommendation",
"criterionBreakdown": {
"evaluationScores": 38,
"innovationImpact": 22,
"feasibility": 18,
"alignment": 14
}
}
]
## Guidelines
- Only include the requested number of top projects
- Score should reflect weighted combination of all criteria
- Be specific in strengths and concerns — avoid generic statements
- Consider feedback themes and evaluator consensus
- Higher evaluator consensus should boost confidence in ranking`
const userPrompt = `Analyze these anonymized project evaluations and produce a ranked shortlist of the top ${topN} projects.
${rubric ? `Evaluation rubric:\n${rubric}\n\n` : ''}Projects:
${JSON.stringify(anonymized, null, 2)}
Return a JSON array following the format specified in your instructions. Only include the top ${topN} projects. Rank by overall quality considering scores and feedback.`
const openai = await getOpenAI()
const model = await getConfiguredModel()
if (!openai) {
return {
success: false,
recommendations: [],
errors: ['OpenAI client not configured'],
}
}
const MAX_PARSE_RETRIES = 2
let parseAttempts = 0
let response = await openai.chat.completions.create(
buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.1,
jsonMode: true,
}),
)
let tokenUsage = extractTokenUsage(response)
await logAIUsage({
action: 'FILTERING',
model,
promptTokens: tokenUsage.promptTokens,
completionTokens: tokenUsage.completionTokens,
totalTokens: tokenUsage.totalTokens,
status: 'SUCCESS',
})
// Parse response with retry logic
let parsed: any[]
while (true) {
try {
const content = response.choices[0]?.message?.content
if (!content) {
return {
success: false,
recommendations: [],
errors: ['Empty AI response'],
tokensUsed: tokenUsage.totalTokens,
}
}
const json = JSON.parse(content)
parsed = Array.isArray(json) ? json : json.rankings ?? json.projects ?? json.shortlist ?? []
break
} catch (parseError) {
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
parseAttempts++
console.warn(`[AI Shortlist] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
// Retry the API call with hint
response = await openai.chat.completions.create(
buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
],
temperature: 0.1,
jsonMode: true,
}),
)
const retryUsage = extractTokenUsage(response)
tokenUsage.totalTokens += retryUsage.totalTokens
continue
}
return {
success: false,
recommendations: [],
errors: ['Failed to parse AI response as JSON'],
tokensUsed: tokenUsage.totalTokens,
}
}
}
// De-anonymize and build recommendations
const recommendations: ShortlistRecommendation[] = parsed
.filter((item: any) => item.anonymousId && idMap.has(item.anonymousId))
.map((item: any) => ({
projectId: idMap.get(item.anonymousId)!,
rank: item.rank ?? 0,
score: item.score ?? 0,
strengths: item.strengths ?? [],
concerns: item.concerns ?? [],
recommendation: item.recommendation ?? '',
}))
.sort((a: ShortlistRecommendation, b: ShortlistRecommendation) => a.rank - b.rank)
return {
success: true,
recommendations,
tokensUsed: tokenUsage.totalTokens,
}
} catch (error) {
const classification = classifyAIError(error)
logAIError('ai-shortlist', 'generateShortlist', classification)
console.error('[AIShortlist] generateShortlist failed:', error)
return {
success: false,
recommendations: [],
errors: [error instanceof Error ? error.message : 'AI shortlist generation failed'],
}
}
}

View File

@@ -86,11 +86,12 @@ Rules:
export async function getTaggingSettings(): Promise<{
enabled: boolean
maxTags: number
confidenceThreshold: number
}> {
const settings = await prisma.systemSettings.findMany({
where: {
key: {
in: ['ai_tagging_enabled', 'ai_tagging_max_tags', 'ai_enabled'],
in: ['ai_tagging_enabled', 'ai_tagging_max_tags', 'ai_tagging_confidence_threshold', 'ai_enabled'],
},
},
})
@@ -108,6 +109,7 @@ export async function getTaggingSettings(): Promise<{
return {
enabled,
maxTags: parseInt(settingsMap.get('ai_tagging_max_tags') || String(DEFAULT_MAX_TAGS)),
confidenceThreshold: parseFloat(settingsMap.get('ai_tagging_confidence_threshold') || String(CONFIDENCE_THRESHOLD)),
}
}
@@ -167,7 +169,7 @@ Suggest relevant tags for this project.`
{ role: 'user', content: userPrompt },
],
jsonMode: true,
temperature: 0.3,
temperature: 0.1,
maxTokens: 2000,
})
@@ -309,9 +311,9 @@ export async function tagProject(
userId
)
// Filter by confidence threshold
// Filter by confidence threshold from settings
const validSuggestions = suggestions.filter(
(s) => s.confidence >= CONFIDENCE_THRESHOLD
(s) => s.confidence >= settings.confidenceThreshold
)
// Get existing tag IDs to avoid duplicates

View File

@@ -0,0 +1,290 @@
import { TRPCError } from '@trpc/server'
import type { AssignmentIntent, AssignmentIntentSource, Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
// ============================================================================
// Create Intent
// ============================================================================
/**
* Creates an assignment intent (pre-assignment signal).
* Enforces uniqueness: one intent per (member, round, project).
*/
export async function createIntent(params: {
juryGroupMemberId: string
roundId: string
projectId: string
source: AssignmentIntentSource
actorId?: string
}): Promise<AssignmentIntent> {
const { juryGroupMemberId, roundId, projectId, source, actorId } = params
const intent = await prisma.$transaction(async (tx) => {
// Check for existing pending intent
const existing = await tx.assignmentIntent.findUnique({
where: {
juryGroupMemberId_roundId_projectId: {
juryGroupMemberId,
roundId,
projectId,
},
},
})
if (existing) {
if (existing.status === 'INTENT_PENDING') {
throw new TRPCError({
code: 'CONFLICT',
message: 'A pending intent already exists for this member/round/project',
})
}
// If previous intent was terminal (HONORED, OVERRIDDEN, EXPIRED, CANCELLED),
// allow creating a new one by updating it back to PENDING
const updated = await tx.assignmentIntent.update({
where: { id: existing.id },
data: { status: 'INTENT_PENDING', source },
})
await logIntentEvent(tx, 'intent.recreated', updated, actorId, {
previousStatus: existing.status,
source,
})
return updated
}
const created = await tx.assignmentIntent.create({
data: {
juryGroupMemberId,
roundId,
projectId,
source,
status: 'INTENT_PENDING',
},
})
await logIntentEvent(tx, 'intent.created', created, actorId, { source })
return created
})
return intent
}
// ============================================================================
// Honor Intent (PENDING → HONORED)
// ============================================================================
/**
* Marks an intent as HONORED when the corresponding assignment is created.
* Only INTENT_PENDING intents can be honored.
*/
export async function honorIntent(
intentId: string,
assignmentId: string,
actorId?: string,
): Promise<AssignmentIntent> {
return prisma.$transaction(async (tx) => {
const intent = await tx.assignmentIntent.findUniqueOrThrow({
where: { id: intentId },
})
assertPending(intent)
const updated = await tx.assignmentIntent.update({
where: { id: intentId },
data: { status: 'HONORED' },
})
await logIntentEvent(tx, 'intent.honored', updated, actorId, {
assignmentId,
})
return updated
})
}
// ============================================================================
// Override Intent (PENDING → OVERRIDDEN)
// ============================================================================
/**
* Marks an intent as OVERRIDDEN when an admin overrides the pre-assignment.
* Only INTENT_PENDING intents can be overridden.
*/
export async function overrideIntent(
intentId: string,
reason: string,
actorId?: string,
): Promise<AssignmentIntent> {
return prisma.$transaction(async (tx) => {
const intent = await tx.assignmentIntent.findUniqueOrThrow({
where: { id: intentId },
})
assertPending(intent)
const updated = await tx.assignmentIntent.update({
where: { id: intentId },
data: { status: 'OVERRIDDEN' },
})
await logIntentEvent(tx, 'intent.overridden', updated, actorId, { reason })
return updated
})
}
// ============================================================================
// Cancel Intent (PENDING → CANCELLED)
// ============================================================================
/**
* Marks an intent as CANCELLED (e.g. admin removes it before it is honored).
* Only INTENT_PENDING intents can be cancelled.
*/
export async function cancelIntent(
intentId: string,
reason: string,
actorId?: string,
): Promise<AssignmentIntent> {
return prisma.$transaction(async (tx) => {
const intent = await tx.assignmentIntent.findUniqueOrThrow({
where: { id: intentId },
})
assertPending(intent)
const updated = await tx.assignmentIntent.update({
where: { id: intentId },
data: { status: 'CANCELLED' },
})
await logIntentEvent(tx, 'intent.cancelled', updated, actorId, { reason })
return updated
})
}
// ============================================================================
// Expire Intents for Round (batch PENDING → EXPIRED)
// ============================================================================
/**
* Expires all INTENT_PENDING intents for a given round.
* Typically called when a round transitions past the assignment phase.
*/
export async function expireIntentsForRound(
roundId: string,
actorId?: string,
): Promise<{ expired: number }> {
return prisma.$transaction(async (tx) => {
const pending = await tx.assignmentIntent.findMany({
where: { roundId, status: 'INTENT_PENDING' },
})
if (pending.length === 0) return { expired: 0 }
await tx.assignmentIntent.updateMany({
where: { roundId, status: 'INTENT_PENDING' },
data: { status: 'EXPIRED' },
})
await tx.decisionAuditLog.create({
data: {
eventType: 'intent.batch_expired',
entityType: 'Round',
entityId: roundId,
actorId: actorId ?? null,
detailsJson: {
expiredCount: pending.length,
intentIds: pending.map((i) => i.id),
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'assignment-intent',
},
},
})
return { expired: pending.length }
})
}
// ============================================================================
// Query Helpers
// ============================================================================
export async function getPendingIntentsForRound(
roundId: string,
): Promise<AssignmentIntent[]> {
return prisma.assignmentIntent.findMany({
where: { roundId, status: 'INTENT_PENDING' },
orderBy: { createdAt: 'asc' },
})
}
export async function getPendingIntentsForMember(
juryGroupMemberId: string,
roundId: string,
): Promise<AssignmentIntent[]> {
return prisma.assignmentIntent.findMany({
where: {
juryGroupMemberId,
roundId,
status: 'INTENT_PENDING',
},
orderBy: { createdAt: 'asc' },
})
}
export async function getIntentsForRound(
roundId: string,
): Promise<AssignmentIntent[]> {
return prisma.assignmentIntent.findMany({
where: { roundId },
orderBy: { createdAt: 'asc' },
})
}
// ============================================================================
// Internals
// ============================================================================
function assertPending(intent: AssignmentIntent): void {
if (intent.status !== 'INTENT_PENDING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Intent ${intent.id} is ${intent.status}, only INTENT_PENDING intents can transition`,
})
}
}
async function logIntentEvent(
tx: Prisma.TransactionClient,
eventType: string,
intent: AssignmentIntent,
actorId: string | undefined,
details: Record<string, unknown>,
): Promise<void> {
await tx.decisionAuditLog.create({
data: {
eventType,
entityType: 'AssignmentIntent',
entityId: intent.id,
actorId: actorId ?? null,
detailsJson: {
juryGroupMemberId: intent.juryGroupMemberId,
roundId: intent.roundId,
projectId: intent.projectId,
status: intent.status,
source: intent.source,
...details,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'assignment-intent',
},
},
})
}

View File

@@ -0,0 +1,262 @@
import type { CapMode } from '@prisma/client'
import type { PolicyResolution } from '@/types/competition'
import type { MemberContext } from './competition-context'
// ============================================================================
// System Defaults (Layer 1)
// ============================================================================
export const SYSTEM_DEFAULT_CAP = 15
export const SYSTEM_DEFAULT_CAP_MODE: CapMode = 'SOFT'
export const SYSTEM_DEFAULT_SOFT_BUFFER = 2
// ============================================================================
// Effective Cap Resolution (5-layer precedence)
// ============================================================================
/**
* Resolves the effective assignment cap for a jury member.
*
* Precedence (first non-null wins):
* Layer 4b: selfServiceCap (bounded by admin max, only if allowJurorCapAdjustment)
* Layer 4a: maxAssignmentsOverride (admin per-member override)
* Layer 3: juryGroup.defaultMaxAssignments
* Layer 1: SYSTEM_DEFAULT_CAP (15)
*/
export function resolveEffectiveCap(ctx: MemberContext): PolicyResolution<number> {
const group = ctx.juryGroup
// Layer 4b: Self-service cap (juror-set during onboarding)
if (group?.allowJurorCapAdjustment && ctx.member.selfServiceCap != null) {
const adminMax =
ctx.member.maxAssignmentsOverride
?? group.defaultMaxAssignments
?? SYSTEM_DEFAULT_CAP
const bounded = Math.min(ctx.member.selfServiceCap, adminMax)
return {
value: bounded,
source: 'member',
explanation: `Self-service cap ${ctx.member.selfServiceCap} (bounded to ${adminMax})`,
}
}
// Layer 4a: Admin per-member override
if (ctx.member.maxAssignmentsOverride != null) {
return {
value: ctx.member.maxAssignmentsOverride,
source: 'member',
explanation: `Admin per-member override: ${ctx.member.maxAssignmentsOverride}`,
}
}
// Layer 3: Jury group default
if (group) {
return {
value: group.defaultMaxAssignments,
source: 'jury_group',
explanation: `Jury group "${group.name}" default: ${group.defaultMaxAssignments}`,
}
}
// Layer 1: System default
return {
value: SYSTEM_DEFAULT_CAP,
source: 'system',
explanation: `System default: ${SYSTEM_DEFAULT_CAP}`,
}
}
// ============================================================================
// Effective Cap Mode Resolution
// ============================================================================
/**
* Resolves the effective cap mode for a jury member.
*
* Precedence:
* Layer 4a: capModeOverride (admin per-member)
* Layer 3: juryGroup.defaultCapMode
* Layer 1: SYSTEM_DEFAULT_CAP_MODE (SOFT)
*/
export function resolveEffectiveCapMode(ctx: MemberContext): PolicyResolution<CapMode> {
// Layer 4a: Admin per-member override
if (ctx.member.capModeOverride != null) {
return {
value: ctx.member.capModeOverride,
source: 'member',
explanation: `Admin per-member cap mode override: ${ctx.member.capModeOverride}`,
}
}
// Layer 3: Jury group default
if (ctx.juryGroup) {
return {
value: ctx.juryGroup.defaultCapMode,
source: 'jury_group',
explanation: `Jury group "${ctx.juryGroup.name}" default: ${ctx.juryGroup.defaultCapMode}`,
}
}
// Layer 1: System default
return {
value: SYSTEM_DEFAULT_CAP_MODE,
source: 'system',
explanation: `System default: ${SYSTEM_DEFAULT_CAP_MODE}`,
}
}
// ============================================================================
// Effective Soft Cap Buffer Resolution
// ============================================================================
/**
* Resolves the effective soft cap buffer.
* Only meaningful when capMode is SOFT.
*
* Precedence:
* Layer 3: juryGroup.softCapBuffer
* Layer 1: SYSTEM_DEFAULT_SOFT_BUFFER (2)
*/
export function resolveEffectiveSoftCapBuffer(
ctx: MemberContext,
): PolicyResolution<number> {
if (ctx.juryGroup) {
return {
value: ctx.juryGroup.softCapBuffer,
source: 'jury_group',
explanation: `Jury group "${ctx.juryGroup.name}" buffer: ${ctx.juryGroup.softCapBuffer}`,
}
}
return {
value: SYSTEM_DEFAULT_SOFT_BUFFER,
source: 'system',
explanation: `System default buffer: ${SYSTEM_DEFAULT_SOFT_BUFFER}`,
}
}
// ============================================================================
// Effective Category Bias Resolution
// ============================================================================
/**
* Resolves the effective category bias (startup ratio) for a jury member.
*
* Precedence:
* Layer 4b: selfServiceRatio (if allowJurorRatioAdjustment)
* Layer 4a: preferredStartupRatio (admin per-member)
* Layer 3: juryGroup.defaultCategoryQuotas (derived ratio)
* Default: null (no preference)
*/
export function resolveEffectiveCategoryBias(
ctx: MemberContext,
): PolicyResolution<Record<string, number> | null> {
const group = ctx.juryGroup
// Layer 4b: Self-service ratio
if (group?.allowJurorRatioAdjustment && ctx.member.selfServiceRatio != null) {
const ratio = ctx.member.selfServiceRatio
return {
value: { STARTUP: ratio, BUSINESS_CONCEPT: 1 - ratio },
source: 'member',
explanation: `Self-service ratio: ${Math.round(ratio * 100)}% startup`,
}
}
// Layer 4a: Admin per-member preferred ratio
if (ctx.member.preferredStartupRatio != null) {
const ratio = ctx.member.preferredStartupRatio
return {
value: { STARTUP: ratio, BUSINESS_CONCEPT: 1 - ratio },
source: 'member',
explanation: `Admin-set ratio: ${Math.round(ratio * 100)}% startup`,
}
}
// Layer 3: Jury group default category quotas (derive ratio from quotas)
if (group?.categoryQuotasEnabled && group.defaultCategoryQuotas) {
const quotas = group.defaultCategoryQuotas as Record<
string,
{ min: number; max: number }
>
const totalMax = Object.values(quotas).reduce((sum, q) => sum + q.max, 0)
if (totalMax > 0) {
const bias: Record<string, number> = {}
for (const [cat, q] of Object.entries(quotas)) {
bias[cat] = q.max / totalMax
}
return {
value: bias,
source: 'jury_group',
explanation: `Derived from group category quotas`,
}
}
}
// No preference
return {
value: null,
source: 'system',
explanation: 'No category bias configured',
}
}
// ============================================================================
// Aggregate Policy Evaluation
// ============================================================================
export type AssignmentPolicyResult = {
effectiveCap: PolicyResolution<number>
effectiveCapMode: PolicyResolution<CapMode>
softCapBuffer: PolicyResolution<number>
categoryBias: PolicyResolution<Record<string, number> | null>
canAssignMore: boolean
remainingCapacity: number
isOverCap: boolean
overCapBy: number
}
/**
* Evaluates all assignment policies for a member in one call.
* Returns resolved values with provenance plus computed flags.
*/
export function evaluateAssignmentPolicy(
ctx: MemberContext,
): AssignmentPolicyResult {
const effectiveCap = resolveEffectiveCap(ctx)
const effectiveCapMode = resolveEffectiveCapMode(ctx)
const softCapBuffer = resolveEffectiveSoftCapBuffer(ctx)
const categoryBias = resolveEffectiveCategoryBias(ctx)
const cap = effectiveCap.value
const mode = effectiveCapMode.value
const buffer = softCapBuffer.value
const count = ctx.currentAssignmentCount
const isOverCap = count > cap
const overCapBy = Math.max(0, count - cap)
const remainingCapacity =
mode === 'NONE'
? Infinity
: mode === 'SOFT'
? Math.max(0, cap + buffer - count)
: Math.max(0, cap - count)
const canAssignMore =
mode === 'NONE'
? true
: mode === 'SOFT'
? count < cap + buffer
: count < cap
return {
effectiveCap,
effectiveCapMode,
softCapBuffer,
categoryBias,
canAssignMore,
remainingCapacity,
isOverCap,
overCapBy,
}
}

View File

@@ -0,0 +1,160 @@
import { TRPCError } from '@trpc/server'
import type {
Competition,
Round,
JuryGroup,
JuryGroupMember,
SubmissionWindow,
AssignmentIntent,
Program,
User,
RoundType,
} from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { validateRoundConfig, type RoundConfigMap } from '@/types/competition-configs'
// ============================================================================
// Context Types
// ============================================================================
export type CompetitionContext = {
competition: Competition & { program: Program }
round: Round
roundConfig: RoundConfigMap[RoundType]
juryGroup: JuryGroup | null
submissionWindows: SubmissionWindow[]
}
export type MemberContext = CompetitionContext & {
member: JuryGroupMember
user: Pick<User, 'id' | 'name' | 'email' | 'role'>
currentAssignmentCount: number
assignmentsByCategory: Record<string, number>
pendingIntents: AssignmentIntent[]
}
// ============================================================================
// resolveCompetitionContext
// ============================================================================
/**
* Load full competition context for a given round.
* Parses the round's configJson into a typed config using the round's RoundType.
*/
export async function resolveCompetitionContext(
roundId: string,
): Promise<CompetitionContext> {
const round = await prisma.round.findUnique({
where: { id: roundId },
include: {
competition: {
include: { program: true },
},
juryGroup: true,
},
})
if (!round) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Round ${roundId} not found` })
}
// Parse the typed config
const roundConfig = validateRoundConfig(
round.roundType,
round.configJson as Record<string, unknown>,
)
// Load submission windows for the competition
const submissionWindows = await prisma.submissionWindow.findMany({
where: { competitionId: round.competitionId },
orderBy: { roundNumber: 'asc' },
})
return {
competition: round.competition,
round,
roundConfig,
juryGroup: round.juryGroup,
submissionWindows,
}
}
// ============================================================================
// resolveMemberContext
// ============================================================================
/**
* Load full member context for a given round + user.
* Includes assignment counts, category breakdown, and pending intents.
*/
export async function resolveMemberContext(
roundId: string,
userId: string,
): Promise<MemberContext> {
const ctx = await resolveCompetitionContext(roundId)
if (!ctx.juryGroup) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Round ${roundId} has no linked jury group`,
})
}
// Find the member in this jury group
const member = await prisma.juryGroupMember.findUnique({
where: {
juryGroupId_userId: {
juryGroupId: ctx.juryGroup.id,
userId,
},
},
include: {
user: { select: { id: true, name: true, email: true, role: true } },
},
})
if (!member) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User ${userId} is not a member of jury group "${ctx.juryGroup.name}"`,
})
}
// Count current assignments for this user in this round
const assignments = await prisma.assignment.findMany({
where: {
userId,
roundId,
},
include: {
project: { select: { competitionCategory: true } },
},
})
const currentAssignmentCount = assignments.length
// Break down assignments by category
const assignmentsByCategory: Record<string, number> = {}
for (const a of assignments) {
const cat = a.project.competitionCategory ?? 'UNCATEGORIZED'
assignmentsByCategory[cat] = (assignmentsByCategory[cat] ?? 0) + 1
}
// Load pending intents
const pendingIntents = await prisma.assignmentIntent.findMany({
where: {
juryGroupMemberId: member.id,
roundId,
status: 'INTENT_PENDING',
},
})
return {
...ctx,
member,
user: member.user,
currentAssignmentCount,
assignmentsByCategory,
pendingIntents,
}
}

View File

@@ -0,0 +1,716 @@
/**
* Deliberation Service
*
* Full deliberation lifecycle: session management, voting, aggregation,
* tie-breaking, and finalization.
*
* Session transitions: DELIB_OPEN → VOTING → TALLYING → DELIB_LOCKED
* → RUNOFF → TALLYING (max 3 runoff rounds)
*/
import type {
PrismaClient,
DeliberationMode,
DeliberationStatus,
TieBreakMethod,
CompetitionCategory,
DeliberationParticipantStatus,
Prisma,
} from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
export type SessionTransitionResult = {
success: boolean
session?: { id: string; status: DeliberationStatus }
errors?: string[]
}
export type AggregationResult = {
rankings: Array<{
projectId: string
rank: number
voteCount: number
score: number
}>
hasTies: boolean
tiedProjectIds: string[]
}
const MAX_RUNOFF_ROUNDS = 3
// ─── Valid Transitions ──────────────────────────────────────────────────────
const VALID_SESSION_TRANSITIONS: Record<string, string[]> = {
DELIB_OPEN: ['VOTING'],
VOTING: ['TALLYING'],
TALLYING: ['DELIB_LOCKED', 'RUNOFF'],
RUNOFF: ['TALLYING'],
DELIB_LOCKED: [],
}
// ─── Session Lifecycle ──────────────────────────────────────────────────────
/**
* Create a new deliberation session with participants.
*/
export async function createSession(
params: {
competitionId: string
roundId: string
category: CompetitionCategory
mode: DeliberationMode
tieBreakMethod: TieBreakMethod
showCollectiveRankings?: boolean
showPriorJuryData?: boolean
participantUserIds: string[] // JuryGroupMember IDs
},
prisma: PrismaClient | any,
) {
return prisma.$transaction(async (tx: any) => {
const session = await tx.deliberationSession.create({
data: {
competitionId: params.competitionId,
roundId: params.roundId,
category: params.category,
mode: params.mode,
tieBreakMethod: params.tieBreakMethod,
showCollectiveRankings: params.showCollectiveRankings ?? false,
showPriorJuryData: params.showPriorJuryData ?? false,
status: 'DELIB_OPEN',
},
})
// Create participant records
for (const userId of params.participantUserIds) {
await tx.deliberationParticipant.create({
data: {
sessionId: session.id,
userId,
status: 'REQUIRED',
},
})
}
await tx.decisionAuditLog.create({
data: {
eventType: 'deliberation.created',
entityType: 'DeliberationSession',
entityId: session.id,
actorId: null,
detailsJson: {
competitionId: params.competitionId,
roundId: params.roundId,
category: params.category,
mode: params.mode,
participantCount: params.participantUserIds.length,
} as Prisma.InputJsonValue,
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' },
},
})
return session
})
}
/**
* Open voting: DELIB_OPEN → VOTING
*/
export async function openVoting(
sessionId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<SessionTransitionResult> {
return transitionSession(sessionId, 'DELIB_OPEN', 'VOTING', actorId, prisma)
}
/**
* Close voting: VOTING → TALLYING
* Triggers vote aggregation.
*/
export async function closeVoting(
sessionId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<SessionTransitionResult> {
return transitionSession(sessionId, 'VOTING', 'TALLYING', actorId, prisma)
}
// ─── Vote Submission ────────────────────────────────────────────────────────
/**
* Submit a vote in a deliberation session.
* Validates: session is VOTING (or RUNOFF), juryMember is active participant.
*/
export async function submitVote(
params: {
sessionId: string
juryMemberId: string // JuryGroupMember ID
projectId: string
rank?: number
isWinnerPick?: boolean
runoffRound?: number
},
prisma: PrismaClient | any,
) {
const session = await prisma.deliberationSession.findUnique({
where: { id: params.sessionId },
})
if (!session) {
throw new Error('Deliberation session not found')
}
if (session.status !== 'VOTING' && session.status !== 'RUNOFF') {
throw new Error(`Cannot vote: session status is ${session.status}`)
}
// Verify participant is active
const participant = await prisma.deliberationParticipant.findUnique({
where: {
sessionId_userId: {
sessionId: params.sessionId,
userId: params.juryMemberId,
},
},
})
if (!participant) {
throw new Error('Juror is not a participant in this deliberation')
}
if (participant.status !== 'REQUIRED' && participant.status !== 'REPLACEMENT_ACTIVE') {
throw new Error(`Participant status ${participant.status} does not allow voting`)
}
const runoffRound = params.runoffRound ?? 0
return prisma.deliberationVote.upsert({
where: {
sessionId_juryMemberId_projectId_runoffRound: {
sessionId: params.sessionId,
juryMemberId: params.juryMemberId,
projectId: params.projectId,
runoffRound,
},
},
create: {
sessionId: params.sessionId,
juryMemberId: params.juryMemberId,
projectId: params.projectId,
rank: params.rank,
isWinnerPick: params.isWinnerPick ?? false,
runoffRound,
},
update: {
rank: params.rank,
isWinnerPick: params.isWinnerPick ?? false,
},
})
}
// ─── Aggregation ────────────────────────────────────────────────────────────
/**
* Aggregate votes for a session.
* - SINGLE_WINNER_VOTE: count isWinnerPick=true per project
* - FULL_RANKING: Borda count (N points for rank 1, N-1 for rank 2, etc.)
*/
export async function aggregateVotes(
sessionId: string,
prisma: PrismaClient | any,
): Promise<AggregationResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
})
if (!session) {
throw new Error('Deliberation session not found')
}
// Get the latest runoff round
const latestVote = await prisma.deliberationVote.findFirst({
where: { sessionId },
orderBy: { runoffRound: 'desc' },
select: { runoffRound: true },
})
const currentRound = latestVote?.runoffRound ?? 0
const votes = await prisma.deliberationVote.findMany({
where: { sessionId, runoffRound: currentRound },
})
const projectScores = new Map<string, number>()
const projectVoteCounts = new Map<string, number>()
if (session.mode === 'SINGLE_WINNER_VOTE') {
// Count isWinnerPick=true per project
for (const vote of votes) {
if (vote.isWinnerPick) {
projectScores.set(vote.projectId, (projectScores.get(vote.projectId) ?? 0) + 1)
projectVoteCounts.set(vote.projectId, (projectVoteCounts.get(vote.projectId) ?? 0) + 1)
}
}
} else {
// FULL_RANKING: Borda count
// First, find N = total unique projects being ranked
const uniqueProjects = new Set(votes.map((v: any) => v.projectId))
const n = uniqueProjects.size
for (const vote of votes) {
if (vote.rank != null) {
// Borda: rank 1 gets N points, rank 2 gets N-1, etc.
const score = Math.max(0, n + 1 - vote.rank)
projectScores.set(vote.projectId, (projectScores.get(vote.projectId) ?? 0) + score)
projectVoteCounts.set(vote.projectId, (projectVoteCounts.get(vote.projectId) ?? 0) + 1)
}
}
}
// Sort by score descending
const sorted = [...projectScores.entries()]
.sort(([, a], [, b]) => b - a)
.map(([projectId, score], index) => ({
projectId,
rank: index + 1,
voteCount: projectVoteCounts.get(projectId) ?? 0,
score,
}))
// Detect ties: projects with same score get same rank
const rankings: typeof sorted = []
let currentRank = 1
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i].score === sorted[i - 1].score) {
rankings.push({ ...sorted[i], rank: rankings[i - 1].rank })
} else {
rankings.push({ ...sorted[i], rank: currentRank })
}
currentRank = rankings[i].rank + 1
}
// Find tied projects (projects sharing rank 1, or if no clear winner)
const topScore = rankings.length > 0 ? rankings[0].score : 0
const tiedProjectIds = rankings.filter((r) => r.score === topScore && topScore > 0).length > 1
? rankings.filter((r) => r.score === topScore).map((r) => r.projectId)
: []
return {
rankings,
hasTies: tiedProjectIds.length > 1,
tiedProjectIds,
}
}
// ─── Tie-Breaking ───────────────────────────────────────────────────────────
/**
* Initiate a runoff vote for tied projects.
* TALLYING → RUNOFF
*/
export async function initRunoff(
sessionId: string,
tiedProjectIds: string[],
actorId: string,
prisma: PrismaClient | any,
): Promise<SessionTransitionResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
})
if (!session) {
return { success: false, errors: ['Session not found'] }
}
if (session.status !== 'TALLYING') {
return { success: false, errors: [`Cannot init runoff: status is ${session.status}`] }
}
// Check max runoff rounds
const latestVote = await prisma.deliberationVote.findFirst({
where: { sessionId },
orderBy: { runoffRound: 'desc' },
select: { runoffRound: true },
})
const nextRound = (latestVote?.runoffRound ?? 0) + 1
if (nextRound > MAX_RUNOFF_ROUNDS) {
return { success: false, errors: [`Maximum runoff rounds (${MAX_RUNOFF_ROUNDS}) exceeded`] }
}
return prisma.$transaction(async (tx: any) => {
const updated = await tx.deliberationSession.update({
where: { id: sessionId },
data: { status: 'RUNOFF' },
})
await tx.decisionAuditLog.create({
data: {
eventType: 'deliberation.runoff_initiated',
entityType: 'DeliberationSession',
entityId: sessionId,
actorId,
detailsJson: {
runoffRound: nextRound,
tiedProjectIds,
} as Prisma.InputJsonValue,
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' },
},
})
return {
success: true,
session: { id: updated.id, status: updated.status },
}
})
}
/**
* Admin override: directly set final rankings.
*/
export async function adminDecide(
sessionId: string,
rankings: Array<{ projectId: string; rank: number }>,
reason: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<SessionTransitionResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
})
if (!session) {
return { success: false, errors: ['Session not found'] }
}
if (session.status !== 'TALLYING') {
return { success: false, errors: [`Cannot admin-decide: status is ${session.status}`] }
}
return prisma.$transaction(async (tx: any) => {
const updated = await tx.deliberationSession.update({
where: { id: sessionId },
data: {
adminOverrideResult: {
rankings,
reason,
decidedBy: actorId,
decidedAt: new Date().toISOString(),
} as Prisma.InputJsonValue,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'deliberation.admin_override',
entityType: 'DeliberationSession',
entityId: sessionId,
actorId,
detailsJson: {
rankings,
reason,
} as Prisma.InputJsonValue,
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' },
},
})
return {
success: true,
session: { id: updated.id, status: updated.status },
}
})
}
// ─── Finalization ───────────────────────────────────────────────────────────
/**
* Finalize deliberation results: TALLYING → DELIB_LOCKED
* Creates DeliberationResult records.
*/
export async function finalizeResults(
sessionId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<SessionTransitionResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
})
if (!session) {
return { success: false, errors: ['Session not found'] }
}
if (session.status !== 'TALLYING') {
return { success: false, errors: [`Cannot finalize: status is ${session.status}`] }
}
// If admin override exists, use those rankings
const override = session.adminOverrideResult as {
rankings: Array<{ projectId: string; rank: number }>
} | null
let finalRankings: Array<{ projectId: string; rank: number; voteCount: number; isAdminOverridden: boolean }>
if (override?.rankings) {
finalRankings = override.rankings.map((r) => ({
projectId: r.projectId,
rank: r.rank,
voteCount: 0,
isAdminOverridden: true,
}))
} else {
// Use aggregated votes
const agg = await aggregateVotes(sessionId, prisma)
finalRankings = agg.rankings.map((r) => ({
projectId: r.projectId,
rank: r.rank,
voteCount: r.voteCount,
isAdminOverridden: false,
}))
}
return prisma.$transaction(async (tx: any) => {
// Create result records
for (const ranking of finalRankings) {
await tx.deliberationResult.upsert({
where: {
sessionId_projectId: {
sessionId,
projectId: ranking.projectId,
},
},
create: {
sessionId,
projectId: ranking.projectId,
finalRank: ranking.rank,
voteCount: ranking.voteCount,
isAdminOverridden: ranking.isAdminOverridden,
overrideReason: ranking.isAdminOverridden
? (session.adminOverrideResult as any)?.reason ?? null
: null,
},
update: {
finalRank: ranking.rank,
voteCount: ranking.voteCount,
isAdminOverridden: ranking.isAdminOverridden,
},
})
}
// Transition to DELIB_LOCKED
const updated = await tx.deliberationSession.update({
where: { id: sessionId },
data: { status: 'DELIB_LOCKED' },
})
await tx.decisionAuditLog.create({
data: {
eventType: 'deliberation.finalized',
entityType: 'DeliberationSession',
entityId: sessionId,
actorId,
detailsJson: {
resultCount: finalRankings.length,
isAdminOverride: finalRankings.some((r) => r.isAdminOverridden),
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'deliberation',
rankings: finalRankings,
},
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: 'DELIBERATION_FINALIZE',
entityType: 'DeliberationSession',
entityId: sessionId,
detailsJson: { resultCount: finalRankings.length },
})
return {
success: true,
session: { id: updated.id, status: updated.status },
}
})
}
// ─── Participant Management ─────────────────────────────────────────────────
/**
* Update a participant's status (e.g. mark absent, replace).
*/
export async function updateParticipantStatus(
sessionId: string,
userId: string,
status: DeliberationParticipantStatus,
replacedById?: string,
actorId?: string,
prisma?: PrismaClient | any,
) {
const db = prisma ?? (await import('@/lib/prisma')).prisma
return db.$transaction(async (tx: any) => {
const updated = await tx.deliberationParticipant.update({
where: { sessionId_userId: { sessionId, userId } },
data: {
status,
replacedById: replacedById ?? null,
},
})
// If replacing, create participant record for replacement
if (status === 'REPLACED' && replacedById) {
await tx.deliberationParticipant.upsert({
where: { sessionId_userId: { sessionId, userId: replacedById } },
create: {
sessionId,
userId: replacedById,
status: 'REPLACEMENT_ACTIVE',
},
update: {
status: 'REPLACEMENT_ACTIVE',
},
})
}
if (actorId) {
await tx.decisionAuditLog.create({
data: {
eventType: 'deliberation.participant_updated',
entityType: 'DeliberationParticipant',
entityId: updated.id,
actorId,
detailsJson: { userId, newStatus: status, replacedById } as Prisma.InputJsonValue,
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' },
},
})
}
return updated
})
}
// ─── Queries ────────────────────────────────────────────────────────────────
/**
* Get a deliberation session with votes, results, and participants.
*/
export async function getSessionWithVotes(
sessionId: string,
prisma: PrismaClient | any,
) {
return prisma.deliberationSession.findUnique({
where: { id: sessionId },
include: {
votes: {
include: {
project: { select: { id: true, title: true, teamName: true } },
juryMember: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
orderBy: [{ runoffRound: 'desc' }, { rank: 'asc' }],
},
results: {
include: {
project: { select: { id: true, title: true, teamName: true } },
},
orderBy: { finalRank: 'asc' },
},
participants: {
include: {
user: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
},
competition: { select: { id: true, name: true } },
round: { select: { id: true, name: true, roundType: true } },
},
})
}
// ─── Internal Helpers ───────────────────────────────────────────────────────
async function transitionSession(
sessionId: string,
expectedStatus: DeliberationStatus,
newStatus: DeliberationStatus,
actorId: string,
prisma: PrismaClient | any,
): Promise<SessionTransitionResult> {
try {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
})
if (!session) {
return { success: false, errors: ['Session not found'] }
}
if (session.status !== expectedStatus) {
return {
success: false,
errors: [`Cannot transition: status is ${session.status}, expected ${expectedStatus}`],
}
}
const valid = VALID_SESSION_TRANSITIONS[expectedStatus] ?? []
if (!valid.includes(newStatus)) {
return {
success: false,
errors: [`Invalid transition: ${expectedStatus}${newStatus}`],
}
}
const updated = await prisma.$transaction(async (tx: any) => {
const result = await tx.deliberationSession.update({
where: { id: sessionId },
data: { status: newStatus },
})
await tx.decisionAuditLog.create({
data: {
eventType: `deliberation.${newStatus.toLowerCase()}`,
entityType: 'DeliberationSession',
entityId: sessionId,
actorId,
detailsJson: {
previousStatus: expectedStatus,
newStatus,
} as Prisma.InputJsonValue,
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'deliberation' },
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: `DELIBERATION_${newStatus}`,
entityType: 'DeliberationSession',
entityId: sessionId,
})
return result
})
return {
success: true,
session: { id: updated.id, status: updated.status },
}
} catch (error) {
console.error('[Deliberation] Session transition failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
}

View File

@@ -145,14 +145,14 @@ async function getDigestContent(
where: {
userId,
isCompleted: false,
stage: {
status: 'STAGE_ACTIVE',
round: {
status: 'ROUND_ACTIVE',
windowCloseAt: { gt: now },
},
},
include: {
project: { select: { id: true, title: true } },
stage: { select: { name: true, windowCloseAt: true } },
round: { select: { name: true, windowCloseAt: true } },
},
})
@@ -162,9 +162,9 @@ async function getDigestContent(
title: `Pending Evaluations (${pendingAssignments.length})`,
items: pendingAssignments.map(
(a) =>
`${a.project.title} - ${a.stage?.name ?? 'Unknown'}${
a.stage?.windowCloseAt
? ` (due ${a.stage.windowCloseAt.toLocaleDateString('en-US', {
`${a.project.title} - ${a.round?.name ?? 'Unknown'}${
a.round?.windowCloseAt
? ` (due ${a.round.windowCloseAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})})`
@@ -175,12 +175,12 @@ async function getDigestContent(
}
}
// 2. Upcoming deadlines (stages closing within 7 days)
// 2. Upcoming deadlines (rounds closing within 7 days)
if (enabledSections.includes('upcoming_deadlines')) {
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
const upcomingStages = await prisma.stage.findMany({
const upcomingRounds = await prisma.round.findMany({
where: {
status: 'STAGE_ACTIVE',
status: 'ROUND_ACTIVE',
windowCloseAt: {
gt: now,
lte: sevenDaysFromNow,
@@ -198,11 +198,11 @@ async function getDigestContent(
},
})
upcomingDeadlines = upcomingStages.length
if (upcomingStages.length > 0) {
upcomingDeadlines = upcomingRounds.length
if (upcomingRounds.length > 0) {
sections.push({
title: 'Upcoming Deadlines',
items: upcomingStages.map(
items: upcomingRounds.map(
(s) =>
`${s.name} - ${s.windowCloseAt?.toLocaleDateString('en-US', {
weekday: 'short',
@@ -233,7 +233,7 @@ async function getDigestContent(
},
include: {
project: { select: { id: true, title: true } },
stage: { select: { name: true } },
round: { select: { name: true } },
},
})
@@ -242,7 +242,7 @@ async function getDigestContent(
sections.push({
title: `New Assignments (${recentAssignments.length})`,
items: recentAssignments.map(
(a) => `${a.project.title} - ${a.stage?.name ?? 'Unknown'}`
(a) => `${a.project.title} - ${a.round?.name ?? 'Unknown'}`
),
})
}

View File

@@ -18,33 +18,33 @@ interface ReminderResult {
* Find active stages with approaching deadlines and send reminders
* to jurors who have incomplete assignments.
*/
export async function processEvaluationReminders(stageId?: string): Promise<ReminderResult> {
export async function processEvaluationReminders(roundId?: string): Promise<ReminderResult> {
const now = new Date()
let totalSent = 0
let totalErrors = 0
// Find active stages with window close dates in the future
const stages = await prisma.stage.findMany({
// Find active rounds with window close dates in the future
const rounds = await prisma.round.findMany({
where: {
status: 'STAGE_ACTIVE',
status: 'ROUND_ACTIVE' as const,
windowCloseAt: { gt: now },
windowOpenAt: { lte: now },
...(stageId && { id: stageId }),
...(roundId && { id: roundId }),
},
select: {
id: true,
name: true,
windowCloseAt: true,
track: { select: { name: true } },
competition: { select: { name: true } },
},
})
for (const stage of stages) {
if (!stage.windowCloseAt) continue
for (const round of rounds) {
if (!round.windowCloseAt) continue
const msUntilDeadline = stage.windowCloseAt.getTime() - now.getTime()
const msUntilDeadline = round.windowCloseAt.getTime() - now.getTime()
// Determine which reminder types should fire for this stage
// Determine which reminder types should fire for this round
const applicableTypes = REMINDER_TYPES.filter(
({ thresholdMs }) => msUntilDeadline <= thresholdMs
)
@@ -52,7 +52,7 @@ export async function processEvaluationReminders(stageId?: string): Promise<Remi
if (applicableTypes.length === 0) continue
for (const { type } of applicableTypes) {
const result = await sendRemindersForStage(stage, type, now)
const result = await sendRemindersForRound(round, type, now)
totalSent += result.sent
totalErrors += result.errors
}
@@ -61,12 +61,12 @@ export async function processEvaluationReminders(stageId?: string): Promise<Remi
return { sent: totalSent, errors: totalErrors }
}
async function sendRemindersForStage(
stage: {
async function sendRemindersForRound(
round: {
id: string
name: string
windowCloseAt: Date | null
track: { name: string }
competition: { name: string } | null
},
type: ReminderType,
now: Date
@@ -74,12 +74,12 @@ async function sendRemindersForStage(
let sent = 0
let errors = 0
if (!stage.windowCloseAt) return { sent, errors }
if (!round.windowCloseAt) return { sent, errors }
// Find jurors with incomplete assignments for this stage
// Find jurors with incomplete assignments for this round
const incompleteAssignments = await prisma.assignment.findMany({
where: {
stageId: stage.id,
roundId: round.id,
isCompleted: false,
},
select: {
@@ -92,10 +92,10 @@ async function sendRemindersForStage(
if (userIds.length === 0) return { sent, errors }
// Check which users already received this reminder type for this stage
// Check which users already received this reminder type for this round
const existingReminders = await prisma.reminderLog.findMany({
where: {
stageId: stage.id,
roundId: round.id,
type,
userId: { in: userIds },
},
@@ -114,7 +114,7 @@ async function sendRemindersForStage(
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const deadlineStr = stage.windowCloseAt.toLocaleDateString('en-US', {
const deadlineStr = round.windowCloseAt.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -144,12 +144,12 @@ async function sendRemindersForStage(
emailTemplateType,
{
name: user.name || undefined,
title: `Evaluation Reminder - ${stage.name}`,
message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${stage.name}.`,
linkUrl: `${baseUrl}/jury/stages/${stage.id}/assignments`,
title: `Evaluation Reminder - ${round.name}`,
message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${round.name}.`,
linkUrl: `${baseUrl}/jury/rounds/${round.id}/assignments`,
metadata: {
pendingCount,
stageName: stage.name,
roundName: round.name,
deadline: deadlineStr,
},
}
@@ -158,7 +158,7 @@ async function sendRemindersForStage(
// Log the sent reminder
await prisma.reminderLog.create({
data: {
stageId: stage.id,
roundId: round.id,
userId: user.id,
type,
},
@@ -167,7 +167,7 @@ async function sendRemindersForStage(
sent++
} catch (error) {
console.error(
`Failed to send ${type} reminder to ${user.email} for stage ${stage.name}:`,
`Failed to send ${type} reminder to ${user.email} for round ${round.name}:`,
error
)
errors++

View File

@@ -307,11 +307,11 @@ export async function notifyAdmins(params: {
* Notify all jury members for a specific stage
*/
export async function notifyStageJury(
stageId: string,
roundId: string,
params: Omit<CreateNotificationParams, 'userId'>
): Promise<void> {
const assignments = await prisma.assignment.findMany({
where: { stageId },
where: { roundId },
select: { userId: true },
distinct: ['userId'],
})

View File

@@ -1,7 +1,7 @@
/**
* Live Control Service
*
* Manages real-time control of live final events within a pipeline stage.
* Manages real-time control of live final events within a round.
* Handles session management, project cursor navigation, queue reordering,
* pause/resume, and cohort voting windows.
*
@@ -22,7 +22,7 @@ export interface SessionResult {
}
export interface CursorState {
stageId: string
roundId: string
sessionId: string
activeProjectId: string | null
activeOrderIndex: number
@@ -41,36 +41,36 @@ function generateSessionId(): string {
// ─── Start Session ──────────────────────────────────────────────────────────
/**
* Create or reset a LiveProgressCursor for a stage. If a cursor already exists,
* Create or reset a LiveProgressCursor for a round. If a cursor already exists,
* it is reset to the beginning. A new sessionId is always generated.
*/
export async function startSession(
stageId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any
): Promise<SessionResult> {
try {
// Verify stage exists and is a LIVE_FINAL type
const stage = await prisma.stage.findUnique({
where: { id: stageId },
// Verify round exists and is a LIVE_FINAL type
const round = await prisma.round.findUnique({
where: { id: roundId },
})
if (!stage) {
if (!round) {
return {
success: false,
sessionId: null,
cursorId: null,
errors: [`Stage ${stageId} not found`],
errors: [`Round ${roundId} not found`],
}
}
if (stage.stageType !== 'LIVE_FINAL') {
if (round.roundType !== 'LIVE_FINAL') {
return {
success: false,
sessionId: null,
cursorId: null,
errors: [
`Stage "${stage.name}" is type ${stage.stageType}, expected LIVE_FINAL`,
`Round "${round.name}" is type ${round.roundType}, expected LIVE_FINAL`,
],
}
}
@@ -78,7 +78,7 @@ export async function startSession(
// Find the first project in the first cohort
const firstCohortProject = await prisma.cohortProject.findFirst({
where: {
cohort: { stageId },
cohort: { roundId },
},
orderBy: { sortOrder: 'asc' as const },
select: { projectId: true },
@@ -86,11 +86,11 @@ export async function startSession(
const sessionId = generateSessionId()
// Upsert the cursor (one per stage)
// Upsert the cursor (one per round)
const cursor = await prisma.liveProgressCursor.upsert({
where: { stageId },
where: { roundId },
create: {
stageId,
roundId,
sessionId,
activeProjectId: firstCohortProject?.projectId ?? null,
activeOrderIndex: 0,
@@ -112,7 +112,7 @@ export async function startSession(
entityId: cursor.id,
actorId,
detailsJson: {
stageId,
roundId,
sessionId,
firstProjectId: firstCohortProject?.projectId ?? null,
},
@@ -125,7 +125,7 @@ export async function startSession(
action: 'LIVE_SESSION_STARTED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { stageId, sessionId },
detailsJson: { roundId, sessionId },
})
return {
@@ -150,11 +150,11 @@ export async function startSession(
/**
* Set the currently active project in the live session.
* Validates that the project belongs to a cohort in this stage and performs
* Validates that the project belongs to a cohort in this round and performs
* a version check on the cursor's sessionId to prevent stale updates.
*/
export async function setActiveProject(
stageId: string,
roundId: string,
projectId: string,
actorId: string,
prisma: PrismaClient | any
@@ -162,21 +162,21 @@ export async function setActiveProject(
try {
// Verify cursor exists
const cursor = await prisma.liveProgressCursor.findUnique({
where: { stageId },
where: { roundId },
})
if (!cursor) {
return {
success: false,
errors: ['No live session found for this stage. Start a session first.'],
errors: ['No live session found for this round. Start a session first.'],
}
}
// Verify project is in a cohort for this stage
// Verify project is in a cohort for this round
const cohortProject = await prisma.cohortProject.findFirst({
where: {
projectId,
cohort: { stageId },
cohort: { roundId },
},
select: { id: true, sortOrder: true },
})
@@ -185,14 +185,14 @@ export async function setActiveProject(
return {
success: false,
errors: [
`Project ${projectId} is not in any cohort for stage ${stageId}`,
`Project ${projectId} is not in any cohort for round ${roundId}`,
],
}
}
// Update cursor
await prisma.liveProgressCursor.update({
where: { stageId },
where: { roundId },
data: {
activeProjectId: projectId,
activeOrderIndex: cohortProject.sortOrder,
@@ -207,7 +207,7 @@ export async function setActiveProject(
entityId: cursor.id,
actorId,
detailsJson: {
stageId,
roundId,
projectId,
orderIndex: cohortProject.sortOrder,
action: 'setActiveProject',
@@ -244,27 +244,27 @@ export async function setActiveProject(
* Jump to a project by its order index in the cohort queue.
*/
export async function jumpToProject(
stageId: string,
roundId: string,
orderIndex: number,
actorId: string,
prisma: PrismaClient | any
): Promise<{ success: boolean; projectId?: string; errors?: string[] }> {
try {
const cursor = await prisma.liveProgressCursor.findUnique({
where: { stageId },
where: { roundId },
})
if (!cursor) {
return {
success: false,
errors: ['No live session found for this stage'],
errors: ['No live session found for this round'],
}
}
// Find the CohortProject at the given sort order
const cohortProject = await prisma.cohortProject.findFirst({
where: {
cohort: { stageId },
cohort: { roundId },
sortOrder: orderIndex,
},
select: { projectId: true, sortOrder: true },
@@ -279,7 +279,7 @@ export async function jumpToProject(
// Update cursor
await prisma.liveProgressCursor.update({
where: { stageId },
where: { roundId },
data: {
activeProjectId: cohortProject.projectId,
activeOrderIndex: orderIndex,
@@ -293,7 +293,7 @@ export async function jumpToProject(
entityId: cursor.id,
actorId,
detailsJson: {
stageId,
roundId,
projectId: cohortProject.projectId,
orderIndex,
action: 'jumpToProject',
@@ -329,17 +329,17 @@ export async function jumpToProject(
* newOrder is an array of cohortProjectIds in the desired order.
*/
export async function reorderQueue(
stageId: string,
roundId: string,
newOrder: string[],
actorId: string,
prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }> {
try {
// Verify all provided IDs belong to cohorts in this stage
// Verify all provided IDs belong to cohorts in this round
const cohortProjects = await prisma.cohortProject.findMany({
where: {
id: { in: newOrder },
cohort: { stageId },
cohort: { roundId },
},
select: { id: true },
})
@@ -351,7 +351,7 @@ export async function reorderQueue(
return {
success: false,
errors: [
`CohortProject IDs not found in stage ${stageId}: ${invalidIds.join(', ')}`,
`CohortProject IDs not found in round ${roundId}: ${invalidIds.join(', ')}`,
],
}
}
@@ -367,7 +367,7 @@ export async function reorderQueue(
)
const cursor = await prisma.liveProgressCursor.findUnique({
where: { stageId },
where: { roundId },
})
if (cursor) {
@@ -378,7 +378,7 @@ export async function reorderQueue(
entityId: cursor.id,
actorId,
detailsJson: {
stageId,
roundId,
newOrderCount: newOrder.length,
},
},
@@ -389,8 +389,8 @@ export async function reorderQueue(
prisma,
userId: actorId,
action: 'LIVE_REORDER_QUEUE',
entityType: 'Stage',
entityId: stageId,
entityType: 'Round',
entityId: roundId,
detailsJson: { reorderedCount: newOrder.length },
})
@@ -412,25 +412,25 @@ export async function reorderQueue(
* Toggle the pause state of a live session.
*/
export async function pauseResume(
stageId: string,
roundId: string,
isPaused: boolean,
actorId: string,
prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }> {
try {
const cursor = await prisma.liveProgressCursor.findUnique({
where: { stageId },
where: { roundId },
})
if (!cursor) {
return {
success: false,
errors: ['No live session found for this stage'],
errors: ['No live session found for this round'],
}
}
await prisma.liveProgressCursor.update({
where: { stageId },
where: { roundId },
data: { isPaused },
})
@@ -441,7 +441,7 @@ export async function pauseResume(
entityId: cursor.id,
actorId,
detailsJson: {
stageId,
roundId,
isPaused,
sessionId: cursor.sessionId,
},
@@ -454,7 +454,7 @@ export async function pauseResume(
action: isPaused ? 'LIVE_SESSION_PAUSED' : 'LIVE_SESSION_RESUMED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { stageId, isPaused },
detailsJson: { roundId, isPaused },
})
return { success: true }
@@ -516,7 +516,7 @@ export async function openCohortWindow(
actorId,
detailsJson: {
cohortName: cohort.name,
stageId: cohort.stageId,
roundId: cohort.roundId,
openedAt: now.toISOString(),
},
},
@@ -528,7 +528,7 @@ export async function openCohortWindow(
action: 'LIVE_COHORT_OPENED',
entityType: 'Cohort',
entityId: cohortId,
detailsJson: { cohortName: cohort.name, stageId: cohort.stageId },
detailsJson: { cohortName: cohort.name, roundId: cohort.roundId },
})
return { success: true }
@@ -588,7 +588,7 @@ export async function closeCohortWindow(
actorId,
detailsJson: {
cohortName: cohort.name,
stageId: cohort.stageId,
roundId: cohort.roundId,
closedAt: now.toISOString(),
},
},
@@ -600,7 +600,7 @@ export async function closeCohortWindow(
action: 'LIVE_COHORT_CLOSED',
entityType: 'Cohort',
entityId: cohortId,
detailsJson: { cohortName: cohort.name, stageId: cohort.stageId },
detailsJson: { cohortName: cohort.name, roundId: cohort.roundId },
})
return { success: true }

View File

@@ -0,0 +1,314 @@
/**
* Mentor Workspace Service
*
* Manages mentor-applicant workspace: activation, messaging, file management,
* and file promotion to official submissions. Operates on MentorAssignment,
* MentorMessage, MentorFile, MentorFileComment, SubmissionPromotionEvent.
*/
import type { PrismaClient, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
type WorkspaceResult = { success: boolean; errors?: string[] }
// ─── Workspace Activation ───────────────────────────────────────────────────
/**
* Activate a mentor workspace for a given assignment.
*/
export async function activateWorkspace(
mentorAssignmentId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<WorkspaceResult> {
try {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: mentorAssignmentId },
})
if (!assignment) {
return { success: false, errors: ['Mentor assignment not found'] }
}
if (assignment.workspaceEnabled) {
return { success: false, errors: ['Workspace is already enabled'] }
}
await prisma.$transaction(async (tx: any) => {
await tx.mentorAssignment.update({
where: { id: mentorAssignmentId },
data: {
workspaceEnabled: true,
workspaceOpenAt: new Date(),
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'mentor_workspace.activated',
entityType: 'MentorAssignment',
entityId: mentorAssignmentId,
actorId,
detailsJson: {
projectId: assignment.projectId,
mentorId: assignment.mentorId,
},
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'mentor-workspace' },
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: 'WORKSPACE_ACTIVATE',
entityType: 'MentorAssignment',
entityId: mentorAssignmentId,
detailsJson: { projectId: assignment.projectId },
})
})
return { success: true }
} catch (error) {
console.error('[MentorWorkspace] activateWorkspace failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
}
// ─── Messaging ──────────────────────────────────────────────────────────────
/**
* Send a message in a mentor workspace.
*/
export async function sendMessage(
params: {
mentorAssignmentId: string
senderId: string
message: string
role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE'
},
prisma: PrismaClient | any,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: params.mentorAssignmentId },
})
if (!assignment) {
throw new Error('Mentor assignment not found')
}
if (!assignment.workspaceEnabled) {
throw new Error('Workspace is not enabled for this assignment')
}
return prisma.mentorMessage.create({
data: {
mentorAssignmentId: params.mentorAssignmentId,
projectId: assignment.projectId,
senderId: params.senderId,
message: params.message,
role: params.role,
},
include: {
sender: { select: { id: true, name: true, email: true } },
},
})
}
/**
* Get messages for a workspace.
*/
export async function getMessages(
mentorAssignmentId: string,
prisma: PrismaClient | any,
) {
return prisma.mentorMessage.findMany({
where: { mentorAssignmentId },
include: {
sender: { select: { id: true, name: true, email: true, role: true } },
},
orderBy: { createdAt: 'asc' },
})
}
/**
* Mark a message as read.
*/
export async function markRead(
messageId: string,
prisma: PrismaClient | any,
): Promise<void> {
await prisma.mentorMessage.update({
where: { id: messageId },
data: { isRead: true },
})
}
// ─── File Management ────────────────────────────────────────────────────────
/**
* Record a file upload in a workspace.
*/
export async function uploadFile(
params: {
mentorAssignmentId: string
uploadedByUserId: string
fileName: string
mimeType: string
size: number
bucket: string
objectKey: string
description?: string
},
prisma: PrismaClient | any,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: params.mentorAssignmentId },
})
if (!assignment) {
throw new Error('Mentor assignment not found')
}
if (!assignment.workspaceEnabled) {
throw new Error('Workspace is not enabled for this assignment')
}
return prisma.mentorFile.create({
data: {
mentorAssignmentId: params.mentorAssignmentId,
uploadedByUserId: params.uploadedByUserId,
fileName: params.fileName,
mimeType: params.mimeType,
size: params.size,
bucket: params.bucket,
objectKey: params.objectKey,
description: params.description,
},
include: {
uploadedBy: { select: { id: true, name: true, email: true } },
},
})
}
/**
* Add a comment to a file.
*/
export async function addFileComment(
params: {
mentorFileId: string
authorId: string
content: string
parentCommentId?: string
},
prisma: PrismaClient | any,
) {
return prisma.mentorFileComment.create({
data: {
mentorFileId: params.mentorFileId,
authorId: params.authorId,
content: params.content,
parentCommentId: params.parentCommentId,
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
}
// ─── File Promotion ─────────────────────────────────────────────────────────
/**
* Promote a mentor file to an official submission.
* Creates SubmissionPromotionEvent and marks MentorFile.isPromoted = true.
*/
export async function promoteFile(
params: {
mentorFileId: string
roundId: string
slotKey: string
promotedById: string
},
prisma: PrismaClient | any,
): Promise<{ success: boolean; errors?: string[] }> {
try {
const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId },
include: {
mentorAssignment: { select: { projectId: true } },
},
})
if (!file) {
return { success: false, errors: ['Mentor file not found'] }
}
if (file.isPromoted) {
return { success: false, errors: ['File is already promoted'] }
}
await prisma.$transaction(async (tx: any) => {
// Mark file as promoted
await tx.mentorFile.update({
where: { id: params.mentorFileId },
data: {
isPromoted: true,
promotedAt: new Date(),
promotedByUserId: params.promotedById,
},
})
// Create promotion event
await tx.submissionPromotionEvent.create({
data: {
projectId: file.mentorAssignment.projectId,
roundId: params.roundId,
slotKey: params.slotKey,
sourceType: 'MENTOR_FILE',
sourceFileId: params.mentorFileId,
promotedById: params.promotedById,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'mentor_file.promoted',
entityType: 'MentorFile',
entityId: params.mentorFileId,
actorId: params.promotedById,
detailsJson: {
projectId: file.mentorAssignment.projectId,
roundId: params.roundId,
slotKey: params.slotKey,
fileName: file.fileName,
},
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'mentor-workspace' },
},
})
await logAudit({
prisma: tx,
userId: params.promotedById,
action: 'MENTOR_FILE_PROMOTE',
entityType: 'MentorFile',
entityId: params.mentorFileId,
detailsJson: {
projectId: file.mentorAssignment.projectId,
slotKey: params.slotKey,
},
})
})
return { success: true }
} catch (error) {
console.error('[MentorWorkspace] promoteFile failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
}

View File

@@ -0,0 +1,284 @@
/**
* Result Lock Service
*
* Immutable result locking with super-admin-only unlock mechanism.
* Creates point-in-time snapshots of deliberation results.
*/
import type { PrismaClient, CompetitionCategory, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
export type LockResult = {
success: boolean
lock?: { id: string; lockedAt: Date }
errors?: string[]
}
export type UnlockResult = {
success: boolean
event?: { id: string; unlockedAt: Date }
errors?: string[]
}
export type LockStatus = {
locked: boolean
lock?: {
id: string
lockedAt: Date
lockedBy: string
resultSnapshot: unknown
}
}
// ─── Lock Results ───────────────────────────────────────────────────────────
/**
* Lock results for a competition/round/category combination.
* Creates a ResultLock with a snapshot of deliberation results.
*/
export async function lockResults(
params: {
competitionId: string
roundId: string
category: CompetitionCategory
lockedById: string
resultSnapshot: unknown
},
prisma: PrismaClient | any,
): Promise<LockResult> {
try {
// Validate deliberation is finalized
const session = await prisma.deliberationSession.findFirst({
where: {
competitionId: params.competitionId,
roundId: params.roundId,
category: params.category,
status: 'DELIB_LOCKED',
},
})
if (!session) {
return {
success: false,
errors: ['No finalized deliberation session found for this competition/round/category'],
}
}
// Check if already locked
const existingLock = await prisma.resultLock.findFirst({
where: {
competitionId: params.competitionId,
roundId: params.roundId,
category: params.category,
},
include: { unlockEvents: true },
})
if (existingLock) {
// If there are no unlock events, it's still locked
const hasBeenUnlocked = existingLock.unlockEvents.length > 0
if (!hasBeenUnlocked) {
return { success: false, errors: ['Results are already locked'] }
}
}
const lock = await prisma.$transaction(async (tx: any) => {
const created = await tx.resultLock.create({
data: {
competitionId: params.competitionId,
roundId: params.roundId,
category: params.category,
lockedById: params.lockedById,
resultSnapshot: params.resultSnapshot as Prisma.InputJsonValue,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'results.locked',
entityType: 'ResultLock',
entityId: created.id,
actorId: params.lockedById,
detailsJson: {
competitionId: params.competitionId,
roundId: params.roundId,
category: params.category,
},
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'result-lock',
resultSnapshot: params.resultSnapshot,
},
},
})
await logAudit({
prisma: tx,
userId: params.lockedById,
action: 'RESULTS_LOCK',
entityType: 'ResultLock',
entityId: created.id,
detailsJson: {
competitionId: params.competitionId,
roundId: params.roundId,
category: params.category,
},
})
return created
})
return {
success: true,
lock: { id: lock.id, lockedAt: lock.lockedAt },
}
} catch (error) {
console.error('[ResultLock] lockResults failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
}
// ─── Unlock Results ─────────────────────────────────────────────────────────
/**
* Unlock results (super-admin only — enforced at router level).
* Creates a ResultUnlockEvent with a required reason.
*/
export async function unlockResults(
params: {
resultLockId: string
unlockedById: string
reason: string
},
prisma: PrismaClient | any,
): Promise<UnlockResult> {
try {
const lock = await prisma.resultLock.findUnique({
where: { id: params.resultLockId },
})
if (!lock) {
return { success: false, errors: ['Result lock not found'] }
}
const event = await prisma.$transaction(async (tx: any) => {
const created = await tx.resultUnlockEvent.create({
data: {
resultLockId: params.resultLockId,
unlockedById: params.unlockedById,
reason: params.reason,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'results.unlocked',
entityType: 'ResultUnlockEvent',
entityId: created.id,
actorId: params.unlockedById,
detailsJson: {
resultLockId: params.resultLockId,
reason: params.reason,
competitionId: lock.competitionId,
roundId: lock.roundId,
category: lock.category,
},
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'result-lock' },
},
})
await logAudit({
prisma: tx,
userId: params.unlockedById,
action: 'RESULTS_UNLOCK',
entityType: 'ResultLock',
entityId: params.resultLockId,
detailsJson: { reason: params.reason },
})
return created
})
return {
success: true,
event: { id: event.id, unlockedAt: event.unlockedAt },
}
} catch (error) {
console.error('[ResultLock] unlockResults failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
}
// ─── Query Helpers ──────────────────────────────────────────────────────────
/**
* Check if results are locked for a competition/round/category.
*/
export async function isLocked(
competitionId: string,
roundId: string,
category: CompetitionCategory,
prisma: PrismaClient | any,
): Promise<LockStatus> {
const lock = await prisma.resultLock.findFirst({
where: { competitionId, roundId, category },
include: {
unlockEvents: { orderBy: { unlockedAt: 'desc' } },
lockedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { lockedAt: 'desc' },
})
if (!lock) {
return { locked: false }
}
// If unlock events exist, the most recent action determines state
// A lock is "unlocked" if there's at least one unlock event after the lock
const hasBeenUnlocked = lock.unlockEvents.length > 0
if (hasBeenUnlocked) {
return { locked: false }
}
return {
locked: true,
lock: {
id: lock.id,
lockedAt: lock.lockedAt,
lockedBy: lock.lockedBy.name ?? lock.lockedBy.email,
resultSnapshot: lock.resultSnapshot,
},
}
}
/**
* Get lock history for a competition.
*/
export async function getLockHistory(
competitionId: string,
prisma: PrismaClient | any,
) {
return prisma.resultLock.findMany({
where: { competitionId },
include: {
round: { select: { id: true, name: true, roundType: true } },
lockedBy: { select: { id: true, name: true, email: true } },
unlockEvents: {
include: {
unlockedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { unlockedAt: 'desc' },
},
},
orderBy: { lockedAt: 'desc' },
})
}

View File

@@ -0,0 +1,568 @@
/**
* Enhanced Assignment Service (Round-Aware)
*
* Builds on existing smart-assignment scoring and integrates with the
* Phase 2 policy engine for cap/mode/bias resolution.
*/
import type { PrismaClient, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
import { resolveCompetitionContext, resolveMemberContext } from './competition-context'
import { evaluateAssignmentPolicy } from './assignment-policy'
import {
calculateTagOverlapScore,
calculateBioMatchScore,
calculateWorkloadScore,
calculateAvailabilityPenalty,
calculateCategoryQuotaPenalty,
type ProjectTagData,
type ScoreBreakdown,
} from './smart-assignment'
// ─── Types ──────────────────────────────────────────────────────────────────
export type AssignmentPreview = {
assignments: AssignmentSuggestion[]
warnings: string[]
stats: {
totalProjects: number
totalJurors: number
assignmentsGenerated: number
unassignedProjects: number
}
}
export type AssignmentSuggestion = {
userId: string
userName: string
projectId: string
projectTitle: string
score: number
breakdown: ScoreBreakdown
reasoning: string[]
matchingTags: string[]
policyViolations: string[]
fromIntent: boolean
}
export type CoverageReport = {
totalProjects: number
fullyAssigned: number
partiallyAssigned: number
unassigned: number
avgReviewsPerProject: number
requiredReviews: number
byCategory: Record<string, { total: number; assigned: number; coverage: number }>
}
// ─── Constants ──────────────────────────────────────────────────────────────
const GEO_DIVERSITY_THRESHOLD = 2
const GEO_DIVERSITY_PENALTY = -15
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
// ─── Preview Assignment ─────────────────────────────────────────────────────
/**
* Preview round assignments without committing them.
* 1. Load round + juryGroup + members via competition-context
* 2. Evaluate policy for each member via evaluateAssignmentPolicy()
* 3. Honor pending AssignmentIntents first
* 4. Run scoring algorithm
* 5. Enforce hard/soft caps per member
* 6. Return preview with policy violation warnings
*/
export async function previewRoundAssignment(
roundId: string,
config?: { honorIntents?: boolean; requiredReviews?: number },
prisma?: PrismaClient | any,
): Promise<AssignmentPreview> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const honorIntents = config?.honorIntents ?? true
const requiredReviews = config?.requiredReviews ?? 3
const ctx = await resolveCompetitionContext(roundId)
const warnings: string[] = []
if (!ctx.juryGroup) {
return {
assignments: [],
warnings: ['Round has no linked jury group'],
stats: { totalProjects: 0, totalJurors: 0, assignmentsGenerated: 0, unassignedProjects: 0 },
}
}
// Load jury group members
const members = await db.juryGroupMember.findMany({
where: { juryGroupId: ctx.juryGroup.id },
include: {
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
},
})
// Load projects in this round (with active ProjectRoundState)
const projectStates = await db.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
include: {
project: {
select: {
id: true,
title: true,
description: true,
country: true,
competitionCategory: true,
projectTags: { include: { tag: true } },
},
},
},
})
const projects = projectStates.map((ps: any) => ps.project)
if (projects.length === 0) {
return {
assignments: [],
warnings: ['No active projects in this round'],
stats: { totalProjects: 0, totalJurors: members.length, assignmentsGenerated: 0, unassignedProjects: 0 },
}
}
// Load existing assignments for this round
const existingAssignments = await db.assignment.findMany({
where: { roundId },
select: { userId: true, projectId: true },
})
const assignedPairs = new Set(existingAssignments.map((a: any) => `${a.userId}:${a.projectId}`))
// Track assignment counts per juror for policy evaluation
const jurorAssignmentCounts = new Map<string, number>()
for (const a of existingAssignments) {
jurorAssignmentCounts.set(a.userId, (jurorAssignmentCounts.get(a.userId) ?? 0) + 1)
}
// Load pending intents
let pendingIntents: any[] = []
if (honorIntents) {
pendingIntents = await db.assignmentIntent.findMany({
where: { roundId, status: 'INTENT_PENDING' },
include: {
juryGroupMember: { include: { user: { select: { id: true } } } },
},
})
}
// Load COI records
const coiRecords = await db.conflictOfInterest.findMany({
where: {
assignment: { roundId },
hasConflict: true,
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c: any) => `${c.userId}:${c.projectId}`))
// Build assignment suggestions
const suggestions: AssignmentSuggestion[] = []
const projectAssignmentCounts = new Map<string, number>()
// Count existing coverage
for (const a of existingAssignments) {
projectAssignmentCounts.set(a.projectId, (projectAssignmentCounts.get(a.projectId) ?? 0) + 1)
}
// First: honor pending intents
const intentAssignments = new Set<string>()
for (const intent of pendingIntents) {
const userId = intent.juryGroupMember.user.id
const projectId = intent.projectId
const pairKey = `${userId}:${projectId}`
if (assignedPairs.has(pairKey) || coiPairs.has(pairKey)) continue
const member = members.find((m: any) => m.userId === userId)
if (!member) continue
const project = projects.find((p: any) => p.id === projectId)
if (!project) continue
suggestions.push({
userId,
userName: member.user.name ?? 'Unknown',
projectId,
projectTitle: project.title,
score: 100, // Intent-based assignments get max priority
breakdown: { tagOverlap: 0, bioMatch: 0, workloadBalance: 0, countryMatch: 0, geoDiversityPenalty: 0, previousRoundFamiliarity: 0, coiPenalty: 0, availabilityPenalty: 0, categoryQuotaPenalty: 0 },
reasoning: ['Honoring assignment intent'],
matchingTags: [],
policyViolations: [],
fromIntent: true,
})
intentAssignments.add(pairKey)
}
// Then: algorithmic matching for remaining needs
for (const member of members) {
const userId = member.userId
const currentCount = (jurorAssignmentCounts.get(userId) ?? 0) +
suggestions.filter((s) => s.userId === userId).length
// Build a minimal member context for policy evaluation
const memberCtx = {
...ctx,
member: member as any,
user: member.user,
currentAssignmentCount: currentCount,
assignmentsByCategory: {},
pendingIntents: [],
}
const policy = evaluateAssignmentPolicy(memberCtx)
if (!policy.canAssignMore) continue
for (const project of projects) {
const pairKey = `${userId}:${project.id}`
if (assignedPairs.has(pairKey) || intentAssignments.has(pairKey) || coiPairs.has(pairKey)) continue
// Check project needs more reviews
const currentProjectReviews = (projectAssignmentCounts.get(project.id) ?? 0) +
suggestions.filter((s) => s.projectId === project.id).length
if (currentProjectReviews >= requiredReviews) continue
// Calculate score
const projectTags: ProjectTagData[] = project.projectTags.map((pt: any) => ({
tagId: pt.tagId,
tagName: pt.tag.name,
confidence: pt.confidence,
}))
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
member.user.expertiseTags ?? [],
projectTags,
)
const { score: bioScore } = calculateBioMatchScore(
member.user.bio,
project.description,
)
const workloadScore = calculateWorkloadScore(
currentCount,
policy.effectiveCap.value,
)
const availabilityPenalty = calculateAvailabilityPenalty(
member.user.availabilityJson,
ctx.round.windowOpenAt,
ctx.round.windowCloseAt,
)
const categoryQuotaPenalty = policy.categoryBias.value
? calculateCategoryQuotaPenalty(
Object.fromEntries(
Object.entries(policy.categoryBias.value).map(([k, v]) => [k, { min: 0, max: Math.round(v * policy.effectiveCap.value) }]),
),
{},
project.competitionCategory,
)
: 0
const totalScore = tagScore + bioScore + workloadScore + availabilityPenalty + categoryQuotaPenalty
const policyViolations: string[] = []
if (policy.isOverCap) {
policyViolations.push(`Over cap by ${policy.overCapBy}`)
}
const reasoning: string[] = []
if (matchingTags.length > 0) reasoning.push(`${matchingTags.length} matching tag(s)`)
if (bioScore > 0) reasoning.push('Bio match')
if (availabilityPenalty < 0) reasoning.push('Availability concern')
suggestions.push({
userId,
userName: member.user.name ?? 'Unknown',
projectId: project.id,
projectTitle: project.title,
score: totalScore,
breakdown: {
tagOverlap: tagScore,
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: 0,
geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
availabilityPenalty,
categoryQuotaPenalty,
},
reasoning,
matchingTags,
policyViolations,
fromIntent: false,
})
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score)
// Greedy assignment: pick top suggestions respecting caps
const finalAssignments: AssignmentSuggestion[] = []
const finalJurorCounts = new Map(jurorAssignmentCounts)
const finalProjectCounts = new Map(projectAssignmentCounts)
for (const suggestion of suggestions) {
const jurorCount = finalJurorCounts.get(suggestion.userId) ?? 0
const projectCount = finalProjectCounts.get(suggestion.projectId) ?? 0
if (projectCount >= requiredReviews) continue
// Re-check cap
const member = members.find((m: any) => m.userId === suggestion.userId)
if (member) {
const memberCtx = {
...ctx,
member: member as any,
user: member.user,
currentAssignmentCount: jurorCount,
assignmentsByCategory: {},
pendingIntents: [],
}
const policy = evaluateAssignmentPolicy(memberCtx)
if (!policy.canAssignMore && !suggestion.fromIntent) continue
}
finalAssignments.push(suggestion)
finalJurorCounts.set(suggestion.userId, jurorCount + 1)
finalProjectCounts.set(suggestion.projectId, projectCount + 1)
}
const unassignedProjects = projects.filter(
(p: any) => (finalProjectCounts.get(p.id) ?? 0) < requiredReviews,
).length
return {
assignments: finalAssignments,
warnings,
stats: {
totalProjects: projects.length,
totalJurors: members.length,
assignmentsGenerated: finalAssignments.length,
unassignedProjects,
},
}
}
// ─── Execute Assignment ─────────────────────────────────────────────────────
/**
* Execute round assignments by creating Assignment records.
*/
export async function executeRoundAssignment(
roundId: string,
assignments: Array<{ userId: string; projectId: string }>,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ created: number; errors: string[] }> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const errors: string[] = []
let created = 0
for (const assignment of assignments) {
try {
await db.$transaction(async (tx: any) => {
// Create assignment record
await tx.assignment.create({
data: {
projectId: assignment.projectId,
userId: assignment.userId,
roundId,
method: 'ALGORITHM',
},
})
// Create or update ProjectRoundState to IN_PROGRESS
await tx.projectRoundState.upsert({
where: {
projectId_roundId: {
projectId: assignment.projectId,
roundId,
},
},
create: {
projectId: assignment.projectId,
roundId,
state: 'IN_PROGRESS',
enteredAt: new Date(),
},
update: {
state: 'IN_PROGRESS',
},
})
// Honor matching intent if exists
const intent = await tx.assignmentIntent.findFirst({
where: {
roundId,
projectId: assignment.projectId,
juryGroupMember: { userId: assignment.userId },
status: 'INTENT_PENDING',
},
})
if (intent) {
await tx.assignmentIntent.update({
where: { id: intent.id },
data: { status: 'HONORED' },
})
}
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_ASSIGNMENT_CREATE',
entityType: 'Assignment',
entityId: `${assignment.userId}:${assignment.projectId}`,
detailsJson: { roundId, userId: assignment.userId, projectId: assignment.projectId },
})
created++
})
} catch (error) {
errors.push(
`Failed to assign ${assignment.userId} to ${assignment.projectId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
}
// Overall audit
await logAudit({
prisma: db,
userId: actorId,
action: 'ROUND_ASSIGNMENT_BATCH',
entityType: 'Round',
entityId: roundId,
detailsJson: { created, failed: errors.length },
})
return { created, errors }
}
// ─── Coverage Report ────────────────────────────────────────────────────────
/**
* Get coverage report for a round's assignments.
*/
export async function getRoundCoverageReport(
roundId: string,
requiredReviews: number = 3,
prisma?: PrismaClient | any,
): Promise<CoverageReport> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const projectStates = await db.projectRoundState.findMany({
where: { roundId },
include: {
project: { select: { id: true, competitionCategory: true } },
},
})
const assignments = await db.assignment.findMany({
where: { roundId },
select: { projectId: true },
})
const projectAssignmentCounts = new Map<string, number>()
for (const a of assignments) {
projectAssignmentCounts.set(a.projectId, (projectAssignmentCounts.get(a.projectId) ?? 0) + 1)
}
let fullyAssigned = 0
let partiallyAssigned = 0
let unassigned = 0
const byCategory: Record<string, { total: number; assigned: number; coverage: number }> = {}
for (const ps of projectStates) {
const count = projectAssignmentCounts.get(ps.project.id) ?? 0
const cat = ps.project.competitionCategory ?? 'UNCATEGORIZED'
if (!byCategory[cat]) {
byCategory[cat] = { total: 0, assigned: 0, coverage: 0 }
}
byCategory[cat].total++
if (count >= requiredReviews) {
fullyAssigned++
byCategory[cat].assigned++
} else if (count > 0) {
partiallyAssigned++
byCategory[cat].assigned++
} else {
unassigned++
}
}
// Calculate coverage percentages
for (const cat of Object.keys(byCategory)) {
byCategory[cat].coverage = byCategory[cat].total > 0
? Math.round((byCategory[cat].assigned / byCategory[cat].total) * 100)
: 0
}
const totalReviews = assignments.length
const avgReviews = projectStates.length > 0 ? totalReviews / projectStates.length : 0
return {
totalProjects: projectStates.length,
fullyAssigned,
partiallyAssigned,
unassigned,
avgReviewsPerProject: Math.round(avgReviews * 10) / 10,
requiredReviews,
byCategory,
}
}
// ─── Unassigned Queue ───────────────────────────────────────────────────────
/**
* Get projects below requiredReviews threshold.
*/
export async function getUnassignedQueue(
roundId: string,
requiredReviews: number = 3,
prisma?: PrismaClient | any,
) {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const projectStates = await db.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
_count: {
select: {
assignments: { where: { roundId } },
},
},
},
},
},
})
return projectStates
.filter((ps: any) => ps.project._count.assignments < requiredReviews)
.map((ps: any) => ({
projectId: ps.project.id,
title: ps.project.title,
teamName: ps.project.teamName,
category: ps.project.competitionCategory,
currentReviews: ps.project._count.assignments,
needed: requiredReviews - ps.project._count.assignments,
state: ps.state,
}))
.sort((a: any, b: any) => a.currentReviews - b.currentReviews)
}

View File

@@ -0,0 +1,510 @@
/**
* Round Engine Service
*
* State machine for round lifecycle transitions, operating on Round +
* ProjectRoundState. Parallels stage-engine.ts but for the Competition/Round
* architecture.
*
* Key invariants:
* - Round transitions follow: ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED
* - Project transitions within an active round only
* - All mutations are transactional with dual audit trail
*/
import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent'
// ─── Types ──────────────────────────────────────────────────────────────────
export type RoundTransitionResult = {
success: boolean
round?: { id: string; status: string }
errors?: string[]
}
export type ProjectRoundTransitionResult = {
success: boolean
projectRoundState?: {
id: string
projectId: string
roundId: string
state: ProjectRoundStateValue
}
errors?: string[]
}
export type BatchProjectTransitionResult = {
succeeded: string[]
failed: Array<{ projectId: string; errors: string[] }>
total: number
}
// ─── Constants ──────────────────────────────────────────────────────────────
const BATCH_SIZE = 50
// ─── Valid Transition Maps ──────────────────────────────────────────────────
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
ROUND_DRAFT: ['ROUND_ACTIVE'],
ROUND_ACTIVE: ['ROUND_CLOSED'],
ROUND_CLOSED: ['ROUND_ARCHIVED'],
ROUND_ARCHIVED: [],
}
// ─── Round-Level Transitions ────────────────────────────────────────────────
/**
* Activate a round: ROUND_DRAFT → ROUND_ACTIVE
* Guards: configJson is valid, competition is not ARCHIVED
* Side effects: expire pending intents from previous round (if any)
*/
export async function activateRound(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<RoundTransitionResult> {
try {
const round = await prisma.round.findUnique({
where: { id: roundId },
include: { competition: true },
})
if (!round) {
return { success: false, errors: [`Round ${roundId} not found`] }
}
// Check valid transition
if (round.status !== 'ROUND_DRAFT') {
return {
success: false,
errors: [`Cannot activate round: current status is ${round.status}, expected ROUND_DRAFT`],
}
}
// Guard: competition must not be ARCHIVED
if (round.competition.status === 'ARCHIVED') {
return {
success: false,
errors: ['Cannot activate round: competition is ARCHIVED'],
}
}
// Guard: configJson must be valid
if (round.configJson) {
const validation = safeValidateRoundConfig(
round.roundType,
round.configJson as Record<string, unknown>,
)
if (!validation.success) {
return {
success: false,
errors: [`Invalid round config: ${validation.error.message}`],
}
}
}
const updated = await prisma.$transaction(async (tx: any) => {
const result = await tx.round.update({
where: { id: roundId },
data: { status: 'ROUND_ACTIVE' },
})
// Dual audit trail
await tx.decisionAuditLog.create({
data: {
eventType: 'round.activated',
entityType: 'Round',
entityId: roundId,
actorId,
detailsJson: {
roundName: round.name,
roundType: round.roundType,
competitionId: round.competitionId,
previousStatus: 'ROUND_DRAFT',
},
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'round-engine',
},
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_ACTIVATE',
entityType: 'Round',
entityId: roundId,
detailsJson: { name: round.name, roundType: round.roundType },
})
return result
})
return {
success: true,
round: { id: updated.id, status: updated.status },
}
} catch (error) {
console.error('[RoundEngine] activateRound failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error during round activation'],
}
}
}
/**
* Close a round: ROUND_ACTIVE → ROUND_CLOSED
* Guards: all submission windows closed (if submission/mentoring round)
* Side effects: expire all INTENT_PENDING for this round
*/
export async function closeRound(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<RoundTransitionResult> {
try {
const round = await prisma.round.findUnique({
where: { id: roundId },
include: { submissionWindow: true },
})
if (!round) {
return { success: false, errors: [`Round ${roundId} not found`] }
}
if (round.status !== 'ROUND_ACTIVE') {
return {
success: false,
errors: [`Cannot close round: current status is ${round.status}, expected ROUND_ACTIVE`],
}
}
// Guard: submission window must be closed/locked for submission/mentoring rounds
if (
(round.roundType === 'SUBMISSION' || round.roundType === 'MENTORING') &&
round.submissionWindow
) {
const sw = round.submissionWindow
if (sw.windowCloseAt && new Date() < sw.windowCloseAt && !sw.isLocked) {
return {
success: false,
errors: ['Cannot close round: linked submission window is still open'],
}
}
}
const updated = await prisma.$transaction(async (tx: any) => {
const result = await tx.round.update({
where: { id: roundId },
data: { status: 'ROUND_CLOSED' },
})
// Expire pending intents
await expireIntentsForRound(roundId, actorId)
await tx.decisionAuditLog.create({
data: {
eventType: 'round.closed',
entityType: 'Round',
entityId: roundId,
actorId,
detailsJson: {
roundName: round.name,
roundType: round.roundType,
previousStatus: 'ROUND_ACTIVE',
},
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'round-engine',
},
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_CLOSE',
entityType: 'Round',
entityId: roundId,
detailsJson: { name: round.name, roundType: round.roundType },
})
return result
})
return {
success: true,
round: { id: updated.id, status: updated.status },
}
} catch (error) {
console.error('[RoundEngine] closeRound failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error during round close'],
}
}
}
/**
* Archive a round: ROUND_CLOSED → ROUND_ARCHIVED
* No guards.
*/
export async function archiveRound(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<RoundTransitionResult> {
try {
const round = await prisma.round.findUnique({ where: { id: roundId } })
if (!round) {
return { success: false, errors: [`Round ${roundId} not found`] }
}
if (round.status !== 'ROUND_CLOSED') {
return {
success: false,
errors: [`Cannot archive round: current status is ${round.status}, expected ROUND_CLOSED`],
}
}
const updated = await prisma.$transaction(async (tx: any) => {
const result = await tx.round.update({
where: { id: roundId },
data: { status: 'ROUND_ARCHIVED' },
})
await tx.decisionAuditLog.create({
data: {
eventType: 'round.archived',
entityType: 'Round',
entityId: roundId,
actorId,
detailsJson: {
roundName: round.name,
previousStatus: 'ROUND_CLOSED',
},
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'round-engine',
},
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_ARCHIVE',
entityType: 'Round',
entityId: roundId,
detailsJson: { name: round.name },
})
return result
})
return {
success: true,
round: { id: updated.id, status: updated.status },
}
} catch (error) {
console.error('[RoundEngine] archiveRound failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error during round archive'],
}
}
}
// ─── Project-Level Transitions ──────────────────────────────────────────────
/**
* Transition a project within a round.
* Upserts ProjectRoundState: create if not exists, update if exists.
* Validate: round must be ROUND_ACTIVE.
* Dual audit trail (DecisionAuditLog + logAudit).
*/
export async function transitionProject(
projectId: string,
roundId: string,
newState: ProjectRoundStateValue,
actorId: string,
prisma: PrismaClient | any,
): Promise<ProjectRoundTransitionResult> {
try {
const round = await prisma.round.findUnique({ where: { id: roundId } })
if (!round) {
return { success: false, errors: [`Round ${roundId} not found`] }
}
if (round.status !== 'ROUND_ACTIVE') {
return {
success: false,
errors: [`Round is ${round.status}, must be ROUND_ACTIVE to transition projects`],
}
}
// Verify project exists
const project = await prisma.project.findUnique({ where: { id: projectId } })
if (!project) {
return { success: false, errors: [`Project ${projectId} not found`] }
}
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
// Upsert ProjectRoundState
const existing = await tx.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
})
let prs
if (existing) {
prs = await tx.projectRoundState.update({
where: { id: existing.id },
data: {
state: newState,
exitedAt: isTerminalState(newState) ? now : null,
},
})
} else {
prs = await tx.projectRoundState.create({
data: {
projectId,
roundId,
state: newState,
enteredAt: now,
},
})
}
// Dual audit trail
await tx.decisionAuditLog.create({
data: {
eventType: 'project_round.transitioned',
entityType: 'ProjectRoundState',
entityId: prs.id,
actorId,
detailsJson: {
projectId,
roundId,
previousState: existing?.state ?? null,
newState,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: now.toISOString(),
emittedBy: 'round-engine',
},
},
})
await logAudit({
prisma: tx,
userId: actorId,
action: 'PROJECT_ROUND_TRANSITION',
entityType: 'ProjectRoundState',
entityId: prs.id,
detailsJson: { projectId, roundId, newState, previousState: existing?.state ?? null },
})
return prs
})
return {
success: true,
projectRoundState: {
id: result.id,
projectId: result.projectId,
roundId: result.roundId,
state: result.state,
},
}
} catch (error) {
console.error('[RoundEngine] transitionProject failed:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error during project transition'],
}
}
}
/**
* Batch transition projects in batches of BATCH_SIZE.
* Each project is processed independently.
*/
export async function batchTransitionProjects(
projectIds: string[],
roundId: string,
newState: ProjectRoundStateValue,
actorId: string,
prisma: PrismaClient | any,
): Promise<BatchProjectTransitionResult> {
const succeeded: string[] = []
const failed: Array<{ projectId: string; errors: string[] }> = []
for (let i = 0; i < projectIds.length; i += BATCH_SIZE) {
const batch = projectIds.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (projectId) => {
const result = await transitionProject(projectId, roundId, newState, actorId, prisma)
if (result.success) {
succeeded.push(projectId)
} else {
failed.push({
projectId,
errors: result.errors ?? ['Transition failed'],
})
}
})
await Promise.all(batchPromises)
}
return { succeeded, failed, total: projectIds.length }
}
// ─── Query Helpers ──────────────────────────────────────────────────────────
export async function getProjectRoundStates(
roundId: string,
prisma: PrismaClient | any,
) {
return prisma.projectRoundState.findMany({
where: { roundId },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
status: true,
},
},
},
orderBy: { enteredAt: 'desc' },
})
}
export async function getProjectRoundState(
projectId: string,
roundId: string,
prisma: PrismaClient | any,
) {
return prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
})
}
// ─── Internals ──────────────────────────────────────────────────────────────
function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].includes(state)
}

View File

@@ -320,19 +320,19 @@ export function calculateCategoryQuotaPenalty(
* Get smart assignment suggestions for a round
*/
export async function getSmartSuggestions(options: {
stageId: string
roundId: string
type: 'jury' | 'mentor'
limit?: number
aiMaxPerJudge?: number
categoryQuotas?: Record<string, { min: number; max: number }>
}): Promise<AssignmentScore[]> {
const { stageId, type, limit = 50, aiMaxPerJudge = 20, categoryQuotas } = options
const { roundId, type, limit = 50, aiMaxPerJudge = 20, categoryQuotas } = options
const projectStageStates = await prisma.projectStageState.findMany({
where: { stageId },
const projectRoundStates = await prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projectIds = projectRoundStates.map((prs) => prs.projectId)
const projects = await prisma.project.findMany({
where: {
@@ -376,7 +376,7 @@ export async function getSmartSuggestions(options: {
_count: {
select: {
assignments: {
where: { stageId },
where: { roundId },
},
},
},
@@ -387,13 +387,13 @@ export async function getSmartSuggestions(options: {
return []
}
const stageForAvailability = await prisma.stage.findUnique({
where: { id: stageId },
const roundForAvailability = await prisma.round.findUnique({
where: { id: roundId },
select: { windowOpenAt: true, windowCloseAt: true },
})
const existingAssignments = await prisma.assignment.findMany({
where: { stageId },
where: { roundId },
select: { userId: true, projectId: true },
})
const assignedPairs = new Set(
@@ -401,7 +401,7 @@ export async function getSmartSuggestions(options: {
)
const assignmentsWithCountry = await prisma.assignment.findMany({
where: { stageId },
where: { roundId },
select: {
userId: true,
project: { select: { country: true } },
@@ -425,7 +425,7 @@ export async function getSmartSuggestions(options: {
const userCategoryDistribution = new Map<string, Record<string, number>>()
if (categoryQuotas) {
const assignmentsWithCategory = await prisma.assignment.findMany({
where: { stageId },
where: { roundId },
select: {
userId: true,
project: { select: { competitionCategory: true } },
@@ -443,38 +443,38 @@ export async function getSmartSuggestions(options: {
}
}
const currentStage = await prisma.stage.findUnique({
where: { id: stageId },
select: { trackId: true, sortOrder: true },
const currentRound = await prisma.round.findUnique({
where: { id: roundId },
select: { competitionId: true, sortOrder: true },
})
const previousStageAssignmentPairs = new Set<string>()
if (currentStage) {
const earlierStages = await prisma.stage.findMany({
const previousRoundAssignmentPairs = new Set<string>()
if (currentRound) {
const earlierRounds = await prisma.round.findMany({
where: {
trackId: currentStage.trackId,
sortOrder: { lt: currentStage.sortOrder },
competitionId: currentRound.competitionId,
sortOrder: { lt: currentRound.sortOrder },
},
select: { id: true },
})
const earlierStageIds = earlierStages.map((s) => s.id)
const earlierRoundIds = earlierRounds.map((r) => r.id)
if (earlierStageIds.length > 0) {
if (earlierRoundIds.length > 0) {
const previousAssignments = await prisma.assignment.findMany({
where: {
stageId: { in: earlierStageIds },
roundId: { in: earlierRoundIds },
},
select: { userId: true, projectId: true },
})
for (const pa of previousAssignments) {
previousStageAssignmentPairs.add(`${pa.userId}:${pa.projectId}`)
previousRoundAssignmentPairs.add(`${pa.userId}:${pa.projectId}`)
}
}
}
const coiRecords = await prisma.conflictOfInterest.findMany({
where: {
assignment: { stageId },
assignment: { roundId },
hasConflict: true,
},
select: { userId: true, projectId: true },
@@ -550,8 +550,8 @@ export async function getSmartSuggestions(options: {
const availabilityPenalty = calculateAvailabilityPenalty(
user.availabilityJson,
stageForAvailability?.windowOpenAt,
stageForAvailability?.windowCloseAt
roundForAvailability?.windowOpenAt,
roundForAvailability?.windowCloseAt
)
// ── New scoring factors ─────────────────────────────────────────────
@@ -581,7 +581,7 @@ export async function getSmartSuggestions(options: {
}
let previousRoundFamiliarity = 0
if (previousStageAssignmentPairs.has(pairKey)) {
if (previousRoundAssignmentPairs.has(pairKey)) {
previousRoundFamiliarity = PREVIOUS_ROUND_FAMILIARITY_BONUS
}

View File

@@ -1,776 +0,0 @@
/**
* Stage Assignment Service
*
* Manages jury-to-project assignments scoped to a specific pipeline stage.
* Provides preview (dry run), execution, coverage reporting, and rebalancing.
*
* Uses the smart-assignment scoring algorithm for matching quality but adds
* stage-awareness and integrates with the pipeline models.
*/
import type { PrismaClient, AssignmentMethod, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
export interface AssignmentConfig {
requiredReviews: number
minAssignmentsPerJuror: number
maxAssignmentsPerJuror: number
respectCOI: boolean
geoBalancing: boolean
expertiseMatching: boolean
}
export interface PreviewAssignment {
userId: string
userName: string
userEmail: string
projectId: string
projectTitle: string
score: number
reasoning: string[]
}
export interface PreviewResult {
assignments: PreviewAssignment[]
unassignedProjects: Array<{ id: string; title: string; reason: string }>
stats: {
totalProjects: number
totalJurors: number
avgAssignmentsPerJuror: number
coveragePercent: number
}
}
export interface AssignmentInput {
userId: string
projectId: string
method?: AssignmentMethod
}
export interface CoverageReport {
stageId: string
totalProjects: number
fullyAssigned: number
partiallyAssigned: number
unassigned: number
coveragePercent: number
averageReviewsPerProject: number
jurorStats: Array<{
userId: string
userName: string
assignmentCount: number
completedCount: number
}>
}
export interface RebalanceSuggestion {
action: 'reassign' | 'add'
fromUserId?: string
fromUserName?: string
toUserId: string
toUserName: string
projectId: string
projectTitle: string
reason: string
}
// ─── Constants ──────────────────────────────────────────────────────────────
const DEFAULT_CONFIG: AssignmentConfig = {
requiredReviews: 3,
minAssignmentsPerJuror: 5,
maxAssignmentsPerJuror: 20,
respectCOI: true,
geoBalancing: true,
expertiseMatching: true,
}
// ─── Scoring Utilities ──────────────────────────────────────────────────────
/**
* Calculate a simple tag overlap score between a juror's expertise tags
* and a project's tags.
*/
function calculateTagOverlapScore(
jurorTags: string[],
projectTags: string[]
): number {
if (jurorTags.length === 0 || projectTags.length === 0) return 0
const jurorSet = new Set(jurorTags.map((t) => t.toLowerCase()))
const matchCount = projectTags.filter((t) =>
jurorSet.has(t.toLowerCase())
).length
return Math.min(matchCount * 10, 40) // Max 40 points
}
/**
* Calculate workload balance score. Jurors closer to their preferred workload
* get higher scores, those near max get penalized.
*/
function calculateWorkloadScore(
currentLoad: number,
preferredWorkload: number | null,
maxAssignments: number
): number {
const target = preferredWorkload ?? Math.floor(maxAssignments * 0.6)
const remaining = target - currentLoad
if (remaining <= 0) return 0
if (currentLoad === 0) return 25 // Full score for unloaded jurors
const ratio = remaining / target
return Math.round(ratio * 25) // Max 25 points
}
// ─── Preview Stage Assignment ───────────────────────────────────────────────
/**
* Generate a preview of assignments for a stage without persisting anything.
* Loads eligible projects (those with active PSS in the stage) and the jury
* pool, then matches them using scoring constraints.
*/
export async function previewStageAssignment(
stageId: string,
config: Partial<AssignmentConfig>,
prisma: PrismaClient | any
): Promise<PreviewResult> {
const cfg = { ...DEFAULT_CONFIG, ...config }
// Load stage with track/pipeline info
const stage = await prisma.stage.findUnique({
where: { id: stageId },
include: {
track: { include: { pipeline: true } },
},
})
if (!stage) {
throw new Error(`Stage ${stageId} not found`)
}
// Load eligible projects: active PSS in stage, not exited
const projectStates = await prisma.projectStageState.findMany({
where: {
stageId,
exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
include: {
project: {
include: {
projectTags: { include: { tag: true } },
},
},
},
})
const projects = projectStates
.map((pss: any) => pss.project)
.filter(Boolean)
// Load jury pool: active jury members
const jurors = await prisma.user.findMany({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
},
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
preferredWorkload: true,
maxAssignments: true,
country: true,
_count: {
select: {
assignments: {
where: { stageId },
},
},
},
},
})
// Load existing assignments for this stage to avoid duplicates
const existingAssignments = await prisma.assignment.findMany({
where: { stageId },
select: { userId: true, projectId: true },
})
const existingPairs = new Set(
existingAssignments.map((a: any) => `${a.userId}:${a.projectId}`)
)
// Load COI data if enabled
let coiPairs = new Set<string>()
if (cfg.respectCOI) {
const coiRecords = await prisma.conflictOfInterest.findMany({
where: { hasConflict: true },
select: { userId: true, projectId: true },
})
coiPairs = new Set(coiRecords.map((c: any) => `${c.userId}:${c.projectId}`))
}
// Score and generate assignments
const assignments: PreviewAssignment[] = []
const projectCoverage = new Map<string, number>()
const jurorLoad = new Map<string, number>()
// Initialize counts
for (const project of projects) {
projectCoverage.set(project.id, 0)
}
for (const juror of jurors) {
jurorLoad.set(juror.id, juror._count.assignments)
}
// For each project, find best available jurors
for (const project of projects) {
const projectTags = project.projectTags.map((pt: any) => pt.tag.name)
// Score each juror for this project
const candidates = jurors
.filter((juror: any) => {
const pairKey = `${juror.id}:${project.id}`
// Skip if already assigned
if (existingPairs.has(pairKey)) return false
// Skip if COI
if (coiPairs.has(pairKey)) return false
// Skip if at max capacity
const currentLoad = jurorLoad.get(juror.id) ?? 0
if (currentLoad >= (juror.maxAssignments ?? cfg.maxAssignmentsPerJuror)) {
return false
}
return true
})
.map((juror: any) => {
const currentLoad = jurorLoad.get(juror.id) ?? 0
const tagScore = cfg.expertiseMatching
? calculateTagOverlapScore(juror.expertiseTags, projectTags)
: 0
const workloadScore = calculateWorkloadScore(
currentLoad,
juror.preferredWorkload,
juror.maxAssignments ?? cfg.maxAssignmentsPerJuror
)
// Geo balancing: slight penalty if same country
let geoScore = 0
if (cfg.geoBalancing && juror.country && project.country) {
geoScore = juror.country === project.country ? -5 : 5
}
const totalScore = tagScore + workloadScore + geoScore
const reasoning: string[] = []
if (tagScore > 0) reasoning.push(`Tag match: +${tagScore}`)
if (workloadScore > 0) reasoning.push(`Workload balance: +${workloadScore}`)
if (geoScore !== 0) reasoning.push(`Geo balance: ${geoScore > 0 ? '+' : ''}${geoScore}`)
return {
userId: juror.id,
userName: juror.name ?? juror.email,
userEmail: juror.email,
score: totalScore,
reasoning,
}
})
.sort((a: any, b: any) => b.score - a.score)
// Assign top N jurors to this project
const needed = cfg.requiredReviews - (projectCoverage.get(project.id) ?? 0)
const selected = candidates.slice(0, needed)
for (const candidate of selected) {
assignments.push({
...candidate,
projectId: project.id,
projectTitle: project.title,
})
existingPairs.add(`${candidate.userId}:${project.id}`)
projectCoverage.set(
project.id,
(projectCoverage.get(project.id) ?? 0) + 1
)
jurorLoad.set(candidate.userId, (jurorLoad.get(candidate.userId) ?? 0) + 1)
}
}
// Identify unassigned projects
const unassignedProjects = projects
.filter(
(p: any) =>
(projectCoverage.get(p.id) ?? 0) < cfg.requiredReviews
)
.map((p: any) => ({
id: p.id,
title: p.title,
reason: `Only ${projectCoverage.get(p.id) ?? 0}/${cfg.requiredReviews} reviewers assigned`,
}))
// Stats
const jurorAssignmentCounts = Array.from(jurorLoad.values())
const avgPerJuror =
jurorAssignmentCounts.length > 0
? jurorAssignmentCounts.reduce((a, b) => a + b, 0) /
jurorAssignmentCounts.length
: 0
const fullyAssigned = projects.filter(
(p: any) => (projectCoverage.get(p.id) ?? 0) >= cfg.requiredReviews
).length
return {
assignments,
unassignedProjects,
stats: {
totalProjects: projects.length,
totalJurors: jurors.length,
avgAssignmentsPerJuror: Math.round(avgPerJuror * 100) / 100,
coveragePercent:
projects.length > 0
? Math.round((fullyAssigned / projects.length) * 100)
: 0,
},
}
}
// ─── Execute Stage Assignment ───────────────────────────────────────────────
/**
* Create Assignment records for a stage. Accepts a list of user/project pairs
* and persists them atomically, also creating an AssignmentJob for tracking.
*/
export async function executeStageAssignment(
stageId: string,
assignments: AssignmentInput[],
actorId: string,
prisma: PrismaClient | any
): Promise<{ jobId: string; created: number; errors: string[] }> {
const stage = await prisma.stage.findUnique({
where: { id: stageId },
include: {
track: { include: { pipeline: true } },
},
})
if (!stage) {
throw new Error(`Stage ${stageId} not found`)
}
const created: string[] = []
const errors: string[] = []
// Create AssignmentJob for tracking
const job = await prisma.assignmentJob.create({
data: {
stageId,
status: 'RUNNING',
totalProjects: assignments.length,
startedAt: new Date(),
},
})
try {
await prisma.$transaction(async (tx: any) => {
for (const assignment of assignments) {
try {
// Check for existing assignment
const existing = await tx.assignment.findUnique({
where: {
userId_projectId_stageId: {
userId: assignment.userId,
projectId: assignment.projectId,
stageId,
},
},
})
if (existing) {
errors.push(
`Assignment already exists for user ${assignment.userId} / project ${assignment.projectId}`
)
continue
}
await tx.assignment.create({
data: {
userId: assignment.userId,
projectId: assignment.projectId,
stageId,
method: assignment.method ?? 'ALGORITHM',
createdBy: actorId,
},
})
created.push(`${assignment.userId}:${assignment.projectId}`)
} catch (err) {
errors.push(
`Failed to create assignment for user ${assignment.userId} / project ${assignment.projectId}: ${err instanceof Error ? err.message : 'Unknown error'}`
)
}
}
})
// Complete the job
await prisma.assignmentJob.update({
where: { id: job.id },
data: {
status: 'COMPLETED',
processedCount: created.length + errors.length,
suggestionsCount: created.length,
completedAt: new Date(),
},
})
// Decision audit log
await prisma.decisionAuditLog.create({
data: {
eventType: 'assignment.generated',
entityType: 'AssignmentJob',
entityId: job.id,
actorId,
detailsJson: {
stageId,
assignmentCount: created.length,
errorCount: errors.length,
},
},
})
// Audit log
await logAudit({
prisma,
userId: actorId,
action: 'STAGE_ASSIGNMENT_EXECUTED',
entityType: 'AssignmentJob',
entityId: job.id,
detailsJson: {
stageId,
created: created.length,
errors: errors.length,
},
})
} catch (error) {
await prisma.assignmentJob.update({
where: { id: job.id },
data: {
status: 'FAILED',
errorMessage:
error instanceof Error ? error.message : 'Transaction failed',
completedAt: new Date(),
},
})
throw error
}
return {
jobId: job.id,
created: created.length,
errors,
}
}
// ─── Coverage Report ────────────────────────────────────────────────────────
/**
* Generate a coverage report for assignments in a stage: how many projects
* are fully covered, partially covered, unassigned, plus per-juror stats.
*/
export async function getCoverageReport(
stageId: string,
prisma: PrismaClient | any
): Promise<CoverageReport> {
// Load stage config for required reviews
const stage = await prisma.stage.findUnique({
where: { id: stageId },
})
const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {}
const requiredReviews = (stageConfig.requiredReviews as number) ?? 3
// Load projects in this stage
const projectStates = await prisma.projectStageState.findMany({
where: {
stageId,
exitedAt: null,
},
select: { projectId: true },
})
const projectIds = projectStates.map((pss: any) => pss.projectId)
// Load assignments for this stage
const assignments = await prisma.assignment.findMany({
where: {
stageId,
projectId: { in: projectIds },
},
select: {
userId: true,
projectId: true,
isCompleted: true,
user: { select: { id: true, name: true, email: true } },
},
})
// Calculate per-project coverage
const projectReviewCounts = new Map<string, number>()
for (const assignment of assignments) {
const current = projectReviewCounts.get(assignment.projectId) ?? 0
projectReviewCounts.set(assignment.projectId, current + 1)
}
let fullyAssigned = 0
let partiallyAssigned = 0
let unassigned = 0
for (const projectId of projectIds) {
const count = projectReviewCounts.get(projectId) ?? 0
if (count >= requiredReviews) {
fullyAssigned++
} else if (count > 0) {
partiallyAssigned++
} else {
unassigned++
}
}
const totalReviewCount = Array.from(projectReviewCounts.values()).reduce(
(a, b) => a + b,
0
)
// Per-juror stats
const jurorMap = new Map<
string,
{ userId: string; userName: string; total: number; completed: number }
>()
for (const a of assignments) {
const key = a.userId
const existing = jurorMap.get(key) ?? {
userId: a.userId,
userName: a.user?.name ?? a.user?.email ?? 'Unknown',
total: 0,
completed: 0,
}
existing.total++
if (a.isCompleted) existing.completed++
jurorMap.set(key, existing)
}
return {
stageId,
totalProjects: projectIds.length,
fullyAssigned,
partiallyAssigned,
unassigned,
coveragePercent:
projectIds.length > 0
? Math.round((fullyAssigned / projectIds.length) * 100)
: 0,
averageReviewsPerProject:
projectIds.length > 0
? Math.round((totalReviewCount / projectIds.length) * 100) / 100
: 0,
jurorStats: Array.from(jurorMap.values()).map((j) => ({
userId: j.userId,
userName: j.userName,
assignmentCount: j.total,
completedCount: j.completed,
})),
}
}
// ─── Rebalance ──────────────────────────────────────────────────────────────
/**
* Analyze assignment distribution for a stage and suggest reassignments
* to balance workload. Identifies overloaded jurors (above max) and
* underloaded jurors (below min) and suggests moves.
*/
export async function rebalance(
stageId: string,
actorId: string,
prisma: PrismaClient | any
): Promise<RebalanceSuggestion[]> {
const stage = await prisma.stage.findUnique({
where: { id: stageId },
})
const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {}
const minLoad =
(stageConfig.minLoadPerJuror as number) ??
(stageConfig.minAssignmentsPerJuror as number) ??
5
const maxLoad =
(stageConfig.maxLoadPerJuror as number) ??
(stageConfig.maxAssignmentsPerJuror as number) ??
20
const requiredReviews = (stageConfig.requiredReviews as number) ?? 3
// Load all assignments for this stage
const assignments = await prisma.assignment.findMany({
where: { stageId },
include: {
user: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
},
})
// Calculate per-juror counts
const jurorCounts = new Map<
string,
{
userId: string
userName: string
count: number
incompleteAssignments: Array<{ assignmentId: string; projectId: string; projectTitle: string }>
}
>()
for (const a of assignments) {
const existing = jurorCounts.get(a.userId) ?? {
userId: a.userId,
userName: a.user?.name ?? a.user?.email ?? 'Unknown',
count: 0,
incompleteAssignments: [] as Array<{ assignmentId: string; projectId: string; projectTitle: string }>,
}
existing.count++
if (!a.isCompleted) {
existing.incompleteAssignments.push({
assignmentId: a.id,
projectId: a.projectId,
projectTitle: a.project?.title ?? 'Unknown',
})
}
jurorCounts.set(a.userId, existing)
}
// Calculate per-project counts
const projectCounts = new Map<string, number>()
for (const a of assignments) {
projectCounts.set(a.projectId, (projectCounts.get(a.projectId) ?? 0) + 1)
}
const overloaded = Array.from(jurorCounts.values()).filter(
(j) => j.count > maxLoad
)
const underloaded = Array.from(jurorCounts.values()).filter(
(j) => j.count < minLoad
)
const suggestions: RebalanceSuggestion[] = []
// Load COI data to avoid suggesting COI reassignments
const coiRecords = await prisma.conflictOfInterest.findMany({
where: { hasConflict: true },
select: { userId: true, projectId: true },
})
const coiPairs = new Set(
coiRecords.map((c: any) => `${c.userId}:${c.projectId}`)
)
// Existing assignment pairs
const existingPairs = new Set(
assignments.map((a: any) => `${a.userId}:${a.projectId}`)
)
// For each overloaded juror, try to move incomplete assignments to underloaded jurors
for (const over of overloaded) {
const excess = over.count - maxLoad
for (
let i = 0;
i < Math.min(excess, over.incompleteAssignments.length);
i++
) {
const candidate = over.incompleteAssignments[i]
// Find an underloaded juror who can take this project
const target = underloaded.find((under) => {
const pairKey = `${under.userId}:${candidate.projectId}`
// No COI, not already assigned, still under max
return (
!coiPairs.has(pairKey) &&
!existingPairs.has(pairKey) &&
under.count < maxLoad
)
})
if (target) {
suggestions.push({
action: 'reassign',
fromUserId: over.userId,
fromUserName: over.userName,
toUserId: target.userId,
toUserName: target.userName,
projectId: candidate.projectId,
projectTitle: candidate.projectTitle,
reason: `${over.userName} is overloaded (${over.count}/${maxLoad}), ${target.userName} is underloaded (${target.count}/${minLoad})`,
})
// Update in-memory counts for subsequent iterations
over.count--
target.count++
existingPairs.add(`${target.userId}:${candidate.projectId}`)
}
}
}
// For projects below required reviews, suggest adding reviewers
for (const [projectId, count] of projectCounts) {
if (count < requiredReviews) {
const needed = requiredReviews - count
const projectAssignment = assignments.find(
(a: any) => a.projectId === projectId
)
for (let i = 0; i < needed; i++) {
const target = underloaded.find((under) => {
const pairKey = `${under.userId}:${projectId}`
return (
!coiPairs.has(pairKey) &&
!existingPairs.has(pairKey) &&
under.count < maxLoad
)
})
if (target) {
suggestions.push({
action: 'add',
toUserId: target.userId,
toUserName: target.userName,
projectId,
projectTitle: projectAssignment?.project?.title ?? 'Unknown',
reason: `Project needs ${needed} more reviewer(s) to reach ${requiredReviews} required`,
})
target.count++
existingPairs.add(`${target.userId}:${projectId}`)
}
}
}
}
// Audit the rebalance analysis
await logAudit({
prisma,
userId: actorId,
action: 'STAGE_ASSIGNMENT_REBALANCE',
entityType: 'Stage',
entityId: stageId,
detailsJson: {
overloadedJurors: overloaded.length,
underloadedJurors: underloaded.length,
suggestionsCount: suggestions.length,
},
})
return suggestions
}

View File

@@ -1,464 +0,0 @@
/**
* Stage Engine Service
*
* State machine service for managing project transitions between stages in
* the pipeline. Handles validation of transitions (guard evaluation, window
* constraints, PSS existence) and atomic execution with full audit logging.
*
* Key invariants:
* - A project can only be in one active PSS per track/stage combination
* - Transitions must follow defined StageTransition records
* - Guard conditions (guardJson) on transitions are evaluated before execution
* - All transitions are logged in DecisionAuditLog and AuditLog
*/
import type { PrismaClient, ProjectStageStateValue, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
export interface TransitionValidationResult {
valid: boolean
errors: string[]
}
export interface TransitionExecutionResult {
success: boolean
projectStageState: {
id: string
projectId: string
trackId: string
stageId: string
state: ProjectStageStateValue
} | null
errors?: string[]
}
export interface BatchTransitionResult {
succeeded: string[]
failed: Array<{ projectId: string; errors: string[] }>
total: number
}
interface GuardCondition {
field: string
operator: 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'lt' | 'exists'
value: unknown
}
interface GuardConfig {
conditions?: GuardCondition[]
logic?: 'AND' | 'OR'
requireAllEvaluationsComplete?: boolean
requireMinScore?: number
}
// ─── Constants ──────────────────────────────────────────────────────────────
const BATCH_SIZE = 50
// ─── Guard Evaluation ───────────────────────────────────────────────────────
function evaluateGuardCondition(
condition: GuardCondition,
context: Record<string, unknown>
): boolean {
const fieldValue = context[condition.field]
switch (condition.operator) {
case 'eq':
return fieldValue === condition.value
case 'neq':
return fieldValue !== condition.value
case 'in': {
if (!Array.isArray(condition.value)) return false
return condition.value.includes(fieldValue)
}
case 'contains': {
if (typeof fieldValue === 'string' && typeof condition.value === 'string') {
return fieldValue.toLowerCase().includes(condition.value.toLowerCase())
}
if (Array.isArray(fieldValue)) {
return fieldValue.includes(condition.value)
}
return false
}
case 'gt':
return Number(fieldValue) > Number(condition.value)
case 'lt':
return Number(fieldValue) < Number(condition.value)
case 'exists':
return fieldValue !== null && fieldValue !== undefined
default:
return false
}
}
function evaluateGuard(
guardJson: Prisma.JsonValue | null | undefined,
context: Record<string, unknown>
): { passed: boolean; failedConditions: string[] } {
if (!guardJson || typeof guardJson !== 'object') {
return { passed: true, failedConditions: [] }
}
const guard = guardJson as unknown as GuardConfig
const conditions = guard.conditions ?? []
if (conditions.length === 0) {
return { passed: true, failedConditions: [] }
}
const failedConditions: string[] = []
const results = conditions.map((condition) => {
const result = evaluateGuardCondition(condition, context)
if (!result) {
failedConditions.push(
`Guard failed: ${condition.field} ${condition.operator} ${JSON.stringify(condition.value)}`
)
}
return result
})
const logic = guard.logic ?? 'AND'
const passed = logic === 'AND'
? results.every(Boolean)
: results.some(Boolean)
return { passed, failedConditions: passed ? [] : failedConditions }
}
// ─── Validate Transition ────────────────────────────────────────────────────
/**
* Validate whether a project can transition from one stage to another.
* Checks:
* 1. Source PSS exists and is not already exited
* 2. A StageTransition record exists for fromStage -> toStage
* 3. Destination stage is active (not DRAFT or ARCHIVED)
* 4. Voting/evaluation window constraints on the destination stage
* 5. Guard conditions on the transition
*/
export async function validateTransition(
projectId: string,
fromStageId: string,
toStageId: string,
prisma: PrismaClient | any
): Promise<TransitionValidationResult> {
const errors: string[] = []
// 1. Check source PSS exists and is active (no exitedAt)
const sourcePSS = await prisma.projectStageState.findFirst({
where: {
projectId,
stageId: fromStageId,
exitedAt: null,
},
})
if (!sourcePSS) {
errors.push(
`Project ${projectId} has no active state in stage ${fromStageId}`
)
}
// 2. Check StageTransition record exists
const transition = await prisma.stageTransition.findUnique({
where: {
fromStageId_toStageId: {
fromStageId,
toStageId,
},
},
})
if (!transition) {
errors.push(
`No transition defined from stage ${fromStageId} to stage ${toStageId}`
)
return { valid: false, errors }
}
// 3. Check destination stage is active
const destStage = await prisma.stage.findUnique({
where: { id: toStageId },
})
if (!destStage) {
errors.push(`Destination stage ${toStageId} not found`)
return { valid: false, errors }
}
if (destStage.status === 'STAGE_ARCHIVED') {
errors.push(`Destination stage "${destStage.name}" is archived`)
}
// 4. Check window constraints on destination stage
const now = new Date()
if (destStage.windowOpenAt && now < destStage.windowOpenAt) {
errors.push(
`Destination stage "${destStage.name}" window has not opened yet (opens ${destStage.windowOpenAt.toISOString()})`
)
}
if (destStage.windowCloseAt && now > destStage.windowCloseAt) {
errors.push(
`Destination stage "${destStage.name}" window has already closed (closed ${destStage.windowCloseAt.toISOString()})`
)
}
// 5. Evaluate guard conditions
if (transition.guardJson && sourcePSS) {
// Build context from the project and its current state for guard evaluation
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
assignments: {
where: { stageId: fromStageId },
include: { evaluation: true },
},
},
})
const evaluations = project?.assignments
?.map((a: any) => a.evaluation)
.filter(Boolean) ?? []
const submittedEvaluations = evaluations.filter(
(e: any) => e.status === 'SUBMITTED'
)
const avgScore =
submittedEvaluations.length > 0
? submittedEvaluations.reduce(
(sum: number, e: any) => sum + (e.globalScore ?? 0),
0
) / submittedEvaluations.length
: 0
const guardContext: Record<string, unknown> = {
state: sourcePSS?.state,
evaluationCount: evaluations.length,
submittedEvaluationCount: submittedEvaluations.length,
averageScore: avgScore,
status: project?.status,
country: project?.country,
competitionCategory: project?.competitionCategory,
tags: project?.tags ?? [],
}
const guardResult = evaluateGuard(transition.guardJson, guardContext)
if (!guardResult.passed) {
errors.push(...guardResult.failedConditions)
}
}
return { valid: errors.length === 0, errors }
}
// ─── Execute Transition ─────────────────────────────────────────────────────
/**
* Execute a stage transition for a single project atomically.
* Within a transaction:
* 1. Sets exitedAt on the source PSS
* 2. Creates or updates the destination PSS with the new state
* 3. Logs the transition in DecisionAuditLog
* 4. Logs the transition in AuditLog
*/
export async function executeTransition(
projectId: string,
trackId: string,
fromStageId: string,
toStageId: string,
newState: ProjectStageStateValue,
actorId: string,
prisma: PrismaClient | any
): Promise<TransitionExecutionResult> {
try {
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
// 1. Exit the source PSS
const sourcePSS = await tx.projectStageState.findFirst({
where: {
projectId,
stageId: fromStageId,
exitedAt: null,
},
})
if (sourcePSS) {
await tx.projectStageState.update({
where: { id: sourcePSS.id },
data: {
exitedAt: now,
state: sourcePSS.state === 'PENDING' || sourcePSS.state === 'IN_PROGRESS'
? 'COMPLETED'
: sourcePSS.state,
},
})
}
// 2. Create or update destination PSS
const existingDestPSS = await tx.projectStageState.findUnique({
where: {
projectId_trackId_stageId: {
projectId,
trackId,
stageId: toStageId,
},
},
})
let destPSS
if (existingDestPSS) {
destPSS = await tx.projectStageState.update({
where: { id: existingDestPSS.id },
data: {
state: newState,
enteredAt: now,
exitedAt: null,
},
})
} else {
destPSS = await tx.projectStageState.create({
data: {
projectId,
trackId,
stageId: toStageId,
state: newState,
enteredAt: now,
},
})
}
// 3. Log in DecisionAuditLog
await tx.decisionAuditLog.create({
data: {
eventType: 'stage.transitioned',
entityType: 'ProjectStageState',
entityId: destPSS.id,
actorId,
detailsJson: {
projectId,
trackId,
fromStageId,
toStageId,
previousState: sourcePSS?.state ?? null,
newState,
},
snapshotJson: {
sourcePSSId: sourcePSS?.id ?? null,
destPSSId: destPSS.id,
timestamp: now.toISOString(),
},
},
})
// 4. Audit log (never throws)
await logAudit({
prisma: tx,
userId: actorId,
action: 'STAGE_TRANSITION',
entityType: 'ProjectStageState',
entityId: destPSS.id,
detailsJson: {
projectId,
fromStageId,
toStageId,
newState,
},
})
return destPSS
})
return {
success: true,
projectStageState: {
id: result.id,
projectId: result.projectId,
trackId: result.trackId,
stageId: result.stageId,
state: result.state,
},
}
} catch (error) {
console.error('[StageEngine] Transition execution failed:', error)
return {
success: false,
projectStageState: null,
errors: [
error instanceof Error
? error.message
: 'Unknown error during transition execution',
],
}
}
}
// ─── Batch Transition ───────────────────────────────────────────────────────
/**
* Execute transitions for multiple projects in batches of 50.
* Each project is processed independently so a failure in one does not
* block others.
*/
export async function executeBatchTransition(
projectIds: string[],
trackId: string,
fromStageId: string,
toStageId: string,
newState: ProjectStageStateValue,
actorId: string,
prisma: PrismaClient | any
): Promise<BatchTransitionResult> {
const succeeded: string[] = []
const failed: Array<{ projectId: string; errors: string[] }> = []
// Process in batches
for (let i = 0; i < projectIds.length; i += BATCH_SIZE) {
const batch = projectIds.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (projectId) => {
// Validate first
const validation = await validateTransition(
projectId,
fromStageId,
toStageId,
prisma
)
if (!validation.valid) {
failed.push({ projectId, errors: validation.errors })
return
}
// Execute transition
const result = await executeTransition(
projectId,
trackId,
fromStageId,
toStageId,
newState,
actorId,
prisma
)
if (result.success) {
succeeded.push(projectId)
} else {
failed.push({
projectId,
errors: result.errors ?? ['Transition execution failed'],
})
}
})
await Promise.all(batchPromises)
}
return {
succeeded,
failed,
total: projectIds.length,
}
}

View File

@@ -1,646 +0,0 @@
/**
* Stage Filtering Service
*
* Runs filtering logic scoped to a specific pipeline stage. Executes deterministic
* (field-based, document-check) rules first; if those pass, proceeds to AI screening.
* Results are banded by confidence and flagged items go to a manual review queue.
*
* This service delegates to the existing `ai-filtering.ts` utilities for rule
* evaluation but adds stage-awareness, FilteringJob tracking, and manual
* decision resolution.
*/
import type { PrismaClient, FilteringOutcome, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
export interface StageFilteringResult {
jobId: string
passed: number
rejected: number
manualQueue: number
total: number
}
export interface ManualQueueItem {
filteringResultId: string
projectId: string
projectTitle: string
outcome: string
ruleResults: Prisma.JsonValue | null
aiScreeningJson: Prisma.JsonValue | null
createdAt: Date
}
interface RuleConfig {
conditions?: Array<{
field: string
operator: string
value: unknown
}>
logic?: 'AND' | 'OR'
action?: string
requiredFileTypes?: string[]
minFileCount?: number
criteriaText?: string
batchSize?: number
}
interface RuleResult {
ruleId: string
ruleName: string
ruleType: string
passed: boolean
action: string
reasoning?: string
}
// ─── Constants ──────────────────────────────────────────────────────────────
const AI_CONFIDENCE_THRESHOLD_PASS = 0.75
const AI_CONFIDENCE_THRESHOLD_REJECT = 0.25
// ─── Field-Based Rule Evaluation ────────────────────────────────────────────
function evaluateFieldCondition(
condition: { field: string; operator: string; value: unknown },
project: Record<string, unknown>
): boolean {
const fieldValue = project[condition.field]
switch (condition.operator) {
case 'equals':
return String(fieldValue) === String(condition.value)
case 'not_equals':
return String(fieldValue) !== String(condition.value)
case 'contains': {
if (Array.isArray(fieldValue)) {
return fieldValue.some((v) =>
String(v).toLowerCase().includes(String(condition.value).toLowerCase())
)
}
return String(fieldValue ?? '')
.toLowerCase()
.includes(String(condition.value).toLowerCase())
}
case 'in': {
if (Array.isArray(condition.value)) {
return (condition.value as unknown[]).includes(fieldValue)
}
return false
}
case 'not_in': {
if (Array.isArray(condition.value)) {
return !(condition.value as unknown[]).includes(fieldValue)
}
return true
}
case 'is_empty':
return (
fieldValue === null ||
fieldValue === undefined ||
(Array.isArray(fieldValue) && fieldValue.length === 0) ||
String(fieldValue).trim() === ''
)
case 'greater_than':
return Number(fieldValue) > Number(condition.value)
case 'less_than':
return Number(fieldValue) < Number(condition.value)
case 'older_than_years': {
if (!(fieldValue instanceof Date)) return false
const cutoff = new Date()
cutoff.setFullYear(cutoff.getFullYear() - Number(condition.value))
return fieldValue < cutoff
}
case 'newer_than_years': {
if (!(fieldValue instanceof Date)) return false
const cutoff = new Date()
cutoff.setFullYear(cutoff.getFullYear() - Number(condition.value))
return fieldValue >= cutoff
}
default:
return false
}
}
function evaluateFieldRule(
config: RuleConfig,
project: Record<string, unknown>
): { passed: boolean; action: string } {
const conditions = config.conditions ?? []
if (conditions.length === 0) return { passed: true, action: config.action ?? 'PASS' }
const results = conditions.map((c) => evaluateFieldCondition(c, project))
const logic = config.logic ?? 'AND'
const allMet = logic === 'AND' ? results.every(Boolean) : results.some(Boolean)
if (config.action === 'PASS') {
return { passed: allMet, action: config.action }
}
// For REJECT/FLAG rules, if conditions are met the project fails the rule
return { passed: !allMet, action: config.action ?? 'REJECT' }
}
function evaluateDocumentCheck(
config: RuleConfig,
projectFiles: Array<{ fileType: string; fileName: string }>
): { passed: boolean; action: string } {
const action = config.action ?? 'FLAG'
if (config.requiredFileTypes && config.requiredFileTypes.length > 0) {
const fileTypes = projectFiles.map((f) => f.fileType)
const hasAllRequired = config.requiredFileTypes.every((ft) =>
fileTypes.includes(ft)
)
if (!hasAllRequired) return { passed: false, action }
}
if (config.minFileCount !== undefined) {
if (projectFiles.length < config.minFileCount) {
return { passed: false, action }
}
}
return { passed: true, action }
}
/**
* Simple AI-screening placeholder that uses confidence banding.
* In production, this calls the OpenAI API (see ai-filtering.ts).
* Here we evaluate based on project metadata heuristics.
*/
function bandByConfidence(
aiScreeningData: { confidence?: number; meetsAllCriteria?: boolean } | null
): { outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'; confidence: number } {
if (!aiScreeningData || aiScreeningData.confidence === undefined) {
return { outcome: 'FLAGGED', confidence: 0 }
}
const confidence = aiScreeningData.confidence
if (confidence >= AI_CONFIDENCE_THRESHOLD_PASS && aiScreeningData.meetsAllCriteria) {
return { outcome: 'PASSED', confidence }
}
if (confidence <= AI_CONFIDENCE_THRESHOLD_REJECT && !aiScreeningData.meetsAllCriteria) {
return { outcome: 'FILTERED_OUT', confidence }
}
return { outcome: 'FLAGGED', confidence }
}
// ─── Run Stage Filtering ────────────────────────────────────────────────────
/**
* Execute the full filtering pipeline for a stage:
* 1. Create a FilteringJob for progress tracking
* 2. Load all projects with active PSS in the stage
* 3. Load filtering rules scoped to this stage (ordered by priority)
* 4. Run deterministic rules first (FIELD_BASED, DOCUMENT_CHECK)
* 5. For projects that pass deterministic rules, run AI screening
* 6. Band AI results by confidence
* 7. Save FilteringResult for every project
*/
export async function runStageFiltering(
stageId: string,
actorId: string,
prisma: PrismaClient | any
): Promise<StageFilteringResult> {
const stage = await prisma.stage.findUnique({
where: { id: stageId },
include: {
track: { include: { pipeline: true } },
},
})
if (!stage) {
throw new Error(`Stage ${stageId} not found`)
}
// Load projects in this stage (active PSS, not exited)
const projectStates = await prisma.projectStageState.findMany({
where: {
stageId,
exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
include: {
project: {
include: {
files: { select: { fileType: true, fileName: true } },
projectTags: { include: { tag: true } },
},
},
},
})
const projects = projectStates.map((pss: any) => pss.project).filter(Boolean)
const job = await prisma.filteringJob.create({
data: {
stageId,
status: 'RUNNING',
totalProjects: projects.length,
startedAt: new Date(),
},
})
// Load filtering rules scoped to this stage
const rules = await prisma.filteringRule.findMany({
where: {
stageId,
isActive: true,
},
orderBy: { priority: 'asc' as const },
})
// Separate deterministic rules from AI rules
const deterministicRules = rules.filter(
(r: any) => r.ruleType === 'FIELD_BASED' || r.ruleType === 'DOCUMENT_CHECK'
)
const aiRules = rules.filter((r: any) => r.ruleType === 'AI_SCREENING')
// ── Built-in: Duplicate submission detection ──────────────────────────────
// Group projects by submitter email to detect duplicate submissions.
// Duplicates are ALWAYS flagged for admin review (never auto-rejected).
const duplicateProjectIds = new Set<string>()
const emailToProjects = new Map<string, Array<{ id: string; title: string }>>()
for (const project of projects) {
const email = (project.submittedByEmail ?? '').toLowerCase().trim()
if (!email) continue
if (!emailToProjects.has(email)) emailToProjects.set(email, [])
emailToProjects.get(email)!.push({ id: project.id, title: project.title })
}
const duplicateGroups: Map<string, string[]> = new Map() // projectId → sibling ids
emailToProjects.forEach((group, _email) => {
if (group.length <= 1) return
const ids = group.map((p) => p.id)
for (const p of group) {
duplicateProjectIds.add(p.id)
duplicateGroups.set(p.id, ids.filter((id) => id !== p.id))
}
})
if (duplicateProjectIds.size > 0) {
console.log(`[Stage Filtering] Detected ${duplicateProjectIds.size} projects in duplicate groups`)
}
// ── End duplicate detection ───────────────────────────────────────────────
let passed = 0
let rejected = 0
let manualQueue = 0
let processedCount = 0
for (const project of projects) {
const ruleResults: RuleResult[] = []
let deterministicPassed = true
let deterministicOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = 'PASSED'
// 0. Check for duplicate submissions (always FLAG, never auto-reject)
if (duplicateProjectIds.has(project.id)) {
const siblingIds = duplicateGroups.get(project.id) ?? []
ruleResults.push({
ruleId: '__duplicate_check',
ruleName: 'Duplicate Submission Check',
ruleType: 'DUPLICATE_CHECK',
passed: false,
action: 'FLAG',
reasoning: `Duplicate submission detected: same applicant email submitted ${siblingIds.length + 1} project(s). Sibling project IDs: ${siblingIds.join(', ')}. Admin must review and decide which to keep.`,
})
deterministicOutcome = 'FLAGGED'
}
// 1. Run deterministic rules
for (const rule of deterministicRules) {
const config = rule.configJson as unknown as RuleConfig
let result: { passed: boolean; action: string }
if (rule.ruleType === 'FIELD_BASED') {
result = evaluateFieldRule(config, {
competitionCategory: project.competitionCategory,
foundedAt: project.foundedAt,
country: project.country,
geographicZone: project.geographicZone,
tags: project.tags,
oceanIssue: project.oceanIssue,
wantsMentorship: project.wantsMentorship,
institution: project.institution,
})
} else {
// DOCUMENT_CHECK
result = evaluateDocumentCheck(config, project.files)
}
ruleResults.push({
ruleId: rule.id,
ruleName: rule.name,
ruleType: rule.ruleType,
passed: result.passed,
action: result.action,
})
if (!result.passed) {
deterministicPassed = false
if (result.action === 'REJECT') {
deterministicOutcome = 'FILTERED_OUT'
break // Hard reject, skip remaining rules
} else if (result.action === 'FLAG') {
deterministicOutcome = 'FLAGGED'
}
}
}
// 2. AI screening (run if deterministic passed, OR if duplicate—so AI can recommend which to keep)
const isDuplicate = duplicateProjectIds.has(project.id)
let aiScreeningJson: Record<string, unknown> | null = null
let finalOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = deterministicOutcome
if ((deterministicPassed || isDuplicate) && aiRules.length > 0) {
// Build a simplified AI screening result using the existing AI criteria
// In production this would call OpenAI via the ai-filtering service
const aiRule = aiRules[0]
const aiConfig = aiRule.configJson as unknown as RuleConfig
// For now, flag projects that have AI rules but need manual review
// The actual AI call would be: await runAIScreening(project, aiConfig)
const hasMinimalData = Boolean(project.description && project.title)
const confidence = hasMinimalData ? 0.5 : 0.2
aiScreeningJson = {
ruleId: aiRule.id,
criteriaText: aiConfig.criteriaText,
confidence,
meetsAllCriteria: hasMinimalData,
reasoning: hasMinimalData
? 'Project has required data, needs manual review'
: 'Insufficient project data for AI screening',
}
// Attach duplicate metadata so admin can see sibling projects
if (isDuplicate) {
const siblingIds = duplicateGroups.get(project.id) ?? []
aiScreeningJson.isDuplicate = true
aiScreeningJson.siblingProjectIds = siblingIds
aiScreeningJson.duplicateNote =
`This project shares a submitter email with ${siblingIds.length} other project(s). ` +
'AI screening should compare these and recommend which to keep.'
}
const banded = bandByConfidence({
confidence,
meetsAllCriteria: hasMinimalData,
})
// For non-duplicate projects, use AI banding; for duplicates, keep FLAGGED
if (!isDuplicate) {
finalOutcome = banded.outcome
}
ruleResults.push({
ruleId: aiRule.id,
ruleName: aiRule.name,
ruleType: 'AI_SCREENING',
passed: banded.outcome === 'PASSED',
action: banded.outcome === 'PASSED' ? 'PASS' : banded.outcome === 'FLAGGED' ? 'FLAG' : 'REJECT',
reasoning: `Confidence: ${banded.confidence.toFixed(2)}`,
})
}
// Duplicate submissions must ALWAYS be flagged for admin review,
// even if other rules would auto-reject them.
if (duplicateProjectIds.has(project.id) && finalOutcome === 'FILTERED_OUT') {
finalOutcome = 'FLAGGED'
}
await prisma.filteringResult.upsert({
where: {
stageId_projectId: {
stageId,
projectId: project.id,
},
},
create: {
stageId,
projectId: project.id,
outcome: finalOutcome as FilteringOutcome,
ruleResultsJson: ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: aiScreeningJson as Prisma.InputJsonValue ?? undefined,
},
update: {
outcome: finalOutcome as FilteringOutcome,
ruleResultsJson: ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: aiScreeningJson as Prisma.InputJsonValue ?? undefined,
finalOutcome: null,
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
},
})
// Track counts
switch (finalOutcome) {
case 'PASSED':
passed++
break
case 'FILTERED_OUT':
rejected++
break
case 'FLAGGED':
manualQueue++
break
}
processedCount++
// Update job progress periodically
if (processedCount % 10 === 0 || processedCount === projects.length) {
await prisma.filteringJob.update({
where: { id: job.id },
data: {
processedCount,
passedCount: passed,
filteredCount: rejected,
flaggedCount: manualQueue,
},
})
}
}
// Complete the job
await prisma.filteringJob.update({
where: { id: job.id },
data: {
status: 'COMPLETED',
processedCount,
passedCount: passed,
filteredCount: rejected,
flaggedCount: manualQueue,
completedAt: new Date(),
},
})
// Decision audit log
await prisma.decisionAuditLog.create({
data: {
eventType: 'filtering.completed',
entityType: 'FilteringJob',
entityId: job.id,
actorId,
detailsJson: {
stageId,
total: projects.length,
passed,
rejected,
manualQueue,
ruleCount: rules.length,
},
},
})
// Audit log
await logAudit({
prisma,
userId: actorId,
action: 'STAGE_FILTERING_RUN',
entityType: 'FilteringJob',
entityId: job.id,
detailsJson: {
stageId,
total: projects.length,
passed,
rejected,
manualQueue,
},
})
return {
jobId: job.id,
passed,
rejected,
manualQueue,
total: projects.length,
}
}
// ─── Resolve Manual Decision ────────────────────────────────────────────────
/**
* Resolve a flagged filtering result with a manual decision.
* Updates the finalOutcome on the FilteringResult and logs the override.
*/
export async function resolveManualDecision(
filteringResultId: string,
outcome: 'PASSED' | 'FILTERED_OUT',
reason: string,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
const existing = await prisma.filteringResult.findUnique({
where: { id: filteringResultId },
})
if (!existing) {
throw new Error(`FilteringResult ${filteringResultId} not found`)
}
if (existing.outcome !== 'FLAGGED') {
throw new Error(
`FilteringResult ${filteringResultId} is not FLAGGED (current: ${existing.outcome})`
)
}
await prisma.$transaction(async (tx: any) => {
// Update the filtering result
await tx.filteringResult.update({
where: { id: filteringResultId },
data: {
finalOutcome: outcome as FilteringOutcome,
overriddenBy: actorId,
overriddenAt: new Date(),
overrideReason: reason,
},
})
// Create override action record
await tx.overrideAction.create({
data: {
entityType: 'FilteringResult',
entityId: filteringResultId,
previousValue: { outcome: existing.outcome },
newValueJson: { finalOutcome: outcome },
reasonCode: 'ADMIN_DISCRETION',
reasonText: reason,
actorId,
},
})
// Decision audit log
await tx.decisionAuditLog.create({
data: {
eventType: 'filtering.manual_decision',
entityType: 'FilteringResult',
entityId: filteringResultId,
actorId,
detailsJson: {
projectId: existing.projectId,
previousOutcome: existing.outcome,
newOutcome: outcome,
reason,
},
},
})
// Audit log
await logAudit({
prisma: tx,
userId: actorId,
action: 'FILTERING_MANUAL_DECISION',
entityType: 'FilteringResult',
entityId: filteringResultId,
detailsJson: {
projectId: existing.projectId,
outcome,
reason,
},
})
})
}
// ─── Get Manual Queue ───────────────────────────────────────────────────────
/**
* Retrieve all flagged filtering results for a stage that have not yet
* been manually resolved.
*/
export async function getManualQueue(
stageId: string,
prisma: PrismaClient | any
): Promise<ManualQueueItem[]> {
const results = await prisma.filteringResult.findMany({
where: {
stageId,
outcome: 'FLAGGED',
finalOutcome: null,
},
include: {
project: {
select: { id: true, title: true },
},
},
orderBy: { createdAt: 'asc' as const },
})
return results.map((r: any) => ({
filteringResultId: r.id,
projectId: r.projectId,
projectTitle: r.project?.title ?? 'Unknown',
outcome: r.outcome,
ruleResults: r.ruleResultsJson,
aiScreeningJson: r.aiScreeningJson,
createdAt: r.createdAt,
}))
}

View File

@@ -1,463 +0,0 @@
/**
* Stage Notifications Service
*
* Event producers called from other pipeline services. Each function creates
* a DecisionAuditLog entry, checks NotificationPolicy configuration, and
* creates in-app notifications (optionally sending email). Producers never
* throw - all errors are caught and logged.
*
* Event types follow a dotted convention:
* stage.transitioned, filtering.completed, assignment.generated,
* live.cursor_updated, decision.overridden
*/
import type { PrismaClient, Prisma } from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
// ─── Types ──────────────────────────────────────────────────────────────────
export interface StageEventDetails {
[key: string]: unknown
}
interface NotificationTarget {
userId: string
name: string
email: string
}
// ─── Constants ──────────────────────────────────────────────────────────────
const EVENT_TYPES = {
STAGE_TRANSITIONED: 'stage.transitioned',
FILTERING_COMPLETED: 'filtering.completed',
ASSIGNMENT_GENERATED: 'assignment.generated',
CURSOR_UPDATED: 'live.cursor_updated',
DECISION_OVERRIDDEN: 'decision.overridden',
} as const
const EVENT_TITLES: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'Stage Transition',
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filtering Complete',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'Assignments Generated',
[EVENT_TYPES.CURSOR_UPDATED]: 'Live Cursor Updated',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'Decision Overridden',
}
const EVENT_ICONS: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'ArrowRight',
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filter',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'ClipboardList',
[EVENT_TYPES.CURSOR_UPDATED]: 'Play',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'ShieldAlert',
}
const EVENT_PRIORITIES: Record<string, string> = {
[EVENT_TYPES.STAGE_TRANSITIONED]: 'normal',
[EVENT_TYPES.FILTERING_COMPLETED]: 'high',
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'high',
[EVENT_TYPES.CURSOR_UPDATED]: 'low',
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'high',
}
// ─── Core Event Emitter ─────────────────────────────────────────────────────
/**
* Core event emission function. Creates a DecisionAuditLog entry, checks
* the NotificationPolicy for the event type, and creates in-app notifications
* for the appropriate recipients.
*
* This function never throws. All errors are caught and logged.
*/
export async function emitStageEvent(
eventType: string,
entityType: string,
entityId: string,
actorId: string,
details: StageEventDetails,
prisma: PrismaClient | any
): Promise<void> {
try {
// 1. Create DecisionAuditLog entry
await prisma.decisionAuditLog.create({
data: {
eventType,
entityType,
entityId,
actorId,
detailsJson: details as Prisma.InputJsonValue,
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'stage-notifications',
},
},
})
// 2. Check NotificationPolicy
const policy = await prisma.notificationPolicy.findUnique({
where: { eventType },
})
// If no policy or policy inactive, just log and return
if (!policy || !policy.isActive) {
return
}
// 3. Determine recipients based on event type
const recipients = await resolveRecipients(
eventType,
details,
prisma
)
if (recipients.length === 0) return
// 4. Determine notification content
const title = EVENT_TITLES[eventType] ?? 'Pipeline Event'
const icon = EVENT_ICONS[eventType] ?? 'Bell'
const priority = EVENT_PRIORITIES[eventType] ?? 'normal'
const message = buildNotificationMessage(eventType, details)
// 5. Create in-app notifications
const channel = policy.channel ?? 'IN_APP'
const shouldCreateInApp = channel === 'IN_APP' || channel === 'BOTH'
const shouldSendEmail = channel === 'EMAIL' || channel === 'BOTH'
if (shouldCreateInApp) {
const notificationData = recipients.map((recipient) => ({
userId: recipient.userId,
type: eventType,
title,
message,
icon,
priority,
metadata: {
entityType,
entityId,
actorId,
...details,
} as object,
groupKey: `${eventType}:${entityId}`,
}))
await prisma.inAppNotification.createMany({
data: notificationData,
})
}
// 6. Optionally send email notifications
if (shouldSendEmail) {
// Email sending is best-effort; we import lazily to avoid circular deps
try {
const { sendStyledNotificationEmail } = await import('@/lib/email')
for (const recipient of recipients) {
try {
await sendStyledNotificationEmail(
recipient.email,
recipient.name,
eventType,
{
title,
message,
metadata: details as Record<string, unknown>,
}
)
} catch (emailError) {
console.error(
`[StageNotifications] Failed to send email to ${recipient.email}:`,
emailError
)
}
}
} catch (importError) {
console.error(
'[StageNotifications] Failed to import email module:',
importError
)
}
}
// 7. Audit log (never throws)
await logAudit({
prisma,
userId: actorId,
action: 'STAGE_EVENT_EMITTED',
entityType,
entityId,
detailsJson: {
eventType,
recipientCount: recipients.length,
channel,
},
})
} catch (error) {
// Never throw from event producers
console.error(
`[StageNotifications] Failed to emit event ${eventType}:`,
error
)
}
}
// ─── Recipient Resolution ───────────────────────────────────────────────────
/**
* Determine who should receive notifications for a given event type.
* Different events notify different audiences (admins, jury, etc.).
*/
async function resolveRecipients(
eventType: string,
details: StageEventDetails,
prisma: PrismaClient | any
): Promise<NotificationTarget[]> {
try {
switch (eventType) {
case EVENT_TYPES.STAGE_TRANSITIONED:
case EVENT_TYPES.FILTERING_COMPLETED:
case EVENT_TYPES.ASSIGNMENT_GENERATED:
case EVENT_TYPES.DECISION_OVERRIDDEN: {
// Notify admins
const admins = await prisma.user.findMany({
where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true },
})
return admins.map((a: any) => ({
userId: a.id,
name: a.name ?? 'Admin',
email: a.email,
}))
}
case EVENT_TYPES.CURSOR_UPDATED: {
// Notify jury members assigned to the stage
const stageId = details.stageId as string | undefined
if (!stageId) return []
const jurors = await prisma.assignment.findMany({
where: { stageId },
select: {
user: { select: { id: true, name: true, email: true } },
},
distinct: ['userId'],
})
return jurors.map((a: any) => ({
userId: a.user.id,
name: a.user.name ?? 'Jury Member',
email: a.user.email,
}))
}
default:
// Default: notify admins
const admins = await prisma.user.findMany({
where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true },
})
return admins.map((a: any) => ({
userId: a.id,
name: a.name ?? 'Admin',
email: a.email,
}))
}
} catch (error) {
console.error(
'[StageNotifications] Failed to resolve recipients:',
error
)
return []
}
}
// ─── Message Builder ────────────────────────────────────────────────────────
/**
* Build a human-readable notification message from event details.
*/
function buildNotificationMessage(
eventType: string,
details: StageEventDetails
): string {
switch (eventType) {
case EVENT_TYPES.STAGE_TRANSITIONED: {
const projectId = details.projectId as string | undefined
const toStageId = details.toStageId as string | undefined
const newState = details.newState as string | undefined
return `Project ${projectId ?? 'unknown'} transitioned to stage ${toStageId ?? 'unknown'} with state ${newState ?? 'unknown'}.`
}
case EVENT_TYPES.FILTERING_COMPLETED: {
const total = details.total as number | undefined
const passed = details.passed as number | undefined
const rejected = details.rejected as number | undefined
const manualQueue = details.manualQueue as number | undefined
return `Filtering completed: ${passed ?? 0} passed, ${rejected ?? 0} rejected, ${manualQueue ?? 0} flagged for review out of ${total ?? 0} projects.`
}
case EVENT_TYPES.ASSIGNMENT_GENERATED: {
const count = details.assignmentCount as number | undefined
return `${count ?? 0} assignments were generated for the stage.`
}
case EVENT_TYPES.CURSOR_UPDATED: {
const projectId = details.projectId as string | undefined
const action = details.action as string | undefined
return `Live cursor updated: ${action ?? 'navigation'} to project ${projectId ?? 'unknown'}.`
}
case EVENT_TYPES.DECISION_OVERRIDDEN: {
const overrideEntityType = details.entityType as string | undefined
const reason = details.reason as string | undefined
return `Decision overridden on ${overrideEntityType ?? 'entity'}: ${reason ?? 'No reason provided'}.`
}
default:
return `Pipeline event: ${eventType}`
}
}
// ─── Convenience Producers ──────────────────────────────────────────────────
/**
* Emit a stage.transitioned event when a project moves between stages.
* Called from stage-engine.ts after executeTransition.
*/
export async function onStageTransitioned(
projectId: string,
trackId: string,
fromStageId: string,
toStageId: string,
newState: string,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
await emitStageEvent(
EVENT_TYPES.STAGE_TRANSITIONED,
'ProjectStageState',
projectId,
actorId,
{
projectId,
trackId,
fromStageId,
toStageId,
newState,
},
prisma
)
}
/**
* Emit a filtering.completed event when a stage filtering job finishes.
* Called from stage-filtering.ts after runStageFiltering.
*/
export async function onFilteringCompleted(
jobId: string,
stageId: string,
total: number,
passed: number,
rejected: number,
manualQueue: number,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
await emitStageEvent(
EVENT_TYPES.FILTERING_COMPLETED,
'FilteringJob',
jobId,
actorId,
{
stageId,
total,
passed,
rejected,
manualQueue,
},
prisma
)
}
/**
* Emit an assignment.generated event when stage assignments are created.
* Called from stage-assignment.ts after executeStageAssignment.
*/
export async function onAssignmentGenerated(
jobId: string,
stageId: string,
assignmentCount: number,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
await emitStageEvent(
EVENT_TYPES.ASSIGNMENT_GENERATED,
'AssignmentJob',
jobId,
actorId,
{
stageId,
assignmentCount,
},
prisma
)
}
/**
* Emit a live.cursor_updated event when the live cursor position changes.
* Called from live-control.ts after setActiveProject or jumpToProject.
*/
export async function onCursorUpdated(
cursorId: string,
stageId: string,
projectId: string | null,
action: string,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
await emitStageEvent(
EVENT_TYPES.CURSOR_UPDATED,
'LiveProgressCursor',
cursorId,
actorId,
{
stageId,
projectId,
action,
},
prisma
)
}
/**
* Emit a decision.overridden event when an admin overrides a pipeline decision.
* Called from manual override handlers.
*/
export async function onDecisionOverridden(
entityType: string,
entityId: string,
previousValue: unknown,
newValue: unknown,
reason: string,
actorId: string,
prisma: PrismaClient | any
): Promise<void> {
await emitStageEvent(
EVENT_TYPES.DECISION_OVERRIDDEN,
entityType,
entityId,
actorId,
{
entityType,
previousValue,
newValue,
reason,
},
prisma
)
}

View 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,
}))
}