Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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: {

View File

@@ -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' },
})

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

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

View File

@@ -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
})

View File

@@ -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 },

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

View File

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

View File

@@ -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(),

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

View File

@@ -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 } : {}),
},
}

View File

@@ -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,

View File

@@ -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()),
})
)

View File

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

View File

@@ -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,
},

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

View File

@@ -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 } },
},
},
},

View File

@@ -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',
},
})

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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) {

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

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

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

View File

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

View File

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

View File

@@ -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
}
}

View File

@@ -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
*/