Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,16 +34,23 @@ import { webhookRouter } from './webhook'
|
||||
import { projectPoolRouter } from './project-pool'
|
||||
import { wizardTemplateRouter } from './wizard-template'
|
||||
import { dashboardRouter } from './dashboard'
|
||||
// Round redesign Phase 2 routers
|
||||
import { pipelineRouter } from './pipeline'
|
||||
import { stageRouter } from './stage'
|
||||
|
||||
import { stageFilteringRouter } from './stageFiltering'
|
||||
import { stageAssignmentRouter } from './stageAssignment'
|
||||
// Legacy round routers (kept)
|
||||
import { cohortRouter } from './cohort'
|
||||
import { liveRouter } from './live'
|
||||
import { decisionRouter } from './decision'
|
||||
import { awardRouter } from './award'
|
||||
// Competition architecture routers (Phase 0+1)
|
||||
import { competitionRouter } from './competition'
|
||||
import { roundRouter } from './round'
|
||||
import { juryGroupRouter } from './juryGroup'
|
||||
// Competition architecture routers (Phase 2)
|
||||
import { assignmentPolicyRouter } from './assignmentPolicy'
|
||||
import { assignmentIntentRouter } from './assignmentIntent'
|
||||
// Competition architecture routers (Phase 4 - Backend Orchestration)
|
||||
import { roundEngineRouter } from './roundEngine'
|
||||
import { roundAssignmentRouter } from './roundAssignment'
|
||||
import { deliberationRouter } from './deliberation'
|
||||
import { resultLockRouter } from './resultLock'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
@@ -84,15 +91,23 @@ export const appRouter = router({
|
||||
projectPool: projectPoolRouter,
|
||||
wizardTemplate: wizardTemplateRouter,
|
||||
dashboard: dashboardRouter,
|
||||
// Round redesign Phase 2 routers
|
||||
pipeline: pipelineRouter,
|
||||
stage: stageRouter,
|
||||
stageFiltering: stageFilteringRouter,
|
||||
stageAssignment: stageAssignmentRouter,
|
||||
// Legacy round routers (kept)
|
||||
cohort: cohortRouter,
|
||||
live: liveRouter,
|
||||
decision: decisionRouter,
|
||||
award: awardRouter,
|
||||
// Competition architecture routers (Phase 0+1)
|
||||
competition: competitionRouter,
|
||||
round: roundRouter,
|
||||
juryGroup: juryGroupRouter,
|
||||
// Competition architecture routers (Phase 2)
|
||||
assignmentPolicy: assignmentPolicyRouter,
|
||||
assignmentIntent: assignmentIntentRouter,
|
||||
// Competition architecture routers (Phase 4 - Backend Orchestration)
|
||||
roundEngine: roundEngineRouter,
|
||||
roundAssignment: roundAssignmentRouter,
|
||||
deliberation: deliberationRouter,
|
||||
resultLock: resultLockRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
@@ -2,27 +2,27 @@ import { z } from 'zod'
|
||||
import { router, observerProcedure } from '../trpc'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
|
||||
const editionOrStageInput = z.object({
|
||||
stageId: z.string().optional(),
|
||||
const editionOrRoundInput = z.object({
|
||||
roundId: z.string().optional(),
|
||||
programId: z.string().optional(),
|
||||
}).refine(data => data.stageId || data.programId, {
|
||||
message: 'Either stageId or programId is required',
|
||||
}).refine(data => data.roundId || data.programId, {
|
||||
message: 'Either roundId or programId is required',
|
||||
})
|
||||
|
||||
function projectWhere(input: { stageId?: string; programId?: string }) {
|
||||
if (input.stageId) return { assignments: { some: { stageId: input.stageId } } }
|
||||
function projectWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { assignments: { some: { roundId: input.roundId } } }
|
||||
return { programId: input.programId! }
|
||||
}
|
||||
|
||||
function assignmentWhere(input: { stageId?: string; programId?: string }) {
|
||||
if (input.stageId) return { stageId: input.stageId }
|
||||
return { stage: { track: { pipeline: { programId: input.programId! } } } }
|
||||
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { roundId: input.roundId }
|
||||
return { round: { competition: { programId: input.programId! } } }
|
||||
}
|
||||
|
||||
function evalWhere(input: { stageId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
|
||||
const base = input.stageId
|
||||
? { assignment: { stageId: input.stageId } }
|
||||
: { assignment: { stage: { track: { pipeline: { programId: input.programId! } } } } }
|
||||
function evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
|
||||
const base = input.roundId
|
||||
? { assignment: { roundId: input.roundId } }
|
||||
: { assignment: { round: { competition: { programId: input.programId! } } } }
|
||||
return { ...base, ...extra }
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const analyticsRouter = router({
|
||||
* Get score distribution (histogram data)
|
||||
*/
|
||||
getScoreDistribution: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
@@ -73,7 +73,7 @@ export const analyticsRouter = router({
|
||||
* Get evaluation completion over time (timeline data)
|
||||
*/
|
||||
getEvaluationTimeline: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
@@ -116,7 +116,7 @@ export const analyticsRouter = router({
|
||||
* Get juror workload distribution
|
||||
*/
|
||||
getJurorWorkload: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: assignmentWhere(input),
|
||||
@@ -165,7 +165,7 @@ export const analyticsRouter = router({
|
||||
* Get project rankings with average scores
|
||||
*/
|
||||
getProjectRankings: observerProcedure
|
||||
.input(editionOrStageInput.and(z.object({ limit: z.number().optional() })))
|
||||
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: projectWhere(input),
|
||||
@@ -233,7 +233,7 @@ export const analyticsRouter = router({
|
||||
* Get status breakdown (pie chart data)
|
||||
*/
|
||||
getStatusBreakdown: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
@@ -251,7 +251,7 @@ export const analyticsRouter = router({
|
||||
* Get overview stats for dashboard
|
||||
*/
|
||||
getOverviewStats: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [
|
||||
projectCount,
|
||||
@@ -298,11 +298,11 @@ export const analyticsRouter = router({
|
||||
* Get criteria-level score distribution
|
||||
*/
|
||||
getCriteriaScores: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const formWhere = input.stageId
|
||||
? { stageId: input.stageId, isActive: true }
|
||||
: { stage: { track: { pipeline: { programId: input.programId! } } }, isActive: true }
|
||||
const formWhere = input.roundId
|
||||
? { roundId: input.roundId, isActive: true }
|
||||
: { round: { competition: { programId: input.programId! } }, isActive: true }
|
||||
|
||||
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: formWhere,
|
||||
@@ -317,7 +317,7 @@ export const analyticsRouter = router({
|
||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
|
||||
if (criteria) {
|
||||
criteria.forEach((c) => {
|
||||
const key = input.stageId ? c.id : c.label
|
||||
const key = input.roundId ? c.id : c.label
|
||||
if (!criteriaMap.has(key)) {
|
||||
criteriaMap.set(key, c)
|
||||
}
|
||||
@@ -371,12 +371,12 @@ export const analyticsRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = input.stageId
|
||||
? { assignments: { some: { stageId: input.stageId } } }
|
||||
const where = input.roundId
|
||||
? { assignments: { some: { roundId: input.roundId } } }
|
||||
: { programId: input.programId }
|
||||
|
||||
const distribution = await ctx.prisma.project.groupBy({
|
||||
@@ -403,26 +403,26 @@ export const analyticsRouter = router({
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Compare metrics across multiple stages
|
||||
* Compare metrics across multiple rounds
|
||||
*/
|
||||
getCrossStageComparison: observerProcedure
|
||||
.input(z.object({ stageIds: z.array(z.string()).min(2) }))
|
||||
getCrossRoundComparison: observerProcedure
|
||||
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const comparisons = await Promise.all(
|
||||
input.stageIds.map(async (stageId) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId },
|
||||
input.roundIds.map(async (roundId) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
|
||||
ctx.prisma.project.count({
|
||||
where: { assignments: { some: { stageId } } },
|
||||
where: { assignments: { some: { roundId } } },
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { stageId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { stageId },
|
||||
assignment: { roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
@@ -434,7 +434,7 @@ export const analyticsRouter = router({
|
||||
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { stageId },
|
||||
assignment: { roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { globalScore: true },
|
||||
@@ -454,8 +454,8 @@ export const analyticsRouter = router({
|
||||
}))
|
||||
|
||||
return {
|
||||
stageId,
|
||||
stageName: stage.name,
|
||||
roundId,
|
||||
roundName: round.name,
|
||||
projectCount,
|
||||
evaluationCount,
|
||||
completionRate,
|
||||
@@ -469,10 +469,10 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get juror consistency metrics for a stage
|
||||
* Get juror consistency metrics for a round
|
||||
*/
|
||||
getJurorConsistency: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
@@ -538,10 +538,10 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get diversity metrics for projects in a stage
|
||||
* Get diversity metrics for projects in a round
|
||||
*/
|
||||
getDiversityMetrics: observerProcedure
|
||||
.input(editionOrStageInput)
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: projectWhere(input),
|
||||
@@ -603,30 +603,37 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get year-over-year stats across all stages in a program
|
||||
* Get year-over-year stats across all rounds in a program
|
||||
*/
|
||||
getYearOverYear: observerProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stages = await ctx.prisma.stage.findMany({
|
||||
where: { track: { pipeline: { programId: input.programId } } },
|
||||
select: { id: true, name: true, createdAt: true },
|
||||
const competitions = await ctx.prisma.competition.findMany({
|
||||
where: { programId: input.programId },
|
||||
include: {
|
||||
rounds: {
|
||||
select: { id: true, name: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const allRounds = competitions.flatMap((c) => c.rounds)
|
||||
|
||||
const stats = await Promise.all(
|
||||
stages.map(async (stage) => {
|
||||
allRounds.map(async (round) => {
|
||||
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
|
||||
ctx.prisma.project.count({
|
||||
where: { assignments: { some: { stageId: stage.id } } },
|
||||
where: { assignments: { some: { roundId: round.id } } },
|
||||
}),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { stageId: stage.id },
|
||||
assignment: { roundId: round.id },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { stageId: stage.id } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
|
||||
])
|
||||
|
||||
const completionRate = assignmentCount > 0
|
||||
@@ -635,7 +642,7 @@ export const analyticsRouter = router({
|
||||
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { stageId: stage.id },
|
||||
assignment: { roundId: round.id },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { globalScore: true },
|
||||
@@ -650,9 +657,9 @@ export const analyticsRouter = router({
|
||||
: null
|
||||
|
||||
return {
|
||||
stageId: stage.id,
|
||||
stageName: stage.name,
|
||||
createdAt: stage.createdAt,
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
createdAt: round.createdAt,
|
||||
projectCount,
|
||||
evaluationCount,
|
||||
completionRate,
|
||||
@@ -665,24 +672,24 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get dashboard stats (optionally scoped to a stage)
|
||||
* Get dashboard stats (optionally scoped to a round)
|
||||
*/
|
||||
getDashboardStats: observerProcedure
|
||||
.input(z.object({ stageId: z.string().optional() }).optional())
|
||||
.input(z.object({ roundId: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stageId = input?.stageId
|
||||
const roundId = input?.roundId
|
||||
|
||||
const projectFilter = stageId
|
||||
? { assignments: { some: { stageId } } }
|
||||
const projectFilter = roundId
|
||||
? { assignments: { some: { roundId } } }
|
||||
: {}
|
||||
const assignmentFilter = stageId ? { stageId } : {}
|
||||
const evalFilter = stageId
|
||||
? { assignment: { stageId }, status: 'SUBMITTED' as const }
|
||||
const assignmentFilter = roundId ? { roundId } : {}
|
||||
const evalFilter = roundId
|
||||
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
||||
: { status: 'SUBMITTED' as const }
|
||||
|
||||
const [
|
||||
programCount,
|
||||
activeStageCount,
|
||||
activeRoundCount,
|
||||
projectCount,
|
||||
jurorCount,
|
||||
submittedEvaluations,
|
||||
@@ -690,7 +697,7 @@ export const analyticsRouter = router({
|
||||
evaluationScores,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.program.count(),
|
||||
ctx.prisma.stage.count({ where: { status: 'STAGE_ACTIVE' } }),
|
||||
ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }),
|
||||
ctx.prisma.project.count({ where: projectFilter }),
|
||||
ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
||||
ctx.prisma.evaluation.count({ where: evalFilter }),
|
||||
@@ -719,7 +726,7 @@ export const analyticsRouter = router({
|
||||
|
||||
return {
|
||||
programCount,
|
||||
activeStageCount,
|
||||
activeRoundCount,
|
||||
projectCount,
|
||||
jurorCount,
|
||||
submittedEvaluations,
|
||||
@@ -734,15 +741,15 @@ export const analyticsRouter = router({
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get score distribution histogram for stage evaluations
|
||||
* Get score distribution histogram for round evaluations
|
||||
*/
|
||||
getStageScoreDistribution: observerProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
getRoundScoreDistribution: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: { stageId: input.stageId },
|
||||
assignment: { roundId: input.roundId },
|
||||
},
|
||||
select: {
|
||||
globalScore: true,
|
||||
@@ -796,80 +803,70 @@ export const analyticsRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get per-stage completion summary for a pipeline
|
||||
* Get per-round completion summary for a competition
|
||||
* NOTE: This replaces the old pipeline-based getStageCompletionOverview
|
||||
*/
|
||||
getStageCompletionOverview: observerProcedure
|
||||
.input(z.object({ pipelineId: z.string() }))
|
||||
getRoundCompletionOverview: observerProcedure
|
||||
.input(z.object({ competitionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get all stages in the pipeline via tracks
|
||||
const tracks = await ctx.prisma.track.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
status: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Get all rounds in the competition
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: { competitionId: input.competitionId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
roundType: true,
|
||||
status: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
})
|
||||
|
||||
const stages = tracks.flatMap((t) =>
|
||||
t.stages.map((s) => ({ ...s, trackName: t.name, trackId: t.id }))
|
||||
)
|
||||
|
||||
// For each stage, get project counts, assignment coverage, evaluation completion
|
||||
const stageOverviews = await Promise.all(
|
||||
stages.map(async (stage) => {
|
||||
// For each round, get assignment coverage and evaluation completion
|
||||
const roundOverviews = await Promise.all(
|
||||
rounds.map(async (round) => {
|
||||
const [
|
||||
projectStates,
|
||||
projectRoundStates,
|
||||
totalAssignments,
|
||||
completedEvaluations,
|
||||
distinctJurors,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.projectStageState.groupBy({
|
||||
ctx.prisma.projectRoundState.groupBy({
|
||||
by: ['state'],
|
||||
where: { stageId: stage.id },
|
||||
where: { roundId: round.id },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stageId: stage.id },
|
||||
where: { roundId: round.id },
|
||||
}),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { stageId: stage.id },
|
||||
assignment: { roundId: round.id },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { stageId: stage.id },
|
||||
where: { roundId: round.id },
|
||||
}),
|
||||
])
|
||||
|
||||
const stateBreakdown = projectStates.map((ps) => ({
|
||||
const stateBreakdown = projectRoundStates.map((ps) => ({
|
||||
state: ps.state,
|
||||
count: ps._count,
|
||||
}))
|
||||
|
||||
const totalProjects = projectStates.reduce((sum, ps) => sum + ps._count, 0)
|
||||
const totalProjects = projectRoundStates.reduce((sum, ps) => sum + ps._count, 0)
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((completedEvaluations / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
return {
|
||||
stageId: stage.id,
|
||||
stageName: stage.name,
|
||||
stageType: stage.stageType,
|
||||
stageStatus: stage.status,
|
||||
trackName: stage.trackName,
|
||||
trackId: stage.trackId,
|
||||
sortOrder: stage.sortOrder,
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
roundType: round.roundType,
|
||||
roundStatus: round.status,
|
||||
sortOrder: round.sortOrder,
|
||||
totalProjects,
|
||||
stateBreakdown,
|
||||
totalAssignments,
|
||||
@@ -882,13 +879,13 @@ export const analyticsRouter = router({
|
||||
)
|
||||
|
||||
return {
|
||||
pipelineId: input.pipelineId,
|
||||
stages: stageOverviews,
|
||||
competitionId: input.competitionId,
|
||||
rounds: roundOverviews,
|
||||
summary: {
|
||||
totalStages: stages.length,
|
||||
totalProjects: stageOverviews.reduce((sum, s) => sum + s.totalProjects, 0),
|
||||
totalAssignments: stageOverviews.reduce((sum, s) => sum + s.totalAssignments, 0),
|
||||
totalCompleted: stageOverviews.reduce((sum, s) => sum + s.completedEvaluations, 0),
|
||||
totalRounds: rounds.length,
|
||||
totalProjects: roundOverviews.reduce((sum, s) => sum + s.totalProjects, 0),
|
||||
totalAssignments: roundOverviews.reduce((sum, s) => sum + s.totalAssignments, 0),
|
||||
totalCompleted: roundOverviews.reduce((sum, s) => sum + s.completedEvaluations, 0),
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -897,204 +894,11 @@ export const analyticsRouter = router({
|
||||
// Award Analytics (Phase 5)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get per-award-track summary for a pipeline
|
||||
*/
|
||||
getAwardSummary: observerProcedure
|
||||
.input(z.object({ pipelineId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Find all AWARD tracks in the pipeline
|
||||
const awardTracks = await ctx.prisma.track.findMany({
|
||||
where: { pipelineId: input.pipelineId, kind: 'AWARD' },
|
||||
include: {
|
||||
specialAward: {
|
||||
include: {
|
||||
winnerProject: { select: { id: true, title: true, teamName: true } },
|
||||
},
|
||||
},
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true, stageType: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
// NOTE: getAwardSummary procedure removed - depends on deleted Pipeline/Track/Stage/SpecialAward models
|
||||
// Will need to be reimplemented with new Competition/Round/Award architecture
|
||||
|
||||
const awards = await Promise.all(
|
||||
awardTracks.map(async (track) => {
|
||||
const award = track.specialAward
|
||||
|
||||
// Count projects in this track (active PSS)
|
||||
const projectCount = await ctx.prisma.projectStageState.count({
|
||||
where: { trackId: track.id },
|
||||
})
|
||||
|
||||
// Count evaluations in this track's stages
|
||||
const stageIds = track.stages.map((s) => s.id)
|
||||
const [totalAssignments, completedEvals] = await Promise.all([
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stageId: { in: stageIds } },
|
||||
}),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { stageId: { in: stageIds } },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((completedEvals / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
return {
|
||||
trackId: track.id,
|
||||
trackName: track.name,
|
||||
routingMode: track.routingMode,
|
||||
awardId: award?.id ?? null,
|
||||
awardName: award?.name ?? track.name,
|
||||
awardStatus: award?.status ?? null,
|
||||
scoringMode: award?.scoringMode ?? null,
|
||||
projectCount,
|
||||
totalAssignments,
|
||||
completedEvaluations: completedEvals,
|
||||
completionRate,
|
||||
winner: award?.winnerProject
|
||||
? {
|
||||
projectId: award.winnerProject.id,
|
||||
title: award.winnerProject.title,
|
||||
teamName: award.winnerProject.teamName,
|
||||
overridden: award.winnerOverridden,
|
||||
}
|
||||
: null,
|
||||
stages: track.stages,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return awards
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get per-project vote/score distribution for an award stage
|
||||
*/
|
||||
getAwardVoteDistribution: observerProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get all evaluations for this stage
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: { stageId: input.stageId },
|
||||
},
|
||||
select: {
|
||||
globalScore: true,
|
||||
assignment: {
|
||||
select: {
|
||||
projectId: true,
|
||||
project: { select: { title: true, teamName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Also get any AwardVotes linked to the stage's track's award
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
select: {
|
||||
track: {
|
||||
select: {
|
||||
specialAward: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const awardId = stage?.track?.specialAward?.id
|
||||
let awardVotes: Array<{ projectId: string; rank: number | null }> = []
|
||||
if (awardId) {
|
||||
awardVotes = await ctx.prisma.awardVote.findMany({
|
||||
where: { awardId },
|
||||
select: { projectId: true, rank: true },
|
||||
})
|
||||
}
|
||||
|
||||
// Group evaluation scores by project
|
||||
const projectMap = new Map<string, {
|
||||
title: string
|
||||
teamName: string | null
|
||||
scores: number[]
|
||||
voteCount: number
|
||||
avgRank: number | null
|
||||
}>()
|
||||
|
||||
for (const ev of evaluations) {
|
||||
const pid = ev.assignment.projectId
|
||||
if (!projectMap.has(pid)) {
|
||||
projectMap.set(pid, {
|
||||
title: ev.assignment.project.title,
|
||||
teamName: ev.assignment.project.teamName,
|
||||
scores: [],
|
||||
voteCount: 0,
|
||||
avgRank: null,
|
||||
})
|
||||
}
|
||||
if (ev.globalScore !== null) {
|
||||
projectMap.get(pid)!.scores.push(ev.globalScore)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge award votes
|
||||
const ranksByProject = new Map<string, number[]>()
|
||||
for (const vote of awardVotes) {
|
||||
const entry = projectMap.get(vote.projectId)
|
||||
if (entry) {
|
||||
entry.voteCount++
|
||||
}
|
||||
if (vote.rank !== null) {
|
||||
if (!ranksByProject.has(vote.projectId)) ranksByProject.set(vote.projectId, [])
|
||||
ranksByProject.get(vote.projectId)!.push(vote.rank)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const results = Array.from(projectMap.entries()).map(([projectId, data]) => {
|
||||
const avgScore = data.scores.length > 0
|
||||
? data.scores.reduce((a, b) => a + b, 0) / data.scores.length
|
||||
: null
|
||||
const minScore = data.scores.length > 0 ? Math.min(...data.scores) : null
|
||||
const maxScore = data.scores.length > 0 ? Math.max(...data.scores) : null
|
||||
|
||||
const ranks = ranksByProject.get(projectId)
|
||||
const avgRank = ranks?.length
|
||||
? ranks.reduce((a, b) => a + b, 0) / ranks.length
|
||||
: null
|
||||
|
||||
return {
|
||||
projectId,
|
||||
title: data.title,
|
||||
teamName: data.teamName,
|
||||
evaluationCount: data.scores.length,
|
||||
voteCount: data.voteCount,
|
||||
avgScore,
|
||||
minScore,
|
||||
maxScore,
|
||||
avgRank,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by avgScore descending
|
||||
results.sort((a, b) => (b.avgScore ?? 0) - (a.avgScore ?? 0))
|
||||
|
||||
return {
|
||||
stageId: input.stageId,
|
||||
projects: results,
|
||||
totalEvaluations: evaluations.length,
|
||||
totalVotes: awardVotes.length,
|
||||
}
|
||||
}),
|
||||
// NOTE: getAwardVoteDistribution procedure removed - depends on deleted Stage/Track/SpecialAward/AwardVote models
|
||||
// Will need to be reimplemented with new Competition/Round/Award architecture
|
||||
|
||||
/**
|
||||
* Get all projects with pagination, filtering, and search (for observer dashboard)
|
||||
@@ -1102,7 +906,7 @@ export const analyticsRouter = router({
|
||||
getAllProjects: observerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
page: z.number().min(1).default(1),
|
||||
@@ -1112,8 +916,8 @@ export const analyticsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (input.stageId) {
|
||||
where.assignments = { some: { stageId: input.stageId } }
|
||||
if (input.roundId) {
|
||||
where.assignments = { some: { roundId: input.roundId } }
|
||||
}
|
||||
|
||||
if (input.status) {
|
||||
@@ -1138,8 +942,8 @@ export const analyticsRouter = router({
|
||||
country: true,
|
||||
assignments: {
|
||||
select: {
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true } },
|
||||
roundId: true,
|
||||
round: { select: { id: true, name: true } },
|
||||
evaluation: {
|
||||
select: { globalScore: true, status: true },
|
||||
},
|
||||
@@ -1173,8 +977,8 @@ export const analyticsRouter = router({
|
||||
teamName: p.teamName,
|
||||
status: p.status,
|
||||
country: p.country,
|
||||
stageId: firstAssignment?.stage?.id ?? '',
|
||||
stageName: firstAssignment?.stage?.name ?? '',
|
||||
roundId: firstAssignment?.round?.id ?? '',
|
||||
roundName: firstAssignment?.round?.name ?? '',
|
||||
averageScore,
|
||||
evaluationCount: submitted.length,
|
||||
}
|
||||
|
||||
@@ -22,42 +22,36 @@ export const applicantRouter = router({
|
||||
getSubmissionBySlug: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findFirst({
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
where: { slug: input.slug },
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true, description: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { id: true, name: true, year: true, description: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
if (!round) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Stage not found',
|
||||
message: 'Round not found',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const isOpen = stage.windowCloseAt
|
||||
? now < stage.windowCloseAt
|
||||
: stage.status === 'STAGE_ACTIVE'
|
||||
const isOpen = round.status === 'ROUND_ACTIVE'
|
||||
|
||||
return {
|
||||
stage: {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
windowCloseAt: stage.windowCloseAt,
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
slug: round.slug,
|
||||
windowCloseAt: null,
|
||||
isOpen,
|
||||
},
|
||||
program: stage.track.pipeline.program,
|
||||
program: round.competition.program,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -65,7 +59,7 @@ export const applicantRouter = router({
|
||||
* Get the current user's submission for a round (as submitter or team member)
|
||||
*/
|
||||
getMySubmission: protectedProcedure
|
||||
.input(z.object({ stageId: z.string().optional(), programId: z.string().optional() }))
|
||||
.input(z.object({ roundId: z.string().optional(), programId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Only applicants can use this
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
@@ -86,8 +80,8 @@ export const applicantRouter = router({
|
||||
],
|
||||
}
|
||||
|
||||
if (input.stageId) {
|
||||
where.stageStates = { some: { stageId: input.stageId } }
|
||||
if (input.roundId) {
|
||||
where.roundAssignments = { some: { roundId: input.roundId } }
|
||||
}
|
||||
if (input.programId) {
|
||||
where.programId = input.programId
|
||||
@@ -239,7 +233,7 @@ export const applicantRouter = router({
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
requirementId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
@@ -323,7 +317,7 @@ export const applicantRouter = router({
|
||||
bucket: SUBMISSIONS_BUCKET,
|
||||
objectKey,
|
||||
isLate,
|
||||
stageId: input.stageId || null,
|
||||
roundId: input.roundId || null,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -340,7 +334,7 @@ export const applicantRouter = router({
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||
bucket: z.string(),
|
||||
objectKey: z.string(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
isLate: z.boolean().optional(),
|
||||
requirementId: z.string().optional(),
|
||||
})
|
||||
@@ -378,7 +372,7 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const { projectId, stageId, isLate, requirementId, ...fileData } = input
|
||||
const { projectId, roundId, isLate, requirementId, ...fileData } = input
|
||||
|
||||
// Delete existing file: by requirementId if provided, otherwise by fileType
|
||||
if (requirementId) {
|
||||
@@ -397,12 +391,12 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Create new file record (roundId column kept null for new data)
|
||||
// Create new file record
|
||||
const file = await ctx.prisma.projectFile.create({
|
||||
data: {
|
||||
projectId,
|
||||
...fileData,
|
||||
roundId: null,
|
||||
roundId: roundId || null,
|
||||
isLate: isLate || false,
|
||||
requirementId: requirementId || null,
|
||||
},
|
||||
@@ -1153,7 +1147,7 @@ export const applicantRouter = router({
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
return { project: null, openStages: [], timeline: [], currentStatus: null }
|
||||
return { project: null, openRounds: [], timeline: [], currentStatus: null }
|
||||
}
|
||||
|
||||
const currentStatus = project.status ?? 'SUBMITTED'
|
||||
@@ -1239,19 +1233,17 @@ export const applicantRouter = router({
|
||||
}
|
||||
|
||||
const programId = project.programId
|
||||
const openStages = programId
|
||||
? await ctx.prisma.stage.findMany({
|
||||
const openRounds = programId
|
||||
? await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
track: { pipeline: { programId } },
|
||||
status: 'STAGE_ACTIVE',
|
||||
competition: { programId },
|
||||
status: 'ROUND_ACTIVE',
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
stageType: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
})
|
||||
@@ -1267,7 +1259,7 @@ export const applicantRouter = router({
|
||||
isTeamLead,
|
||||
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
||||
},
|
||||
openStages,
|
||||
openRounds,
|
||||
timeline,
|
||||
currentStatus,
|
||||
}
|
||||
|
||||
@@ -170,23 +170,19 @@ export const applicationRouter = router({
|
||||
competitionCategories: wizardConfig.competitionCategories ?? [],
|
||||
}
|
||||
} else {
|
||||
// Stage-specific application mode (backward compatible with round slug)
|
||||
const stage = await ctx.prisma.stage.findFirst({
|
||||
// Round-specific application mode (backward compatible with round slug)
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
where: { slug: input.slug },
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
year: true,
|
||||
description: true,
|
||||
settingsJson: true,
|
||||
},
|
||||
},
|
||||
program: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
year: true,
|
||||
description: true,
|
||||
settingsJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -194,38 +190,36 @@ export const applicationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
if (!round) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Application stage not found',
|
||||
message: 'Application round not found',
|
||||
})
|
||||
}
|
||||
|
||||
const stageProgram = stage.track.pipeline.program
|
||||
const isOpen = stage.windowOpenAt && stage.windowCloseAt
|
||||
? now >= stage.windowOpenAt && now <= stage.windowCloseAt
|
||||
: stage.status === 'STAGE_ACTIVE'
|
||||
const roundProgram = round.competition.program
|
||||
const isOpen = round.status === 'ROUND_ACTIVE'
|
||||
|
||||
const stageWizardConfig = parseWizardConfig(stageProgram.settingsJson)
|
||||
const { settingsJson: _s, ...programData } = stageProgram
|
||||
const roundWizardConfig = parseWizardConfig(roundProgram.settingsJson)
|
||||
const { settingsJson: _s, ...programData } = roundProgram
|
||||
|
||||
return {
|
||||
mode: 'stage' as const,
|
||||
stage: {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
submissionStartDate: stage.windowOpenAt,
|
||||
submissionEndDate: stage.windowCloseAt,
|
||||
submissionDeadline: stage.windowCloseAt,
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
slug: round.slug,
|
||||
submissionStartDate: null,
|
||||
submissionEndDate: null,
|
||||
submissionDeadline: null,
|
||||
lateSubmissionGrace: null,
|
||||
gracePeriodEnd: null,
|
||||
isOpen,
|
||||
},
|
||||
program: programData,
|
||||
wizardConfig: stageWizardConfig,
|
||||
oceanIssueOptions: stageWizardConfig.oceanIssues ?? [],
|
||||
competitionCategories: stageWizardConfig.competitionCategories ?? [],
|
||||
wizardConfig: roundWizardConfig,
|
||||
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
|
||||
competitionCategories: roundWizardConfig.competitionCategories ?? [],
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -238,7 +232,7 @@ export const applicationRouter = router({
|
||||
z.object({
|
||||
mode: z.enum(['edition', 'stage']).default('stage'),
|
||||
programId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
data: applicationInputSchema,
|
||||
})
|
||||
)
|
||||
@@ -253,7 +247,7 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const { mode, programId, stageId, data } = input
|
||||
const { mode, programId, roundId, data } = input
|
||||
|
||||
// Validate input based on mode
|
||||
if (mode === 'edition' && !programId) {
|
||||
@@ -263,10 +257,10 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
if (mode === 'stage' && !stageId) {
|
||||
if (mode === 'stage' && !roundId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'stageId is required for stage-specific applications',
|
||||
message: 'roundId is required for round-specific applications',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -341,35 +335,31 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Stage-specific application
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId! },
|
||||
// Round-specific application
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId! },
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: { include: { program: true } },
|
||||
program: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
program = stage.track.pipeline.program
|
||||
program = round.competition.program
|
||||
|
||||
// Check submission window
|
||||
if (stage.windowOpenAt && stage.windowCloseAt) {
|
||||
isOpen = now >= stage.windowOpenAt && now <= stage.windowCloseAt
|
||||
} else {
|
||||
isOpen = stage.status === 'STAGE_ACTIVE'
|
||||
}
|
||||
isOpen = round.status === 'ROUND_ACTIVE'
|
||||
|
||||
if (!isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Applications are currently closed for this stage',
|
||||
message: 'Applications are currently closed for this round',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if email already submitted for this stage
|
||||
// Check if email already submitted for this round
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
programId: program.id,
|
||||
@@ -380,7 +370,7 @@ export const applicationRouter = router({
|
||||
if (existingProject) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'An application with this email already exists for this stage',
|
||||
message: 'An application with this email already exists for this round',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -546,7 +536,7 @@ export const applicationRouter = router({
|
||||
z.object({
|
||||
mode: z.enum(['edition', 'stage']).default('stage'),
|
||||
programId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
})
|
||||
)
|
||||
@@ -570,16 +560,16 @@ export const applicationRouter = router({
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// For stage-specific applications, check by program (derived from stage)
|
||||
if (input.stageId) {
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
include: { track: { include: { pipeline: { select: { programId: true } } } } },
|
||||
// For round-specific applications, check by program (derived from round)
|
||||
if (input.roundId) {
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { competition: { select: { programId: true } } },
|
||||
})
|
||||
if (stage) {
|
||||
if (round) {
|
||||
existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
programId: stage.track.pipeline.programId,
|
||||
programId: round.competition.programId,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
@@ -613,40 +603,38 @@ export const applicationRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Find stage by slug
|
||||
const stage = await ctx.prisma.stage.findFirst({
|
||||
// Find round by slug
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
where: { slug: input.roundSlug },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { programId: true } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
competition: { select: { programId: true } },
|
||||
configJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
if (!round) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Stage not found',
|
||||
message: 'Round not found',
|
||||
})
|
||||
}
|
||||
|
||||
const stageConfig = (stage.configJson as Record<string, unknown>) || {}
|
||||
if (stageConfig.drafts_enabled === false) {
|
||||
const roundConfig = (round.configJson as Record<string, unknown>) || {}
|
||||
if (roundConfig.drafts_enabled === false) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Draft saving is not enabled for this stage',
|
||||
message: 'Draft saving is not enabled for this round',
|
||||
})
|
||||
}
|
||||
|
||||
const draftExpiryDays = (stageConfig.draft_expiry_days as number) || 30
|
||||
const draftExpiryDays = (roundConfig.draft_expiry_days as number) || 30
|
||||
const draftExpiresAt = new Date()
|
||||
draftExpiresAt.setDate(draftExpiresAt.getDate() + draftExpiryDays)
|
||||
|
||||
const draftToken = `draft_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
|
||||
const programId = input.programId || stage.track.pipeline.programId
|
||||
const programId = input.programId || round.competition.programId
|
||||
|
||||
const existingDraft = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -17,22 +17,23 @@ import {
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
async function runAIAssignmentJob(jobId: string, stageId: string, userId: string) {
|
||||
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||
try {
|
||||
await prisma.assignmentJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: 'RUNNING', startedAt: new Date() },
|
||||
})
|
||||
|
||||
const stage = await prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId },
|
||||
const round = await prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: {
|
||||
name: true,
|
||||
configJson: true,
|
||||
competitionId: true,
|
||||
},
|
||||
})
|
||||
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
||||
const minAssignmentsPerJuror =
|
||||
(config.minLoadPerJuror as number) ??
|
||||
@@ -53,17 +54,17 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
|
||||
maxAssignments: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: { where: { stageId } },
|
||||
assignments: { where: { roundId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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: { id: { in: projectIds } },
|
||||
@@ -73,12 +74,12 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
|
||||
description: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
_count: { select: { assignments: { where: { stageId } } } },
|
||||
_count: { select: { assignments: { where: { roundId } } } },
|
||||
},
|
||||
})
|
||||
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { stageId },
|
||||
where: { roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
@@ -126,7 +127,7 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
|
||||
projects,
|
||||
constraints,
|
||||
userId,
|
||||
stageId,
|
||||
roundId,
|
||||
onProgress
|
||||
)
|
||||
|
||||
@@ -157,12 +158,12 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
||||
title: 'AI Assignment Suggestions Ready',
|
||||
message: `AI generated ${result.suggestions.length} assignment suggestions for ${stage.name || 'stage'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/assignments`,
|
||||
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||
linkUrl: `/admin/competitions/${round.competitionId}/assignments`,
|
||||
linkLabel: 'View Suggestions',
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
stageId,
|
||||
roundId,
|
||||
jobId,
|
||||
projectCount: projects.length,
|
||||
suggestionsCount: result.suggestions.length,
|
||||
@@ -187,10 +188,10 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
|
||||
|
||||
export const assignmentRouter = router({
|
||||
listByStage: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.assignment.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
||||
project: { select: { id: true, title: true, tags: true } },
|
||||
@@ -233,18 +234,18 @@ export const assignmentRouter = router({
|
||||
myAssignments: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
status: z.enum(['all', 'pending', 'completed']).default('all'),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
userId: ctx.user.id,
|
||||
stage: { status: 'STAGE_ACTIVE' },
|
||||
round: { status: 'STAGE_ACTIVE' },
|
||||
}
|
||||
|
||||
if (input.stageId) {
|
||||
where.stageId = input.stageId
|
||||
if (input.roundId) {
|
||||
where.roundId = input.roundId
|
||||
}
|
||||
|
||||
if (input.status === 'pending') {
|
||||
@@ -259,7 +260,7 @@ export const assignmentRouter = router({
|
||||
project: {
|
||||
include: { files: true },
|
||||
},
|
||||
stage: true,
|
||||
round: true,
|
||||
evaluation: true,
|
||||
},
|
||||
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
|
||||
@@ -277,7 +278,7 @@ export const assignmentRouter = router({
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
project: { include: { files: true } },
|
||||
stage: { include: { evaluationForms: { where: { isActive: true } } } },
|
||||
round: { include: { evaluationForms: { where: { isActive: true } } } },
|
||||
evaluation: true,
|
||||
},
|
||||
})
|
||||
@@ -304,7 +305,7 @@ export const assignmentRouter = router({
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
isRequired: z.boolean().default(true),
|
||||
forceOverride: z.boolean().default(false),
|
||||
})
|
||||
@@ -312,10 +313,10 @@ export const assignmentRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.assignment.findUnique({
|
||||
where: {
|
||||
userId_projectId_stageId: {
|
||||
userId_projectId_roundId: {
|
||||
userId: input.userId,
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -328,8 +329,8 @@ export const assignmentRouter = router({
|
||||
}
|
||||
|
||||
const [stage, user] = await Promise.all([
|
||||
ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { configJson: true },
|
||||
}),
|
||||
ctx.prisma.user.findUniqueOrThrow({
|
||||
@@ -346,7 +347,7 @@ export const assignmentRouter = router({
|
||||
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
|
||||
|
||||
const currentCount = await ctx.prisma.assignment.count({
|
||||
where: { userId: input.userId, stageId: input.stageId },
|
||||
where: { userId: input.userId, roundId: input.roundId },
|
||||
})
|
||||
|
||||
// Check if at or over limit
|
||||
@@ -387,8 +388,8 @@ export const assignmentRouter = router({
|
||||
where: { id: input.projectId },
|
||||
select: { title: true },
|
||||
}),
|
||||
ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, windowCloseAt: true },
|
||||
}),
|
||||
])
|
||||
@@ -408,7 +409,7 @@ export const assignmentRouter = router({
|
||||
type: NotificationTypes.ASSIGNED_TO_PROJECT,
|
||||
title: 'New Project Assignment',
|
||||
message: `You have been assigned to evaluate "${project.title}" for ${stageInfo.name}.`,
|
||||
linkUrl: `/jury/stages`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignment',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
@@ -428,7 +429,7 @@ export const assignmentRouter = router({
|
||||
bulkCreate: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
@@ -448,7 +449,7 @@ export const assignmentRouter = router({
|
||||
maxAssignments: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: { where: { stageId: input.stageId } },
|
||||
assignments: { where: { roundId: input.roundId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -456,8 +457,8 @@ export const assignmentRouter = router({
|
||||
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||
|
||||
// Get stage default max
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { configJson: true, name: true, windowCloseAt: true },
|
||||
})
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
@@ -494,7 +495,7 @@ export const assignmentRouter = router({
|
||||
const result = await ctx.prisma.assignment.createMany({
|
||||
data: allowedAssignments.map((a) => ({
|
||||
...a,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
method: 'BULK',
|
||||
createdBy: ctx.user.id,
|
||||
})),
|
||||
@@ -550,7 +551,7 @@ export const assignmentRouter = router({
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: `${projectCount} Projects Assigned`,
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
|
||||
linkUrl: `/jury/stages`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: {
|
||||
projectCount,
|
||||
@@ -601,20 +602,20 @@ export const assignmentRouter = router({
|
||||
* Get assignment statistics for a round
|
||||
*/
|
||||
getStats: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
||||
|
||||
const projectStageStates = await ctx.prisma.projectStageState.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStageStates.map((pss) => pss.projectId)
|
||||
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
||||
|
||||
const [
|
||||
totalAssignments,
|
||||
@@ -622,13 +623,13 @@ export const assignmentRouter = router({
|
||||
assignmentsByUser,
|
||||
projectCoverage,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.assignment.count({ where: { stageId: input.stageId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stageId: input.stageId, isCompleted: true },
|
||||
where: { roundId: input.roundId, isCompleted: true },
|
||||
}),
|
||||
ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.findMany({
|
||||
@@ -636,7 +637,7 @@ export const assignmentRouter = router({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
|
||||
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -670,12 +671,12 @@ export const assignmentRouter = router({
|
||||
getSuggestions: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
@@ -705,17 +706,17 @@ export const assignmentRouter = router({
|
||||
maxAssignments: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: { where: { stageId: input.stageId } },
|
||||
assignments: { where: { roundId: input.roundId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const projectStageStates = await ctx.prisma.projectStageState.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStageStates.map((pss) => pss.projectId)
|
||||
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
@@ -727,12 +728,12 @@ export const assignmentRouter = router({
|
||||
projectTags: {
|
||||
include: { tag: { select: { name: true } } },
|
||||
},
|
||||
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
|
||||
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
||||
},
|
||||
})
|
||||
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const assignmentSet = new Set(
|
||||
@@ -743,7 +744,7 @@ export const assignmentRouter = router({
|
||||
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
|
||||
if (categoryQuotas) {
|
||||
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
select: {
|
||||
userId: true,
|
||||
project: { select: { competitionCategory: true } },
|
||||
@@ -884,14 +885,14 @@ export const assignmentRouter = router({
|
||||
getAISuggestions: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
useAI: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const completedJob = await ctx.prisma.assignmentJob.findFirst({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
orderBy: { completedAt: 'desc' },
|
||||
@@ -914,7 +915,7 @@ export const assignmentRouter = router({
|
||||
}>
|
||||
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const assignmentSet = new Set(
|
||||
@@ -949,7 +950,7 @@ export const assignmentRouter = router({
|
||||
applyAISuggestions: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
@@ -977,15 +978,15 @@ export const assignmentRouter = router({
|
||||
maxAssignments: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: { where: { stageId: input.stageId } },
|
||||
assignments: { where: { roundId: input.roundId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||
|
||||
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||
@@ -1020,7 +1021,7 @@ export const assignmentRouter = router({
|
||||
data: assignmentsToCreate.map((a) => ({
|
||||
userId: a.userId,
|
||||
projectId: a.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
|
||||
aiConfidenceScore: a.confidenceScore,
|
||||
expertiseMatchScore: a.expertiseMatchScore,
|
||||
@@ -1036,7 +1037,7 @@ export const assignmentRouter = router({
|
||||
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
count: created.count,
|
||||
usedAI: input.usedAI,
|
||||
forceOverride: input.forceOverride,
|
||||
@@ -1055,8 +1056,8 @@ export const assignmentRouter = router({
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
const stage = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, windowCloseAt: true },
|
||||
})
|
||||
|
||||
@@ -1083,7 +1084,7 @@ export const assignmentRouter = router({
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: `${projectCount} Projects Assigned`,
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
|
||||
linkUrl: `/jury/stages`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: {
|
||||
projectCount,
|
||||
@@ -1107,7 +1108,7 @@ export const assignmentRouter = router({
|
||||
applySuggestions: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
@@ -1132,15 +1133,15 @@ export const assignmentRouter = router({
|
||||
maxAssignments: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: { where: { stageId: input.stageId } },
|
||||
assignments: { where: { roundId: input.roundId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||
|
||||
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||
@@ -1175,7 +1176,7 @@ export const assignmentRouter = router({
|
||||
data: assignmentsToCreate.map((a) => ({
|
||||
userId: a.userId,
|
||||
projectId: a.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
method: 'ALGORITHM',
|
||||
aiReasoning: a.reasoning,
|
||||
createdBy: ctx.user.id,
|
||||
@@ -1189,7 +1190,7 @@ export const assignmentRouter = router({
|
||||
action: 'APPLY_SUGGESTIONS',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
count: created.count,
|
||||
forceOverride: input.forceOverride,
|
||||
skippedDueToCapacity,
|
||||
@@ -1207,8 +1208,8 @@ export const assignmentRouter = router({
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
const stage = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, windowCloseAt: true },
|
||||
})
|
||||
|
||||
@@ -1235,7 +1236,7 @@ export const assignmentRouter = router({
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: `${projectCount} Projects Assigned`,
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
|
||||
linkUrl: `/jury/stages`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: {
|
||||
projectCount,
|
||||
@@ -1257,11 +1258,11 @@ export const assignmentRouter = router({
|
||||
* Start an AI assignment job (background processing)
|
||||
*/
|
||||
startAIAssignmentJob: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existingJob = await ctx.prisma.assignmentJob.findFirst({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
status: { in: ['PENDING', 'RUNNING'] },
|
||||
},
|
||||
})
|
||||
@@ -1282,12 +1283,12 @@ export const assignmentRouter = router({
|
||||
|
||||
const job = await ctx.prisma.assignmentJob.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
runAIAssignmentJob(job.id, input.stageId, ctx.user.id).catch(console.error)
|
||||
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
|
||||
|
||||
return { jobId: job.id }
|
||||
}),
|
||||
@@ -1321,10 +1322,10 @@ export const assignmentRouter = router({
|
||||
* Get the latest AI assignment job for a round
|
||||
*/
|
||||
getLatestAIAssignmentJob: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const job = await ctx.prisma.assignmentJob.findFirst({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
|
||||
82
src/server/routers/assignmentIntent.ts
Normal file
82
src/server/routers/assignmentIntent.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import {
|
||||
createIntent,
|
||||
cancelIntent,
|
||||
getPendingIntentsForRound,
|
||||
getPendingIntentsForMember,
|
||||
getIntentsForRound,
|
||||
} from '@/server/services/assignment-intent'
|
||||
|
||||
const intentSourceEnum = z.enum(['INVITE', 'ADMIN', 'SYSTEM'])
|
||||
|
||||
export const assignmentIntentRouter = router({
|
||||
/**
|
||||
* Create an assignment intent (pre-assignment signal).
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
juryGroupMemberId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectId: z.string(),
|
||||
source: intentSourceEnum.default('ADMIN'),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return createIntent({
|
||||
juryGroupMemberId: input.juryGroupMemberId,
|
||||
roundId: input.roundId,
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
actorId: ctx.user.id,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all intents for a round (all statuses).
|
||||
*/
|
||||
listForRound: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return getIntentsForRound(input.roundId)
|
||||
}),
|
||||
|
||||
/**
|
||||
* List pending intents for a round.
|
||||
*/
|
||||
listPendingForRound: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return getPendingIntentsForRound(input.roundId)
|
||||
}),
|
||||
|
||||
/**
|
||||
* List pending intents for a specific member in a round.
|
||||
* Protected (not admin-only) so jurors can see their own intents.
|
||||
*/
|
||||
listForMember: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
juryGroupMemberId: z.string(),
|
||||
roundId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return getPendingIntentsForMember(input.juryGroupMemberId, input.roundId)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Cancel a pending intent.
|
||||
*/
|
||||
cancel: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return cancelIntent(input.id, input.reason, ctx.user.id)
|
||||
}),
|
||||
})
|
||||
113
src/server/routers/assignmentPolicy.ts
Normal file
113
src/server/routers/assignmentPolicy.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { resolveMemberContext } from '@/server/services/competition-context'
|
||||
import { evaluateAssignmentPolicy } from '@/server/services/assignment-policy'
|
||||
|
||||
export const assignmentPolicyRouter = router({
|
||||
/**
|
||||
* Get the fully-resolved assignment policy for a specific member in a round.
|
||||
* Returns cap, cap mode, buffer, category bias — all with provenance.
|
||||
*/
|
||||
getMemberPolicy: adminProcedure
|
||||
.input(z.object({ roundId: z.string(), userId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const ctx = await resolveMemberContext(input.roundId, input.userId)
|
||||
return evaluateAssignmentPolicy(ctx)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get policy summary for all members in a jury group for a given round.
|
||||
* Useful for admin dashboards showing cap compliance across the group.
|
||||
*/
|
||||
getGroupPolicySummary: adminProcedure
|
||||
.input(z.object({ juryGroupId: z.string(), roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: input.juryGroupId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
members.map(async (member) => {
|
||||
try {
|
||||
const memberCtx = await resolveMemberContext(input.roundId, member.userId)
|
||||
const policy = evaluateAssignmentPolicy(memberCtx)
|
||||
return {
|
||||
userId: member.userId,
|
||||
userName: member.user.name,
|
||||
userEmail: member.user.email,
|
||||
role: member.role,
|
||||
policy,
|
||||
}
|
||||
} catch {
|
||||
// Member may not be linked to this round's jury group
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return results.filter(Boolean)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get cap compliance report for a round.
|
||||
* Groups members into overCap, atCap, belowCap, and noCap buckets.
|
||||
*/
|
||||
getCapComplianceReport: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { juryGroupId: true },
|
||||
})
|
||||
|
||||
if (!round.juryGroupId) {
|
||||
return { overCap: [], atCap: [], belowCap: [], noCap: [] }
|
||||
}
|
||||
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const report: {
|
||||
overCap: Array<{ userId: string; userName: string | null; overCapBy: number }>
|
||||
atCap: Array<{ userId: string; userName: string | null }>
|
||||
belowCap: Array<{ userId: string; userName: string | null; remaining: number }>
|
||||
noCap: Array<{ userId: string; userName: string | null }>
|
||||
} = { overCap: [], atCap: [], belowCap: [], noCap: [] }
|
||||
|
||||
for (const member of members) {
|
||||
try {
|
||||
const memberCtx = await resolveMemberContext(input.roundId, member.userId)
|
||||
const policy = evaluateAssignmentPolicy(memberCtx)
|
||||
|
||||
if (policy.effectiveCapMode.value === 'NONE') {
|
||||
report.noCap.push({ userId: member.userId, userName: member.user.name })
|
||||
} else if (policy.isOverCap) {
|
||||
report.overCap.push({
|
||||
userId: member.userId,
|
||||
userName: member.user.name,
|
||||
overCapBy: policy.overCapBy,
|
||||
})
|
||||
} else if (policy.remainingCapacity === 0) {
|
||||
report.atCap.push({ userId: member.userId, userName: member.user.name })
|
||||
} else {
|
||||
report.belowCap.push({
|
||||
userId: member.userId,
|
||||
userName: member.user.name,
|
||||
remaining: policy.remainingCapacity,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Skip members that can't be resolved
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}),
|
||||
})
|
||||
@@ -1,561 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { router } from '../trpc'
|
||||
|
||||
// NOTE: All award procedures have been temporarily disabled because they depended on
|
||||
// deleted models: Pipeline, Track (AWARD kind), SpecialAward linked via Track.
|
||||
// This router will need complete reimplementation with the new Competition/Round/Award architecture.
|
||||
// The SpecialAward model still exists and is linked directly to Competition (competitionId FK).
|
||||
|
||||
export const awardRouter = router({
|
||||
/**
|
||||
* Create a new award track within a pipeline
|
||||
*/
|
||||
createTrack: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
routingMode: z.enum(['SHARED', 'EXCLUSIVE']).optional(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
awardConfig: z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(5000).optional(),
|
||||
criteriaText: z.string().max(5000).optional(),
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).default('PICK_WINNER'),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
useAiEligibility: z.boolean().default(true),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify pipeline exists
|
||||
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
|
||||
where: { id: input.pipelineId },
|
||||
})
|
||||
|
||||
// Check slug uniqueness within pipeline
|
||||
const existingTrack = await ctx.prisma.track.findFirst({
|
||||
where: {
|
||||
pipelineId: input.pipelineId,
|
||||
slug: input.slug,
|
||||
},
|
||||
})
|
||||
if (existingTrack) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A track with slug "${input.slug}" already exists in this pipeline`,
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-set sortOrder
|
||||
const maxOrder = await ctx.prisma.track.aggregate({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
||||
|
||||
const { awardConfig, settingsJson, ...trackData } = input
|
||||
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create the track
|
||||
const track = await tx.track.create({
|
||||
data: {
|
||||
pipelineId: input.pipelineId,
|
||||
name: trackData.name,
|
||||
slug: trackData.slug,
|
||||
kind: 'AWARD',
|
||||
routingMode: trackData.routingMode ?? null,
|
||||
decisionMode: trackData.decisionMode ?? 'JURY_VOTE',
|
||||
sortOrder,
|
||||
settingsJson: (settingsJson as Prisma.InputJsonValue) ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Create the linked SpecialAward
|
||||
const award = await tx.specialAward.create({
|
||||
data: {
|
||||
programId: pipeline.programId,
|
||||
name: awardConfig.name,
|
||||
description: awardConfig.description ?? null,
|
||||
criteriaText: awardConfig.criteriaText ?? null,
|
||||
scoringMode: awardConfig.scoringMode,
|
||||
maxRankedPicks: awardConfig.maxRankedPicks ?? null,
|
||||
useAiEligibility: awardConfig.useAiEligibility,
|
||||
trackId: track.id,
|
||||
sortOrder,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_AWARD_TRACK',
|
||||
entityType: 'Track',
|
||||
entityId: track.id,
|
||||
detailsJson: {
|
||||
pipelineId: input.pipelineId,
|
||||
trackName: track.name,
|
||||
awardId: award.id,
|
||||
awardName: award.name,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { track, award }
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Configure governance settings for an award track
|
||||
*/
|
||||
configureGovernance: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
||||
jurorIds: z.array(z.string()).optional(),
|
||||
votingStartAt: z.date().optional().nullable(),
|
||||
votingEndAt: z.date().optional().nullable(),
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (track.kind !== 'AWARD') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This track is not an AWARD track',
|
||||
})
|
||||
}
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate voting dates
|
||||
if (input.votingStartAt && input.votingEndAt) {
|
||||
if (input.votingEndAt <= input.votingStartAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Voting end date must be after start date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Update track decision mode
|
||||
if (input.decisionMode) {
|
||||
await tx.track.update({
|
||||
where: { id: input.trackId },
|
||||
data: { decisionMode: input.decisionMode },
|
||||
})
|
||||
}
|
||||
|
||||
// Update award config
|
||||
const awardUpdate: Record<string, unknown> = {}
|
||||
if (input.votingStartAt !== undefined) awardUpdate.votingStartAt = input.votingStartAt
|
||||
if (input.votingEndAt !== undefined) awardUpdate.votingEndAt = input.votingEndAt
|
||||
if (input.scoringMode) awardUpdate.scoringMode = input.scoringMode
|
||||
if (input.maxRankedPicks !== undefined) awardUpdate.maxRankedPicks = input.maxRankedPicks
|
||||
|
||||
let updatedAward = track.specialAward
|
||||
if (Object.keys(awardUpdate).length > 0) {
|
||||
updatedAward = await tx.specialAward.update({
|
||||
where: { id: track.specialAward!.id },
|
||||
data: awardUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
// Manage jurors if provided
|
||||
if (input.jurorIds) {
|
||||
// Remove all existing jurors
|
||||
await tx.awardJuror.deleteMany({
|
||||
where: { awardId: track.specialAward!.id },
|
||||
})
|
||||
|
||||
// Add new jurors
|
||||
if (input.jurorIds.length > 0) {
|
||||
await tx.awardJuror.createMany({
|
||||
data: input.jurorIds.map((userId) => ({
|
||||
awardId: track.specialAward!.id,
|
||||
userId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CONFIGURE_AWARD_GOVERNANCE',
|
||||
entityType: 'Track',
|
||||
entityId: input.trackId,
|
||||
detailsJson: {
|
||||
awardId: track.specialAward!.id,
|
||||
decisionMode: input.decisionMode,
|
||||
jurorCount: input.jurorIds?.length,
|
||||
scoringMode: input.scoringMode,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
track: { id: track.id, decisionMode: input.decisionMode ?? track.decisionMode },
|
||||
award: updatedAward,
|
||||
jurorsSet: input.jurorIds?.length ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Route projects to an award track (set eligibility)
|
||||
*/
|
||||
routeProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
eligible: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
const awardId = track.specialAward.id
|
||||
|
||||
// Upsert eligibility records
|
||||
let createdCount = 0
|
||||
let updatedCount = 0
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const projectId of input.projectIds) {
|
||||
const existing = await tx.awardEligibility.findUnique({
|
||||
where: { awardId_projectId: { awardId, projectId } },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await tx.awardEligibility.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
eligible: input.eligible,
|
||||
method: 'MANUAL',
|
||||
overriddenBy: ctx.user.id,
|
||||
overriddenAt: new Date(),
|
||||
},
|
||||
})
|
||||
updatedCount++
|
||||
} else {
|
||||
await tx.awardEligibility.create({
|
||||
data: {
|
||||
awardId,
|
||||
projectId,
|
||||
eligible: input.eligible,
|
||||
method: 'MANUAL',
|
||||
overriddenBy: ctx.user.id,
|
||||
overriddenAt: new Date(),
|
||||
},
|
||||
})
|
||||
createdCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Also create ProjectStageState entries for routing through pipeline
|
||||
const firstStage = await tx.stage.findFirst({
|
||||
where: { trackId: input.trackId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
if (firstStage) {
|
||||
for (const projectId of input.projectIds) {
|
||||
await tx.projectStageState.upsert({
|
||||
where: {
|
||||
projectId_trackId_stageId: {
|
||||
projectId,
|
||||
trackId: input.trackId,
|
||||
stageId: firstStage.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
trackId: input.trackId,
|
||||
stageId: firstStage.id,
|
||||
state: input.eligible ? 'PENDING' : 'REJECTED',
|
||||
},
|
||||
update: {
|
||||
state: input.eligible ? 'PENDING' : 'REJECTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'ROUTE_PROJECTS_TO_AWARD',
|
||||
entityType: 'Track',
|
||||
entityId: input.trackId,
|
||||
detailsJson: {
|
||||
awardId,
|
||||
projectCount: input.projectIds.length,
|
||||
eligible: input.eligible,
|
||||
created: createdCount,
|
||||
updated: updatedCount,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
return { created: createdCount, updated: updatedCount, total: input.projectIds.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Finalize winners for an award (Award Master only)
|
||||
*/
|
||||
finalizeWinners: awardMasterProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
winnerProjectId: z.string(),
|
||||
override: z.boolean().default(false),
|
||||
reasonText: z.string().max(2000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
const award = track.specialAward
|
||||
|
||||
// Verify the winning project is eligible
|
||||
const eligibility = await ctx.prisma.awardEligibility.findUnique({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId: award.id,
|
||||
projectId: input.winnerProjectId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!eligibility || !eligibility.eligible) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Selected project is not eligible for this award',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if award already has a winner
|
||||
if (award.winnerProjectId && !input.override) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `Award already has a winner. Set override=true to change it.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate award is in VOTING_OPEN or CLOSED status (appropriate for finalization)
|
||||
if (!['VOTING_OPEN', 'CLOSED'].includes(award.status)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: `Award must be in VOTING_OPEN or CLOSED status to finalize. Current: ${award.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
const previousWinnerId = award.winnerProjectId
|
||||
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.specialAward.update({
|
||||
where: { id: award.id },
|
||||
data: {
|
||||
winnerProjectId: input.winnerProjectId,
|
||||
winnerOverridden: input.override,
|
||||
winnerOverriddenBy: input.override ? ctx.user.id : null,
|
||||
status: 'CLOSED',
|
||||
},
|
||||
})
|
||||
|
||||
// Mark winner project as COMPLETED in the award track
|
||||
const firstStage = await tx.stage.findFirst({
|
||||
where: { trackId: input.trackId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
if (firstStage) {
|
||||
await tx.projectStageState.updateMany({
|
||||
where: {
|
||||
trackId: input.trackId,
|
||||
stageId: firstStage.id,
|
||||
projectId: input.winnerProjectId,
|
||||
},
|
||||
data: { state: 'COMPLETED' },
|
||||
})
|
||||
}
|
||||
|
||||
// Record in decision audit
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'award.winner_finalized',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
winnerProjectId: input.winnerProjectId,
|
||||
previousWinnerId,
|
||||
override: input.override,
|
||||
reasonText: input.reasonText,
|
||||
} as Prisma.InputJsonValue,
|
||||
snapshotJson: {
|
||||
awardName: award.name,
|
||||
previousStatus: award.status,
|
||||
previousWinner: previousWinnerId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
if (input.override && previousWinnerId) {
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
previousValue: { winnerProjectId: previousWinnerId } as Prisma.InputJsonValue,
|
||||
newValueJson: { winnerProjectId: input.winnerProjectId } as Prisma.InputJsonValue,
|
||||
reasonCode: 'ADMIN_DISCRETION',
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'AWARD_WINNER_FINALIZED',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
detailsJson: {
|
||||
awardName: award.name,
|
||||
winnerProjectId: input.winnerProjectId,
|
||||
override: input.override,
|
||||
previousWinnerId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get projects routed to an award track with eligibility and votes
|
||||
*/
|
||||
getTrackProjects: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
eligibleOnly: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
include: { specialAward: true },
|
||||
})
|
||||
|
||||
if (!track.specialAward) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No award linked to this track',
|
||||
})
|
||||
}
|
||||
|
||||
const awardId = track.specialAward.id
|
||||
|
||||
const eligibilityWhere: Prisma.AwardEligibilityWhereInput = {
|
||||
awardId,
|
||||
}
|
||||
if (input.eligibleOnly) {
|
||||
eligibilityWhere.eligible = true
|
||||
}
|
||||
|
||||
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
||||
where: eligibilityWhere,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
tags: true,
|
||||
description: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// Get vote counts per project
|
||||
const projectIds = eligibilities.map((e) => e.projectId)
|
||||
const voteSummary =
|
||||
projectIds.length > 0
|
||||
? await ctx.prisma.awardVote.groupBy({
|
||||
by: ['projectId'],
|
||||
where: { awardId, projectId: { in: projectIds } },
|
||||
_count: true,
|
||||
})
|
||||
: []
|
||||
|
||||
const voteMap = new Map(
|
||||
voteSummary.map((v) => [v.projectId, v._count])
|
||||
)
|
||||
|
||||
return {
|
||||
trackId: input.trackId,
|
||||
awardId,
|
||||
awardName: track.specialAward.name,
|
||||
winnerProjectId: track.specialAward.winnerProjectId,
|
||||
status: track.specialAward.status,
|
||||
projects: eligibilities.map((e) => ({
|
||||
...e,
|
||||
voteCount: voteMap.get(e.projectId) ?? 0,
|
||||
isWinner: e.projectId === track.specialAward!.winnerProjectId,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
// TODO: Reimplement award procedures with new Competition/Round architecture
|
||||
// Procedures to reimplement:
|
||||
// - createAwardTrack → createAward (link SpecialAward to Competition directly)
|
||||
// - configureGovernance → configureAwardGovernance
|
||||
// - routeProjects → setAwardEligibility
|
||||
// - finalizeWinners → finalizeAwardWinner
|
||||
// - getTrackProjects → getAwardProjects
|
||||
})
|
||||
|
||||
@@ -5,12 +5,12 @@ import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const cohortRouter = router({
|
||||
/**
|
||||
* Create a new cohort within a stage
|
||||
* Create a new cohort within a round
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
votingMode: z.enum(['simple', 'criteria', 'ranked']).default('simple'),
|
||||
windowOpenAt: z.date().optional(),
|
||||
@@ -18,18 +18,11 @@ export const cohortRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify stage exists and is of a type that supports cohorts
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
// Verify round exists
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
if (stage.stageType !== 'LIVE_FINAL' && stage.stageType !== 'SELECTION') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Cohorts can only be created in LIVE_FINAL or SELECTION stages',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate window dates
|
||||
if (input.windowOpenAt && input.windowCloseAt) {
|
||||
if (input.windowCloseAt <= input.windowOpenAt) {
|
||||
@@ -43,7 +36,7 @@ export const cohortRouter = router({
|
||||
const cohort = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.cohort.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
name: input.name,
|
||||
votingMode: input.votingMode,
|
||||
windowOpenAt: input.windowOpenAt ?? null,
|
||||
@@ -58,7 +51,7 @@ export const cohortRouter = router({
|
||||
entityType: 'Cohort',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
name: input.name,
|
||||
votingMode: input.votingMode,
|
||||
},
|
||||
@@ -244,13 +237,13 @@ export const cohortRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* List cohorts for a stage
|
||||
* List cohorts for a round
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.cohort.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
_count: { select: { projects: true } },
|
||||
@@ -267,18 +260,11 @@ export const cohortRouter = router({
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
stage: {
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
track: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
competition: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
@@ -298,7 +284,7 @@ export const cohortRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Get vote counts per project in the cohort's stage session
|
||||
// Get vote counts per project in the cohort's round session
|
||||
const projectIds = cohort.projects.map((p) => p.projectId)
|
||||
const voteSummary =
|
||||
projectIds.length > 0
|
||||
@@ -306,7 +292,7 @@ export const cohortRouter = router({
|
||||
by: ['projectId'],
|
||||
where: {
|
||||
projectId: { in: projectIds },
|
||||
session: { stageId: cohort.stage.id },
|
||||
session: { roundId: cohort.round.id },
|
||||
},
|
||||
_count: true,
|
||||
_avg: { score: true },
|
||||
|
||||
252
src/server/routers/competition.ts
Normal file
252
src/server/routers/competition.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const competitionRouter = router({
|
||||
/**
|
||||
* Create a new competition for a program
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
categoryMode: z.string().default('SHARED'),
|
||||
startupFinalistCount: z.number().int().positive().default(3),
|
||||
conceptFinalistCount: z.number().int().positive().default(3),
|
||||
notifyOnRoundAdvance: z.boolean().default(true),
|
||||
notifyOnDeadlineApproach: z.boolean().default(true),
|
||||
deadlineReminderDays: z.array(z.number().int().positive()).default([7, 3, 1]),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.competition.findUnique({
|
||||
where: { slug: input.slug },
|
||||
})
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A competition with slug "${input.slug}" already exists`,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
const competition = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.competition.create({
|
||||
data: input,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Competition',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, programId: input.programId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get competition by ID with rounds, jury groups, and submission windows
|
||||
*/
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const competition = await ctx.prisma.competition.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
roundType: true,
|
||||
status: true,
|
||||
sortOrder: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
},
|
||||
juryGroups: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
sortOrder: true,
|
||||
defaultMaxAssignments: true,
|
||||
defaultCapMode: true,
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
},
|
||||
submissionWindows: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
roundNumber: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
isLocked: true,
|
||||
_count: { select: { fileRequirements: true, projectFiles: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!competition) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Competition not found' })
|
||||
}
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* List competitions for a program
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { programId: input.programId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { rounds: true, juryGroups: true, submissionWindows: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update competition settings
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']).optional(),
|
||||
categoryMode: z.string().optional(),
|
||||
startupFinalistCount: z.number().int().positive().optional(),
|
||||
conceptFinalistCount: z.number().int().positive().optional(),
|
||||
notifyOnRoundAdvance: z.boolean().optional(),
|
||||
notifyOnDeadlineApproach: z.boolean().optional(),
|
||||
deadlineReminderDays: z.array(z.number().int().positive()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
if (data.slug) {
|
||||
const existing = await ctx.prisma.competition.findFirst({
|
||||
where: { slug: data.slug, NOT: { id } },
|
||||
})
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A competition with slug "${data.slug}" already exists`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const competition = await ctx.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.competition.findUniqueOrThrow({ where: { id } })
|
||||
|
||||
const updated = await tx.competition.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Competition',
|
||||
entityId: id,
|
||||
detailsJson: {
|
||||
changes: data,
|
||||
previous: {
|
||||
name: previous.name,
|
||||
status: previous.status,
|
||||
slug: previous.slug,
|
||||
},
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete (archive) a competition
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const competition = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.competition.update({
|
||||
where: { id: input.id },
|
||||
data: { status: 'ARCHIVED' },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Competition',
|
||||
entityId: input.id,
|
||||
detailsJson: { action: 'archived' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get competitions where the current user is a jury group member
|
||||
*/
|
||||
getMyCompetitions: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Find competitions where the user is a jury group member
|
||||
const memberships = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
select: { juryGroup: { select: { competitionId: true } } },
|
||||
})
|
||||
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
||||
if (competitionIds.length === 0) return []
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
|
||||
include: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true, roundType: true, status: true },
|
||||
},
|
||||
_count: { select: { rounds: true, juryGroups: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -22,28 +22,28 @@ export const dashboardRouter = router({
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
activeRoundCount,
|
||||
totalRoundCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentStages,
|
||||
recentRounds,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftStages,
|
||||
draftRounds,
|
||||
unassignedProjects,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_ACTIVE' },
|
||||
ctx.prisma.round.count({
|
||||
where: { competition: { programId: editionId }, status: 'ROUND_ACTIVE' },
|
||||
}),
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } } },
|
||||
ctx.prisma.round.count({
|
||||
where: { competition: { programId: editionId } },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: { programId: editionId },
|
||||
@@ -58,38 +58,38 @@ export const dashboardRouter = router({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: { assignment: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
where: { assignment: { round: { competition: { programId: editionId } } } },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stage: { track: { pipeline: { programId: editionId } } } },
|
||||
where: { round: { competition: { programId: editionId } } },
|
||||
}),
|
||||
ctx.prisma.stage.findMany({
|
||||
where: { track: { pipeline: { programId: editionId } } },
|
||||
ctx.prisma.round.findMany({
|
||||
where: { competition: { programId: editionId } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
stageType: true,
|
||||
roundType: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
projectStageStates: true,
|
||||
projectRoundStates: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
@@ -145,18 +145,18 @@ export const dashboardRouter = router({
|
||||
where: {
|
||||
hasConflict: true,
|
||||
reviewedAt: null,
|
||||
assignment: { stage: { track: { pipeline: { programId: editionId } } } },
|
||||
assignment: { round: { competition: { programId: editionId } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_DRAFT' },
|
||||
ctx.prisma.round.count({
|
||||
where: { competition: { programId: editionId }, status: 'ROUND_DRAFT' },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: {
|
||||
programId: editionId,
|
||||
projectStageStates: {
|
||||
projectRoundStates: {
|
||||
some: {
|
||||
stage: { status: 'STAGE_ACTIVE' },
|
||||
round: { status: 'ROUND_ACTIVE' },
|
||||
},
|
||||
},
|
||||
assignments: { none: {} },
|
||||
@@ -166,21 +166,21 @@ export const dashboardRouter = router({
|
||||
|
||||
return {
|
||||
edition,
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
activeRoundCount,
|
||||
totalRoundCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentStages,
|
||||
recentRounds,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftStages,
|
||||
draftRounds,
|
||||
unassignedProjects,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma, FilteringOutcome } from '@prisma/client'
|
||||
import { Prisma, FilteringOutcome, ProjectRoundStateValue } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
@@ -12,7 +12,7 @@ export const decisionRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.enum([
|
||||
'ProjectStageState',
|
||||
'ProjectRoundState',
|
||||
'FilteringResult',
|
||||
'AwardEligibility',
|
||||
]),
|
||||
@@ -33,13 +33,13 @@ export const decisionRouter = router({
|
||||
|
||||
// Fetch current value based on entity type
|
||||
switch (input.entityType) {
|
||||
case 'ProjectStageState': {
|
||||
const pss = await ctx.prisma.projectStageState.findUniqueOrThrow({
|
||||
case 'ProjectRoundState': {
|
||||
const prs = await ctx.prisma.projectRoundState.findUniqueOrThrow({
|
||||
where: { id: input.entityId },
|
||||
})
|
||||
previousValue = {
|
||||
state: pss.state,
|
||||
metadataJson: pss.metadataJson,
|
||||
state: prs.state,
|
||||
metadataJson: prs.metadataJson,
|
||||
}
|
||||
|
||||
// Validate the new state
|
||||
@@ -55,12 +55,12 @@ export const decisionRouter = router({
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.projectStageState.update({
|
||||
await tx.projectRoundState.update({
|
||||
where: { id: input.entityId },
|
||||
data: {
|
||||
state: (newState as Prisma.EnumProjectStageStateValueFieldUpdateOperationsInput['set']) ?? pss.state,
|
||||
state: newState ? (newState as ProjectRoundStateValue) : prs.state,
|
||||
metadataJson: {
|
||||
...(pss.metadataJson as Record<string, unknown> ?? {}),
|
||||
...(prs.metadataJson as Record<string, unknown> ?? {}),
|
||||
lastOverride: {
|
||||
by: ctx.user.id,
|
||||
at: new Date().toISOString(),
|
||||
|
||||
244
src/server/routers/deliberation.ts
Normal file
244
src/server/routers/deliberation.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, juryProcedure, protectedProcedure } from '../trpc'
|
||||
import {
|
||||
createSession,
|
||||
openVoting,
|
||||
closeVoting,
|
||||
submitVote,
|
||||
aggregateVotes,
|
||||
initRunoff,
|
||||
adminDecide,
|
||||
finalizeResults,
|
||||
updateParticipantStatus,
|
||||
getSessionWithVotes,
|
||||
} from '../services/deliberation'
|
||||
|
||||
const categoryEnum = z.enum([
|
||||
'STARTUP',
|
||||
'BUSINESS_CONCEPT',
|
||||
])
|
||||
|
||||
const deliberationModeEnum = z.enum(['SINGLE_WINNER_VOTE', 'FULL_RANKING'])
|
||||
|
||||
const tieBreakMethodEnum = z.enum(['TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK'])
|
||||
|
||||
const participantStatusEnum = z.enum([
|
||||
'REQUIRED',
|
||||
'ABSENT_EXCUSED',
|
||||
'REPLACED',
|
||||
'REPLACEMENT_ACTIVE',
|
||||
])
|
||||
|
||||
export const deliberationRouter = router({
|
||||
/**
|
||||
* Create a new deliberation session with participants
|
||||
*/
|
||||
createSession: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
roundId: z.string(),
|
||||
category: categoryEnum,
|
||||
mode: deliberationModeEnum,
|
||||
tieBreakMethod: tieBreakMethodEnum,
|
||||
showCollectiveRankings: z.boolean().default(false),
|
||||
showPriorJuryData: z.boolean().default(false),
|
||||
participantUserIds: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return createSession(input, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open voting: DELIB_OPEN → VOTING
|
||||
*/
|
||||
openVoting: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await openVoting(input.sessionId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to open voting',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close voting: VOTING → TALLYING
|
||||
*/
|
||||
closeVoting: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await closeVoting(input.sessionId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to close voting',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Submit a vote (jury member)
|
||||
*/
|
||||
submitVote: juryProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
juryMemberId: z.string(),
|
||||
projectId: z.string(),
|
||||
rank: z.number().int().min(1).optional(),
|
||||
isWinnerPick: z.boolean().optional(),
|
||||
runoffRound: z.number().int().min(0).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return submitVote(input, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Aggregate votes for a session
|
||||
*/
|
||||
aggregate: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return aggregateVotes(input.sessionId, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Initiate a runoff: TALLYING → RUNOFF
|
||||
*/
|
||||
initRunoff: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
tiedProjectIds: z.array(z.string()).min(2),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await initRunoff(
|
||||
input.sessionId,
|
||||
input.tiedProjectIds,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to initiate runoff',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin override: directly set final rankings
|
||||
*/
|
||||
adminDecide: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
rankings: z.array(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
rank: z.number().int().min(1),
|
||||
})
|
||||
).min(1),
|
||||
reason: z.string().min(1).max(2000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await adminDecide(
|
||||
input.sessionId,
|
||||
input.rankings,
|
||||
input.reason,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to admin-decide',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Finalize results: TALLYING → DELIB_LOCKED
|
||||
*/
|
||||
finalize: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await finalizeResults(input.sessionId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to finalize results',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get session with votes, results, and participants
|
||||
*/
|
||||
getSession: protectedProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await getSessionWithVotes(input.sessionId, ctx.prisma)
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' })
|
||||
}
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* List deliberation sessions for a competition
|
||||
*/
|
||||
listSessions: adminProcedure
|
||||
.input(z.object({ competitionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.deliberationSession.findMany({
|
||||
where: {
|
||||
round: { competitionId: input.competitionId },
|
||||
},
|
||||
include: {
|
||||
round: { select: { id: true, name: true, roundType: true } },
|
||||
_count: { select: { votes: true, participants: true } },
|
||||
participants: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update participant status (mark absent, replace, etc.)
|
||||
*/
|
||||
updateParticipant: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
userId: z.string(),
|
||||
status: participantStatusEnum,
|
||||
replacedById: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return updateParticipantStatus(
|
||||
input.sessionId,
|
||||
input.userId,
|
||||
input.status,
|
||||
input.replacedById,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -54,7 +54,7 @@ export const evaluationRouter = router({
|
||||
|
||||
// Get active form for this stage
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { stageId: assignment.stageId, isActive: true },
|
||||
where: { roundId: assignment.roundId, isActive: true },
|
||||
})
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
@@ -152,23 +152,23 @@ export const evaluationRouter = router({
|
||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
}
|
||||
|
||||
// Check voting window via stage
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: evaluation.assignment.stageId },
|
||||
// Check voting window via round
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: evaluation.assignment.roundId },
|
||||
})
|
||||
const now = new Date()
|
||||
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
if (round.status !== 'ROUND_ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Stage is not active',
|
||||
message: 'Round is not active',
|
||||
})
|
||||
}
|
||||
|
||||
// Check for grace period
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
|
||||
where: {
|
||||
stageId: stage.id,
|
||||
roundId: round.id,
|
||||
userId: ctx.user.id,
|
||||
OR: [
|
||||
{ projectId: null },
|
||||
@@ -178,9 +178,9 @@ export const evaluationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
const effectiveEndDate = gracePeriod?.extendedUntil ?? stage.windowCloseAt
|
||||
const effectiveEndDate = gracePeriod?.extendedUntil ?? round.windowCloseAt
|
||||
|
||||
if (stage.windowOpenAt && now < stage.windowOpenAt) {
|
||||
if (round.windowOpenAt && now < round.windowOpenAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Voting has not started yet',
|
||||
@@ -219,7 +219,7 @@ export const evaluationRouter = router({
|
||||
entityId: id,
|
||||
detailsJson: {
|
||||
projectId: evaluation.assignment.projectId,
|
||||
stageId: evaluation.assignment.stageId,
|
||||
roundId: evaluation.assignment.roundId,
|
||||
globalScore: data.globalScore,
|
||||
binaryDecision: data.binaryDecision,
|
||||
},
|
||||
@@ -275,14 +275,14 @@ export const evaluationRouter = router({
|
||||
listByStage: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
status: z.enum(['NOT_STARTED', 'DRAFT', 'SUBMITTED', 'LOCKED']).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { stageId: input.stageId },
|
||||
assignment: { roundId: input.roundId },
|
||||
...(input.status && { status: input.status }),
|
||||
},
|
||||
include: {
|
||||
@@ -301,13 +301,13 @@ export const evaluationRouter = router({
|
||||
* Get my past evaluations (read-only for jury)
|
||||
*/
|
||||
myPastEvaluations: protectedProcedure
|
||||
.input(z.object({ stageId: z.string().optional() }))
|
||||
.input(z.object({ roundId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: {
|
||||
userId: ctx.user.id,
|
||||
...(input.stageId && { stageId: input.stageId }),
|
||||
...(input.roundId && { roundId: input.roundId }),
|
||||
},
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
@@ -315,7 +315,7 @@ export const evaluationRouter = router({
|
||||
assignment: {
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
stage: { select: { id: true, name: true } },
|
||||
round: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -340,12 +340,12 @@ export const evaluationRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Look up the assignment to get projectId, stageId, userId
|
||||
// Look up the assignment to get projectId, roundId, userId
|
||||
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
||||
where: { id: input.assignmentId },
|
||||
include: {
|
||||
project: { select: { title: true } },
|
||||
stage: { select: { id: true, name: true } },
|
||||
round: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -378,15 +378,15 @@ export const evaluationRouter = router({
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.JURY_INACTIVE,
|
||||
title: 'Conflict of Interest Declared',
|
||||
message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.stage.name}.`,
|
||||
linkUrl: `/admin/stages/${assignment.stageId}/coi`,
|
||||
message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.round.name}.`,
|
||||
linkUrl: `/admin/stages/${assignment.roundId}/coi`,
|
||||
linkLabel: 'Review COI',
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
assignmentId: input.assignmentId,
|
||||
userId: ctx.user.id,
|
||||
projectId: assignment.projectId,
|
||||
stageId: assignment.stageId,
|
||||
roundId: assignment.roundId,
|
||||
conflictType: input.conflictType,
|
||||
},
|
||||
})
|
||||
@@ -402,7 +402,7 @@ export const evaluationRouter = router({
|
||||
detailsJson: {
|
||||
assignmentId: input.assignmentId,
|
||||
projectId: assignment.projectId,
|
||||
stageId: assignment.stageId,
|
||||
roundId: assignment.roundId,
|
||||
hasConflict: input.hasConflict,
|
||||
conflictType: input.conflictType,
|
||||
},
|
||||
@@ -430,14 +430,14 @@ export const evaluationRouter = router({
|
||||
listCOIByStage: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
hasConflictOnly: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
assignment: { stageId: input.stageId },
|
||||
assignment: { roundId: input.roundId },
|
||||
...(input.hasConflictOnly && { hasConflict: true }),
|
||||
},
|
||||
include: {
|
||||
@@ -501,16 +501,16 @@ export const evaluationRouter = router({
|
||||
* Manually trigger reminder check for a specific stage (admin only)
|
||||
*/
|
||||
triggerReminders: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await processEvaluationReminders(input.stageId)
|
||||
const result = await processEvaluationReminders(input.roundId)
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REMINDERS_TRIGGERED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
sent: result.sent,
|
||||
errors: result.errors,
|
||||
@@ -533,13 +533,13 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return generateSummary({
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userId: ctx.user.id,
|
||||
prisma: ctx.prisma,
|
||||
})
|
||||
@@ -552,15 +552,15 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.evaluationSummary.findUnique({
|
||||
where: {
|
||||
projectId_stageId: {
|
||||
projectId_roundId: {
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -570,12 +570,12 @@ export const evaluationRouter = router({
|
||||
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
|
||||
*/
|
||||
generateBulkSummaries: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Find all projects with at least 1 submitted evaluation in this stage
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
evaluation: {
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
@@ -594,7 +594,7 @@ export const evaluationRouter = router({
|
||||
try {
|
||||
await generateSummary({
|
||||
projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userId: ctx.user.id,
|
||||
prisma: ctx.prisma,
|
||||
})
|
||||
@@ -625,7 +625,7 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(2).max(3),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -633,7 +633,7 @@ export const evaluationRouter = router({
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
projectId: { in: input.projectIds },
|
||||
},
|
||||
include: {
|
||||
@@ -668,7 +668,7 @@ export const evaluationRouter = router({
|
||||
|
||||
// Fetch the active evaluation form for this stage to get criteria labels
|
||||
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { stageId: input.stageId, isActive: true },
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
select: { criteriaJson: true, scalesJson: true },
|
||||
})
|
||||
|
||||
@@ -696,7 +696,7 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -705,7 +705,7 @@ export const evaluationRouter = router({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
include: { evaluation: true },
|
||||
})
|
||||
@@ -718,8 +718,8 @@ export const evaluationRouter = router({
|
||||
}
|
||||
|
||||
// Check stage settings for peer review
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
const settings = (stage.configJson as Record<string, unknown>) || {}
|
||||
@@ -736,7 +736,7 @@ export const evaluationRouter = router({
|
||||
status: 'SUBMITTED',
|
||||
assignment: {
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -821,16 +821,16 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get or create discussion
|
||||
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
|
||||
where: {
|
||||
projectId_stageId: {
|
||||
projectId_roundId: {
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -847,7 +847,7 @@ export const evaluationRouter = router({
|
||||
discussion = await ctx.prisma.evaluationDiscussion.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
include: {
|
||||
comments: {
|
||||
@@ -860,11 +860,11 @@ export const evaluationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Anonymize comments based on stage settings
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
// Anonymize comments based on round settings
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (stage.configJson as Record<string, unknown>) || {}
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
||||
|
||||
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
|
||||
@@ -907,16 +907,16 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
content: z.string().min(1).max(2000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check max comment length from stage settings
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
// Check max comment length from round settings
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (stage.configJson as Record<string, unknown>) || {}
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const maxLength = (settings.max_comment_length as number) || 2000
|
||||
if (input.content.length > maxLength) {
|
||||
throw new TRPCError({
|
||||
@@ -928,9 +928,9 @@ export const evaluationRouter = router({
|
||||
// Get or create discussion
|
||||
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
|
||||
where: {
|
||||
projectId_stageId: {
|
||||
projectId_roundId: {
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -939,7 +939,7 @@ export const evaluationRouter = router({
|
||||
discussion = await ctx.prisma.evaluationDiscussion.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -970,7 +970,7 @@ export const evaluationRouter = router({
|
||||
detailsJson: {
|
||||
discussionId: discussion.id,
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
@@ -1007,7 +1007,7 @@ export const evaluationRouter = router({
|
||||
entityId: input.discussionId,
|
||||
detailsJson: {
|
||||
projectId: discussion.projectId,
|
||||
stageId: discussion.stageId,
|
||||
roundId: discussion.roundId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
@@ -1030,11 +1030,11 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
assignmentId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify assignment ownership and stageId match
|
||||
// Verify assignment ownership and roundId match
|
||||
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
||||
where: { id: input.assignmentId },
|
||||
})
|
||||
@@ -1043,30 +1043,30 @@ export const evaluationRouter = router({
|
||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
}
|
||||
|
||||
if (assignment.stageId !== input.stageId) {
|
||||
if (assignment.roundId !== input.roundId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Assignment does not belong to this stage',
|
||||
})
|
||||
}
|
||||
|
||||
// Check stage window
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
// Check round window
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
if (round.status !== 'ROUND_ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage is not active',
|
||||
message: 'Round is not active',
|
||||
})
|
||||
}
|
||||
|
||||
// Check grace period
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userId: ctx.user.id,
|
||||
OR: [
|
||||
{ projectId: null },
|
||||
@@ -1076,8 +1076,8 @@ export const evaluationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
const effectiveClose = gracePeriod?.extendedUntil ?? stage.windowCloseAt
|
||||
if (stage.windowOpenAt && now < stage.windowOpenAt) {
|
||||
const effectiveClose = gracePeriod?.extendedUntil ?? round.windowCloseAt
|
||||
if (round.windowOpenAt && now < round.windowOpenAt) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Evaluation window has not opened yet',
|
||||
@@ -1098,7 +1098,7 @@ export const evaluationRouter = router({
|
||||
|
||||
// Get active evaluation form for this stage
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { stageId: input.stageId, isActive: true },
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
@@ -1120,10 +1120,10 @@ export const evaluationRouter = router({
|
||||
* Get the active evaluation form for a stage
|
||||
*/
|
||||
getStageForm: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { stageId: input.stageId, isActive: true },
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
|
||||
if (!form) {
|
||||
@@ -1152,13 +1152,13 @@ export const evaluationRouter = router({
|
||||
checkStageWindow: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
@@ -1173,7 +1173,7 @@ export const evaluationRouter = router({
|
||||
// Check for grace period
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userId,
|
||||
extendedUntil: { gte: now },
|
||||
},
|
||||
@@ -1183,13 +1183,13 @@ export const evaluationRouter = router({
|
||||
const effectiveClose = gracePeriod?.extendedUntil ?? stage.windowCloseAt
|
||||
|
||||
const isOpen =
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
stage.status === 'ROUND_ACTIVE' &&
|
||||
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
|
||||
(!effectiveClose || now <= effectiveClose)
|
||||
|
||||
let reason = ''
|
||||
if (!isOpen) {
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
if (stage.status !== 'ROUND_ACTIVE') {
|
||||
reason = 'Stage is not active'
|
||||
} else if (stage.windowOpenAt && now < stage.windowOpenAt) {
|
||||
reason = 'Window has not opened yet'
|
||||
@@ -1214,7 +1214,7 @@ export const evaluationRouter = router({
|
||||
listStageEvaluations: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
@@ -1222,7 +1222,7 @@ export const evaluationRouter = router({
|
||||
const where: Record<string, unknown> = {
|
||||
assignment: {
|
||||
userId: ctx.user.id,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const exportRouter = router({
|
||||
evaluations: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
includeDetails: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
@@ -17,7 +17,7 @@ export const exportRouter = router({
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: { stageId: input.stageId },
|
||||
assignment: { roundId: input.roundId },
|
||||
},
|
||||
include: {
|
||||
assignment: {
|
||||
@@ -75,7 +75,7 @@ export const exportRouter = router({
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'Evaluation',
|
||||
detailsJson: { stageId: input.stageId, count: data.length },
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -101,11 +101,11 @@ export const exportRouter = router({
|
||||
* Export project scores summary
|
||||
*/
|
||||
projectScores: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
assignments: { some: { stageId: input.stageId } },
|
||||
assignments: { some: { roundId: input.roundId } },
|
||||
},
|
||||
include: {
|
||||
assignments: {
|
||||
@@ -161,7 +161,7 @@ export const exportRouter = router({
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'ProjectScores',
|
||||
detailsJson: { stageId: input.stageId, count: data.length },
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -188,10 +188,10 @@ export const exportRouter = router({
|
||||
* Export assignments
|
||||
*/
|
||||
assignments: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
project: { select: { title: true, teamName: true } },
|
||||
@@ -234,10 +234,10 @@ export const exportRouter = router({
|
||||
* Export filtering results as CSV data
|
||||
*/
|
||||
filteringResults: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await ctx.prisma.filteringResult.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
@@ -316,7 +316,7 @@ export const exportRouter = router({
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'FilteringResult',
|
||||
detailsJson: { stageId: input.stageId, count: data.length },
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -401,7 +401,7 @@ export const exportRouter = router({
|
||||
getReportData: observerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
sections: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
@@ -409,25 +409,21 @@ export const exportRouter = router({
|
||||
const includeSection = (name: string) =>
|
||||
!input.sections || input.sections.length === 0 || input.sections.includes(name)
|
||||
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
stageName: stage.name,
|
||||
programName: stage.track.pipeline.program.name,
|
||||
programYear: stage.track.pipeline.program.year,
|
||||
roundName: round.name,
|
||||
programName: round.competition.program.name,
|
||||
programYear: round.competition.program.year,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
@@ -435,18 +431,18 @@ export const exportRouter = router({
|
||||
if (includeSection('summary')) {
|
||||
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
||||
ctx.prisma.project.count({
|
||||
where: { assignments: { some: { stageId: input.stageId } } },
|
||||
where: { assignments: { some: { roundId: input.roundId } } },
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { stageId: input.stageId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { stageId: input.stageId },
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -465,7 +461,7 @@ export const exportRouter = router({
|
||||
if (includeSection('scoreDistribution')) {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { stageId: input.stageId },
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { globalScore: true },
|
||||
@@ -490,7 +486,7 @@ export const exportRouter = router({
|
||||
// Rankings
|
||||
if (includeSection('rankings')) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { assignments: { some: { stageId: input.stageId } } },
|
||||
where: { assignments: { some: { roundId: input.roundId } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -538,7 +534,7 @@ export const exportRouter = router({
|
||||
// Juror stats
|
||||
if (includeSection('jurorStats')) {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
evaluation: { select: { status: true, globalScore: true } },
|
||||
@@ -578,14 +574,14 @@ export const exportRouter = router({
|
||||
// Criteria breakdown
|
||||
if (includeSection('criteriaBreakdown')) {
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { stageId: input.stageId, isActive: true },
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
|
||||
if (form?.criteriaJson) {
|
||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { stageId: input.stageId },
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { criterionScoresJson: true },
|
||||
@@ -618,8 +614,8 @@ export const exportRouter = router({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REPORT_GENERATED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { sections: input.sections },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const fileRouter = router({
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: file.projectId },
|
||||
select: { id: true, stageId: true },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
||||
@@ -63,25 +63,25 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
if (juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
const assignedStage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: juryAssignment.stageId },
|
||||
select: { trackId: true, sortOrder: true },
|
||||
const assignedRound = await ctx.prisma.round.findUnique({
|
||||
where: { id: juryAssignment.roundId },
|
||||
select: { competitionId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
if (assignedStage) {
|
||||
const priorOrCurrentStages = await ctx.prisma.stage.findMany({
|
||||
if (assignedRound) {
|
||||
const priorOrCurrentRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
trackId: assignedStage.trackId,
|
||||
sortOrder: { lte: assignedStage.sortOrder },
|
||||
competitionId: assignedRound.competitionId,
|
||||
sortOrder: { lte: assignedRound.sortOrder },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const stageIds = priorOrCurrentStages.map((s) => s.id)
|
||||
const roundIds = priorOrCurrentRounds.map((r) => r.id)
|
||||
|
||||
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
|
||||
where: {
|
||||
stageId: { in: stageIds },
|
||||
roundId: { in: roundIds },
|
||||
files: { some: { bucket: input.bucket, objectKey: input.objectKey } },
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -135,7 +135,7 @@ export const fileRouter = router({
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().positive(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -150,9 +150,9 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
let isLate = false
|
||||
if (input.stageId) {
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
if (input.roundId) {
|
||||
const stage = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { windowCloseAt: true },
|
||||
})
|
||||
|
||||
@@ -191,7 +191,7 @@ export const fileRouter = router({
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
fileType: input.fileType,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
isLate,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
@@ -262,7 +262,7 @@ export const fileRouter = router({
|
||||
listByProject: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
@@ -298,8 +298,8 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { projectId: input.projectId }
|
||||
if (input.stageId) {
|
||||
where.requirement = { stageId: input.stageId }
|
||||
if (input.roundId) {
|
||||
where.requirement = { roundId: input.roundId }
|
||||
}
|
||||
|
||||
return ctx.prisma.projectFile.findMany({
|
||||
@@ -311,8 +311,8 @@ export const fileRouter = router({
|
||||
name: true,
|
||||
description: true,
|
||||
isRequired: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, sortOrder: true } },
|
||||
roundId: true,
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -327,7 +327,7 @@ export const fileRouter = router({
|
||||
listByProjectForStage: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
@@ -336,7 +336,7 @@ export const fileRouter = router({
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true, stageId: true },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
@@ -362,27 +362,27 @@ export const fileRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const targetStage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
select: { trackId: true, sortOrder: true },
|
||||
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { competitionId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
const eligibleStages = await ctx.prisma.stage.findMany({
|
||||
const eligibleRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
trackId: targetStage.trackId,
|
||||
sortOrder: { lte: targetStage.sortOrder },
|
||||
competitionId: targetRound.competitionId,
|
||||
sortOrder: { lte: targetRound.sortOrder },
|
||||
},
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const eligibleStageIds = eligibleStages.map((s) => s.id)
|
||||
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
||||
|
||||
const files = await ctx.prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
OR: [
|
||||
{ requirement: { stageId: { in: eligibleStageIds } } },
|
||||
{ requirement: { roundId: { in: eligibleRoundIds } } },
|
||||
{ requirementId: null },
|
||||
],
|
||||
},
|
||||
@@ -393,8 +393,8 @@ export const fileRouter = router({
|
||||
name: true,
|
||||
description: true,
|
||||
isRequired: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, sortOrder: true } },
|
||||
roundId: true,
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -402,8 +402,8 @@ export const fileRouter = router({
|
||||
})
|
||||
|
||||
const grouped: Array<{
|
||||
stageId: string | null
|
||||
stageName: string
|
||||
roundId: string | null
|
||||
roundName: string
|
||||
sortOrder: number
|
||||
files: typeof files
|
||||
}> = []
|
||||
@@ -411,21 +411,21 @@ export const fileRouter = router({
|
||||
const generalFiles = files.filter((f) => !f.requirementId)
|
||||
if (generalFiles.length > 0) {
|
||||
grouped.push({
|
||||
stageId: null,
|
||||
stageName: 'General',
|
||||
roundId: null,
|
||||
roundName: 'General',
|
||||
sortOrder: -1,
|
||||
files: generalFiles,
|
||||
})
|
||||
}
|
||||
|
||||
for (const stage of eligibleStages) {
|
||||
const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id)
|
||||
if (stageFiles.length > 0) {
|
||||
for (const round of eligibleRounds) {
|
||||
const roundFiles = files.filter((f) => f.requirement?.roundId === round.id)
|
||||
if (roundFiles.length > 0) {
|
||||
grouped.push({
|
||||
stageId: stage.id,
|
||||
stageName: stage.name,
|
||||
sortOrder: stage.sortOrder,
|
||||
files: stageFiles,
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
sortOrder: round.sortOrder,
|
||||
files: roundFiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -696,239 +696,116 @@ export const fileRouter = router({
|
||||
return results
|
||||
}),
|
||||
|
||||
// NOTE: getProjectRequirements procedure removed - depends on deleted Pipeline/Track/Stage models
|
||||
// Will need to be reimplemented with new Competition/Round architecture
|
||||
|
||||
// =========================================================================
|
||||
// FILE REQUIREMENTS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get file requirements for a project from its pipeline's intake stage.
|
||||
* Returns both configJson-based requirements and actual FileRequirement records,
|
||||
* along with which ones are already fulfilled by uploaded files.
|
||||
* Materialize legacy configJson file requirements into FileRequirement rows.
|
||||
* No-op if the stage already has DB-backed requirements.
|
||||
*/
|
||||
getProjectRequirements: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 1. Get the project and its program
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
select: { programId: true },
|
||||
})
|
||||
|
||||
// 2. Find the pipeline for this program
|
||||
const pipeline = await ctx.prisma.pipeline.findFirst({
|
||||
where: { programId: project.programId },
|
||||
include: {
|
||||
tracks: {
|
||||
where: { kind: 'MAIN' },
|
||||
include: {
|
||||
stages: {
|
||||
where: { stageType: 'INTAKE' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
materializeRequirementsFromConfig: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
roundType: true,
|
||||
configJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!pipeline) return null
|
||||
if (stage.roundType !== 'INTAKE') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Requirements can only be materialized for INTAKE stages',
|
||||
})
|
||||
}
|
||||
|
||||
const mainTrack = pipeline.tracks[0]
|
||||
if (!mainTrack) return null
|
||||
|
||||
const intakeStage = mainTrack.stages[0]
|
||||
if (!intakeStage) return null
|
||||
|
||||
// 3. Check for actual FileRequirement records first
|
||||
const dbRequirements = await ctx.prisma.fileRequirement.findMany({
|
||||
where: { stageId: intakeStage.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
files: {
|
||||
where: { projectId: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
const existingCount = await ctx.prisma.fileRequirement.count({
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
if (existingCount > 0) {
|
||||
return { created: 0, skipped: true, reason: 'already_materialized' as const }
|
||||
}
|
||||
|
||||
// 4. If we have DB requirements, return those (they're the canonical source)
|
||||
if (dbRequirements.length > 0) {
|
||||
return {
|
||||
stageId: intakeStage.id,
|
||||
requirements: dbRequirements.map((req) => ({
|
||||
id: req.id,
|
||||
name: req.name,
|
||||
description: req.description,
|
||||
acceptedMimeTypes: req.acceptedMimeTypes,
|
||||
maxSizeMB: req.maxSizeMB,
|
||||
isRequired: req.isRequired,
|
||||
fulfilled: req.files.length > 0,
|
||||
fulfilledFile: req.files[0] ?? null,
|
||||
})),
|
||||
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
|
||||
const configRequirements = Array.isArray(config.fileRequirements)
|
||||
? (config.fileRequirements as Array<Record<string, unknown>>)
|
||||
: []
|
||||
|
||||
if (configRequirements.length === 0) {
|
||||
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
|
||||
}
|
||||
|
||||
const mapLegacyMimeType = (type: unknown): string[] => {
|
||||
switch (String(type ?? '').toUpperCase()) {
|
||||
case 'PDF':
|
||||
return ['application/pdf']
|
||||
case 'VIDEO':
|
||||
return ['video/*']
|
||||
case 'IMAGE':
|
||||
return ['image/*']
|
||||
case 'DOC':
|
||||
case 'DOCX':
|
||||
return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
|
||||
case 'PPT':
|
||||
case 'PPTX':
|
||||
return ['application/vnd.openxmlformats-officedocument.presentationml.presentation']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fall back to configJson requirements
|
||||
const configJson = intakeStage.configJson as Record<string, unknown> | null
|
||||
const fileRequirements = (configJson?.fileRequirements as Array<{
|
||||
name: string
|
||||
description?: string
|
||||
acceptedMimeTypes?: string[]
|
||||
maxSizeMB?: number
|
||||
isRequired?: boolean
|
||||
type?: string
|
||||
required?: boolean
|
||||
}>) ?? []
|
||||
let created = 0
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < configRequirements.length; i++) {
|
||||
const raw = configRequirements[i]
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) continue
|
||||
|
||||
if (fileRequirements.length === 0) return null
|
||||
const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes)
|
||||
? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string')
|
||||
: mapLegacyMimeType(raw.type)
|
||||
|
||||
// 6. Get project files to check fulfillment
|
||||
const projectFiles = await ctx.prisma.projectFile.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
},
|
||||
await tx.fileRequirement.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
name,
|
||||
description:
|
||||
typeof raw.description === 'string' && raw.description.trim().length > 0
|
||||
? raw.description.trim()
|
||||
: undefined,
|
||||
acceptedMimeTypes,
|
||||
maxSizeMB:
|
||||
typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB)
|
||||
? Math.trunc(raw.maxSizeMB)
|
||||
: undefined,
|
||||
isRequired:
|
||||
(raw.isRequired as boolean | undefined) ??
|
||||
((raw.required as boolean | undefined) ?? false),
|
||||
sortOrder: i,
|
||||
},
|
||||
})
|
||||
created++
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
stageId: intakeStage.id,
|
||||
requirements: fileRequirements.map((req) => {
|
||||
const reqName = req.name.toLowerCase()
|
||||
// Match by checking if any uploaded file's fileName contains the requirement name
|
||||
const matchingFile = projectFiles.find((f) =>
|
||||
f.fileName.toLowerCase().includes(reqName) ||
|
||||
reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
)
|
||||
|
||||
return {
|
||||
id: null as string | null,
|
||||
name: req.name,
|
||||
description: req.description ?? null,
|
||||
acceptedMimeTypes: req.acceptedMimeTypes ?? [],
|
||||
maxSizeMB: req.maxSizeMB ?? null,
|
||||
// Handle both formats: isRequired (wizard type) and required (seed data)
|
||||
isRequired: req.isRequired ?? req.required ?? false,
|
||||
fulfilled: !!matchingFile,
|
||||
fulfilledFile: matchingFile ?? null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
return { created, skipped: false as const }
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// FILE REQUIREMENTS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Materialize legacy configJson file requirements into FileRequirement rows.
|
||||
* No-op if the stage already has DB-backed requirements.
|
||||
*/
|
||||
materializeRequirementsFromConfig: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
stageType: true,
|
||||
configJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (stage.stageType !== 'INTAKE') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Requirements can only be materialized for INTAKE stages',
|
||||
})
|
||||
}
|
||||
|
||||
const existingCount = await ctx.prisma.fileRequirement.count({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
if (existingCount > 0) {
|
||||
return { created: 0, skipped: true, reason: 'already_materialized' as const }
|
||||
}
|
||||
|
||||
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
|
||||
const configRequirements = Array.isArray(config.fileRequirements)
|
||||
? (config.fileRequirements as Array<Record<string, unknown>>)
|
||||
: []
|
||||
|
||||
if (configRequirements.length === 0) {
|
||||
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
|
||||
}
|
||||
|
||||
const mapLegacyMimeType = (type: unknown): string[] => {
|
||||
switch (String(type ?? '').toUpperCase()) {
|
||||
case 'PDF':
|
||||
return ['application/pdf']
|
||||
case 'VIDEO':
|
||||
return ['video/*']
|
||||
case 'IMAGE':
|
||||
return ['image/*']
|
||||
case 'DOC':
|
||||
case 'DOCX':
|
||||
return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
|
||||
case 'PPT':
|
||||
case 'PPTX':
|
||||
return ['application/vnd.openxmlformats-officedocument.presentationml.presentation']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
let created = 0
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < configRequirements.length; i++) {
|
||||
const raw = configRequirements[i]
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) continue
|
||||
|
||||
const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes)
|
||||
? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string')
|
||||
: mapLegacyMimeType(raw.type)
|
||||
|
||||
await tx.fileRequirement.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
name,
|
||||
description:
|
||||
typeof raw.description === 'string' && raw.description.trim().length > 0
|
||||
? raw.description.trim()
|
||||
: undefined,
|
||||
acceptedMimeTypes,
|
||||
maxSizeMB:
|
||||
typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB)
|
||||
? Math.trunc(raw.maxSizeMB)
|
||||
: undefined,
|
||||
isRequired:
|
||||
(raw.isRequired as boolean | undefined) ??
|
||||
((raw.required as boolean | undefined) ?? false),
|
||||
sortOrder: i,
|
||||
},
|
||||
})
|
||||
created++
|
||||
}
|
||||
})
|
||||
|
||||
return { created, skipped: false as const }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List file requirements for a stage (available to any authenticated user)
|
||||
*/
|
||||
/**
|
||||
* List file requirements for a stage (available to any authenticated user)
|
||||
*/
|
||||
listRequirements: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.fileRequirement.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}),
|
||||
@@ -939,7 +816,7 @@ export const fileRouter = router({
|
||||
createRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
acceptedMimeTypes: z.array(z.string()).default([]),
|
||||
@@ -960,7 +837,7 @@ export const fileRouter = router({
|
||||
action: 'CREATE',
|
||||
entityType: 'FileRequirement',
|
||||
entityId: requirement.id,
|
||||
detailsJson: { name: input.name, stageId: input.stageId },
|
||||
detailsJson: { name: input.name, roundId: input.roundId },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
@@ -1032,7 +909,7 @@ export const fileRouter = router({
|
||||
reorderRequirements: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
orderedIds: z.array(z.string()),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ function getAIConfidenceScore(aiScreeningJson: Prisma.JsonValue | null): number
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function runFilteringJob(jobId: string, stageId: string, userId: string) {
|
||||
export async function runFilteringJob(jobId: string, roundId: string, userId: string) {
|
||||
try {
|
||||
// Update job to running
|
||||
await prisma.filteringJob.update({
|
||||
@@ -39,14 +39,14 @@ export async function runFilteringJob(jobId: string, stageId: string, userId: st
|
||||
|
||||
// Get rules
|
||||
const rules = await prisma.filteringRule.findMany({
|
||||
where: { stageId },
|
||||
where: { roundId },
|
||||
orderBy: { priority: 'asc' },
|
||||
})
|
||||
|
||||
// Get projects in this stage via ProjectStageState
|
||||
const projectStates = await prisma.projectStageState.findMany({
|
||||
// Get projects in this round via ProjectRoundState
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: {
|
||||
stageId,
|
||||
roundId,
|
||||
exitedAt: null,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
@@ -83,7 +83,7 @@ export async function runFilteringJob(jobId: string, stageId: string, userId: st
|
||||
}
|
||||
|
||||
// Execute rules
|
||||
const results = await executeFilteringRules(rules, projects, userId, stageId, onProgress)
|
||||
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress)
|
||||
|
||||
// Count outcomes
|
||||
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
|
||||
@@ -95,13 +95,13 @@ export async function runFilteringJob(jobId: string, stageId: string, userId: st
|
||||
results.map((r) =>
|
||||
prisma.filteringResult.upsert({
|
||||
where: {
|
||||
stageId_projectId: {
|
||||
stageId,
|
||||
roundId_projectId: {
|
||||
roundId,
|
||||
projectId: r.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
stageId,
|
||||
roundId,
|
||||
projectId: r.projectId,
|
||||
outcome: r.outcome,
|
||||
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||
@@ -138,7 +138,7 @@ export async function runFilteringJob(jobId: string, stageId: string, userId: st
|
||||
userId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Stage',
|
||||
entityId: stageId,
|
||||
entityId: roundId,
|
||||
detailsJson: {
|
||||
action: 'EXECUTE_FILTERING',
|
||||
jobId,
|
||||
@@ -149,22 +149,22 @@ export async function runFilteringJob(jobId: string, stageId: string, userId: st
|
||||
},
|
||||
})
|
||||
|
||||
// Get stage name for notification
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
select: { name: true },
|
||||
// Get round name and competitionId for notification
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { name: true, competitionId: true },
|
||||
})
|
||||
|
||||
// Notify admins that filtering is complete
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.FILTERING_COMPLETE,
|
||||
title: 'AI Filtering Complete',
|
||||
message: `Filtering complete for ${stage?.name || 'stage'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
|
||||
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/filtering/results`,
|
||||
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
|
||||
linkUrl: `/admin/competitions/${round?.competitionId}/rounds/${roundId}`,
|
||||
linkLabel: 'View Results',
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
stageId,
|
||||
roundId,
|
||||
jobId,
|
||||
projectCount: projects.length,
|
||||
passedCount,
|
||||
@@ -183,15 +183,19 @@ export async function runFilteringJob(jobId: string, stageId: string, userId: st
|
||||
},
|
||||
})
|
||||
|
||||
// Notify admins of failure
|
||||
// Notify admins of failure - need to fetch round info for competitionId
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { competitionId: true },
|
||||
})
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.FILTERING_FAILED,
|
||||
title: 'AI Filtering Failed',
|
||||
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/filtering`,
|
||||
linkUrl: round?.competitionId ? `/admin/competitions/${round.competitionId}/rounds/${roundId}` : `/admin/competitions`,
|
||||
linkLabel: 'View Details',
|
||||
priority: 'urgent',
|
||||
metadata: { stageId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -201,11 +205,11 @@ export const filteringRouter = router({
|
||||
* Check if AI is configured and ready for filtering
|
||||
*/
|
||||
checkAIStatus: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const aiRules = await ctx.prisma.filteringRule.count({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
ruleType: 'AI_SCREENING',
|
||||
isActive: true,
|
||||
},
|
||||
@@ -239,10 +243,10 @@ export const filteringRouter = router({
|
||||
* Get filtering rules for a stage
|
||||
*/
|
||||
getRules: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.filteringRule.findMany({
|
||||
where: { stageId: input.stageId, isActive: true },
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
orderBy: { priority: 'asc' },
|
||||
})
|
||||
}),
|
||||
@@ -253,7 +257,7 @@ export const filteringRouter = router({
|
||||
createRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
name: z.string().min(1),
|
||||
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING']),
|
||||
configJson: z.record(z.unknown()),
|
||||
@@ -263,7 +267,7 @@ export const filteringRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rule = await ctx.prisma.filteringRule.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
name: input.name,
|
||||
ruleType: input.ruleType,
|
||||
configJson: input.configJson as Prisma.InputJsonValue,
|
||||
@@ -276,7 +280,7 @@ export const filteringRouter = router({
|
||||
action: 'CREATE',
|
||||
entityType: 'FilteringRule',
|
||||
entityId: rule.id,
|
||||
detailsJson: { stageId: input.stageId, name: input.name, ruleType: input.ruleType },
|
||||
detailsJson: { roundId: input.roundId, name: input.name, ruleType: input.ruleType },
|
||||
})
|
||||
|
||||
return rule
|
||||
@@ -361,10 +365,10 @@ export const filteringRouter = router({
|
||||
* Start a filtering job (runs in background)
|
||||
*/
|
||||
startJob: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existingJob = await ctx.prisma.filteringJob.findFirst({
|
||||
where: { stageId: input.stageId, status: 'RUNNING' },
|
||||
where: { roundId: input.roundId, status: 'RUNNING' },
|
||||
})
|
||||
if (existingJob) {
|
||||
throw new TRPCError({
|
||||
@@ -374,7 +378,7 @@ export const filteringRouter = router({
|
||||
}
|
||||
|
||||
const rules = await ctx.prisma.filteringRule.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { priority: 'asc' },
|
||||
})
|
||||
|
||||
@@ -404,9 +408,9 @@ export const filteringRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const projectCount = await ctx.prisma.projectStageState.count({
|
||||
const projectCount = await ctx.prisma.projectRoundState.count({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
exitedAt: null,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
@@ -420,14 +424,14 @@ export const filteringRouter = router({
|
||||
|
||||
const job = await ctx.prisma.filteringJob.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
status: 'PENDING',
|
||||
totalProjects: projectCount,
|
||||
},
|
||||
})
|
||||
|
||||
setImmediate(() => {
|
||||
runFilteringJob(job.id, input.stageId, ctx.user.id).catch(console.error)
|
||||
runFilteringJob(job.id, input.roundId, ctx.user.id).catch(console.error)
|
||||
})
|
||||
|
||||
return { jobId: job.id, message: 'Filtering job started' }
|
||||
@@ -452,10 +456,10 @@ export const filteringRouter = router({
|
||||
* Get latest job for a stage
|
||||
*/
|
||||
getLatestJob: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.filteringJob.findFirst({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
@@ -464,10 +468,10 @@ export const filteringRouter = router({
|
||||
* Execute all filtering rules against projects in a stage (synchronous)
|
||||
*/
|
||||
executeRules: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rules = await ctx.prisma.filteringRule.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { priority: 'asc' },
|
||||
})
|
||||
|
||||
@@ -499,9 +503,9 @@ export const filteringRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const projectStates = await ctx.prisma.projectStageState.findMany({
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
exitedAt: null,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
@@ -533,13 +537,13 @@ export const filteringRouter = router({
|
||||
batch.map((r) =>
|
||||
ctx.prisma.filteringResult.upsert({
|
||||
where: {
|
||||
stageId_projectId: {
|
||||
stageId: input.stageId,
|
||||
roundId_projectId: {
|
||||
roundId: input.roundId,
|
||||
projectId: r.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
projectId: r.projectId,
|
||||
outcome: r.outcome,
|
||||
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||
@@ -563,7 +567,7 @@ export const filteringRouter = router({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
action: 'EXECUTE_FILTERING',
|
||||
projectCount: projects.length,
|
||||
@@ -587,17 +591,17 @@ export const filteringRouter = router({
|
||||
getResults: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
outcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { stageId, outcome, page, perPage } = input
|
||||
const { roundId, outcome, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = { stageId }
|
||||
const where: Record<string, unknown> = { roundId }
|
||||
if (outcome) where.outcome = outcome
|
||||
|
||||
const [results, total] = await Promise.all([
|
||||
@@ -637,20 +641,20 @@ export const filteringRouter = router({
|
||||
* Get aggregate stats for filtering results
|
||||
*/
|
||||
getResultStats: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [passed, filteredOut, flagged, overridden] = await Promise.all([
|
||||
ctx.prisma.filteringResult.count({
|
||||
where: { stageId: input.stageId, outcome: 'PASSED' },
|
||||
where: { roundId: input.roundId, outcome: 'PASSED' },
|
||||
}),
|
||||
ctx.prisma.filteringResult.count({
|
||||
where: { stageId: input.stageId, outcome: 'FILTERED_OUT' },
|
||||
where: { roundId: input.roundId, outcome: 'FILTERED_OUT' },
|
||||
}),
|
||||
ctx.prisma.filteringResult.count({
|
||||
where: { stageId: input.stageId, outcome: 'FLAGGED' },
|
||||
where: { roundId: input.roundId, outcome: 'FLAGGED' },
|
||||
}),
|
||||
ctx.prisma.filteringResult.count({
|
||||
where: { stageId: input.stageId, overriddenBy: { not: null } },
|
||||
where: { roundId: input.roundId, overriddenBy: { not: null } },
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -739,27 +743,27 @@ export const filteringRouter = router({
|
||||
finalizeResults: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
categoryTargets: z.record(z.number().int().min(0)).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const currentStage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
select: { id: true, trackId: true, sortOrder: true, name: true },
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { id: true, competitionId: true, sortOrder: true, name: true },
|
||||
})
|
||||
|
||||
const nextStage = await ctx.prisma.stage.findFirst({
|
||||
const nextRound = await ctx.prisma.round.findFirst({
|
||||
where: {
|
||||
trackId: currentStage.trackId,
|
||||
sortOrder: { gt: currentStage.sortOrder },
|
||||
competitionId: currentRound.competitionId,
|
||||
sortOrder: { gt: currentRound.sortOrder },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const results = await ctx.prisma.filteringResult.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: { competitionCategory: true },
|
||||
@@ -876,7 +880,7 @@ export const filteringRouter = router({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
action: 'FINALIZE_FILTERING',
|
||||
passed: passedIds.length,
|
||||
@@ -884,7 +888,7 @@ export const filteringRouter = router({
|
||||
demotedToFlagged: demotedIds.length,
|
||||
categoryTargets: input.categoryTargets || null,
|
||||
categoryWarnings,
|
||||
advancedToStage: nextStage?.name || null,
|
||||
advancedToStage: nextRound?.name || null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -894,8 +898,8 @@ export const filteringRouter = router({
|
||||
demotedToFlagged: demotedIds.length,
|
||||
categoryCounts,
|
||||
categoryWarnings,
|
||||
advancedToStageId: nextStage?.id || null,
|
||||
advancedToStageName: nextStage?.name || null,
|
||||
advancedToStageId: nextRound?.id || null,
|
||||
advancedToStageName: nextRound?.name || null,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -905,15 +909,15 @@ export const filteringRouter = router({
|
||||
reinstateProject: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.filteringResult.update({
|
||||
where: {
|
||||
stageId_projectId: {
|
||||
stageId: input.stageId,
|
||||
roundId_projectId: {
|
||||
roundId: input.roundId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
@@ -936,7 +940,7 @@ export const filteringRouter = router({
|
||||
entityType: 'FilteringResult',
|
||||
detailsJson: {
|
||||
action: 'REINSTATE',
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
})
|
||||
@@ -948,7 +952,7 @@ export const filteringRouter = router({
|
||||
bulkReinstate: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectIds: z.array(z.string()),
|
||||
})
|
||||
)
|
||||
@@ -957,8 +961,8 @@ export const filteringRouter = router({
|
||||
...input.projectIds.map((projectId) =>
|
||||
ctx.prisma.filteringResult.update({
|
||||
where: {
|
||||
stageId_projectId: {
|
||||
stageId: input.stageId,
|
||||
roundId_projectId: {
|
||||
roundId: input.roundId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
@@ -982,7 +986,7 @@ export const filteringRouter = router({
|
||||
entityType: 'FilteringResult',
|
||||
detailsJson: {
|
||||
action: 'BULK_REINSTATE',
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
count: input.projectIds.length,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export const gracePeriodRouter = router({
|
||||
grant: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
userId: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
extendedUntil: z.date(),
|
||||
@@ -32,7 +32,7 @@ export const gracePeriodRouter = router({
|
||||
entityType: 'GracePeriod',
|
||||
entityId: gracePeriod.id,
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userId: input.userId,
|
||||
projectId: input.projectId,
|
||||
extendedUntil: input.extendedUntil.toISOString(),
|
||||
@@ -45,13 +45,13 @@ export const gracePeriodRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* List grace periods for a stage
|
||||
* List grace periods for a round
|
||||
*/
|
||||
listByStage: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
listByRound: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
grantedBy: { select: { id: true, name: true } },
|
||||
@@ -61,14 +61,14 @@ export const gracePeriodRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* List active grace periods for a stage
|
||||
* List active grace periods for a round
|
||||
*/
|
||||
listActiveByStage: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
listActiveByRound: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
extendedUntil: { gte: new Date() },
|
||||
},
|
||||
include: {
|
||||
@@ -80,19 +80,19 @@ export const gracePeriodRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get grace periods for a specific user in a stage
|
||||
* Get grace periods for a specific user in a round
|
||||
*/
|
||||
getByUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
userId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userId: input.userId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -152,7 +152,7 @@ export const gracePeriodRouter = router({
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
userId: gracePeriod.userId,
|
||||
stageId: gracePeriod.stageId,
|
||||
roundId: gracePeriod.roundId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
@@ -167,7 +167,7 @@ export const gracePeriodRouter = router({
|
||||
bulkGrant: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
userIds: z.array(z.string()),
|
||||
extendedUntil: z.date(),
|
||||
reason: z.string().optional(),
|
||||
@@ -176,7 +176,7 @@ export const gracePeriodRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const created = await ctx.prisma.gracePeriod.createMany({
|
||||
data: input.userIds.map((userId) => ({
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userId,
|
||||
extendedUntil: input.extendedUntil,
|
||||
reason: input.reason,
|
||||
@@ -192,7 +192,7 @@ export const gracePeriodRouter = router({
|
||||
action: 'BULK_GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
userCount: input.userIds.length,
|
||||
created: created.count,
|
||||
},
|
||||
|
||||
348
src/server/routers/juryGroup.ts
Normal file
348
src/server/routers/juryGroup.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
const capModeEnum = z.enum(['HARD', 'SOFT', 'NONE'])
|
||||
|
||||
export const juryGroupRouter = router({
|
||||
/**
|
||||
* Create a new jury group for a competition
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
description: z.string().optional(),
|
||||
sortOrder: z.number().int().nonnegative().default(0),
|
||||
defaultMaxAssignments: z.number().int().positive().default(20),
|
||||
defaultCapMode: capModeEnum.default('SOFT'),
|
||||
softCapBuffer: z.number().int().nonnegative().default(2),
|
||||
categoryQuotasEnabled: z.boolean().default(false),
|
||||
defaultCategoryQuotas: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.optional(),
|
||||
allowJurorCapAdjustment: z.boolean().default(false),
|
||||
allowJurorRatioAdjustment: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.competition.findUniqueOrThrow({
|
||||
where: { id: input.competitionId },
|
||||
})
|
||||
|
||||
const { defaultCategoryQuotas, ...rest } = input
|
||||
|
||||
const juryGroup = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.juryGroup.create({
|
||||
data: {
|
||||
...rest,
|
||||
defaultCategoryQuotas: defaultCategoryQuotas ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'JuryGroup',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, competitionId: input.competitionId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return juryGroup
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get jury group by ID with members
|
||||
*/
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const group = await ctx.prisma.juryGroup.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!group) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Jury group not found' })
|
||||
}
|
||||
|
||||
return group
|
||||
}),
|
||||
|
||||
/**
|
||||
* List jury groups for a competition
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ competitionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.juryGroup.findMany({
|
||||
where: { competitionId: input.competitionId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: { select: { members: true, assignments: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update jury group settings
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
||||
description: z.string().optional(),
|
||||
sortOrder: z.number().int().nonnegative().optional(),
|
||||
defaultMaxAssignments: z.number().int().positive().optional(),
|
||||
defaultCapMode: capModeEnum.optional(),
|
||||
softCapBuffer: z.number().int().nonnegative().optional(),
|
||||
categoryQuotasEnabled: z.boolean().optional(),
|
||||
defaultCategoryQuotas: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
allowJurorCapAdjustment: z.boolean().optional(),
|
||||
allowJurorRatioAdjustment: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, defaultCategoryQuotas, ...rest } = input
|
||||
|
||||
return ctx.prisma.juryGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(defaultCategoryQuotas !== undefined
|
||||
? {
|
||||
defaultCategoryQuotas:
|
||||
defaultCategoryQuotas === null
|
||||
? Prisma.JsonNull
|
||||
: (defaultCategoryQuotas as Prisma.InputJsonValue),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Add a member to a jury group
|
||||
*/
|
||||
addMember: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
juryGroupId: z.string(),
|
||||
userId: z.string(),
|
||||
role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'),
|
||||
maxAssignmentsOverride: z.number().int().positive().nullable().optional(),
|
||||
capModeOverride: capModeEnum.nullable().optional(),
|
||||
categoryQuotasOverride: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
preferredStartupRatio: z.number().min(0).max(1).nullable().optional(),
|
||||
availabilityNotes: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify the user exists
|
||||
await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.userId },
|
||||
})
|
||||
|
||||
// Check if already a member
|
||||
const existing = await ctx.prisma.juryGroupMember.findUnique({
|
||||
where: {
|
||||
juryGroupId_userId: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
userId: input.userId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'User is already a member of this jury group',
|
||||
})
|
||||
}
|
||||
|
||||
const member = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.juryGroupMember.create({
|
||||
data: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined,
|
||||
capModeOverride: input.capModeOverride ?? undefined,
|
||||
categoryQuotasOverride: input.categoryQuotasOverride ?? undefined,
|
||||
preferredStartupRatio: input.preferredStartupRatio ?? undefined,
|
||||
availabilityNotes: input.availabilityNotes ?? undefined,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, role: true } },
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'JuryGroupMember',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
addedUserId: input.userId,
|
||||
role: input.role,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove a member from a jury group
|
||||
*/
|
||||
removeMember: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.juryGroupMember.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
await tx.juryGroupMember.delete({ where: { id: input.id } })
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'JuryGroupMember',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
juryGroupId: existing.juryGroupId,
|
||||
removedUserId: existing.userId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return existing
|
||||
})
|
||||
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a jury group member's role/overrides
|
||||
*/
|
||||
updateMember: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).optional(),
|
||||
maxAssignmentsOverride: z.number().int().positive().nullable().optional(),
|
||||
capModeOverride: capModeEnum.nullable().optional(),
|
||||
categoryQuotasOverride: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
preferredStartupRatio: z.number().min(0).max(1).nullable().optional(),
|
||||
availabilityNotes: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, categoryQuotasOverride, ...rest } = input
|
||||
|
||||
return ctx.prisma.juryGroupMember.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(categoryQuotasOverride !== undefined
|
||||
? {
|
||||
categoryQuotasOverride:
|
||||
categoryQuotasOverride === null
|
||||
? Prisma.JsonNull
|
||||
: (categoryQuotasOverride as Prisma.InputJsonValue),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, role: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Review self-service values set by jurors during onboarding.
|
||||
* Returns members who have self-service cap or ratio adjustments.
|
||||
*/
|
||||
reviewSelfServiceValues: adminProcedure
|
||||
.input(z.object({ juryGroupId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const group = await ctx.prisma.juryGroup.findUniqueOrThrow({
|
||||
where: { id: input.juryGroupId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
defaultMaxAssignments: true,
|
||||
allowJurorCapAdjustment: true,
|
||||
allowJurorRatioAdjustment: true,
|
||||
},
|
||||
})
|
||||
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
OR: [
|
||||
{ selfServiceCap: { not: null } },
|
||||
{ selfServiceRatio: { not: null } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
})
|
||||
|
||||
return {
|
||||
group,
|
||||
members: members.map((m) => ({
|
||||
id: m.id,
|
||||
userId: m.userId,
|
||||
userName: m.user.name,
|
||||
userEmail: m.user.email,
|
||||
role: m.role,
|
||||
adminCap: m.maxAssignmentsOverride ?? group.defaultMaxAssignments,
|
||||
selfServiceCap: m.selfServiceCap,
|
||||
selfServiceRatio: m.selfServiceRatio,
|
||||
preferredStartupRatio: m.preferredStartupRatio,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -13,23 +13,19 @@ interface LiveVotingCriterion {
|
||||
|
||||
export const liveVotingRouter = router({
|
||||
/**
|
||||
* Get or create a live voting session for a stage
|
||||
* Get or create a live voting session for a round
|
||||
*/
|
||||
getSession: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
let session = await ctx.prisma.liveVotingSession.findUnique({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
stage: {
|
||||
round: {
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -40,18 +36,14 @@ export const liveVotingRouter = router({
|
||||
if (!session) {
|
||||
session = await ctx.prisma.liveVotingSession.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
},
|
||||
include: {
|
||||
stage: {
|
||||
round: {
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -94,15 +86,11 @@ export const liveVotingRouter = router({
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
include: {
|
||||
stage: {
|
||||
round: {
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -144,7 +132,7 @@ export const liveVotingRouter = router({
|
||||
votingMode: session.votingMode,
|
||||
criteriaJson: session.criteriaJson,
|
||||
},
|
||||
stage: session.stage,
|
||||
round: session.round,
|
||||
currentProject,
|
||||
userVote,
|
||||
timeRemaining,
|
||||
@@ -160,15 +148,11 @@ export const liveVotingRouter = router({
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
include: {
|
||||
stage: {
|
||||
round: {
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -209,7 +193,7 @@ export const liveVotingRouter = router({
|
||||
currentProjectId: session.currentProjectId,
|
||||
votingEndsAt: session.votingEndsAt,
|
||||
},
|
||||
stage: session.stage,
|
||||
round: session.round,
|
||||
projects: projectsWithScores,
|
||||
}
|
||||
}),
|
||||
@@ -569,15 +553,11 @@ export const liveVotingRouter = router({
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
include: {
|
||||
stage: {
|
||||
round: {
|
||||
include: {
|
||||
track: {
|
||||
competition: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -929,16 +909,12 @@ export const liveVotingRouter = router({
|
||||
audienceVotingMode: true,
|
||||
audienceRequireId: true,
|
||||
audienceMaxFavorites: true,
|
||||
stage: {
|
||||
round: {
|
||||
select: {
|
||||
name: true,
|
||||
track: {
|
||||
competition: {
|
||||
select: {
|
||||
pipeline: {
|
||||
select: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,38 +11,31 @@ export const liveRouter = router({
|
||||
start: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectOrder: z.array(z.string()).min(1), // Ordered project IDs
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
if (stage.stageType !== 'LIVE_FINAL') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Live sessions can only be started for LIVE_FINAL stages',
|
||||
})
|
||||
}
|
||||
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
if (round.status !== 'ROUND_ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage must be ACTIVE to start a live session',
|
||||
message: 'Round must be ACTIVE to start a live session',
|
||||
})
|
||||
}
|
||||
|
||||
// Check for existing active cursor
|
||||
const existingCursor = await ctx.prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
if (existingCursor) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A live session already exists for this stage. Use jump/reorder to modify it.',
|
||||
message: 'A live session already exists for this round. Use jump/reorder to modify it.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,12 +52,12 @@ export const liveRouter = router({
|
||||
}
|
||||
|
||||
const cursor = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Store the project order in stage config
|
||||
await tx.stage.update({
|
||||
where: { id: input.stageId },
|
||||
// Store the project order in round config
|
||||
await tx.round.update({
|
||||
where: { id: input.roundId },
|
||||
data: {
|
||||
configJson: {
|
||||
...(stage.configJson as Record<string, unknown> ?? {}),
|
||||
...(round.configJson as Record<string, unknown> ?? {}),
|
||||
projectOrder: input.projectOrder,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
@@ -72,7 +65,7 @@ export const liveRouter = router({
|
||||
|
||||
const created = await tx.liveProgressCursor.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
activeProjectId: input.projectOrder[0],
|
||||
activeOrderIndex: 0,
|
||||
isPaused: false,
|
||||
@@ -83,8 +76,8 @@ export const liveRouter = router({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'LIVE_SESSION_STARTED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
sessionId: created.sessionId,
|
||||
projectCount: input.projectOrder.length,
|
||||
@@ -106,20 +99,20 @@ export const liveRouter = router({
|
||||
setActiveProject: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
// Get project order from stage config
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
// Get project order from round config
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||
const projectOrder = (config.projectOrder as string[]) ?? []
|
||||
|
||||
const index = projectOrder.indexOf(input.projectId)
|
||||
@@ -165,19 +158,19 @@ export const liveRouter = router({
|
||||
jump: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
index: z.number().int().min(0),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||
const projectOrder = (config.projectOrder as string[]) ?? []
|
||||
|
||||
if (input.index >= projectOrder.length) {
|
||||
@@ -225,26 +218,26 @@ export const liveRouter = router({
|
||||
reorder: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectOrder: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
// Update config with new order
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.stage.update({
|
||||
where: { id: input.stageId },
|
||||
await tx.round.update({
|
||||
where: { id: input.roundId },
|
||||
data: {
|
||||
configJson: {
|
||||
...(stage.configJson as Record<string, unknown> ?? {}),
|
||||
...(round.configJson as Record<string, unknown> ?? {}),
|
||||
projectOrder: input.projectOrder,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
@@ -285,10 +278,10 @@ export const liveRouter = router({
|
||||
* Pause the live session
|
||||
*/
|
||||
pause: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
if (cursor.isPaused) {
|
||||
@@ -325,10 +318,10 @@ export const liveRouter = router({
|
||||
* Resume the live session
|
||||
*/
|
||||
resume: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
if (!cursor.isPaused) {
|
||||
@@ -365,21 +358,21 @@ export const liveRouter = router({
|
||||
* Get current cursor state (for all users, including audience)
|
||||
*/
|
||||
getCursor: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get stage config for project order
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
// Get round config for project order
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||
const projectOrder = (config.projectOrder as string[]) ?? []
|
||||
|
||||
// Get current project details
|
||||
@@ -397,9 +390,9 @@ export const liveRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Get open cohorts for this stage (if any)
|
||||
// Get open cohorts for this round (if any)
|
||||
const openCohorts = await ctx.prisma.cohort.findMany({
|
||||
where: { stageId: input.stageId, isOpen: true },
|
||||
where: { roundId: input.roundId, isOpen: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -424,7 +417,7 @@ export const liveRouter = router({
|
||||
castVote: audienceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
projectId: z.string(),
|
||||
score: z.number().int().min(1).max(10),
|
||||
criterionScoresJson: z.record(z.number()).optional(),
|
||||
@@ -433,7 +426,7 @@ export const liveRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify live session exists and is not paused
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
|
||||
if (cursor.isPaused) {
|
||||
@@ -446,7 +439,7 @@ export const liveRouter = router({
|
||||
// Check if there's an open cohort containing this project
|
||||
const openCohort = await ctx.prisma.cohort.findFirst({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
isOpen: true,
|
||||
projects: { some: { projectId: input.projectId } },
|
||||
},
|
||||
@@ -463,25 +456,16 @@ export const liveRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Find the LiveVotingSession linked to this stage's round
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { programId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
// Find the LiveVotingSession linked to this round
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { competition: { select: { programId: true } } },
|
||||
})
|
||||
|
||||
// Find or check existing LiveVotingSession for this stage
|
||||
// We look for any session linked to a round in this program
|
||||
// Find or check existing LiveVotingSession for this round
|
||||
const session = await ctx.prisma.liveVotingSession.findFirst({
|
||||
where: {
|
||||
stage: {
|
||||
track: { pipeline: { programId: stage.track.pipeline.programId } },
|
||||
},
|
||||
round: { competition: { programId: round.competition.programId } },
|
||||
status: 'IN_PROGRESS',
|
||||
},
|
||||
})
|
||||
@@ -560,15 +544,12 @@ export const liveRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Get stage info
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: cursor.stageId },
|
||||
// Get round info
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: cursor.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
status: true,
|
||||
configJson: true,
|
||||
},
|
||||
@@ -592,7 +573,7 @@ export const liveRouter = router({
|
||||
|
||||
// Get open cohorts
|
||||
const openCohorts = await ctx.prisma.cohort.findMany({
|
||||
where: { stageId: cursor.stageId, isOpen: true },
|
||||
where: { roundId: cursor.roundId, isOpen: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -605,27 +586,17 @@ export const liveRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||
const projectOrder = (config.projectOrder as string[]) ?? []
|
||||
|
||||
const now = new Date()
|
||||
const isWindowOpen =
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
|
||||
(!stage.windowCloseAt || now <= stage.windowCloseAt)
|
||||
const isWindowOpen = round.status === 'ROUND_ACTIVE'
|
||||
|
||||
// Aggregate project scores from LiveVote for the scoreboard
|
||||
// Find the active LiveVotingSession for this stage's program
|
||||
const stageWithTrack = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: cursor.stageId },
|
||||
select: { track: { select: { pipeline: { select: { programId: true } } } } },
|
||||
})
|
||||
|
||||
// Find the active LiveVotingSession for this round's program
|
||||
const votingSession = await ctx.prisma.liveVotingSession.findFirst({
|
||||
where: {
|
||||
stage: {
|
||||
track: { pipeline: { programId: stageWithTrack.track.pipeline.programId } },
|
||||
},
|
||||
round: { competition: { programId: round.id } },
|
||||
status: 'IN_PROGRESS',
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -692,14 +663,13 @@ export const liveRouter = router({
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
projectScores,
|
||||
stageInfo: {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
stageType: stage.stageType,
|
||||
roundInfo: {
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
},
|
||||
windowStatus: {
|
||||
isOpen: isWindowOpen,
|
||||
closesAt: stage.windowCloseAt,
|
||||
isOpen: true,
|
||||
closesAt: null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -739,7 +709,7 @@ export const liveRouter = router({
|
||||
// Check if there's an open cohort containing this project
|
||||
const openCohort = await ctx.prisma.cohort.findFirst({
|
||||
where: {
|
||||
stageId: cursor.stageId,
|
||||
roundId: cursor.roundId,
|
||||
isOpen: true,
|
||||
projects: { some: { projectId: input.projectId } },
|
||||
},
|
||||
@@ -756,22 +726,14 @@ export const liveRouter = router({
|
||||
}
|
||||
|
||||
// Find an active LiveVotingSession
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: cursor.stageId },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { programId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: cursor.roundId },
|
||||
select: { competition: { select: { programId: true } } },
|
||||
})
|
||||
|
||||
const session = await ctx.prisma.liveVotingSession.findFirst({
|
||||
where: {
|
||||
stage: {
|
||||
track: { pipeline: { programId: stage.track.pipeline.programId } },
|
||||
},
|
||||
round: { competition: { programId: round.competition.programId } },
|
||||
status: 'IN_PROGRESS',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, mentorProcedure, adminProcedure } from '../trpc'
|
||||
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { MentorAssignmentMethod } from '@prisma/client'
|
||||
import {
|
||||
getAIMentorSuggestions,
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import {
|
||||
activateWorkspace,
|
||||
sendMessage as workspaceSendMessage,
|
||||
getMessages as workspaceGetMessages,
|
||||
markRead as workspaceMarkRead,
|
||||
uploadFile as workspaceUploadFile,
|
||||
addFileComment as workspaceAddFileComment,
|
||||
promoteFile as workspacePromoteFile,
|
||||
} from '../services/mentor-workspace'
|
||||
|
||||
export const mentorRouter = router({
|
||||
/**
|
||||
@@ -1284,4 +1293,150 @@ export const mentorRouter = router({
|
||||
|
||||
return Array.from(mentorStats.values())
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Workspace Procedures (Phase 4)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Activate a mentor workspace
|
||||
*/
|
||||
activateWorkspace: adminProcedure
|
||||
.input(z.object({ mentorAssignmentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await activateWorkspace(input.mentorAssignmentId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to activate workspace',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a message in a mentor workspace
|
||||
*/
|
||||
workspaceSendMessage: mentorProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mentorAssignmentId: z.string(),
|
||||
message: z.string().min(1).max(5000),
|
||||
role: z.enum(['MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return workspaceSendMessage(
|
||||
{
|
||||
mentorAssignmentId: input.mentorAssignmentId,
|
||||
senderId: ctx.user.id,
|
||||
message: input.message,
|
||||
role: input.role,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get workspace messages
|
||||
*/
|
||||
workspaceGetMessages: mentorProcedure
|
||||
.input(z.object({ mentorAssignmentId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return workspaceGetMessages(input.mentorAssignmentId, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark a workspace message as read
|
||||
*/
|
||||
workspaceMarkRead: mentorProcedure
|
||||
.input(z.object({ messageId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await workspaceMarkRead(input.messageId, ctx.prisma)
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Upload a file to a workspace
|
||||
*/
|
||||
workspaceUploadFile: mentorProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mentorAssignmentId: z.string(),
|
||||
fileName: z.string().min(1).max(255),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().min(0),
|
||||
bucket: z.string(),
|
||||
objectKey: z.string(),
|
||||
description: z.string().max(2000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return workspaceUploadFile(
|
||||
{
|
||||
mentorAssignmentId: input.mentorAssignmentId,
|
||||
uploadedByUserId: ctx.user.id,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
description: input.description,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Add a comment to a workspace file
|
||||
*/
|
||||
workspaceAddFileComment: mentorProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mentorFileId: z.string(),
|
||||
content: z.string().min(1).max(5000),
|
||||
parentCommentId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return workspaceAddFileComment(
|
||||
{
|
||||
mentorFileId: input.mentorFileId,
|
||||
authorId: ctx.user.id,
|
||||
content: input.content,
|
||||
parentCommentId: input.parentCommentId,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Promote a workspace file to official submission
|
||||
*/
|
||||
workspacePromoteFile: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mentorFileId: z.string(),
|
||||
roundId: z.string(),
|
||||
slotKey: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await workspacePromoteFile(
|
||||
{
|
||||
mentorFileId: input.mentorFileId,
|
||||
roundId: input.roundId,
|
||||
slotKey: input.slotKey,
|
||||
promotedById: ctx.user.id,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to promote file',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -12,9 +12,9 @@ export const messageRouter = router({
|
||||
send: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
recipientType: z.enum(['USER', 'ROLE', 'STAGE_JURY', 'PROGRAM_TEAM', 'ALL']),
|
||||
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL']),
|
||||
recipientFilter: z.any().optional(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
subject: z.string().min(1).max(500),
|
||||
body: z.string().min(1),
|
||||
deliveryChannels: z.array(z.string()).min(1),
|
||||
@@ -28,7 +28,7 @@ export const messageRouter = router({
|
||||
ctx.prisma,
|
||||
input.recipientType,
|
||||
input.recipientFilter,
|
||||
input.stageId
|
||||
input.roundId
|
||||
)
|
||||
|
||||
if (recipientUserIds.length === 0) {
|
||||
@@ -47,7 +47,7 @@ export const messageRouter = router({
|
||||
senderId: ctx.user.id,
|
||||
recipientType: input.recipientType,
|
||||
recipientFilter: input.recipientFilter ?? undefined,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
templateId: input.templateId,
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
@@ -344,7 +344,7 @@ async function resolveRecipients(
|
||||
prisma: PrismaClient,
|
||||
recipientType: string,
|
||||
recipientFilter: unknown,
|
||||
stageId?: string
|
||||
roundId?: string
|
||||
): Promise<string[]> {
|
||||
const filter = recipientFilter as Record<string, unknown> | undefined
|
||||
|
||||
@@ -369,11 +369,11 @@ async function resolveRecipients(
|
||||
return users.map((u) => u.id)
|
||||
}
|
||||
|
||||
case 'STAGE_JURY': {
|
||||
const targetStageId = stageId || (filter?.stageId as string)
|
||||
if (!targetStageId) return []
|
||||
case 'ROUND_JURY': {
|
||||
const targetRoundId = roundId || (filter?.roundId as string)
|
||||
if (!targetRoundId) return []
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { stageId: targetStageId },
|
||||
where: { roundId: targetRoundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,17 +26,13 @@ export const programRouter = router({
|
||||
orderBy: { year: 'desc' },
|
||||
include: includeStages
|
||||
? {
|
||||
pipelines: {
|
||||
competitions: {
|
||||
include: {
|
||||
tracks: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, projectStageStates: true },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { assignments: true, projectRoundStates: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -46,42 +42,33 @@ export const programRouter = router({
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Flatten stages into a rounds-compatible shape for backward compatibility
|
||||
return programs.map((p) => ({
|
||||
...p,
|
||||
// Provide a flat `stages` array for convenience
|
||||
stages: (p as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
...stage,
|
||||
pipelineName: pipeline.name,
|
||||
trackName: track.name,
|
||||
// Backward-compatible _count shape
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || [],
|
||||
// Legacy alias
|
||||
rounds: (p as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
status: stage.status === 'STAGE_ACTIVE' ? 'ACTIVE'
|
||||
: stage.status === 'STAGE_CLOSED' ? 'CLOSED'
|
||||
: stage.status,
|
||||
votingEndAt: stage.windowCloseAt,
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || [],
|
||||
}))
|
||||
// Return programs with rounds flattened
|
||||
return programs.map((p) => {
|
||||
const allRounds = (p as any).competitions?.flatMap((c: any) => c.rounds || []) || []
|
||||
return {
|
||||
...p,
|
||||
// Provide `stages` as alias for backward compatibility
|
||||
stages: allRounds.map((round: any) => ({
|
||||
...round,
|
||||
// Backward-compatible _count shape
|
||||
_count: {
|
||||
projects: round._count?.projectRoundStates || 0,
|
||||
assignments: round._count?.assignments || 0,
|
||||
},
|
||||
})),
|
||||
// Main rounds array
|
||||
rounds: allRounds.map((round: any) => ({
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
status: round.status,
|
||||
votingEndAt: round.windowCloseAt,
|
||||
_count: {
|
||||
projects: round._count?.projectRoundStates || 0,
|
||||
assignments: round._count?.assignments || 0,
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -93,17 +80,13 @@ export const programRouter = router({
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
pipelines: {
|
||||
competitions: {
|
||||
include: {
|
||||
tracks: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, projectStageStates: true },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { assignments: true, projectRoundStates: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -112,32 +95,21 @@ export const programRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Flatten stages for convenience
|
||||
const stages = (program as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
...stage,
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || []
|
||||
// Flatten rounds from all competitions
|
||||
const allRounds = (program as any).competitions?.flatMap((c: any) => c.rounds || []) || []
|
||||
const rounds = allRounds.map((round: any) => ({
|
||||
...round,
|
||||
_count: {
|
||||
projects: round._count?.projectRoundStates || 0,
|
||||
assignments: round._count?.assignments || 0,
|
||||
},
|
||||
})) || []
|
||||
|
||||
return {
|
||||
...program,
|
||||
stages,
|
||||
// Legacy alias
|
||||
rounds: stages.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status === 'STAGE_ACTIVE' ? 'ACTIVE'
|
||||
: s.status === 'STAGE_CLOSED' ? 'CLOSED'
|
||||
: s.status,
|
||||
votingEndAt: s.windowCloseAt,
|
||||
_count: s._count,
|
||||
})),
|
||||
// stages as alias for backward compatibility
|
||||
stages: rounds,
|
||||
rounds,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { logAudit } from '../utils/audit'
|
||||
export const projectPoolRouter = router({
|
||||
/**
|
||||
* List unassigned projects with filtering and pagination
|
||||
* Projects not assigned to any stage
|
||||
* Projects not assigned to any round
|
||||
*/
|
||||
listUnassigned: adminProcedure
|
||||
.input(
|
||||
@@ -33,7 +33,7 @@ export const projectPoolRouter = router({
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
stageStates: { none: {} }, // Only unassigned projects (not in any stage)
|
||||
projectRoundStates: { none: {} }, // Only unassigned projects (not in any round)
|
||||
}
|
||||
|
||||
// Filter by competition category
|
||||
@@ -92,27 +92,27 @@ export const projectPoolRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk assign projects to a stage
|
||||
* Bulk assign projects to a round
|
||||
*
|
||||
* Validates that:
|
||||
* - All projects exist
|
||||
* - Stage exists
|
||||
* - Round exists
|
||||
*
|
||||
* Creates:
|
||||
* - ProjectStageState entries for each project
|
||||
* - RoundAssignment entries for each project
|
||||
* - Project.status updated to 'ASSIGNED'
|
||||
* - ProjectStatusHistory records for each project
|
||||
* - Audit log
|
||||
*/
|
||||
assignToStage: adminProcedure
|
||||
assignToRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { projectIds, stageId } = input
|
||||
const { projectIds, roundId } = input
|
||||
|
||||
// Step 1: Fetch all projects to validate
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
@@ -136,24 +136,22 @@ export const projectPoolRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Verify stage exists and get its trackId
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId },
|
||||
select: { id: true, trackId: true },
|
||||
// Verify round exists
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// Step 2: Perform bulk assignment in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create ProjectStageState entries for each project (skip existing)
|
||||
const stageStateData = projectIds.map((projectId) => ({
|
||||
// Create ProjectRoundState entries for each project (skip existing)
|
||||
const assignmentData = projectIds.map((projectId) => ({
|
||||
projectId,
|
||||
stageId,
|
||||
trackId: stage.trackId,
|
||||
state: 'PENDING' as const,
|
||||
roundId,
|
||||
}))
|
||||
|
||||
await tx.projectStageState.createMany({
|
||||
data: stageStateData,
|
||||
await tx.projectRoundState.createMany({
|
||||
data: assignmentData,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
@@ -180,10 +178,10 @@ export const projectPoolRouter = router({
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user?.id,
|
||||
action: 'BULK_ASSIGN_TO_STAGE',
|
||||
action: 'BULK_ASSIGN_TO_ROUND',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
stageId,
|
||||
roundId,
|
||||
projectCount: projectIds.length,
|
||||
projectIds,
|
||||
},
|
||||
@@ -197,7 +195,7 @@ export const projectPoolRouter = router({
|
||||
return {
|
||||
success: true,
|
||||
assignedCount: result.count,
|
||||
stageId,
|
||||
roundId,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ export const projectRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
'SUBMITTED',
|
||||
@@ -55,8 +55,8 @@ export const projectRouter = router({
|
||||
'REJECTED',
|
||||
])
|
||||
).optional(),
|
||||
excludeInStageId: z.string().optional(), // Exclude projects already in this stage
|
||||
unassignedOnly: z.boolean().optional(), // Projects not in any stage
|
||||
excludeInRoundId: z.string().optional(), // Exclude projects already in this round
|
||||
unassignedOnly: z.boolean().optional(), // Projects not in any round
|
||||
search: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
@@ -76,7 +76,7 @@ export const projectRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
programId, stageId, excludeInStageId, status, statuses, unassignedOnly, search, tags,
|
||||
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
|
||||
competitionCategory, oceanIssue, country,
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
page, perPage,
|
||||
@@ -89,19 +89,19 @@ export const projectRouter = router({
|
||||
// Filter by program
|
||||
if (programId) where.programId = programId
|
||||
|
||||
// Filter by stage (via ProjectStageState join)
|
||||
if (stageId) {
|
||||
where.stageStates = { some: { stageId } }
|
||||
// Filter by round (via RoundAssignment join)
|
||||
if (roundId) {
|
||||
where.roundAssignments = { some: { roundId } }
|
||||
}
|
||||
|
||||
// Exclude projects already in a specific stage
|
||||
if (excludeInStageId) {
|
||||
where.stageStates = { none: { stageId: excludeInStageId } }
|
||||
// Exclude projects already in a specific round
|
||||
if (excludeInRoundId) {
|
||||
where.roundAssignments = { none: { roundId: excludeInRoundId } }
|
||||
}
|
||||
|
||||
// Filter by unassigned (not in any stage)
|
||||
// Filter by unassigned (not in any round)
|
||||
if (unassignedOnly) {
|
||||
where.stageStates = { none: {} }
|
||||
where.roundAssignments = { none: {} }
|
||||
}
|
||||
|
||||
// Status filter
|
||||
@@ -171,8 +171,8 @@ export const projectRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
excludeInStageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
excludeInRoundId: z.string().optional(),
|
||||
unassignedOnly: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
statuses: z.array(
|
||||
@@ -201,7 +201,7 @@ export const projectRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
programId, stageId, excludeInStageId, unassignedOnly,
|
||||
programId, roundId, excludeInRoundId, unassignedOnly,
|
||||
search, statuses, tags,
|
||||
competitionCategory, oceanIssue, country,
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
@@ -210,14 +210,14 @@ export const projectRouter = router({
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (programId) where.programId = programId
|
||||
if (stageId) {
|
||||
where.stageStates = { some: { stageId } }
|
||||
if (roundId) {
|
||||
where.roundAssignments = { some: { roundId } }
|
||||
}
|
||||
if (excludeInStageId) {
|
||||
where.stageStates = { none: { stageId: excludeInStageId } }
|
||||
if (excludeInRoundId) {
|
||||
where.roundAssignments = { none: { roundId: excludeInRoundId } }
|
||||
}
|
||||
if (unassignedOnly) {
|
||||
where.stageStates = { none: {} }
|
||||
where.roundAssignments = { none: {} }
|
||||
}
|
||||
if (statuses?.length) where.status = { in: statuses }
|
||||
if (tags && tags.length > 0) where.tags = { hasSome: tags }
|
||||
@@ -1072,7 +1072,7 @@ export const projectRouter = router({
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
stageStates: { none: {} }, // Projects not assigned to any stage
|
||||
roundAssignments: { none: {} }, // Projects not assigned to any round
|
||||
}
|
||||
|
||||
if (search) {
|
||||
|
||||
100
src/server/routers/resultLock.ts
Normal file
100
src/server/routers/resultLock.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, superAdminProcedure, protectedProcedure } from '../trpc'
|
||||
import {
|
||||
lockResults,
|
||||
unlockResults,
|
||||
isLocked,
|
||||
getLockHistory,
|
||||
} from '../services/result-lock'
|
||||
|
||||
const categoryEnum = z.enum([
|
||||
'STARTUP',
|
||||
'BUSINESS_CONCEPT',
|
||||
])
|
||||
|
||||
export const resultLockRouter = router({
|
||||
/**
|
||||
* Lock results for a competition/round/category (admin)
|
||||
*/
|
||||
lock: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
roundId: z.string(),
|
||||
category: categoryEnum,
|
||||
resultSnapshot: z.unknown(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await lockResults(
|
||||
{
|
||||
competitionId: input.competitionId,
|
||||
roundId: input.roundId,
|
||||
category: input.category,
|
||||
lockedById: ctx.user.id,
|
||||
resultSnapshot: input.resultSnapshot,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to lock results',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Unlock results (super-admin only)
|
||||
*/
|
||||
unlock: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resultLockId: z.string(),
|
||||
reason: z.string().min(1).max(2000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await unlockResults(
|
||||
{
|
||||
resultLockId: input.resultLockId,
|
||||
unlockedById: ctx.user.id,
|
||||
reason: input.reason,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to unlock results',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if results are locked
|
||||
*/
|
||||
isLocked: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
roundId: z.string(),
|
||||
category: categoryEnum,
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return isLocked(input.competitionId, input.roundId, input.category, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get lock history for a competition
|
||||
*/
|
||||
history: adminProcedure
|
||||
.input(z.object({ competitionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getLockHistory(input.competitionId, ctx.prisma)
|
||||
}),
|
||||
})
|
||||
457
src/server/routers/round.ts
Normal file
457
src/server/routers/round.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||
import {
|
||||
openWindow,
|
||||
closeWindow,
|
||||
lockWindow,
|
||||
checkDeadlinePolicy,
|
||||
validateSubmission,
|
||||
getVisibleWindows,
|
||||
} from '../services/submission-manager'
|
||||
|
||||
const roundTypeEnum = z.enum([
|
||||
'INTAKE',
|
||||
'FILTERING',
|
||||
'EVALUATION',
|
||||
'SUBMISSION',
|
||||
'MENTORING',
|
||||
'LIVE_FINAL',
|
||||
'DELIBERATION',
|
||||
])
|
||||
|
||||
export const roundRouter = router({
|
||||
/**
|
||||
* Create a new round within a competition
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
roundType: roundTypeEnum,
|
||||
sortOrder: z.number().int().nonnegative(),
|
||||
configJson: z.record(z.unknown()).optional(),
|
||||
windowOpenAt: z.date().nullable().optional(),
|
||||
windowCloseAt: z.date().nullable().optional(),
|
||||
juryGroupId: z.string().nullable().optional(),
|
||||
submissionWindowId: z.string().nullable().optional(),
|
||||
purposeKey: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify competition exists
|
||||
await ctx.prisma.competition.findUniqueOrThrow({
|
||||
where: { id: input.competitionId },
|
||||
})
|
||||
|
||||
// Validate configJson against the Zod schema for this roundType
|
||||
const config = input.configJson
|
||||
? validateRoundConfig(input.roundType, input.configJson)
|
||||
: defaultRoundConfig(input.roundType)
|
||||
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.round.create({
|
||||
data: {
|
||||
competitionId: input.competitionId,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
roundType: input.roundType,
|
||||
sortOrder: input.sortOrder,
|
||||
configJson: config as unknown as Prisma.InputJsonValue,
|
||||
windowOpenAt: input.windowOpenAt ?? undefined,
|
||||
windowCloseAt: input.windowCloseAt ?? undefined,
|
||||
juryGroupId: input.juryGroupId ?? undefined,
|
||||
submissionWindowId: input.submissionWindowId ?? undefined,
|
||||
purposeKey: input.purposeKey ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Round',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
name: input.name,
|
||||
roundType: input.roundType,
|
||||
competitionId: input.competitionId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get round by ID with all relations
|
||||
*/
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
juryGroup: {
|
||||
include: { members: true },
|
||||
},
|
||||
submissionWindow: {
|
||||
include: { fileRequirements: true },
|
||||
},
|
||||
advancementRules: { orderBy: { sortOrder: 'asc' } },
|
||||
visibleSubmissionWindows: {
|
||||
include: { submissionWindow: true },
|
||||
},
|
||||
_count: {
|
||||
select: { projectRoundStates: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||||
}
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update round settings/config
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
||||
status: z.enum(['ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED']).optional(),
|
||||
configJson: z.record(z.unknown()).optional(),
|
||||
windowOpenAt: z.date().nullable().optional(),
|
||||
windowCloseAt: z.date().nullable().optional(),
|
||||
juryGroupId: z.string().nullable().optional(),
|
||||
submissionWindowId: z.string().nullable().optional(),
|
||||
purposeKey: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, configJson, ...data } = input
|
||||
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.round.findUniqueOrThrow({ where: { id } })
|
||||
|
||||
// If configJson provided, validate it against the round type
|
||||
let validatedConfig: Prisma.InputJsonValue | undefined
|
||||
if (configJson) {
|
||||
const parsed = validateRoundConfig(existing.roundType, configJson)
|
||||
validatedConfig = parsed as unknown as Prisma.InputJsonValue
|
||||
}
|
||||
|
||||
const updated = await tx.round.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Round',
|
||||
entityId: id,
|
||||
detailsJson: {
|
||||
changes: input,
|
||||
previous: {
|
||||
name: existing.name,
|
||||
status: existing.status,
|
||||
},
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder rounds within a competition
|
||||
*/
|
||||
updateOrder: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
roundIds: z.array(z.string()),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.$transaction(
|
||||
input.roundIds.map((roundId, index) =>
|
||||
ctx.prisma.round.update({
|
||||
where: { id: roundId },
|
||||
data: { sortOrder: index },
|
||||
})
|
||||
)
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a round
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.round.findUniqueOrThrow({ where: { id: input.id } })
|
||||
|
||||
await tx.round.delete({ where: { id: input.id } })
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Round',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
name: existing.name,
|
||||
roundType: existing.roundType,
|
||||
competitionId: existing.competitionId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return existing
|
||||
})
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Submission Window Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a submission window for a round
|
||||
*/
|
||||
createSubmissionWindow: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
competitionId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
roundNumber: z.number().int().min(1),
|
||||
windowOpenAt: z.date().optional(),
|
||||
windowCloseAt: z.date().optional(),
|
||||
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).default('HARD_DEADLINE'),
|
||||
graceHours: z.number().int().min(0).optional(),
|
||||
lockOnClose: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const window = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.submissionWindow.create({
|
||||
data: {
|
||||
competitionId: input.competitionId,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
roundNumber: input.roundNumber,
|
||||
windowOpenAt: input.windowOpenAt,
|
||||
windowCloseAt: input.windowCloseAt,
|
||||
deadlinePolicy: input.deadlinePolicy,
|
||||
graceHours: input.graceHours,
|
||||
lockOnClose: input.lockOnClose,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, competitionId: input.competitionId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return window
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open a submission window
|
||||
*/
|
||||
openSubmissionWindow: adminProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await openWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to open window',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close a submission window
|
||||
*/
|
||||
closeSubmissionWindow: adminProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await closeWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to close window',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Lock a submission window
|
||||
*/
|
||||
lockSubmissionWindow: adminProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await lockWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to lock window',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check deadline status of a window
|
||||
*/
|
||||
checkDeadline: protectedProcedure
|
||||
.input(z.object({ windowId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return checkDeadlinePolicy(input.windowId, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Validate files against window requirements
|
||||
*/
|
||||
validateSubmission: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
windowId: z.string(),
|
||||
files: z.array(
|
||||
z.object({
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
requirementId: z.string().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return validateSubmission(input.projectId, input.windowId, input.files, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get visible submission windows for a round
|
||||
*/
|
||||
getVisibleWindows: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getVisibleWindows(input.roundId, ctx.prisma)
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// File Requirements Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a file requirement for a submission window
|
||||
*/
|
||||
createFileRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
submissionWindowId: z.string(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
label: z.string().min(1).max(255),
|
||||
description: z.string().max(2000).optional(),
|
||||
mimeTypes: z.array(z.string()).default([]),
|
||||
maxSizeMb: z.number().int().min(0).optional(),
|
||||
required: z.boolean().default(false),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.submissionFileRequirement.create({
|
||||
data: input,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a file requirement
|
||||
*/
|
||||
updateFileRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
mimeTypes: z.array(z.string()).optional(),
|
||||
maxSizeMb: z.number().min(0).optional().nullable(),
|
||||
required: z.boolean().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
return ctx.prisma.submissionFileRequirement.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a file requirement
|
||||
*/
|
||||
deleteFileRequirement: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.submissionFileRequirement.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get submission windows for applicants in a competition
|
||||
*/
|
||||
getApplicantWindows: protectedProcedure
|
||||
.input(z.object({ competitionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.submissionWindow.findMany({
|
||||
where: { competitionId: input.competitionId },
|
||||
include: {
|
||||
fileRequirements: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}),
|
||||
})
|
||||
117
src/server/routers/roundAssignment.ts
Normal file
117
src/server/routers/roundAssignment.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure, juryProcedure } from '../trpc'
|
||||
import {
|
||||
previewRoundAssignment,
|
||||
executeRoundAssignment,
|
||||
getRoundCoverageReport,
|
||||
getUnassignedQueue,
|
||||
} from '../services/round-assignment'
|
||||
|
||||
export const roundAssignmentRouter = router({
|
||||
/**
|
||||
* Preview round assignments without committing
|
||||
*/
|
||||
preview: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
honorIntents: z.boolean().default(true),
|
||||
requiredReviews: z.number().int().min(1).max(20).default(3),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return previewRoundAssignment(
|
||||
input.roundId,
|
||||
{
|
||||
honorIntents: input.honorIntents,
|
||||
requiredReviews: input.requiredReviews,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Execute round assignments (create Assignment records)
|
||||
*/
|
||||
execute: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await executeRoundAssignment(
|
||||
input.roundId,
|
||||
input.assignments,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
|
||||
if (result.errors.length > 0 && result.created === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: result.errors.join('; '),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get coverage report for a round
|
||||
*/
|
||||
coverageReport: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
requiredReviews: z.number().int().min(1).max(20).default(3),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getRoundCoverageReport(input.roundId, input.requiredReviews, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get projects below required reviews threshold
|
||||
*/
|
||||
unassignedQueue: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
requiredReviews: z.number().int().min(1).max(20).default(3),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getUnassignedQueue(input.roundId, input.requiredReviews, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get assignments for the current jury member in a specific round
|
||||
*/
|
||||
getMyAssignments: juryProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, competitionCategory: true },
|
||||
},
|
||||
evaluation: {
|
||||
select: { id: true, status: true, globalScore: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
}),
|
||||
})
|
||||
143
src/server/routers/roundEngine.ts
Normal file
143
src/server/routers/roundEngine.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import {
|
||||
activateRound,
|
||||
closeRound,
|
||||
archiveRound,
|
||||
transitionProject,
|
||||
batchTransitionProjects,
|
||||
getProjectRoundStates,
|
||||
getProjectRoundState,
|
||||
} from '../services/round-engine'
|
||||
|
||||
const projectRoundStateEnum = z.enum([
|
||||
'PENDING',
|
||||
'IN_PROGRESS',
|
||||
'PASSED',
|
||||
'REJECTED',
|
||||
'COMPLETED',
|
||||
'WITHDRAWN',
|
||||
])
|
||||
|
||||
export const roundEngineRouter = router({
|
||||
/**
|
||||
* Activate a round: ROUND_DRAFT → ROUND_ACTIVE
|
||||
*/
|
||||
activate: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await activateRound(input.roundId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to activate round',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close a round: ROUND_ACTIVE → ROUND_CLOSED
|
||||
*/
|
||||
close: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await closeRound(input.roundId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to close round',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Archive a round: ROUND_CLOSED → ROUND_ARCHIVED
|
||||
*/
|
||||
archive: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await archiveRound(input.roundId, ctx.user.id, ctx.prisma)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to archive round',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Transition a single project within a round
|
||||
*/
|
||||
transitionProject: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
newState: projectRoundStateEnum,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await transitionProject(
|
||||
input.projectId,
|
||||
input.roundId,
|
||||
input.newState,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: result.errors?.join('; ') ?? 'Failed to transition project',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch transition multiple projects within a round
|
||||
*/
|
||||
batchTransition: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(1),
|
||||
roundId: z.string(),
|
||||
newState: projectRoundStateEnum,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return batchTransitionProjects(
|
||||
input.projectIds,
|
||||
input.roundId,
|
||||
input.newState,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all project round states for a round
|
||||
*/
|
||||
getProjectStates: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getProjectRoundStates(input.roundId, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single project's state within a round
|
||||
*/
|
||||
getProjectState: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getProjectRoundState(input.projectId, input.roundId, ctx.prisma)
|
||||
}),
|
||||
})
|
||||
@@ -1,966 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
|
||||
|
||||
// Valid stage status transitions
|
||||
const VALID_STAGE_TRANSITIONS: Record<string, string[]> = {
|
||||
STAGE_DRAFT: ['STAGE_ACTIVE'],
|
||||
STAGE_ACTIVE: ['STAGE_CLOSED'],
|
||||
STAGE_CLOSED: ['STAGE_ARCHIVED', 'STAGE_ACTIVE'], // Can reopen
|
||||
STAGE_ARCHIVED: [],
|
||||
}
|
||||
|
||||
export const stageRouter = router({
|
||||
/**
|
||||
* Create a new stage within a track
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trackId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
stageType: z.enum(['INTAKE', 'FILTER', 'EVALUATION', 'SELECTION', 'LIVE_FINAL', 'RESULTS']),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
configJson: z.record(z.unknown()).optional(),
|
||||
windowOpenAt: z.date().optional(),
|
||||
windowCloseAt: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify track exists
|
||||
const track = await ctx.prisma.track.findUniqueOrThrow({
|
||||
where: { id: input.trackId },
|
||||
})
|
||||
|
||||
// Validate window dates
|
||||
if (input.windowOpenAt && input.windowCloseAt) {
|
||||
if (input.windowCloseAt <= input.windowOpenAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Window close date must be after open date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-set sortOrder if not provided
|
||||
let sortOrder = input.sortOrder
|
||||
if (sortOrder === undefined) {
|
||||
const maxOrder = await ctx.prisma.stage.aggregate({
|
||||
where: { trackId: input.trackId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
sortOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
||||
}
|
||||
|
||||
// Check slug uniqueness within track
|
||||
const existingSlug = await ctx.prisma.stage.findUnique({
|
||||
where: { trackId_slug: { trackId: input.trackId, slug: input.slug } },
|
||||
})
|
||||
if (existingSlug) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `A stage with slug "${input.slug}" already exists in this track`,
|
||||
})
|
||||
}
|
||||
|
||||
const { configJson, sortOrder: _so, ...rest } = input
|
||||
let parsedConfigJson: Prisma.InputJsonValue | undefined
|
||||
|
||||
if (configJson !== undefined) {
|
||||
try {
|
||||
const { config } = parseAndValidateStageConfig(
|
||||
input.stageType,
|
||||
configJson,
|
||||
{ strictUnknownKeys: true }
|
||||
)
|
||||
parsedConfigJson = config as Prisma.InputJsonValue
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Invalid stage configuration payload',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stage = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.stage.create({
|
||||
data: {
|
||||
...rest,
|
||||
sortOrder,
|
||||
configJson: parsedConfigJson,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Stage',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
trackId: track.id,
|
||||
name: input.name,
|
||||
stageType: input.stageType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return stage
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update stage configuration
|
||||
*/
|
||||
updateConfig: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
configJson: z.record(z.unknown()).optional(),
|
||||
windowOpenAt: z.date().optional().nullable(),
|
||||
windowCloseAt: z.date().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, configJson, ...data } = input
|
||||
const existing = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: {
|
||||
stageType: true,
|
||||
},
|
||||
})
|
||||
let parsedConfigJson: Prisma.InputJsonValue | undefined
|
||||
|
||||
// Validate window dates if both provided
|
||||
if (data.windowOpenAt && data.windowCloseAt) {
|
||||
if (data.windowCloseAt <= data.windowOpenAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Window close date must be after open date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (configJson !== undefined) {
|
||||
try {
|
||||
const { config } = parseAndValidateStageConfig(
|
||||
existing.stageType,
|
||||
configJson,
|
||||
{ strictUnknownKeys: true }
|
||||
)
|
||||
parsedConfigJson = config as Prisma.InputJsonValue
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Invalid stage configuration payload',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stage = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.stage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
configJson: parsedConfigJson,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Stage',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, configJson } as Record<string, unknown>,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return stage
|
||||
}),
|
||||
|
||||
/**
|
||||
* List stages for a track
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ trackId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.stage.findMany({
|
||||
where: { trackId: input.trackId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
projectStageStates: true,
|
||||
cohorts: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single stage with details
|
||||
*/
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { id: true, name: true, programId: true } },
|
||||
},
|
||||
},
|
||||
cohorts: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { _count: { select: { projects: true } } },
|
||||
},
|
||||
transitionsFrom: {
|
||||
include: { toStage: { select: { id: true, name: true } } },
|
||||
},
|
||||
transitionsTo: {
|
||||
include: { fromStage: { select: { id: true, name: true } } },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
projectStageStates: true,
|
||||
assignments: true,
|
||||
evaluationForms: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get state distribution for this stage
|
||||
const stateDistribution = await ctx.prisma.projectStageState.groupBy({
|
||||
by: ['state'],
|
||||
where: { stageId: input.id },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
return {
|
||||
...stage,
|
||||
stateDistribution: stateDistribution.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.state] = curr._count
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* List transitions for a track
|
||||
*/
|
||||
listTransitions: protectedProcedure
|
||||
.input(z.object({ trackId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.stageTransition.findMany({
|
||||
where: {
|
||||
fromStage: {
|
||||
trackId: input.trackId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fromStage: {
|
||||
select: { id: true, name: true, slug: true, trackId: true },
|
||||
},
|
||||
toStage: {
|
||||
select: { id: true, name: true, slug: true, trackId: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ fromStage: { sortOrder: 'asc' } }, { toStage: { sortOrder: 'asc' } }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a transition between stages
|
||||
*/
|
||||
createTransition: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
fromStageId: z.string(),
|
||||
toStageId: z.string(),
|
||||
isDefault: z.boolean().optional(),
|
||||
guardJson: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.fromStageId === input.toStageId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'fromStageId and toStageId must be different',
|
||||
})
|
||||
}
|
||||
|
||||
const [fromStage, toStage] = await Promise.all([
|
||||
ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.fromStageId },
|
||||
select: { id: true, name: true, trackId: true },
|
||||
}),
|
||||
ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.toStageId },
|
||||
select: { id: true, name: true, trackId: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (fromStage.trackId !== toStage.trackId) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Transitions can only connect stages within the same track',
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await ctx.prisma.stageTransition.findUnique({
|
||||
where: {
|
||||
fromStageId_toStageId: {
|
||||
fromStageId: input.fromStageId,
|
||||
toStageId: input.toStageId,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Transition already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const transition = await ctx.prisma.$transaction(async (tx) => {
|
||||
if (input.isDefault) {
|
||||
await tx.stageTransition.updateMany({
|
||||
where: { fromStageId: input.fromStageId },
|
||||
data: { isDefault: false },
|
||||
})
|
||||
}
|
||||
|
||||
const created = await tx.stageTransition.create({
|
||||
data: {
|
||||
fromStageId: input.fromStageId,
|
||||
toStageId: input.toStageId,
|
||||
isDefault: input.isDefault ?? false,
|
||||
guardJson:
|
||||
input.guardJson === undefined
|
||||
? undefined
|
||||
: (input.guardJson as Prisma.InputJsonValue),
|
||||
},
|
||||
include: {
|
||||
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
|
||||
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'StageTransition',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
fromStageId: input.fromStageId,
|
||||
toStageId: input.toStageId,
|
||||
isDefault: created.isDefault,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return transition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update transition properties
|
||||
*/
|
||||
updateTransition: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
isDefault: z.boolean().optional(),
|
||||
guardJson: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
id: true,
|
||||
fromStageId: true,
|
||||
toStageId: true,
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
if (input.isDefault) {
|
||||
await tx.stageTransition.updateMany({
|
||||
where: { fromStageId: transition.fromStageId },
|
||||
data: { isDefault: false },
|
||||
})
|
||||
}
|
||||
|
||||
const next = await tx.stageTransition.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
|
||||
...(input.guardJson !== undefined
|
||||
? {
|
||||
guardJson:
|
||||
input.guardJson === null
|
||||
? Prisma.JsonNull
|
||||
: (input.guardJson as Prisma.InputJsonValue),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
|
||||
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'StageTransition',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
isDefault: input.isDefault,
|
||||
guardUpdated: input.guardJson !== undefined,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a transition
|
||||
*/
|
||||
deleteTransition: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
id: true,
|
||||
fromStageId: true,
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
|
||||
const fromTransitionCount = await ctx.prisma.stageTransition.count({
|
||||
where: { fromStageId: transition.fromStageId },
|
||||
})
|
||||
|
||||
if (fromTransitionCount <= 1) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Cannot delete the last transition from a stage',
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.stageTransition.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (transition.isDefault) {
|
||||
const replacement = await tx.stageTransition.findFirst({
|
||||
where: { fromStageId: transition.fromStageId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (replacement) {
|
||||
await tx.stageTransition.update({
|
||||
where: { id: replacement.id },
|
||||
data: { isDefault: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'StageTransition',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
fromStageId: transition.fromStageId,
|
||||
wasDefault: transition.isDefault,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Transition a stage status (state machine)
|
||||
*/
|
||||
transition: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
targetStatus: z.enum(['STAGE_DRAFT', 'STAGE_ACTIVE', 'STAGE_CLOSED', 'STAGE_ARCHIVED']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Validate transition
|
||||
const allowed = VALID_STAGE_TRANSITIONS[stage.status] ?? []
|
||||
if (!allowed.includes(input.targetStatus)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: `Cannot transition stage from ${stage.status} to ${input.targetStatus}. Allowed: ${allowed.join(', ') || 'none'}`,
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.stage.update({
|
||||
where: { id: input.id },
|
||||
data: { status: input.targetStatus },
|
||||
})
|
||||
|
||||
// Record the transition in DecisionAuditLog
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'stage.transitioned',
|
||||
entityType: 'Stage',
|
||||
entityId: input.id,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
fromStatus: stage.status,
|
||||
toStatus: input.targetStatus,
|
||||
stageName: stage.name,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'STAGE_TRANSITION',
|
||||
entityType: 'Stage',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
fromStatus: stage.status,
|
||||
toStatus: input.targetStatus,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open the voting/evaluation window for a stage
|
||||
*/
|
||||
openWindow: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
windowCloseAt: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage must be ACTIVE to open the window',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.stage.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
windowOpenAt: now,
|
||||
windowCloseAt: input.windowCloseAt ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'STAGE_WINDOW_OPENED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
openedAt: now.toISOString(),
|
||||
closesAt: input.windowCloseAt?.toISOString() ?? null,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close the voting/evaluation window for a stage
|
||||
*/
|
||||
closeWindow: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!stage.windowOpenAt) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage window is not open',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.stage.update({
|
||||
where: { id: input.id },
|
||||
data: { windowCloseAt: now },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'STAGE_WINDOW_CLOSED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
closedAt: now.toISOString(),
|
||||
wasOpenSince: stage.windowOpenAt?.toISOString(),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get project states within a stage (paginated)
|
||||
*/
|
||||
getProjectStates: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
state: z.enum(['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'ROUTED', 'COMPLETED', 'WITHDRAWN']).optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Prisma.ProjectStageStateWhereInput = {
|
||||
stageId: input.stageId,
|
||||
}
|
||||
if (input.state) {
|
||||
where.state = input.state
|
||||
}
|
||||
|
||||
const items = await ctx.prisma.projectStageState.findMany({
|
||||
where,
|
||||
take: input.limit + 1,
|
||||
cursor: input.cursor ? { id: input.cursor } : undefined,
|
||||
orderBy: { enteredAt: 'desc' },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
competitionCategory: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (items.length > input.limit) {
|
||||
const next = items.pop()
|
||||
nextCursor = next?.id
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Phase 4: Participant-facing procedures
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get stage details for jury members with window status and assignment stats
|
||||
*/
|
||||
getForJury: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { id: true, name: true, programId: true } },
|
||||
},
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
select: { id: true, criteriaJson: true, scalesJson: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const isWindowOpen =
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
|
||||
(!stage.windowCloseAt || now <= stage.windowCloseAt)
|
||||
|
||||
const windowTimeRemaining =
|
||||
stage.windowCloseAt && isWindowOpen
|
||||
? Math.max(0, stage.windowCloseAt.getTime() - now.getTime())
|
||||
: null
|
||||
|
||||
// Count user's assignments in this stage
|
||||
const [myAssignmentCount, myCompletedCount] = await Promise.all([
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stageId: input.id, userId: ctx.user.id },
|
||||
}),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stageId: input.id, userId: ctx.user.id, isCompleted: true },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
...stage,
|
||||
isWindowOpen,
|
||||
windowTimeRemaining,
|
||||
myAssignmentCount,
|
||||
myCompletedCount,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the timeline of stages a project has traversed in a pipeline
|
||||
*/
|
||||
getApplicantTimeline: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
pipelineId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verify the user owns this project, is an admin, or has an assignment
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
if (!isAdmin) {
|
||||
const hasAccess = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ assignments: { some: { userId: ctx.user.id } } },
|
||||
{ mentorAssignment: { mentorId: ctx.user.id } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (!hasAccess) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get all project stage states for this project in the pipeline
|
||||
const states = await ctx.prisma.projectStageState.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
track: { pipelineId: input.pipelineId },
|
||||
},
|
||||
include: {
|
||||
stage: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
track: {
|
||||
select: {
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ track: { sortOrder: 'asc' } }, { stage: { sortOrder: 'asc' } }],
|
||||
})
|
||||
|
||||
// Determine current stage (latest non-exited)
|
||||
const currentState = states.find((s) => !s.exitedAt)
|
||||
|
||||
return states.map((s) => ({
|
||||
stageId: s.stage.id,
|
||||
stageName: s.stage.name,
|
||||
stageType: s.stage.stageType,
|
||||
state: s.state,
|
||||
enteredAt: s.enteredAt,
|
||||
exitedAt: s.exitedAt,
|
||||
isCurrent: currentState?.id === s.id,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get file requirements and upload status for a stage
|
||||
*/
|
||||
getRequirements: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verify the user owns this project, is an admin, or has an assignment
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
if (!isAdmin) {
|
||||
const hasAccess = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ assignments: { some: { userId: ctx.user.id } } },
|
||||
{ mentorAssignment: { mentorId: ctx.user.id } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (!hasAccess) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
status: true,
|
||||
configJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Get file requirements for this stage
|
||||
const fileRequirements = await ctx.prisma.fileRequirement.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
// Get uploaded files for this project
|
||||
const uploadedFiles = await ctx.prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
requirement: { stageId: input.stageId },
|
||||
},
|
||||
include: {
|
||||
requirement: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Compute window status
|
||||
const now = new Date()
|
||||
const isOpen =
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
|
||||
(!stage.windowCloseAt || now <= stage.windowCloseAt)
|
||||
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const lateGraceHours =
|
||||
(config.lateGraceHours as number) ??
|
||||
(config.lateSubmissionGrace as number) ??
|
||||
0
|
||||
const isLateWindow =
|
||||
!isOpen &&
|
||||
stage.windowCloseAt &&
|
||||
lateGraceHours > 0 &&
|
||||
now.getTime() <=
|
||||
stage.windowCloseAt.getTime() + lateGraceHours * 60 * 60 * 1000
|
||||
|
||||
return {
|
||||
fileRequirements,
|
||||
uploadedFiles,
|
||||
windowStatus: {
|
||||
isOpen: isOpen || !!isLateWindow,
|
||||
closesAt: stage.windowCloseAt,
|
||||
isLate: !!isLateWindow && !isOpen,
|
||||
},
|
||||
deadlineInfo: {
|
||||
windowOpenAt: stage.windowOpenAt,
|
||||
windowCloseAt: stage.windowCloseAt,
|
||||
lateGraceHours,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,632 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const stageAssignmentRouter = router({
|
||||
/**
|
||||
* Preview which projects in a stage need assignment and show coverage gaps
|
||||
*/
|
||||
previewStageProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
requiredReviews: z.number().int().min(1).max(20).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
include: { track: true },
|
||||
})
|
||||
|
||||
// Get the stage config for default required reviews
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const requiredReviews = input.requiredReviews ?? (config.requiredReviews as number) ?? 3
|
||||
|
||||
// Get projects that PASSED or are IN_PROGRESS in this stage
|
||||
const projectStates = await ctx.prisma.projectStageState.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
state: { in: ['PASSED', 'IN_PROGRESS', 'PENDING'] },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: {
|
||||
where: { stageId: input.stageId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const results = projectStates.map((ps) => {
|
||||
const currentAssignments = ps.project._count.assignments
|
||||
const gap = Math.max(0, requiredReviews - currentAssignments)
|
||||
return {
|
||||
projectId: ps.project.id,
|
||||
projectTitle: ps.project.title,
|
||||
tags: ps.project.tags,
|
||||
teamName: ps.project.teamName,
|
||||
stageState: ps.state,
|
||||
currentAssignments,
|
||||
requiredReviews,
|
||||
gap,
|
||||
fullyCovered: gap === 0,
|
||||
}
|
||||
})
|
||||
|
||||
const needsAssignment = results.filter((r) => r.gap > 0)
|
||||
|
||||
return {
|
||||
stageId: input.stageId,
|
||||
stageName: stage.name,
|
||||
totalProjects: results.length,
|
||||
fullyCovered: results.filter((r) => r.fullyCovered).length,
|
||||
needsAssignment: needsAssignment.length,
|
||||
totalGap: needsAssignment.reduce((sum, r) => sum + r.gap, 0),
|
||||
projects: results,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Execute stage-level project-to-juror assignment
|
||||
*/
|
||||
assignStageProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
projectId: z.string(),
|
||||
reasoning: z.string().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.assignments.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No assignments provided',
|
||||
})
|
||||
}
|
||||
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage must be ACTIVE to create assignments',
|
||||
})
|
||||
}
|
||||
|
||||
// Bulk create assignments with stageId
|
||||
const created = await ctx.prisma.assignment.createMany({
|
||||
data: input.assignments.map((a) => ({
|
||||
userId: a.userId,
|
||||
projectId: a.projectId,
|
||||
stageId: input.stageId,
|
||||
method: 'ALGORITHM',
|
||||
aiReasoning: a.reasoning ?? null,
|
||||
createdBy: ctx.user.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'STAGE_ASSIGNMENTS_CREATED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
detailsJson: {
|
||||
assignmentCount: created.count,
|
||||
requestedCount: input.assignments.length,
|
||||
skipped: input.assignments.length - created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
created: created.count,
|
||||
requested: input.assignments.length,
|
||||
skipped: input.assignments.length - created.count,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get assignment coverage report for a stage
|
||||
*/
|
||||
getCoverageReport: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
requiredReviews: z.number().int().min(1).max(20).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const requiredReviews = input.requiredReviews ?? (config.requiredReviews as number) ?? 3
|
||||
|
||||
// Get assignments grouped by project
|
||||
const projectCoverage = await ctx.prisma.assignment.groupBy({
|
||||
by: ['projectId'],
|
||||
where: { stageId: input.stageId },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
// Get assignments grouped by juror
|
||||
const jurorLoad = await ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { stageId: input.stageId },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
// Get total projects in stage
|
||||
const totalProjectsInStage = await ctx.prisma.projectStageState.count({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS', 'PASSED'] },
|
||||
},
|
||||
})
|
||||
|
||||
// Completion stats
|
||||
const totalAssignments = await ctx.prisma.assignment.count({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
const completedAssignments = await ctx.prisma.assignment.count({
|
||||
where: { stageId: input.stageId, isCompleted: true },
|
||||
})
|
||||
|
||||
const fullyCoveredProjects = projectCoverage.filter(
|
||||
(p) => p._count >= requiredReviews
|
||||
).length
|
||||
const partiallyCoveredProjects = projectCoverage.filter(
|
||||
(p) => p._count > 0 && p._count < requiredReviews
|
||||
).length
|
||||
const uncoveredProjects = totalProjectsInStage - projectCoverage.length
|
||||
|
||||
return {
|
||||
stageId: input.stageId,
|
||||
stageName: stage.name,
|
||||
requiredReviews,
|
||||
totalProjectsInStage,
|
||||
fullyCoveredProjects,
|
||||
partiallyCoveredProjects,
|
||||
uncoveredProjects,
|
||||
coveragePercentage:
|
||||
totalProjectsInStage > 0
|
||||
? Math.round((fullyCoveredProjects / totalProjectsInStage) * 100)
|
||||
: 0,
|
||||
totalAssignments,
|
||||
completedAssignments,
|
||||
completionPercentage:
|
||||
totalAssignments > 0
|
||||
? Math.round((completedAssignments / totalAssignments) * 100)
|
||||
: 0,
|
||||
jurorCount: jurorLoad.length,
|
||||
jurorLoadDistribution: {
|
||||
min: jurorLoad.length > 0 ? Math.min(...jurorLoad.map((j) => j._count)) : 0,
|
||||
max: jurorLoad.length > 0 ? Math.max(...jurorLoad.map((j) => j._count)) : 0,
|
||||
avg:
|
||||
jurorLoad.length > 0
|
||||
? Math.round(
|
||||
jurorLoad.reduce((sum, j) => sum + j._count, 0) / jurorLoad.length
|
||||
)
|
||||
: 0,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Rebalance assignments within a stage
|
||||
* Moves excess assignments from over-loaded jurors to under-loaded ones
|
||||
*/
|
||||
rebalance: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
targetPerJuror: z.number().int().min(1).max(100),
|
||||
dryRun: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage must be ACTIVE to rebalance assignments',
|
||||
})
|
||||
}
|
||||
|
||||
// Get current load per juror (only incomplete assignments can be moved)
|
||||
const jurorLoads = await ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { stageId: input.stageId, isCompleted: false },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
// Fetch per-juror maxAssignments for all jurors involved
|
||||
const allJurorIds = jurorLoads.map((j) => j.userId)
|
||||
const jurorUsers = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: allJurorIds } },
|
||||
select: { id: true, maxAssignments: true },
|
||||
})
|
||||
const jurorMaxMap = new Map(jurorUsers.map((u) => [u.id, u.maxAssignments]))
|
||||
|
||||
const overLoaded = jurorLoads.filter(
|
||||
(j) => j._count > input.targetPerJuror
|
||||
)
|
||||
|
||||
// For under-loaded jurors, also check they haven't hit their personal maxAssignments
|
||||
const underLoaded = jurorLoads.filter((j) => {
|
||||
if (j._count >= input.targetPerJuror) return false
|
||||
const userMax = jurorMaxMap.get(j.userId)
|
||||
// If user has a personal max and is already at it, they can't receive more
|
||||
if (userMax !== null && userMax !== undefined && j._count >= userMax) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Calculate how many can be moved, respecting per-juror limits
|
||||
const excessTotal = overLoaded.reduce(
|
||||
(sum, j) => sum + (j._count - input.targetPerJuror),
|
||||
0
|
||||
)
|
||||
const capacityTotal = underLoaded.reduce((sum, j) => {
|
||||
const userMax = jurorMaxMap.get(j.userId)
|
||||
const effectiveTarget = (userMax !== null && userMax !== undefined)
|
||||
? Math.min(input.targetPerJuror, userMax)
|
||||
: input.targetPerJuror
|
||||
return sum + Math.max(0, effectiveTarget - j._count)
|
||||
}, 0)
|
||||
const movableCount = Math.min(excessTotal, capacityTotal)
|
||||
|
||||
if (input.dryRun) {
|
||||
return {
|
||||
dryRun: true,
|
||||
overLoadedJurors: overLoaded.length,
|
||||
underLoadedJurors: underLoaded.length,
|
||||
excessAssignments: excessTotal,
|
||||
availableCapacity: capacityTotal,
|
||||
wouldMove: movableCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute rebalance
|
||||
let movedCount = 0
|
||||
let remaining = movableCount
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const over of overLoaded) {
|
||||
if (remaining <= 0) break
|
||||
|
||||
const excess = over._count - input.targetPerJuror
|
||||
const toMove = Math.min(excess, remaining)
|
||||
|
||||
// Get the assignments to move (oldest incomplete first)
|
||||
const assignmentsToMove = await tx.assignment.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
userId: over.userId,
|
||||
isCompleted: false,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: toMove,
|
||||
select: { id: true, projectId: true },
|
||||
})
|
||||
|
||||
for (const assignment of assignmentsToMove) {
|
||||
// Find an under-loaded juror who doesn't already have this project
|
||||
for (const under of underLoaded) {
|
||||
// Respect both target and personal maxAssignments
|
||||
const userMax = jurorMaxMap.get(under.userId)
|
||||
const effectiveCapacity = (userMax !== null && userMax !== undefined)
|
||||
? Math.min(input.targetPerJuror, userMax)
|
||||
: input.targetPerJuror
|
||||
if (under._count >= effectiveCapacity) continue
|
||||
|
||||
// Check no existing assignment for this juror-project pair
|
||||
const exists = await tx.assignment.findFirst({
|
||||
where: {
|
||||
userId: under.userId,
|
||||
projectId: assignment.projectId,
|
||||
stageId: input.stageId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
// Delete old assignment and create new one
|
||||
await tx.assignment.delete({ where: { id: assignment.id } })
|
||||
await tx.assignment.create({
|
||||
data: {
|
||||
userId: under.userId,
|
||||
projectId: assignment.projectId,
|
||||
stageId: input.stageId,
|
||||
method: 'ALGORITHM',
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
under._count++
|
||||
movedCount++
|
||||
remaining--
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'STAGE_ASSIGNMENTS_REBALANCED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
detailsJson: {
|
||||
targetPerJuror: input.targetPerJuror,
|
||||
movedCount,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
dryRun: false,
|
||||
movedCount,
|
||||
targetPerJuror: input.targetPerJuror,
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Phase 4: Jury-facing procedures
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get all assignments for the current user, optionally filtered by stage/pipeline/program
|
||||
*/
|
||||
myAssignments: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string().optional(),
|
||||
pipelineId: z.string().optional(),
|
||||
programId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
...(input.stageId ? { stageId: input.stageId } : {}),
|
||||
...(input.pipelineId && {
|
||||
stage: { track: { pipelineId: input.pipelineId } },
|
||||
}),
|
||||
...(input.programId && {
|
||||
stage: { track: { pipeline: { programId: input.programId } } },
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
country: true,
|
||||
tags: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
stage: {
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
globalScore: true,
|
||||
binaryDecision: true,
|
||||
submittedAt: true,
|
||||
},
|
||||
},
|
||||
conflictOfInterest: {
|
||||
select: {
|
||||
id: true,
|
||||
hasConflict: true,
|
||||
conflictType: true,
|
||||
reviewAction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
return assignments
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get stages where current user has assignments, with per-stage completion stats
|
||||
*/
|
||||
myStages: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get all stage-scoped assignments for this user in this program
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
stage: { track: { pipeline: { programId: input.programId } } },
|
||||
},
|
||||
select: {
|
||||
stageId: true,
|
||||
isCompleted: true,
|
||||
evaluation: { select: { status: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Group by stage and compute stats
|
||||
const stageMap = new Map<
|
||||
string,
|
||||
{ total: number; completed: number; pending: number; inProgress: number }
|
||||
>()
|
||||
|
||||
for (const a of assignments) {
|
||||
if (!a.stageId) continue
|
||||
if (!stageMap.has(a.stageId)) {
|
||||
stageMap.set(a.stageId, { total: 0, completed: 0, pending: 0, inProgress: 0 })
|
||||
}
|
||||
const stats = stageMap.get(a.stageId)!
|
||||
stats.total++
|
||||
if (a.evaluation?.status === 'SUBMITTED') {
|
||||
stats.completed++
|
||||
} else if (a.evaluation?.status === 'DRAFT') {
|
||||
stats.inProgress++
|
||||
} else {
|
||||
stats.pending++
|
||||
}
|
||||
}
|
||||
|
||||
const stageIds = Array.from(stageMap.keys())
|
||||
if (stageIds.length === 0) return []
|
||||
|
||||
// Fetch stage details
|
||||
const stages = await ctx.prisma.stage.findMany({
|
||||
where: { id: { in: stageIds } },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
return stages.map((stage) => ({
|
||||
...stage,
|
||||
stats: stageMap.get(stage.id) ?? { total: 0, completed: 0, pending: 0, inProgress: 0 },
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single assignment with full details for evaluation page
|
||||
*/
|
||||
getMyAssignment: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignment = await ctx.prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
projectId: input.projectId,
|
||||
stageId: input.stageId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
country: true,
|
||||
tags: true,
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
size: true,
|
||||
mimeType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stage: {
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { id: true, name: true, programId: true } },
|
||||
},
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
evaluation: true,
|
||||
conflictOfInterest: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!assignment) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Assignment not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Compute window status
|
||||
const now = new Date()
|
||||
const stage = assignment.stage!
|
||||
const isWindowOpen =
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
|
||||
(!stage.windowCloseAt || now <= stage.windowCloseAt)
|
||||
|
||||
// Check grace period
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
userId: ctx.user.id,
|
||||
OR: [
|
||||
{ projectId: null },
|
||||
{ projectId: input.projectId },
|
||||
],
|
||||
extendedUntil: { gte: now },
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...assignment,
|
||||
windowStatus: {
|
||||
isOpen: isWindowOpen || !!gracePeriod,
|
||||
opensAt: stage.windowOpenAt,
|
||||
closesAt: stage.windowCloseAt,
|
||||
hasGracePeriod: !!gracePeriod,
|
||||
graceExpiresAt: gracePeriod?.extendedUntil ?? null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,514 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const stageFilteringRouter = router({
|
||||
/**
|
||||
* Preview batch filtering: dry-run showing which projects would pass/fail
|
||||
*/
|
||||
previewBatch: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
rules: z
|
||||
.array(
|
||||
z.object({
|
||||
field: z.string(),
|
||||
operator: z.enum([
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'exists',
|
||||
]),
|
||||
value: z.union([z.number(), z.string(), z.boolean()]),
|
||||
weight: z.number().min(0).max(1).default(1),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
include: { track: true },
|
||||
})
|
||||
|
||||
// Get all projects in this stage
|
||||
const projectStates = await ctx.prisma.projectStageState.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
metadataJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Load filtering rules from DB if not provided inline
|
||||
const rules =
|
||||
input.rules ??
|
||||
((
|
||||
await ctx.prisma.filteringRule.findMany({
|
||||
where: { stageId: input.stageId, isActive: true },
|
||||
orderBy: { priority: 'desc' },
|
||||
})
|
||||
).map((r) => {
|
||||
const config = r.configJson as Record<string, unknown>
|
||||
return {
|
||||
field: (config.field as string) ?? '',
|
||||
operator: (config.operator as string) ?? 'equals',
|
||||
value: config.value as string | number | boolean,
|
||||
weight: (config.weight as number) ?? 1,
|
||||
}
|
||||
}))
|
||||
|
||||
// Evaluate each project against rules
|
||||
const results = projectStates.map((ps) => {
|
||||
const project = ps.project
|
||||
const meta = (project.metadataJson as Record<string, unknown>) ?? {}
|
||||
let passed = true
|
||||
const ruleResults: Array<{
|
||||
field: string
|
||||
operator: string
|
||||
expected: unknown
|
||||
actual: unknown
|
||||
passed: boolean
|
||||
}> = []
|
||||
|
||||
for (const rule of rules) {
|
||||
const fieldValue = meta[rule.field] ?? (project as Record<string, unknown>)[rule.field]
|
||||
const rulePassed = evaluateRule(fieldValue, rule.operator, rule.value)
|
||||
|
||||
ruleResults.push({
|
||||
field: rule.field,
|
||||
operator: rule.operator,
|
||||
expected: rule.value,
|
||||
actual: fieldValue ?? null,
|
||||
passed: rulePassed,
|
||||
})
|
||||
|
||||
if (!rulePassed && rule.weight >= 1) {
|
||||
passed = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
currentState: ps.state,
|
||||
wouldPass: passed,
|
||||
ruleResults,
|
||||
}
|
||||
})
|
||||
|
||||
const passCount = results.filter((r) => r.wouldPass).length
|
||||
const failCount = results.filter((r) => !r.wouldPass).length
|
||||
|
||||
return {
|
||||
stageId: input.stageId,
|
||||
stageName: stage.name,
|
||||
totalProjects: results.length,
|
||||
passCount,
|
||||
failCount,
|
||||
results,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Run stage filtering: apply rules and update project states
|
||||
*/
|
||||
runStageFiltering: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
include: { track: true },
|
||||
})
|
||||
|
||||
if (stage.status !== 'STAGE_ACTIVE' && stage.status !== 'STAGE_CLOSED') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage must be ACTIVE or CLOSED to run filtering',
|
||||
})
|
||||
}
|
||||
|
||||
// Get filtering rules
|
||||
const filteringRules = await ctx.prisma.filteringRule.findMany({
|
||||
where: { stageId: input.stageId, isActive: true },
|
||||
orderBy: { priority: 'desc' },
|
||||
})
|
||||
|
||||
if (filteringRules.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'No active filtering rules configured for this stage',
|
||||
})
|
||||
}
|
||||
|
||||
// Get projects in PENDING or IN_PROGRESS state
|
||||
const projectStates = await ctx.prisma.projectStageState.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, tags: true, metadataJson: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let passedCount = 0
|
||||
let rejectedCount = 0
|
||||
let flaggedCount = 0
|
||||
|
||||
// Create a filtering job for tracking
|
||||
const job = await ctx.prisma.filteringJob.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
status: 'RUNNING',
|
||||
totalProjects: projectStates.length,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (const ps of projectStates) {
|
||||
const meta = (ps.project.metadataJson as Record<string, unknown>) ?? {}
|
||||
let passed = true
|
||||
let flagForManualReview = false
|
||||
|
||||
for (const rule of filteringRules) {
|
||||
const config = rule.configJson as Record<string, unknown>
|
||||
const field = config.field as string
|
||||
const operator = config.operator as string
|
||||
const value = config.value
|
||||
const weight = (config.weight as number) ?? 1
|
||||
|
||||
const fieldValue = meta[field] ?? (ps.project as Record<string, unknown>)[field]
|
||||
const rulePassed = evaluateRule(fieldValue, operator, value)
|
||||
|
||||
if (!rulePassed) {
|
||||
if (weight >= 1) {
|
||||
passed = false
|
||||
} else if (weight > 0) {
|
||||
flagForManualReview = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newState = passed
|
||||
? flagForManualReview
|
||||
? 'IN_PROGRESS' // Flagged for manual review
|
||||
: 'PASSED'
|
||||
: 'REJECTED'
|
||||
|
||||
await tx.projectStageState.update({
|
||||
where: { id: ps.id },
|
||||
data: {
|
||||
state: newState,
|
||||
metadataJson: {
|
||||
...(ps.metadataJson as Record<string, unknown> ?? {}),
|
||||
filteringResult: newState,
|
||||
filteredAt: new Date().toISOString(),
|
||||
flaggedForReview: flagForManualReview,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
if (newState === 'PASSED') passedCount++
|
||||
else if (newState === 'REJECTED') rejectedCount++
|
||||
if (flagForManualReview) flaggedCount++
|
||||
}
|
||||
|
||||
// Record decision audit
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'filtering.completed',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
totalProjects: projectStates.length,
|
||||
passedCount,
|
||||
rejectedCount,
|
||||
flaggedCount,
|
||||
rulesApplied: filteringRules.length,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'STAGE_FILTERING_RUN',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
detailsJson: {
|
||||
totalProjects: projectStates.length,
|
||||
passedCount,
|
||||
rejectedCount,
|
||||
flaggedCount,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
// Mark job as completed
|
||||
await ctx.prisma.filteringJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
processedCount: projectStates.length,
|
||||
passedCount,
|
||||
filteredCount: rejectedCount,
|
||||
flaggedCount,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Mark job as failed
|
||||
await ctx.prisma.filteringJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
completedAt: new Date(),
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
totalProjects: projectStates.length,
|
||||
passedCount,
|
||||
rejectedCount,
|
||||
flaggedCount,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get projects flagged for manual review (paginated)
|
||||
*/
|
||||
getManualQueue: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(100).default(25),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Flagged projects are IN_PROGRESS with flaggedForReview metadata
|
||||
const items = await ctx.prisma.projectStageState.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
state: 'IN_PROGRESS',
|
||||
},
|
||||
take: input.limit + 1,
|
||||
cursor: input.cursor ? { id: input.cursor } : undefined,
|
||||
orderBy: { enteredAt: 'asc' },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
metadataJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Filter to only those flagged for review
|
||||
const flaggedItems = items.filter((item) => {
|
||||
const meta = (item.metadataJson as Record<string, unknown>) ?? {}
|
||||
return meta.flaggedForReview === true
|
||||
})
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (items.length > input.limit) {
|
||||
const next = items.pop()
|
||||
nextCursor = next?.id
|
||||
}
|
||||
|
||||
return {
|
||||
items: flaggedItems,
|
||||
nextCursor,
|
||||
totalFlagged: flaggedItems.length,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resolve a manual filtering decision for a flagged project
|
||||
*/
|
||||
resolveManualDecision: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectStageStateId: z.string(),
|
||||
decision: z.enum(['PASSED', 'REJECTED']),
|
||||
reason: z.string().max(1000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const pss = await ctx.prisma.projectStageState.findUniqueOrThrow({
|
||||
where: { id: input.projectStageStateId },
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (pss.state !== 'IN_PROGRESS') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Only flagged (IN_PROGRESS) projects can be manually resolved',
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.projectStageState.update({
|
||||
where: { id: input.projectStageStateId },
|
||||
data: {
|
||||
state: input.decision,
|
||||
metadataJson: {
|
||||
...(pss.metadataJson as Record<string, unknown> ?? {}),
|
||||
manualDecision: input.decision,
|
||||
manualDecisionBy: ctx.user.id,
|
||||
manualDecisionAt: new Date().toISOString(),
|
||||
manualDecisionReason: input.reason ?? null,
|
||||
flaggedForReview: false,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
// Record override action
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: input.projectStageStateId,
|
||||
previousValue: { state: pss.state } as Prisma.InputJsonValue,
|
||||
newValueJson: { state: input.decision, reason: input.reason } as Prisma.InputJsonValue,
|
||||
reasonCode: 'ADMIN_DISCRETION',
|
||||
reasonText: input.reason ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'MANUAL_FILTERING_DECISION',
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: input.projectStageStateId,
|
||||
detailsJson: {
|
||||
projectId: pss.project.id,
|
||||
projectTitle: pss.project.title,
|
||||
decision: input.decision,
|
||||
reason: input.reason,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get filtering job status
|
||||
*/
|
||||
getJob: adminProcedure
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.filteringJob.findUniqueOrThrow({
|
||||
where: { id: input.jobId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
totalProjects: true,
|
||||
processedCount: true,
|
||||
passedCount: true,
|
||||
filteredCount: true,
|
||||
flaggedCount: true,
|
||||
errorMessage: true,
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Evaluate a single filtering rule against a field value
|
||||
*/
|
||||
function evaluateRule(
|
||||
fieldValue: unknown,
|
||||
operator: string,
|
||||
expected: unknown
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case 'equals':
|
||||
return fieldValue === expected
|
||||
case 'notEquals':
|
||||
return fieldValue !== expected
|
||||
case 'greaterThan':
|
||||
return (
|
||||
typeof fieldValue === 'number' &&
|
||||
typeof expected === 'number' &&
|
||||
fieldValue > expected
|
||||
)
|
||||
case 'lessThan':
|
||||
return (
|
||||
typeof fieldValue === 'number' &&
|
||||
typeof expected === 'number' &&
|
||||
fieldValue < expected
|
||||
)
|
||||
case 'greaterThanOrEqual':
|
||||
return (
|
||||
typeof fieldValue === 'number' &&
|
||||
typeof expected === 'number' &&
|
||||
fieldValue >= expected
|
||||
)
|
||||
case 'lessThanOrEqual':
|
||||
return (
|
||||
typeof fieldValue === 'number' &&
|
||||
typeof expected === 'number' &&
|
||||
fieldValue <= expected
|
||||
)
|
||||
case 'contains':
|
||||
if (typeof fieldValue === 'string' && typeof expected === 'string')
|
||||
return fieldValue.toLowerCase().includes(expected.toLowerCase())
|
||||
if (Array.isArray(fieldValue)) return fieldValue.includes(expected)
|
||||
return false
|
||||
case 'exists':
|
||||
return fieldValue !== undefined && fieldValue !== null
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -581,7 +581,19 @@ export const userRouter = router({
|
||||
.array(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// Competition architecture: optional jury group memberships
|
||||
juryGroupIds: z.array(z.string()).optional(),
|
||||
juryGroupRole: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'),
|
||||
// Competition architecture: optional assignment intents
|
||||
assignmentIntents: z
|
||||
.array(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
@@ -633,11 +645,19 @@ export const userRouter = router({
|
||||
return { created: 0, skipped }
|
||||
}
|
||||
|
||||
const emailToAssignments = new Map<string, Array<{ projectId: string; stageId: string }>>()
|
||||
const emailToAssignments = new Map<string, Array<{ projectId: string; roundId: string }>>()
|
||||
const emailToJuryGroupIds = new Map<string, { ids: string[]; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }>()
|
||||
const emailToIntents = new Map<string, Array<{ roundId: string; projectId: string }>>()
|
||||
for (const u of newUsers) {
|
||||
if (u.assignments && u.assignments.length > 0) {
|
||||
emailToAssignments.set(u.email.toLowerCase(), u.assignments)
|
||||
}
|
||||
if (u.juryGroupIds && u.juryGroupIds.length > 0) {
|
||||
emailToJuryGroupIds.set(u.email.toLowerCase(), { ids: u.juryGroupIds, role: u.juryGroupRole })
|
||||
}
|
||||
if (u.assignmentIntents && u.assignmentIntents.length > 0) {
|
||||
emailToIntents.set(u.email.toLowerCase(), u.assignmentIntents)
|
||||
}
|
||||
}
|
||||
|
||||
const created = await ctx.prisma.user.createMany({
|
||||
@@ -678,7 +698,7 @@ export const userRouter = router({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: assignment.projectId,
|
||||
stageId: assignment.stageId,
|
||||
roundId: assignment.roundId,
|
||||
method: 'MANUAL',
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
@@ -704,6 +724,79 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Create JuryGroupMember records for users with juryGroupIds
|
||||
let juryGroupMembershipsCreated = 0
|
||||
let assignmentIntentsCreated = 0
|
||||
for (const user of createdUsers) {
|
||||
const groupInfo = emailToJuryGroupIds.get(user.email.toLowerCase())
|
||||
if (groupInfo) {
|
||||
for (const groupId of groupInfo.ids) {
|
||||
try {
|
||||
await ctx.prisma.juryGroupMember.create({
|
||||
data: {
|
||||
juryGroupId: groupId,
|
||||
userId: user.id,
|
||||
role: groupInfo.role,
|
||||
},
|
||||
})
|
||||
juryGroupMembershipsCreated++
|
||||
} catch {
|
||||
// Skip if membership already exists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create AssignmentIntents for users who have them
|
||||
const intents = emailToIntents.get(user.email.toLowerCase())
|
||||
if (intents) {
|
||||
for (const intent of intents) {
|
||||
try {
|
||||
// Look up the round's juryGroupId to find the matching JuryGroupMember
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: intent.roundId },
|
||||
select: { juryGroupId: true },
|
||||
})
|
||||
if (round?.juryGroupId) {
|
||||
const member = await ctx.prisma.juryGroupMember.findUnique({
|
||||
where: {
|
||||
juryGroupId_userId: {
|
||||
juryGroupId: round.juryGroupId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (member) {
|
||||
await ctx.prisma.assignmentIntent.create({
|
||||
data: {
|
||||
juryGroupMemberId: member.id,
|
||||
roundId: intent.roundId,
|
||||
projectId: intent.projectId,
|
||||
source: 'INVITE',
|
||||
status: 'INTENT_PENDING',
|
||||
},
|
||||
})
|
||||
assignmentIntentsCreated++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip duplicate intents
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (juryGroupMembershipsCreated > 0) {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'JuryGroupMember',
|
||||
detailsJson: { count: juryGroupMembershipsCreated, context: 'invitation_jury_group_binding' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// Send invitation emails if requested
|
||||
let emailsSent = 0
|
||||
const emailErrors: string[] = []
|
||||
@@ -751,7 +844,7 @@ export const userRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, invitationSent: input.sendInvitation }
|
||||
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, juryGroupMembershipsCreated, assignmentIntentsCreated, invitationSent: input.sendInvitation }
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -760,7 +853,7 @@ export const userRouter = router({
|
||||
getJuryMembers: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
)
|
||||
@@ -791,8 +884,8 @@ export const userRouter = router({
|
||||
profileImageProvider: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: input.stageId
|
||||
? { where: { stageId: input.stageId } }
|
||||
assignments: input.roundId
|
||||
? { where: { roundId: input.roundId } }
|
||||
: true,
|
||||
},
|
||||
},
|
||||
@@ -816,7 +909,10 @@ export const userRouter = router({
|
||||
* Send invitation email to a user
|
||||
*/
|
||||
sendInvitation: adminProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.input(z.object({
|
||||
userId: z.string(),
|
||||
juryGroupId: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.userId },
|
||||
@@ -829,6 +925,24 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Bind to jury group if specified (upsert to be idempotent)
|
||||
if (input.juryGroupId) {
|
||||
await ctx.prisma.juryGroupMember.upsert({
|
||||
where: {
|
||||
juryGroupId_userId: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
userId: user.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
update: {}, // No-op if already exists
|
||||
})
|
||||
}
|
||||
|
||||
// Generate invite token, set status to INVITED, and store on user
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
@@ -961,6 +1075,16 @@ export const userRouter = router({
|
||||
bio: z.string().max(500).optional(),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
||||
// Competition architecture: jury self-service preferences
|
||||
juryPreferences: z
|
||||
.array(
|
||||
z.object({
|
||||
juryGroupMemberId: z.string(),
|
||||
selfServiceCap: z.number().int().positive().optional(),
|
||||
selfServiceRatio: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -990,13 +1114,46 @@ export const userRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Process jury self-service preferences
|
||||
if (input.juryPreferences && input.juryPreferences.length > 0) {
|
||||
for (const pref of input.juryPreferences) {
|
||||
// Security: verify this member belongs to the current user
|
||||
const member = await tx.juryGroupMember.findUnique({
|
||||
where: { id: pref.juryGroupMemberId },
|
||||
include: { juryGroup: { select: { allowJurorCapAdjustment: true, allowJurorRatioAdjustment: true, defaultMaxAssignments: true } } },
|
||||
})
|
||||
if (!member || member.userId !== ctx.user.id) continue
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
// Only set selfServiceCap if group allows it
|
||||
if (pref.selfServiceCap != null && member.juryGroup.allowJurorCapAdjustment) {
|
||||
// Bound by admin max (override or group default)
|
||||
const adminMax = member.maxAssignmentsOverride ?? member.juryGroup.defaultMaxAssignments
|
||||
updateData.selfServiceCap = Math.min(pref.selfServiceCap, adminMax)
|
||||
}
|
||||
|
||||
// Only set selfServiceRatio if group allows it
|
||||
if (pref.selfServiceRatio != null && member.juryGroup.allowJurorRatioAdjustment) {
|
||||
updateData.selfServiceRatio = pref.selfServiceRatio
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await tx.juryGroupMember.update({
|
||||
where: { id: pref.juryGroupMemberId },
|
||||
data: updateData,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'COMPLETE_ONBOARDING',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { name: input.name },
|
||||
detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -1007,6 +1164,46 @@ export const userRouter = router({
|
||||
return user
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get onboarding context for the current user.
|
||||
* Returns jury group memberships that allow self-service preferences.
|
||||
*/
|
||||
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
|
||||
const memberships = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
juryGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
defaultMaxAssignments: true,
|
||||
allowJurorCapAdjustment: true,
|
||||
allowJurorRatioAdjustment: true,
|
||||
categoryQuotasEnabled: true,
|
||||
defaultCategoryQuotas: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const selfServiceGroups = memberships.filter(
|
||||
(m) => m.juryGroup.allowJurorCapAdjustment || m.juryGroup.allowJurorRatioAdjustment,
|
||||
)
|
||||
|
||||
return {
|
||||
hasSelfServiceOptions: selfServiceGroups.length > 0,
|
||||
memberships: selfServiceGroups.map((m) => ({
|
||||
juryGroupMemberId: m.id,
|
||||
juryGroupName: m.juryGroup.name,
|
||||
currentCap: m.maxAssignmentsOverride ?? m.juryGroup.defaultMaxAssignments,
|
||||
allowCapAdjustment: m.juryGroup.allowJurorCapAdjustment,
|
||||
allowRatioAdjustment: m.juryGroup.allowJurorRatioAdjustment,
|
||||
selfServiceCap: m.selfServiceCap,
|
||||
selfServiceRatio: m.selfServiceRatio,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if current user needs onboarding
|
||||
*/
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
167
src/server/services/ai-prompt-guard.ts
Normal file
167
src/server/services/ai-prompt-guard.ts
Normal 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)] }
|
||||
}
|
||||
284
src/server/services/ai-shortlist.ts
Normal file
284
src/server/services/ai-shortlist.ts
Normal 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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
290
src/server/services/assignment-intent.ts
Normal file
290
src/server/services/assignment-intent.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
262
src/server/services/assignment-policy.ts
Normal file
262
src/server/services/assignment-policy.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
160
src/server/services/competition-context.ts
Normal file
160
src/server/services/competition-context.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
716
src/server/services/deliberation.ts
Normal file
716
src/server/services/deliberation.ts
Normal 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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}`
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
314
src/server/services/mentor-workspace.ts
Normal file
314
src/server/services/mentor-workspace.ts
Normal 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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
284
src/server/services/result-lock.ts
Normal file
284
src/server/services/result-lock.ts
Normal 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' },
|
||||
})
|
||||
}
|
||||
568
src/server/services/round-assignment.ts
Normal file
568
src/server/services/round-assignment.ts
Normal 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)
|
||||
}
|
||||
510
src/server/services/round-engine.ts
Normal file
510
src/server/services/round-engine.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
358
src/server/services/submission-manager.ts
Normal file
358
src/server/services/submission-manager.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Submission Round Manager Service
|
||||
*
|
||||
* Manages SubmissionWindow lifecycle, file requirement enforcement,
|
||||
* and deadline policies.
|
||||
*/
|
||||
|
||||
import type { PrismaClient, DeadlinePolicy, Prisma } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WindowLifecycleResult = {
|
||||
success: boolean
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export type DeadlineStatus = {
|
||||
status: 'OPEN' | 'GRACE' | 'CLOSED' | 'LOCKED'
|
||||
graceExpiresAt?: Date
|
||||
deadlinePolicy: DeadlinePolicy
|
||||
}
|
||||
|
||||
export type SubmissionValidationResult = {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// ─── Window Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a submission window for accepting files.
|
||||
*/
|
||||
export async function openWindow(
|
||||
windowId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<WindowLifecycleResult> {
|
||||
try {
|
||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||
|
||||
if (!window) {
|
||||
return { success: false, errors: [`Submission window ${windowId} not found`] }
|
||||
}
|
||||
|
||||
if (window.isLocked) {
|
||||
return { success: false, errors: ['Cannot open a locked window'] }
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
await tx.submissionWindow.update({
|
||||
where: { id: windowId },
|
||||
data: {
|
||||
windowOpenAt: new Date(),
|
||||
isLocked: false,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'submission_window.opened',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: windowId,
|
||||
actorId,
|
||||
detailsJson: { windowName: window.name },
|
||||
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'submission-manager' },
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'SUBMISSION_WINDOW_OPEN',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: windowId,
|
||||
detailsJson: { name: window.name },
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[SubmissionManager] openWindow failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a submission window (respects deadline policy).
|
||||
*/
|
||||
export async function closeWindow(
|
||||
windowId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<WindowLifecycleResult> {
|
||||
try {
|
||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||
|
||||
if (!window) {
|
||||
return { success: false, errors: [`Submission window ${windowId} not found`] }
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
const data: Record<string, unknown> = {
|
||||
windowCloseAt: new Date(),
|
||||
}
|
||||
|
||||
// Auto-lock on close if configured
|
||||
if (window.lockOnClose && window.deadlinePolicy === 'HARD_DEADLINE') {
|
||||
data.isLocked = true
|
||||
}
|
||||
|
||||
await tx.submissionWindow.update({ where: { id: windowId }, data })
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'submission_window.closed',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: windowId,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
windowName: window.name,
|
||||
deadlinePolicy: window.deadlinePolicy,
|
||||
autoLocked: data.isLocked === true,
|
||||
},
|
||||
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'submission-manager' },
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'SUBMISSION_WINDOW_CLOSE',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: windowId,
|
||||
detailsJson: { name: window.name },
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[SubmissionManager] closeWindow failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock a submission window (no further uploads allowed).
|
||||
*/
|
||||
export async function lockWindow(
|
||||
windowId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<WindowLifecycleResult> {
|
||||
try {
|
||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||
|
||||
if (!window) {
|
||||
return { success: false, errors: [`Submission window ${windowId} not found`] }
|
||||
}
|
||||
|
||||
if (window.isLocked) {
|
||||
return { success: false, errors: ['Window is already locked'] }
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
await tx.submissionWindow.update({
|
||||
where: { id: windowId },
|
||||
data: { isLocked: true },
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'submission_window.locked',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: windowId,
|
||||
actorId,
|
||||
detailsJson: { windowName: window.name },
|
||||
snapshotJson: { timestamp: new Date().toISOString(), emittedBy: 'submission-manager' },
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'SUBMISSION_WINDOW_LOCK',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: windowId,
|
||||
detailsJson: { name: window.name },
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[SubmissionManager] lockWindow failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Deadline Enforcement ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check the current deadline status of a submission window.
|
||||
*/
|
||||
export async function checkDeadlinePolicy(
|
||||
windowId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<DeadlineStatus> {
|
||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||
|
||||
if (!window) {
|
||||
return { status: 'LOCKED', deadlinePolicy: 'HARD_DEADLINE' }
|
||||
}
|
||||
|
||||
if (window.isLocked) {
|
||||
return { status: 'LOCKED', deadlinePolicy: window.deadlinePolicy }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Not yet open
|
||||
if (window.windowOpenAt && now < window.windowOpenAt) {
|
||||
return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy }
|
||||
}
|
||||
|
||||
// No close time or still before close
|
||||
if (!window.windowCloseAt || now < window.windowCloseAt) {
|
||||
return { status: 'OPEN', deadlinePolicy: window.deadlinePolicy }
|
||||
}
|
||||
|
||||
// Past the close time — policy determines behavior
|
||||
switch (window.deadlinePolicy) {
|
||||
case 'HARD_DEADLINE':
|
||||
return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy }
|
||||
|
||||
case 'FLAG':
|
||||
// Allow uploads but flag them
|
||||
return { status: 'OPEN', deadlinePolicy: window.deadlinePolicy }
|
||||
|
||||
case 'GRACE': {
|
||||
if (window.graceHours) {
|
||||
const graceEnd = new Date(window.windowCloseAt.getTime() + window.graceHours * 60 * 60 * 1000)
|
||||
if (now < graceEnd) {
|
||||
return {
|
||||
status: 'GRACE',
|
||||
graceExpiresAt: graceEnd,
|
||||
deadlinePolicy: window.deadlinePolicy,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy }
|
||||
}
|
||||
|
||||
default:
|
||||
return { status: 'CLOSED', deadlinePolicy: window.deadlinePolicy }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── File Requirement Validation ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a project's submission against the window's file requirements.
|
||||
*/
|
||||
export async function validateSubmission(
|
||||
projectId: string,
|
||||
windowId: string,
|
||||
files: Array<{ mimeType: string; size: number; requirementId?: string }>,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<SubmissionValidationResult> {
|
||||
const errors: string[] = []
|
||||
|
||||
const requirements = await prisma.submissionFileRequirement.findMany({
|
||||
where: { submissionWindowId: windowId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
// Check required files are present
|
||||
for (const req of requirements) {
|
||||
if (!req.required) continue
|
||||
|
||||
const matchingFiles = files.filter((f) => f.requirementId === req.id)
|
||||
if (matchingFiles.length === 0) {
|
||||
errors.push(`Missing required file: ${req.label}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each file against its requirement
|
||||
for (const file of files) {
|
||||
if (!file.requirementId) continue
|
||||
|
||||
const req = requirements.find((r: any) => r.id === file.requirementId)
|
||||
if (!req) {
|
||||
errors.push(`Unknown file requirement: ${file.requirementId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check mime type
|
||||
if (req.mimeTypes.length > 0 && !req.mimeTypes.includes(file.mimeType)) {
|
||||
errors.push(
|
||||
`File for "${req.label}" has invalid type ${file.mimeType}. Allowed: ${req.mimeTypes.join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Check size
|
||||
if (req.maxSizeMb && file.size > req.maxSizeMb * 1024 * 1024) {
|
||||
errors.push(
|
||||
`File for "${req.label}" exceeds max size of ${req.maxSizeMb}MB`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
// ─── Read-Only Enforcement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a window is read-only (locked or hard-closed).
|
||||
*/
|
||||
export async function isWindowReadOnly(
|
||||
windowId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<boolean> {
|
||||
const status = await checkDeadlinePolicy(windowId, prisma)
|
||||
return status.status === 'LOCKED' || status.status === 'CLOSED'
|
||||
}
|
||||
|
||||
// ─── Visibility Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get visible submission windows for a round.
|
||||
*/
|
||||
export async function getVisibleWindows(
|
||||
roundId: string,
|
||||
prisma: PrismaClient | any,
|
||||
) {
|
||||
const visibility = await prisma.roundSubmissionVisibility.findMany({
|
||||
where: { roundId, canView: true },
|
||||
include: {
|
||||
submissionWindow: {
|
||||
include: { fileRequirements: { orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return visibility.map((v: any) => ({
|
||||
...v.submissionWindow,
|
||||
displayLabel: v.displayLabel,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user