Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -1,6 +1,5 @@
import { router } from '../trpc'
import { programRouter } from './program'
import { roundRouter } from './round'
import { projectRouter } from './project'
import { userRouter } from './user'
import { assignmentRouter } from './assignment'
@@ -30,19 +29,27 @@ import { filteringRouter } from './filtering'
import { specialAwardRouter } from './specialAward'
import { notificationRouter } from './notification'
// Feature expansion routers
import { roundTemplateRouter } from './roundTemplate'
import { messageRouter } from './message'
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 { routingRouter } from './routing'
import { stageFilteringRouter } from './stageFiltering'
import { stageAssignmentRouter } from './stageAssignment'
import { cohortRouter } from './cohort'
import { liveRouter } from './live'
import { decisionRouter } from './decision'
import { awardRouter } from './award'
/**
* Root tRPC router that combines all domain routers
*/
export const appRouter = router({
program: programRouter,
round: roundRouter,
project: projectRouter,
user: userRouter,
assignment: assignmentRouter,
@@ -72,12 +79,21 @@ export const appRouter = router({
specialAward: specialAwardRouter,
notification: notificationRouter,
// Feature expansion routers
roundTemplate: roundTemplateRouter,
message: messageRouter,
webhook: webhookRouter,
projectPool: projectPoolRouter,
wizardTemplate: wizardTemplateRouter,
dashboard: dashboardRouter,
// Round redesign Phase 2 routers
pipeline: pipelineRouter,
stage: stageRouter,
routing: routingRouter,
stageFiltering: stageFilteringRouter,
stageAssignment: stageAssignmentRouter,
cohort: cohortRouter,
live: liveRouter,
decision: decisionRouter,
award: awardRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -1,38 +1,36 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
import { router, observerProcedure } from '../trpc'
// Shared input schema: either roundId or programId (for entire edition)
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
const editionOrStageInput = z.object({
stageId: z.string().optional(),
programId: z.string().optional(),
}).refine(data => data.roundId || data.programId, {
message: 'Either roundId or programId is required',
}).refine(data => data.stageId || data.programId, {
message: 'Either stageId or programId is required',
})
// Build Prisma where-clauses from the shared input
function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { roundId: input.roundId }
function projectWhere(input: { stageId?: string; programId?: string }) {
if (input.stageId) return { assignments: { some: { stageId: input.stageId } } }
return { programId: input.programId! }
}
function assignmentWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { roundId: input.roundId }
return { round: { 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 evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
const base = input.roundId
? { assignment: { roundId: input.roundId } }
: { assignment: { round: { 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! } } } } }
return { ...base, ...extra }
}
export const analyticsRouter = router({
/**
* Get score distribution for a round (histogram data)
* Get score distribution (histogram data)
*/
getScoreDistribution: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: evalWhere(input, { status: 'SUBMITTED' }),
@@ -74,7 +72,7 @@ export const analyticsRouter = router({
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: evalWhere(input, { status: 'SUBMITTED' }),
@@ -117,7 +115,7 @@ export const analyticsRouter = router({
* Get juror workload distribution
*/
getJurorWorkload: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: assignmentWhere(input),
@@ -166,7 +164,7 @@ export const analyticsRouter = router({
* Get project rankings with average scores
*/
getProjectRankings: observerProcedure
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
.input(editionOrStageInput.and(z.object({ limit: z.number().optional() })))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: projectWhere(input),
@@ -234,7 +232,7 @@ export const analyticsRouter = router({
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
by: ['status'],
@@ -252,7 +250,7 @@ export const analyticsRouter = router({
* Get overview stats for dashboard
*/
getOverviewStats: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
const [
projectCount,
@@ -299,12 +297,11 @@ export const analyticsRouter = router({
* Get criteria-level score distribution
*/
getCriteriaScores: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
// Get active evaluation forms — either for a specific round or all rounds in the edition
const formWhere = input.roundId
? { roundId: input.roundId, isActive: true }
: { round: { programId: input.programId! }, isActive: true }
const formWhere = input.stageId
? { stageId: input.stageId, isActive: true }
: { stage: { track: { pipeline: { programId: input.programId! } } }, isActive: true }
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
where: formWhere,
@@ -314,14 +311,12 @@ export const analyticsRouter = router({
return []
}
// Merge criteria from all forms (deduplicate by label for edition-wide)
const criteriaMap = new Map<string, { id: string; label: string }>()
evaluationForms.forEach((form) => {
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
if (criteria) {
criteria.forEach((c) => {
// Use label as dedup key for edition-wide, id for single round
const key = input.roundId ? c.id : c.label
const key = input.stageId ? c.id : c.label
if (!criteriaMap.has(key)) {
criteriaMap.set(key, c)
}
@@ -375,12 +370,12 @@ export const analyticsRouter = router({
.input(
z.object({
programId: z.string(),
roundId: z.string().optional(),
stageId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { roundId: input.roundId }
const where = input.stageId
? { assignments: { some: { stageId: input.stageId } } }
: { programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({
@@ -400,24 +395,26 @@ export const analyticsRouter = router({
// =========================================================================
/**
* Compare metrics across multiple rounds
* Compare metrics across multiple stages
*/
getCrossRoundComparison: observerProcedure
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
getCrossStageComparison: observerProcedure
.input(z.object({ stageIds: z.array(z.string()).min(2) }))
.query(async ({ ctx, input }) => {
const comparisons = await Promise.all(
input.roundIds.map(async (roundId) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
input.stageIds.map(async (stageId) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: stageId },
select: { id: true, name: true },
})
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId } }),
ctx.prisma.assignment.count({ where: { roundId } }),
ctx.prisma.project.count({
where: { assignments: { some: { stageId } } },
}),
ctx.prisma.assignment.count({ where: { stageId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId },
assignment: { stageId },
status: 'SUBMITTED',
},
}),
@@ -427,10 +424,9 @@ export const analyticsRouter = router({
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Get average scores
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId },
assignment: { stageId },
status: 'SUBMITTED',
},
select: { globalScore: true },
@@ -444,15 +440,14 @@ export const analyticsRouter = router({
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
// Score distribution
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
}))
return {
roundId,
roundName: round.name,
stageId,
stageName: stage.name,
projectCount,
evaluationCount,
completionRate,
@@ -466,10 +461,10 @@ export const analyticsRouter = router({
}),
/**
* Get juror consistency metrics for a round
* Get juror consistency metrics for a stage
*/
getJurorConsistency: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: evalWhere(input, { status: 'SUBMITTED' }),
@@ -535,10 +530,10 @@ export const analyticsRouter = router({
}),
/**
* Get diversity metrics for projects in a round
* Get diversity metrics for projects in a stage
*/
getDiversityMetrics: observerProcedure
.input(editionOrRoundInput)
.input(editionOrStageInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: projectWhere(input),
@@ -600,38 +595,39 @@ export const analyticsRouter = router({
}),
/**
* Get year-over-year stats across all rounds in a program
* Get year-over-year stats across all stages in a program
*/
getYearOverYear: observerProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const rounds = await ctx.prisma.round.findMany({
where: { programId: input.programId },
const stages = await ctx.prisma.stage.findMany({
where: { track: { pipeline: { programId: input.programId } } },
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
})
const stats = await Promise.all(
rounds.map(async (round) => {
stages.map(async (stage) => {
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: round.id } }),
ctx.prisma.project.count({
where: { assignments: { some: { stageId: stage.id } } },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
assignment: { stageId: stage.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
ctx.prisma.assignment.count({ where: { stageId: stage.id } }),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Average score
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
assignment: { stageId: stage.id },
status: 'SUBMITTED',
},
select: { globalScore: true },
@@ -646,9 +642,9 @@ export const analyticsRouter = router({
: null
return {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
stageId: stage.id,
stageName: stage.name,
createdAt: stage.createdAt,
projectCount,
evaluationCount,
completionRate,
@@ -661,22 +657,24 @@ export const analyticsRouter = router({
}),
/**
* Get dashboard stats (optionally scoped to a round)
* Get dashboard stats (optionally scoped to a stage)
*/
getDashboardStats: observerProcedure
.input(z.object({ roundId: z.string().optional() }).optional())
.input(z.object({ stageId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const roundId = input?.roundId
const stageId = input?.stageId
const roundWhere = roundId ? { roundId } : {}
const assignmentWhere = roundId ? { roundId } : {}
const evalWhere = roundId
? { assignment: { roundId }, status: 'SUBMITTED' as const }
const projectFilter = stageId
? { assignments: { some: { stageId } } }
: {}
const assignmentFilter = stageId ? { stageId } : {}
const evalFilter = stageId
? { assignment: { stageId }, status: 'SUBMITTED' as const }
: { status: 'SUBMITTED' as const }
const [
programCount,
activeRoundCount,
activeStageCount,
projectCount,
jurorCount,
submittedEvaluations,
@@ -684,13 +682,13 @@ export const analyticsRouter = router({
evaluationScores,
] = await Promise.all([
ctx.prisma.program.count(),
ctx.prisma.round.count({ where: { status: 'ACTIVE' } }),
ctx.prisma.project.count({ where: roundWhere }),
ctx.prisma.stage.count({ where: { status: 'STAGE_ACTIVE' } }),
ctx.prisma.project.count({ where: projectFilter }),
ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
ctx.prisma.evaluation.count({ where: evalWhere }),
ctx.prisma.assignment.count({ where: assignmentWhere }),
ctx.prisma.evaluation.count({ where: evalFilter }),
ctx.prisma.assignment.count({ where: assignmentFilter }),
ctx.prisma.evaluation.findMany({
where: { ...evalWhere, globalScore: { not: null } },
where: { ...evalFilter, globalScore: { not: null } },
select: { globalScore: true },
}),
])
@@ -713,7 +711,7 @@ export const analyticsRouter = router({
return {
programCount,
activeRoundCount,
activeStageCount,
projectCount,
jurorCount,
submittedEvaluations,
@@ -723,13 +721,380 @@ export const analyticsRouter = router({
}
}),
// =========================================================================
// Stage-Scoped Analytics (Phase 4)
// =========================================================================
/**
* Get score distribution histogram for stage evaluations
*/
getStageScoreDistribution: observerProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { stageId: input.stageId },
},
select: {
globalScore: true,
criterionScoresJson: true,
},
})
// Global score distribution (1-10 buckets)
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const globalDistribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
}))
// Per-criterion score distribution
const criterionScores: Record<string, number[]> = {}
evaluations.forEach((e) => {
const scores = e.criterionScoresJson as Record<string, number> | null
if (scores) {
Object.entries(scores).forEach(([key, value]) => {
if (typeof value === 'number') {
if (!criterionScores[key]) criterionScores[key] = []
criterionScores[key].push(value)
}
})
}
})
const criterionDistributions = Object.entries(criterionScores).map(([criterionId, scores]) => ({
criterionId,
average: scores.reduce((a, b) => a + b, 0) / scores.length,
count: scores.length,
distribution: Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: scores.filter((s) => Math.round(s) === i + 1).length,
})),
}))
return {
globalDistribution,
totalEvaluations: evaluations.length,
averageGlobalScore:
globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: 0,
criterionDistributions,
}
}),
/**
* Get per-stage completion summary for a pipeline
*/
getStageCompletionOverview: observerProcedure
.input(z.object({ pipelineId: 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,
},
},
},
orderBy: { sortOrder: 'asc' },
})
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) => {
const [
projectStates,
totalAssignments,
completedEvaluations,
distinctJurors,
] = await Promise.all([
ctx.prisma.projectStageState.groupBy({
by: ['state'],
where: { stageId: stage.id },
_count: true,
}),
ctx.prisma.assignment.count({
where: { stageId: stage.id },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { stageId: stage.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { stageId: stage.id },
}),
])
const stateBreakdown = projectStates.map((ps) => ({
state: ps.state,
count: ps._count,
}))
const totalProjects = projectStates.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,
totalProjects,
stateBreakdown,
totalAssignments,
completedEvaluations,
pendingEvaluations: totalAssignments - completedEvaluations,
completionRate,
jurorCount: distinctJurors.length,
}
})
)
return {
pipelineId: input.pipelineId,
stages: stageOverviews,
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),
},
}
}),
// =========================================================================
// 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' },
})
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,
}
}),
/**
* Get all projects with pagination, filtering, and search (for observer dashboard)
*/
getAllProjects: observerProcedure
.input(
z.object({
roundId: z.string().optional(),
stageId: z.string().optional(),
search: z.string().optional(),
status: z.string().optional(),
page: z.number().min(1).default(1),
@@ -739,8 +1104,8 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.roundId) {
where.roundId = input.roundId
if (input.stageId) {
where.assignments = { some: { stageId: input.stageId } }
}
if (input.status) {
@@ -763,9 +1128,10 @@ export const analyticsRouter = router({
teamName: true,
status: true,
country: true,
round: { select: { id: true, name: true } },
assignments: {
select: {
stageId: true,
stage: { select: { id: true, name: true } },
evaluation: {
select: { globalScore: true, status: true },
},
@@ -791,14 +1157,16 @@ export const analyticsRouter = router({
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
const firstAssignment = p.assignments[0]
return {
id: p.id,
title: p.title,
teamName: p.teamName,
status: p.status,
country: p.country,
roundId: p.round?.id ?? '',
roundName: p.round?.name ?? '',
stageId: firstAssignment?.stage?.id ?? '',
stageName: firstAssignment?.stage?.name ?? '',
averageScore,
evaluationCount: submitted.length,
}

View File

@@ -22,36 +22,42 @@ export const applicantRouter = router({
getSubmissionBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
// Find the round by slug
const round = await ctx.prisma.round.findFirst({
const stage = await ctx.prisma.stage.findFirst({
where: { slug: input.slug },
include: {
program: { select: { id: true, name: true, year: true, description: true } },
track: {
include: {
pipeline: {
include: {
program: { select: { id: true, name: true, year: true, description: true } },
},
},
},
},
},
})
if (!round) {
if (!stage) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
message: 'Stage not found',
})
}
// Check if submissions are open
const now = new Date()
const isOpen = round.submissionDeadline
? now < round.submissionDeadline
: round.status === 'ACTIVE'
const isOpen = stage.windowCloseAt
? now < stage.windowCloseAt
: stage.status === 'STAGE_ACTIVE'
return {
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionDeadline: round.submissionDeadline,
stage: {
id: stage.id,
name: stage.name,
slug: stage.slug,
windowCloseAt: stage.windowCloseAt,
isOpen,
},
program: round.program,
program: stage.track.pipeline.program,
}
}),
@@ -59,7 +65,7 @@ export const applicantRouter = router({
* Get the current user's submission for a round (as submitter or team member)
*/
getMySubmission: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string().optional(), programId: z.string().optional() }))
.query(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
@@ -69,25 +75,29 @@ export const applicantRouter = router({
})
}
const project = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
files: true,
round: {
include: {
program: { select: { name: true, year: true } },
const where: Record<string, unknown> = {
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
}
if (input.stageId) {
where.stageStates = { some: { stageId: input.stageId } }
}
if (input.programId) {
where.programId = input.programId
}
const project = await ctx.prisma.project.findFirst({
where,
include: {
files: true,
program: { select: { id: true, name: true, year: true } },
teamMembers: {
include: {
user: {
@@ -116,14 +126,14 @@ export const applicantRouter = router({
saveSubmission: protectedProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string().optional(), // If updating existing
programId: z.string().optional(),
projectId: z.string().optional(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
submit: z.boolean().default(false), // Whether to submit or just save draft
submit: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
@@ -135,20 +145,9 @@ export const applicantRouter = router({
})
}
// Check if the round is open for submissions
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const now = new Date()
if (round.submissionDeadline && now > round.submissionDeadline) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Submission deadline has passed',
})
}
const { projectId, submit, roundId, metadataJson, ...data } = input
const { projectId, submit, programId, metadataJson, ...data } = input
if (projectId) {
// Update existing
@@ -193,17 +192,17 @@ export const applicantRouter = router({
return project
} else {
// Get the round to find the programId
const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { programId: true },
})
if (!programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'programId is required when creating a new submission',
})
}
// Create new project
const project = await ctx.prisma.project.create({
data: {
programId: roundForCreate.programId,
roundId,
programId,
...data,
metadataJson: metadataJson as unknown ?? undefined,
submittedByUserId: ctx.user.id,
@@ -240,7 +239,7 @@ export const applicantRouter = router({
fileName: z.string(),
mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
roundId: z.string().optional(),
stageId: z.string().optional(),
requirementId: z.string().optional(),
})
)
@@ -269,9 +268,6 @@ export const applicantRouter = router({
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
},
})
if (!project) {
@@ -306,37 +302,9 @@ export const applicantRouter = router({
}
}
// Check round upload deadline policy if roundId provided
let isLate = false
const targetRoundId = input.roundId || project.roundId
if (targetRoundId) {
const round = input.roundId
? await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingStartAt: true, settingsJson: true },
})
: project.round
if (round) {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const now = new Date()
const roundStarted = round.votingStartAt && now > round.votingStartAt
if (roundStarted && uploadPolicy === 'BLOCK') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Uploads are blocked after the round has started',
})
}
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
isLate = true
}
}
}
// Can't upload if already submitted (unless round allows it)
// Can't upload if already submitted
if (project.submittedAt && !isLate) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -355,7 +323,7 @@ export const applicantRouter = router({
bucket: SUBMISSIONS_BUCKET,
objectKey,
isLate,
roundId: targetRoundId,
stageId: input.stageId || null,
}
}),
@@ -372,7 +340,7 @@ export const applicantRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
bucket: z.string(),
objectKey: z.string(),
roundId: z.string().optional(),
stageId: z.string().optional(),
isLate: z.boolean().optional(),
requirementId: z.string().optional(),
})
@@ -410,15 +378,14 @@ export const applicantRouter = router({
})
}
const { projectId, roundId, isLate, requirementId, ...fileData } = input
const { projectId, stageId, isLate, requirementId, ...fileData } = input
// Delete existing file: by requirementId if provided, otherwise by fileType+roundId
// Delete existing file: by requirementId if provided, otherwise by fileType
if (requirementId) {
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
requirementId,
...(roundId ? { roundId } : {}),
},
})
} else {
@@ -426,17 +393,16 @@ export const applicantRouter = router({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
}
// Create new file record
// Create new file record (roundId column kept null for new data)
const file = await ctx.prisma.projectFile.create({
data: {
projectId,
...fileData,
roundId: roundId || null,
roundId: null,
isLate: isLate || false,
requirementId: requirementId || null,
},
@@ -543,11 +509,7 @@ export const applicantRouter = router({
],
},
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
files: true,
teamMembers: {
include: {
@@ -686,11 +648,7 @@ export const applicantRouter = router({
],
},
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
files: true,
teamMembers: {
include: {
@@ -764,7 +722,6 @@ export const applicantRouter = router({
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
roundId: project.roundId,
}
}),
@@ -1166,11 +1123,7 @@ export const applicantRouter = router({
],
},
include: {
round: {
include: {
program: { select: { id: true, name: true, year: true, status: true } },
},
},
program: { select: { id: true, name: true, year: true, status: true } },
files: {
orderBy: { createdAt: 'desc' },
},
@@ -1200,7 +1153,7 @@ export const applicantRouter = router({
})
if (!project) {
return { project: null, openRounds: [], timeline: [], currentStatus: null }
return { project: null, openStages: [], timeline: [], currentStatus: null }
}
const currentStatus = project.status ?? 'SUBMITTED'
@@ -1285,32 +1238,25 @@ export const applicantRouter = router({
}
}
// Find open rounds in the same program where documents can be submitted
const programId = project.round?.programId || project.programId
const now = new Date()
const openRounds = programId
? await ctx.prisma.round.findMany({
const programId = project.programId
const openStages = programId
? await ctx.prisma.stage.findMany({
where: {
programId,
status: 'ACTIVE',
track: { pipeline: { programId } },
status: 'STAGE_ACTIVE',
},
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
slug: true,
stageType: true,
windowOpenAt: true,
windowCloseAt: true,
},
})
: []
// Filter: only rounds that still accept uploads
const uploadableRounds = openRounds.filter((round) => {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const roundStarted = round.votingStartAt && now > round.votingStartAt
// If deadline passed and policy is BLOCK, skip
if (roundStarted && uploadPolicy === 'BLOCK') return false
return true
})
// Determine user's role in the project
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
@@ -1321,7 +1267,7 @@ export const applicantRouter = router({
isTeamLead,
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
},
openRounds: uploadableRounds,
openStages,
timeline,
currentStatus,
}

View File

@@ -8,7 +8,6 @@ import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
import { checkRateLimit } from '@/lib/rate-limit'
import { logAudit } from '@/server/utils/audit'
import { parseWizardConfig } from '@/lib/wizard-config'
@@ -97,7 +96,7 @@ export const applicationRouter = router({
.input(
z.object({
slug: z.string(),
mode: z.enum(['edition', 'round']).default('round'),
mode: z.enum(['edition', 'stage']).default('stage'),
})
)
.query(async ({ ctx, input }) => {
@@ -171,71 +170,62 @@ export const applicationRouter = router({
competitionCategories: wizardConfig.competitionCategories ?? [],
}
} else {
// Round-specific application mode (backward compatible)
const round = await ctx.prisma.round.findFirst({
// Stage-specific application mode (backward compatible with round slug)
const stage = await ctx.prisma.stage.findFirst({
where: { slug: input.slug },
include: {
program: {
select: {
id: true,
name: true,
year: true,
description: true,
settingsJson: true,
track: {
include: {
pipeline: {
include: {
program: {
select: {
id: true,
name: true,
year: true,
description: true,
settingsJson: true,
},
},
},
},
},
},
},
})
if (!round) {
if (!stage) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application round not found',
message: 'Application stage not found',
})
}
// Check if submissions are open
let isOpen = false
const stageProgram = stage.track.pipeline.program
const isOpen = stage.windowOpenAt && stage.windowCloseAt
? now >= stage.windowOpenAt && now <= stage.windowCloseAt
: stage.status === 'STAGE_ACTIVE'
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
} else {
isOpen = round.status === 'ACTIVE'
}
// Calculate grace period if applicable
let gracePeriodEnd: Date | null = null
if (round.lateSubmissionGrace && round.submissionEndDate) {
gracePeriodEnd = new Date(round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000)
if (now <= gracePeriodEnd) {
isOpen = true
}
}
const roundWizardConfig = parseWizardConfig(round.program.settingsJson)
const { settingsJson: _s, ...programData } = round.program
const stageWizardConfig = parseWizardConfig(stageProgram.settingsJson)
const { settingsJson: _s, ...programData } = stageProgram
return {
mode: 'round' as const,
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionStartDate: round.submissionStartDate,
submissionEndDate: round.submissionEndDate,
submissionDeadline: round.submissionDeadline,
lateSubmissionGrace: round.lateSubmissionGrace,
gracePeriodEnd,
phase1Deadline: round.phase1Deadline,
phase2Deadline: round.phase2Deadline,
mode: 'stage' as const,
stage: {
id: stage.id,
name: stage.name,
slug: stage.slug,
submissionStartDate: stage.windowOpenAt,
submissionEndDate: stage.windowCloseAt,
submissionDeadline: stage.windowCloseAt,
lateSubmissionGrace: null,
gracePeriodEnd: null,
isOpen,
},
program: programData,
wizardConfig: roundWizardConfig,
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
competitionCategories: roundWizardConfig.competitionCategories ?? [],
wizardConfig: stageWizardConfig,
oceanIssueOptions: stageWizardConfig.oceanIssues ?? [],
competitionCategories: stageWizardConfig.competitionCategories ?? [],
}
}
}),
@@ -246,9 +236,9 @@ export const applicationRouter = router({
submit: publicProcedure
.input(
z.object({
mode: z.enum(['edition', 'round']).default('round'),
mode: z.enum(['edition', 'stage']).default('stage'),
programId: z.string().optional(),
roundId: z.string().optional(),
stageId: z.string().optional(),
data: applicationInputSchema,
})
)
@@ -263,7 +253,7 @@ export const applicationRouter = router({
})
}
const { mode, programId, roundId, data } = input
const { mode, programId, stageId, data } = input
// Validate input based on mode
if (mode === 'edition' && !programId) {
@@ -273,10 +263,10 @@ export const applicationRouter = router({
})
}
if (mode === 'round' && !roundId) {
if (mode === 'stage' && !stageId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'roundId is required for round-specific applications',
message: 'stageId is required for stage-specific applications',
})
}
@@ -340,7 +330,6 @@ export const applicationRouter = router({
const existingProject = await ctx.prisma.project.findFirst({
where: {
programId,
roundId: null,
submittedByEmail: data.contactEmail,
},
})
@@ -352,42 +341,38 @@ export const applicationRouter = router({
})
}
} else {
// Round-specific application (backward compatible)
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: { program: true },
// Stage-specific application
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: stageId! },
include: {
track: {
include: {
pipeline: { include: { program: true } },
},
},
},
})
program = round.program
program = stage.track.pipeline.program
// Check submission window
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
// Check grace period
if (!isOpen && round.lateSubmissionGrace) {
const gracePeriodEnd = new Date(
round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000
)
isOpen = now <= gracePeriodEnd
}
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
if (stage.windowOpenAt && stage.windowCloseAt) {
isOpen = now >= stage.windowOpenAt && now <= stage.windowCloseAt
} else {
isOpen = round.status === 'ACTIVE'
isOpen = stage.status === 'STAGE_ACTIVE'
}
if (!isOpen) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Applications are currently closed for this round',
message: 'Applications are currently closed for this stage',
})
}
// Check if email already submitted for this round
// Check if email already submitted for this stage
const existingProject = await ctx.prisma.project.findFirst({
where: {
roundId,
programId: program.id,
submittedByEmail: data.contactEmail,
},
})
@@ -395,7 +380,7 @@ export const applicationRouter = router({
if (existingProject) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An application with this email already exists for this round',
message: 'An application with this email already exists for this stage',
})
}
}
@@ -431,7 +416,6 @@ export const applicationRouter = router({
const project = await ctx.prisma.project.create({
data: {
programId: program.id,
roundId: mode === 'round' ? roundId! : null,
title: data.projectName,
teamName: data.teamName,
description: data.description,
@@ -460,18 +444,6 @@ export const applicationRouter = router({
},
})
// Auto-assign to first round if project has no roundId (edition-wide mode)
let assignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
if (!project.roundId) {
assignedRound = await getFirstRoundForProgram(ctx.prisma, program.id)
if (assignedRound) {
await ctx.prisma.project.update({
where: { id: project.id },
data: { roundId: assignedRound.id },
})
}
}
// Create team lead membership
await ctx.prisma.teamMember.create({
data: {
@@ -524,7 +496,7 @@ export const applicationRouter = router({
source: 'public_application_form',
title: data.projectName,
category: data.competitionCategory,
autoAssignedRound: assignedRound?.name || null,
autoAssignedStage: null,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
@@ -559,26 +531,6 @@ export const applicationRouter = router({
},
})
// Send SUBMISSION_RECEIVED notification if the round is configured for it
if (assignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
try {
await notifyProjectTeam(project.id, {
type: NotificationTypes.SUBMISSION_RECEIVED,
title: 'Submission Received',
message: `Your submission "${data.projectName}" has been received and is now under review.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Submission',
metadata: {
projectName: data.projectName,
roundName: assignedRound.name,
programName: program.name,
},
})
} catch {
// Never fail on notification
}
}
return {
success: true,
projectId: project.id,
@@ -592,9 +544,9 @@ export const applicationRouter = router({
checkEmailAvailability: publicProcedure
.input(
z.object({
mode: z.enum(['edition', 'round']).default('round'),
mode: z.enum(['edition', 'stage']).default('stage'),
programId: z.string().optional(),
roundId: z.string().optional(),
stageId: z.string().optional(),
email: z.string().email(),
})
)
@@ -614,23 +566,31 @@ export const applicationRouter = router({
existing = await ctx.prisma.project.findFirst({
where: {
programId: input.programId,
roundId: null,
submittedByEmail: input.email,
},
})
} else {
existing = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
submittedByEmail: input.email,
},
})
// 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 } } } } },
})
if (stage) {
existing = await ctx.prisma.project.findFirst({
where: {
programId: stage.track.pipeline.programId,
submittedByEmail: input.email,
},
})
}
}
}
return {
available: !existing,
message: existing
? `An application with this email already exists for this ${input.mode === 'edition' ? 'edition' : 'round'}`
? `An application with this email already exists for this ${input.mode === 'edition' ? 'edition' : 'stage'}`
: null,
}
}),
@@ -646,52 +606,57 @@ export const applicationRouter = router({
.input(
z.object({
roundSlug: z.string(),
programId: z.string().optional(),
email: z.string().email(),
draftDataJson: z.record(z.unknown()),
title: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Find round by slug
const round = await ctx.prisma.round.findFirst({
// Find stage by slug
const stage = await ctx.prisma.stage.findFirst({
where: { slug: input.roundSlug },
include: {
track: {
include: {
pipeline: { select: { programId: true } },
},
},
},
})
if (!round) {
if (!stage) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
message: 'Stage not found',
})
}
// Check if drafts are enabled
const settings = (round.settingsJson as Record<string, unknown>) || {}
if (settings.drafts_enabled === false) {
const stageConfig = (stage.configJson as Record<string, unknown>) || {}
if (stageConfig.drafts_enabled === false) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Draft saving is not enabled for this round',
message: 'Draft saving is not enabled for this stage',
})
}
// Calculate draft expiry
const draftExpiryDays = (settings.draft_expiry_days as number) || 30
const draftExpiryDays = (stageConfig.draft_expiry_days as number) || 30
const draftExpiresAt = new Date()
draftExpiresAt.setDate(draftExpiresAt.getDate() + draftExpiryDays)
// Generate resume token
const draftToken = `draft_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
// Find or create draft project for this email+round
const programId = input.programId || stage.track.pipeline.programId
const existingDraft = await ctx.prisma.project.findFirst({
where: {
roundId: round.id,
programId,
submittedByEmail: input.email,
isDraft: true,
},
})
if (existingDraft) {
// Update existing draft
const updated = await ctx.prisma.project.update({
where: { id: existingDraft.id },
data: {
@@ -708,11 +673,9 @@ export const applicationRouter = router({
return { projectId: updated.id, draftToken }
}
// Create new draft project
const project = await ctx.prisma.project.create({
data: {
programId: round.programId,
roundId: round.id,
programId,
title: input.title || 'Untitled Draft',
isDraft: true,
draftDataJson: input.draftDataJson as Prisma.InputJsonValue,
@@ -764,7 +727,6 @@ export const applicationRouter = router({
projectId: project.id,
draftDataJson: project.draftDataJson,
title: project.title,
roundId: project.roundId,
}
}),
@@ -782,7 +744,7 @@ export const applicationRouter = router({
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { round: { include: { program: true } } },
include: { program: true },
})
// Verify token
@@ -851,18 +813,6 @@ export const applicationRouter = router({
},
})
// Auto-assign to first round if project has no roundId
let draftAssignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
if (!updated.roundId) {
draftAssignedRound = await getFirstRoundForProgram(ctx.prisma, updated.programId)
if (draftAssignedRound) {
await ctx.prisma.project.update({
where: { id: updated.id },
data: { roundId: draftAssignedRound.id },
})
}
}
// Audit log
try {
await logAudit({
@@ -875,7 +825,6 @@ export const applicationRouter = router({
source: 'draft_submission',
title: data.projectName,
category: data.competitionCategory,
autoAssignedRound: draftAssignedRound?.name || null,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
@@ -884,29 +833,10 @@ export const applicationRouter = router({
// Never throw on audit failure
}
// Send SUBMISSION_RECEIVED notification if the round is configured for it
if (draftAssignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
try {
await notifyProjectTeam(updated.id, {
type: NotificationTypes.SUBMISSION_RECEIVED,
title: 'Submission Received',
message: `Your submission "${data.projectName}" has been received and is now under review.`,
linkUrl: `/team/projects/${updated.id}`,
linkLabel: 'View Submission',
metadata: {
projectName: data.projectName,
roundName: draftAssignedRound.name,
},
})
} catch {
// Never fail on notification
}
}
return {
success: true,
projectId: updated.id,
message: `Thank you for applying to ${project.round?.program.name ?? 'the program'}!`,
message: `Thank you for applying to ${project.program?.name ?? 'the program'}!`,
}
}),

View File

@@ -17,27 +17,26 @@ import {
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
// Background job execution function
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
async function runAIAssignmentJob(jobId: string, stageId: string, userId: string) {
try {
// Update job to running
await prisma.assignmentJob.update({
where: { id: jobId },
data: { status: 'RUNNING', startedAt: new Date() },
})
// Get round constraints
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
const stage = await prisma.stage.findUniqueOrThrow({
where: { id: stageId },
select: {
name: true,
requiredReviews: true,
minAssignmentsPerJuror: true,
maxAssignmentsPerJuror: true,
configJson: true,
},
})
// Get all active jury members with their expertise and current load
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
const jurors = await prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
select: {
@@ -48,28 +47,32 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId } },
assignments: { where: { stageId } },
},
},
},
})
// Get all projects in the round
const projectStageStates = await prisma.projectStageState.findMany({
where: { stageId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projects = await prisma.project.findMany({
where: { roundId },
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
description: true,
tags: true,
teamName: true,
_count: { select: { assignments: true } },
_count: { select: { assignments: { where: { stageId } } } },
},
})
// Get existing assignments
const existingAssignments = await prisma.assignment.findMany({
where: { roundId },
where: { stageId },
select: { userId: true, projectId: true },
})
@@ -94,22 +97,21 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
}
const constraints = {
requiredReviewsPerProject: round.requiredReviews,
minAssignmentsPerJuror: round.minAssignmentsPerJuror,
maxAssignmentsPerJuror: round.maxAssignmentsPerJuror,
requiredReviewsPerProject: requiredReviews,
minAssignmentsPerJuror,
maxAssignmentsPerJuror,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
})),
}
// Execute AI assignment with progress callback
const result = await generateAIAssignments(
jurors,
projects,
constraints,
userId,
roundId,
stageId,
onProgress
)
@@ -137,16 +139,15 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
},
})
// Notify admins that AI assignment is complete
await notifyAdmins({
type: NotificationTypes.AI_SUGGESTIONS_READY,
title: 'AI Assignment Suggestions Ready',
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/rounds/${roundId}/assignments`,
message: `AI generated ${result.suggestions.length} assignment suggestions for ${stage.name || 'stage'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/assignments`,
linkLabel: 'View Suggestions',
priority: 'high',
metadata: {
roundId,
stageId,
jobId,
projectCount: projects.length,
suggestionsCount: result.suggestions.length,
@@ -170,14 +171,11 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
}
export const assignmentRouter = router({
/**
* List assignments for a round (admin only)
*/
listByRound: adminProcedure
.input(z.object({ roundId: z.string() }))
listByStage: adminProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
project: { select: { id: true, title: true, tags: true } },
@@ -220,18 +218,18 @@ export const assignmentRouter = router({
myAssignments: protectedProcedure
.input(
z.object({
roundId: z.string().optional(),
stageId: z.string().optional(),
status: z.enum(['all', 'pending', 'completed']).default('all'),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
userId: ctx.user.id,
round: { status: 'ACTIVE' },
stage: { status: 'STAGE_ACTIVE' },
}
if (input.roundId) {
where.roundId = input.roundId
if (input.stageId) {
where.stageId = input.stageId
}
if (input.status === 'pending') {
@@ -246,7 +244,7 @@ export const assignmentRouter = router({
project: {
include: { files: true },
},
round: true,
stage: true,
evaluation: true,
},
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
@@ -264,7 +262,7 @@ export const assignmentRouter = router({
include: {
user: { select: { id: true, name: true, email: true } },
project: { include: { files: true } },
round: { include: { evaluationForms: { where: { isActive: true } } } },
stage: { include: { evaluationForms: { where: { isActive: true } } } },
evaluation: true,
},
})
@@ -291,19 +289,18 @@ export const assignmentRouter = router({
z.object({
userId: z.string(),
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
isRequired: z.boolean().default(true),
forceOverride: z.boolean().default(false), // Allow manual override of limits
forceOverride: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// Check if assignment already exists
const existing = await ctx.prisma.assignment.findUnique({
where: {
userId_projectId_roundId: {
userId_projectId_stageId: {
userId: input.userId,
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
},
})
@@ -315,11 +312,10 @@ export const assignmentRouter = router({
})
}
// Get round constraints and user limit
const [round, user] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { maxAssignmentsPerJuror: true },
const [stage, user] = await Promise.all([
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true },
}),
ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
@@ -327,11 +323,12 @@ export const assignmentRouter = router({
}),
])
// Calculate effective max: user override takes precedence if set
const effectiveMax = user.maxAssignments ?? round.maxAssignmentsPerJuror
const config = (stage.configJson ?? {}) as Record<string, unknown>
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
const currentCount = await ctx.prisma.assignment.count({
where: { userId: input.userId, roundId: input.roundId },
where: { userId: input.userId, stageId: input.stageId },
})
// Check if at or over limit
@@ -367,21 +364,20 @@ export const assignmentRouter = router({
userAgent: ctx.userAgent,
})
// Send notification to the assigned jury member
const [project, roundInfo] = await Promise.all([
const [project, stageInfo] = await Promise.all([
ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { title: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
}),
])
if (project && roundInfo) {
const deadline = roundInfo.votingEndAt
? new Date(roundInfo.votingEndAt).toLocaleDateString('en-US', {
if (project && stageInfo) {
const deadline = stageInfo.windowCloseAt
? new Date(stageInfo.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -393,12 +389,12 @@ export const assignmentRouter = router({
userId: input.userId,
type: NotificationTypes.ASSIGNED_TO_PROJECT,
title: 'New Project Assignment',
message: `You have been assigned to evaluate "${project.title}" for ${roundInfo.name}.`,
linkUrl: `/jury/assignments`,
message: `You have been assigned to evaluate "${project.title}" for ${stageInfo.name}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignment',
metadata: {
projectName: project.title,
roundName: roundInfo.name,
stageName: stageInfo.name,
deadline,
assignmentId: assignment.id,
},
@@ -414,11 +410,11 @@ export const assignmentRouter = router({
bulkCreate: adminProcedure
.input(
z.object({
stageId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
projectId: z.string(),
roundId: z.string(),
})
),
})
@@ -427,6 +423,7 @@ export const assignmentRouter = router({
const result = await ctx.prisma.assignment.createMany({
data: input.assignments.map((a) => ({
...a,
stageId: input.stageId,
method: 'BULK',
createdBy: ctx.user.id,
})),
@@ -455,15 +452,13 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
// Get round info for deadline
const roundId = input.assignments[0].roundId
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: { name: true, votingEndAt: true },
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -471,7 +466,6 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
@@ -479,19 +473,18 @@ export const assignmentRouter = router({
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
stageName: stage?.name,
deadline,
},
})
@@ -537,40 +530,48 @@ export const assignmentRouter = router({
* Get assignment statistics for a round
*/
getStats: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
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 },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const [
totalAssignments,
completedAssignments,
assignmentsByUser,
projectCoverage,
round,
] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { stageId: input.stageId } }),
ctx.prisma.assignment.count({
where: { roundId: input.roundId, isCompleted: true },
where: { stageId: input.stageId, isCompleted: true },
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
where: { stageId: input.stageId },
_count: true,
}),
ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
_count: { select: { assignments: true } },
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
},
}),
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
}),
])
const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews
(p) => p._count.assignments >= requiredReviews
).length
return {
@@ -598,21 +599,19 @@ export const assignmentRouter = router({
getSuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Get round constraints
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
requiredReviews: true,
minAssignmentsPerJuror: true,
maxAssignmentsPerJuror: true,
},
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
// Get all active jury members with their expertise and current load
const jurors = await ctx.prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
select: {
@@ -623,15 +622,20 @@ export const assignmentRouter = router({
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId: input.roundId } },
assignments: { where: { stageId: input.stageId } },
},
},
},
})
// Get all projects that need more assignments
const projectStageStates = await ctx.prisma.projectStageState.findMany({
where: { stageId: input.stageId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
@@ -639,20 +643,18 @@ export const assignmentRouter = router({
projectTags: {
include: { tag: { select: { name: true } } },
},
_count: { select: { assignments: true } },
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
},
})
// Get existing assignments to avoid duplicates
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
select: { userId: true, projectId: true },
})
const assignmentSet = new Set(
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
)
// Simple scoring algorithm
const suggestions: Array<{
userId: string
jurorName: string
@@ -663,18 +665,14 @@ export const assignmentRouter = router({
}> = []
for (const project of projects) {
// Skip if project has enough assignments
if (project._count.assignments >= round.requiredReviews) continue
if (project._count.assignments >= requiredReviews) continue
const neededAssignments = round.requiredReviews - project._count.assignments
const neededAssignments = requiredReviews - project._count.assignments
// Score each juror for this project
const jurorScores = jurors
.filter((j) => {
// Skip if already assigned
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
// Skip if at max capacity (user override takes precedence)
const effectiveMax = j.maxAssignments ?? round.maxAssignmentsPerJuror
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
if (j._count.assignments >= effectiveMax) return false
return true
})
@@ -682,10 +680,8 @@ export const assignmentRouter = router({
const reasoning: string[] = []
let score = 0
// Expertise match (35% weight) - use AI-assigned projectTags if available
const projectTagNames = project.projectTags.map((pt) => pt.tag.name.toLowerCase())
// Match against AI-assigned tags first, fall back to raw tags
const matchingTags = projectTagNames.length > 0
? juror.expertiseTags.filter((tag) =>
projectTagNames.includes(tag.toLowerCase())
@@ -704,22 +700,19 @@ export const assignmentRouter = router({
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
}
// Load balancing (20% weight)
const effectiveMax = juror.maxAssignments ?? round.maxAssignmentsPerJuror
const effectiveMax = juror.maxAssignments ?? maxAssignmentsPerJuror
const loadScore = 1 - juror._count.assignments / effectiveMax
score += loadScore * 20
// Under min target bonus (15% weight) - prioritize judges who need more projects
const underMinBonus =
juror._count.assignments < round.minAssignmentsPerJuror
? (round.minAssignmentsPerJuror - juror._count.assignments) * 3
juror._count.assignments < minAssignmentsPerJuror
? (minAssignmentsPerJuror - juror._count.assignments) * 3
: 0
score += Math.min(15, underMinBonus)
// Build reasoning
if (juror._count.assignments < round.minAssignmentsPerJuror) {
if (juror._count.assignments < minAssignmentsPerJuror) {
reasoning.push(
`Under target: ${juror._count.assignments}/${round.minAssignmentsPerJuror} min`
`Under target: ${juror._count.assignments}/${minAssignmentsPerJuror} min`
)
}
reasoning.push(
@@ -741,7 +734,6 @@ export const assignmentRouter = router({
suggestions.push(...jurorScores)
}
// Sort by score and return
return suggestions.sort((a, b) => b.score - a.score)
}),
@@ -758,15 +750,14 @@ export const assignmentRouter = router({
getAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
useAI: z.boolean().default(true),
})
)
.query(async ({ ctx, input }) => {
// Find the latest completed job for this round
const completedJob = await ctx.prisma.assignmentJob.findFirst({
where: {
roundId: input.roundId,
stageId: input.stageId,
status: 'COMPLETED',
},
orderBy: { completedAt: 'desc' },
@@ -777,7 +768,6 @@ export const assignmentRouter = router({
},
})
// If we have stored suggestions, return them
if (completedJob?.suggestionsJson) {
const suggestions = completedJob.suggestionsJson as Array<{
jurorId: string
@@ -789,9 +779,8 @@ export const assignmentRouter = router({
reasoning: string
}>
// Filter out suggestions for assignments that already exist
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
select: { userId: true, projectId: true },
})
const assignmentSet = new Set(
@@ -811,7 +800,6 @@ export const assignmentRouter = router({
}
}
// No completed job with suggestions - return empty
return {
success: true,
suggestions: [],
@@ -827,7 +815,7 @@ export const assignmentRouter = router({
applyAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
@@ -845,7 +833,7 @@ export const assignmentRouter = router({
data: input.assignments.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
stageId: input.stageId,
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
aiConfidenceScore: a.confidenceScore,
expertiseMatchScore: a.expertiseMatchScore,
@@ -855,14 +843,13 @@ export const assignmentRouter = router({
skipDuplicates: true,
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
stageId: input.stageId,
count: created.count,
usedAI: input.usedAI,
},
@@ -870,7 +857,6 @@ export const assignmentRouter = router({
userAgent: ctx.userAgent,
})
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
@@ -880,13 +866,13 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -894,7 +880,6 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
@@ -902,19 +887,18 @@ export const assignmentRouter = router({
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
stageName: stage?.name,
deadline,
},
})
@@ -930,7 +914,7 @@ export const assignmentRouter = router({
applySuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
@@ -945,7 +929,7 @@ export const assignmentRouter = router({
data: input.assignments.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
stageId: input.stageId,
method: 'ALGORITHM',
aiReasoning: a.reasoning,
createdBy: ctx.user.id,
@@ -953,21 +937,19 @@ export const assignmentRouter = router({
skipDuplicates: true,
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
stageId: input.stageId,
count: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
@@ -977,13 +959,13 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -991,7 +973,6 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
@@ -999,19 +980,18 @@ export const assignmentRouter = router({
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
stageName: stage?.name,
deadline,
},
})
@@ -1025,12 +1005,11 @@ export const assignmentRouter = router({
* Start an AI assignment job (background processing)
*/
startAIAssignmentJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Check for existing running job
const existingJob = await ctx.prisma.assignmentJob.findFirst({
where: {
roundId: input.roundId,
stageId: input.stageId,
status: { in: ['PENDING', 'RUNNING'] },
},
})
@@ -1038,11 +1017,10 @@ export const assignmentRouter = router({
if (existingJob) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'An AI assignment job is already running for this round',
message: 'An AI assignment job is already running for this stage',
})
}
// Verify AI is available
if (!isOpenAIConfigured()) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -1050,16 +1028,14 @@ export const assignmentRouter = router({
})
}
// Create job record
const job = await ctx.prisma.assignmentJob.create({
data: {
roundId: input.roundId,
stageId: input.stageId,
status: 'PENDING',
},
})
// Start background job (non-blocking)
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
runAIAssignmentJob(job.id, input.stageId, ctx.user.id).catch(console.error)
return { jobId: job.id }
}),
@@ -1093,10 +1069,10 @@ export const assignmentRouter = router({
* Get the latest AI assignment job for a round
*/
getLatestAIAssignmentJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.assignmentJob.findFirst({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
orderBy: { createdAt: 'desc' },
})

561
src/server/routers/award.ts Normal file
View File

@@ -0,0 +1,561 @@
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'
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(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).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,
})),
}
}),
})

View File

@@ -0,0 +1,331 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const cohortRouter = router({
/**
* Create a new cohort within a stage
*/
create: adminProcedure
.input(
z.object({
stageId: z.string(),
name: z.string().min(1).max(255),
votingMode: z.enum(['simple', 'criteria', 'ranked']).default('simple'),
windowOpenAt: z.date().optional(),
windowCloseAt: z.date().optional(),
})
)
.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 },
})
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) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Window close date must be after open date',
})
}
}
const cohort = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.cohort.create({
data: {
stageId: input.stageId,
name: input.name,
votingMode: input.votingMode,
windowOpenAt: input.windowOpenAt ?? null,
windowCloseAt: input.windowCloseAt ?? null,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Cohort',
entityId: created.id,
detailsJson: {
stageId: input.stageId,
name: input.name,
votingMode: input.votingMode,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return cohort
}),
/**
* Assign projects to a cohort
*/
assignProjects: adminProcedure
.input(
z.object({
cohortId: z.string(),
projectIds: z.array(z.string()).min(1).max(200),
})
)
.mutation(async ({ ctx, input }) => {
// Verify cohort exists
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
where: { id: input.cohortId },
})
if (cohort.isOpen) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Cannot modify projects while voting is open',
})
}
// Get current max sortOrder
const maxOrder = await ctx.prisma.cohortProject.aggregate({
where: { cohortId: input.cohortId },
_max: { sortOrder: true },
})
let nextOrder = (maxOrder._max.sortOrder ?? -1) + 1
// Create cohort project entries (skip duplicates)
const created = await ctx.prisma.cohortProject.createMany({
data: input.projectIds.map((projectId) => ({
cohortId: input.cohortId,
projectId,
sortOrder: nextOrder++,
})),
skipDuplicates: true,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COHORT_PROJECTS_ASSIGNED',
entityType: 'Cohort',
entityId: input.cohortId,
detailsJson: {
projectCount: created.count,
requested: input.projectIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { assigned: created.count, requested: input.projectIds.length }
}),
/**
* Open voting for a cohort
*/
openVoting: adminProcedure
.input(
z.object({
cohortId: z.string(),
durationMinutes: z.number().int().min(1).max(1440).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
where: { id: input.cohortId },
include: { _count: { select: { projects: true } } },
})
if (cohort.isOpen) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Voting is already open for this cohort',
})
}
if (cohort._count.projects === 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Cohort must have at least one project before opening voting',
})
}
const now = new Date()
const closeAt = input.durationMinutes
? new Date(now.getTime() + input.durationMinutes * 60 * 1000)
: cohort.windowCloseAt
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.cohort.update({
where: { id: input.cohortId },
data: {
isOpen: true,
windowOpenAt: now,
windowCloseAt: closeAt,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COHORT_VOTING_OPENED',
entityType: 'Cohort',
entityId: input.cohortId,
detailsJson: {
openedAt: now.toISOString(),
closesAt: closeAt?.toISOString() ?? null,
projectCount: cohort._count.projects,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
return updated
}),
/**
* Close voting for a cohort
*/
closeVoting: adminProcedure
.input(z.object({ cohortId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
where: { id: input.cohortId },
})
if (!cohort.isOpen) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is not currently open for this cohort',
})
}
const now = new Date()
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.cohort.update({
where: { id: input.cohortId },
data: {
isOpen: false,
windowCloseAt: now,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COHORT_VOTING_CLOSED',
entityType: 'Cohort',
entityId: input.cohortId,
detailsJson: {
closedAt: now.toISOString(),
wasOpenSince: cohort.windowOpenAt?.toISOString(),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
return updated
}),
/**
* List cohorts for a stage
*/
list: protectedProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.cohort.findMany({
where: { stageId: input.stageId },
orderBy: { createdAt: 'asc' },
include: {
_count: { select: { projects: true } },
},
})
}),
/**
* Get cohort with projects and vote summary
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
where: { id: input.id },
include: {
stage: {
select: {
id: true,
name: true,
stageType: true,
track: {
select: {
id: true,
name: true,
pipeline: { select: { id: true, name: true } },
},
},
},
},
projects: {
orderBy: { sortOrder: 'asc' },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
tags: true,
description: true,
},
},
},
},
},
})
// Get vote counts per project in the cohort's stage session
const projectIds = cohort.projects.map((p) => p.projectId)
const voteSummary =
projectIds.length > 0
? await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: {
projectId: { in: projectIds },
session: { stageId: cohort.stage.id },
},
_count: true,
_avg: { score: true },
})
: []
const voteMap = new Map(
voteSummary.map((v) => [
v.projectId,
{ voteCount: v._count, avgScore: v._avg?.score ?? 0 },
])
)
return {
...cohort,
projects: cohort.projects.map((cp) => ({
...cp,
votes: voteMap.get(cp.projectId) ?? { voteCount: 0, avgScore: 0 },
})),
}
}),
})

View File

@@ -22,28 +22,28 @@ export const dashboardRouter = router({
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const [
activeRoundCount,
totalRoundCount,
activeStageCount,
totalStageCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
recentStages,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
draftStages,
unassignedProjects,
] = await Promise.all([
ctx.prisma.round.count({
where: { programId: editionId, status: 'ACTIVE' },
ctx.prisma.stage.count({
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_ACTIVE' },
}),
ctx.prisma.round.count({
where: { programId: editionId },
ctx.prisma.stage.count({
where: { track: { pipeline: { 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: { round: { programId: editionId } } },
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
},
}),
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
assignments: { some: { round: { programId: editionId } } },
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
},
}),
ctx.prisma.evaluation.groupBy({
by: ['status'],
where: { assignment: { round: { programId: editionId } } },
where: { assignment: { stage: { track: { pipeline: { programId: editionId } } } } },
_count: true,
}),
ctx.prisma.assignment.count({
where: { round: { programId: editionId } },
where: { stage: { track: { pipeline: { programId: editionId } } } },
}),
ctx.prisma.round.findMany({
where: { programId: editionId },
ctx.prisma.stage.findMany({
where: { track: { pipeline: { programId: editionId } } },
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
submissionEndDate: true,
stageType: true,
windowOpenAt: true,
windowCloseAt: true,
_count: {
select: {
projects: true,
projectStageStates: true,
assignments: true,
},
},
@@ -115,7 +115,6 @@ export const dashboardRouter = router({
createdAt: true,
submittedAt: true,
status: true,
round: { select: { name: true } },
},
}),
ctx.prisma.project.groupBy({
@@ -146,16 +145,20 @@ export const dashboardRouter = router({
where: {
hasConflict: true,
reviewedAt: null,
assignment: { round: { programId: editionId } },
assignment: { stage: { track: { pipeline: { programId: editionId } } } },
},
}),
ctx.prisma.round.count({
where: { programId: editionId, status: 'DRAFT' },
ctx.prisma.stage.count({
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_DRAFT' },
}),
ctx.prisma.project.count({
where: {
programId: editionId,
round: { status: 'ACTIVE' },
projectStageStates: {
some: {
stage: { status: 'STAGE_ACTIVE' },
},
},
assignments: { none: {} },
},
}),
@@ -163,21 +166,21 @@ export const dashboardRouter = router({
return {
edition,
activeRoundCount,
totalRoundCount,
activeStageCount,
totalStageCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
recentStages,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftRounds,
draftStages,
unassignedProjects,
}
}),

View File

@@ -0,0 +1,353 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma, FilteringOutcome } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const decisionRouter = router({
/**
* Override a project's stage state or filtering result
*/
override: adminProcedure
.input(
z.object({
entityType: z.enum([
'ProjectStageState',
'FilteringResult',
'AwardEligibility',
]),
entityId: z.string(),
newValue: z.record(z.unknown()),
reasonCode: z.enum([
'DATA_CORRECTION',
'POLICY_EXCEPTION',
'JURY_CONFLICT',
'SPONSOR_DECISION',
'ADMIN_DISCRETION',
]),
reasonText: z.string().max(2000).optional(),
})
)
.mutation(async ({ ctx, input }) => {
let previousValue: Record<string, unknown> = {}
// Fetch current value based on entity type
switch (input.entityType) {
case 'ProjectStageState': {
const pss = await ctx.prisma.projectStageState.findUniqueOrThrow({
where: { id: input.entityId },
})
previousValue = {
state: pss.state,
metadataJson: pss.metadataJson,
}
// Validate the new state
const newState = input.newValue.state as string | undefined
if (
newState &&
!['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'ROUTED', 'COMPLETED', 'WITHDRAWN'].includes(newState)
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid state: ${newState}`,
})
}
await ctx.prisma.$transaction(async (tx) => {
await tx.projectStageState.update({
where: { id: input.entityId },
data: {
state: (newState as Prisma.EnumProjectStageStateValueFieldUpdateOperationsInput['set']) ?? pss.state,
metadataJson: {
...(pss.metadataJson as Record<string, unknown> ?? {}),
lastOverride: {
by: ctx.user.id,
at: new Date().toISOString(),
reason: input.reasonCode,
},
} as Prisma.InputJsonValue,
},
})
await tx.overrideAction.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
previousValue: previousValue as Prisma.InputJsonValue,
newValueJson: input.newValue as Prisma.InputJsonValue,
reasonCode: input.reasonCode,
reasonText: input.reasonText ?? null,
actorId: ctx.user.id,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'override.applied',
entityType: input.entityType,
entityId: input.entityId,
actorId: ctx.user.id,
detailsJson: {
previousValue,
newValue: input.newValue,
reasonCode: input.reasonCode,
reasonText: input.reasonText,
} as Prisma.InputJsonValue,
snapshotJson: previousValue as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
reasonText: input.reasonText,
previousState: previousValue.state,
newState: input.newValue.state,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
break
}
case 'FilteringResult': {
const fr = await ctx.prisma.filteringResult.findUniqueOrThrow({
where: { id: input.entityId },
})
previousValue = {
outcome: fr.outcome,
aiScreeningJson: fr.aiScreeningJson,
}
const newOutcome = input.newValue.outcome as string | undefined
await ctx.prisma.$transaction(async (tx) => {
if (newOutcome) {
await tx.filteringResult.update({
where: { id: input.entityId },
data: { finalOutcome: newOutcome as FilteringOutcome },
})
}
await tx.overrideAction.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
previousValue: previousValue as Prisma.InputJsonValue,
newValueJson: input.newValue as Prisma.InputJsonValue,
reasonCode: input.reasonCode,
reasonText: input.reasonText ?? null,
actorId: ctx.user.id,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'override.applied',
entityType: input.entityType,
entityId: input.entityId,
actorId: ctx.user.id,
detailsJson: {
previousValue,
newValue: input.newValue,
reasonCode: input.reasonCode,
} as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
previousOutcome: (previousValue as Record<string, unknown>).outcome,
newOutcome,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
break
}
case 'AwardEligibility': {
const ae = await ctx.prisma.awardEligibility.findUniqueOrThrow({
where: { id: input.entityId },
})
previousValue = {
eligible: ae.eligible,
method: ae.method,
}
const newEligible = input.newValue.eligible as boolean | undefined
await ctx.prisma.$transaction(async (tx) => {
if (newEligible !== undefined) {
await tx.awardEligibility.update({
where: { id: input.entityId },
data: {
eligible: newEligible,
method: 'MANUAL',
overriddenBy: ctx.user.id,
overriddenAt: new Date(),
},
})
}
await tx.overrideAction.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
previousValue: previousValue as Prisma.InputJsonValue,
newValueJson: input.newValue as Prisma.InputJsonValue,
reasonCode: input.reasonCode,
reasonText: input.reasonText ?? null,
actorId: ctx.user.id,
},
})
await tx.decisionAuditLog.create({
data: {
eventType: 'override.applied',
entityType: input.entityType,
entityId: input.entityId,
actorId: ctx.user.id,
detailsJson: {
previousValue,
newValue: input.newValue,
reasonCode: input.reasonCode,
} as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
previousEligible: previousValue.eligible,
newEligible,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
break
}
}
return { success: true, entityType: input.entityType, entityId: input.entityId }
}),
/**
* Get the full decision audit timeline for an entity
*/
auditTimeline: protectedProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const [decisionLogs, overrideActions] = await Promise.all([
ctx.prisma.decisionAuditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.overrideAction.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
orderBy: { createdAt: 'desc' },
}),
])
// Merge and sort by timestamp
const timeline = [
...decisionLogs.map((dl) => ({
type: 'decision' as const,
id: dl.id,
eventType: dl.eventType,
actorId: dl.actorId,
details: dl.detailsJson,
snapshot: dl.snapshotJson,
createdAt: dl.createdAt,
})),
...overrideActions.map((oa) => ({
type: 'override' as const,
id: oa.id,
eventType: `override.${oa.reasonCode}`,
actorId: oa.actorId,
details: {
previousValue: oa.previousValue,
newValue: oa.newValueJson,
reasonCode: oa.reasonCode,
reasonText: oa.reasonText,
},
snapshot: null,
createdAt: oa.createdAt,
})),
].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
return { entityType: input.entityType, entityId: input.entityId, timeline }
}),
/**
* Get override actions (paginated, admin only)
*/
getOverrides: adminProcedure
.input(
z.object({
entityType: z.string().optional(),
reasonCode: z
.enum([
'DATA_CORRECTION',
'POLICY_EXCEPTION',
'JURY_CONFLICT',
'SPONSOR_DECISION',
'ADMIN_DISCRETION',
])
.optional(),
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const where: Prisma.OverrideActionWhereInput = {}
if (input.entityType) where.entityType = input.entityType
if (input.reasonCode) where.reasonCode = input.reasonCode
const items = await ctx.prisma.overrideAction.findMany({
where,
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
})
let nextCursor: string | undefined
if (items.length > input.limit) {
const next = items.pop()
nextCursor = next?.id
}
return { items, nextCursor }
}),
})

View File

@@ -16,7 +16,6 @@ export const evaluationRouter = router({
// Verify ownership or admin
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: { round: true },
})
if (
@@ -47,25 +46,20 @@ export const evaluationRouter = router({
// Verify assignment ownership
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: {
round: {
include: {
evaluationForms: { where: { isActive: true }, take: 1 },
},
},
},
})
if (assignment.userId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
// Get active form
const form = assignment.round.evaluationForms[0]
// Get active form for this stage
const form = await ctx.prisma.evaluationForm.findFirst({
where: { stageId: assignment.stageId, isActive: true },
})
if (!form) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No active evaluation form for this round',
message: 'No active evaluation form for this stage',
})
}
@@ -150,9 +144,7 @@ export const evaluationRouter = router({
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
where: { id },
include: {
assignment: {
include: { round: true },
},
assignment: true,
},
})
@@ -160,21 +152,23 @@ export const evaluationRouter = router({
throw new TRPCError({ code: 'FORBIDDEN' })
}
// Check voting window
const round = evaluation.assignment.round
// Check voting window via stage
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: evaluation.assignment.stageId },
})
const now = new Date()
if (round.status !== 'ACTIVE') {
if (stage.status !== 'STAGE_ACTIVE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round is not active',
message: 'Stage is not active',
})
}
// Check for grace period
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
where: {
roundId: round.id,
stageId: stage.id,
userId: ctx.user.id,
OR: [
{ projectId: null },
@@ -184,9 +178,9 @@ export const evaluationRouter = router({
},
})
const effectiveEndDate = gracePeriod?.extendedUntil ?? round.votingEndAt
const effectiveEndDate = gracePeriod?.extendedUntil ?? stage.windowCloseAt
if (round.votingStartAt && now < round.votingStartAt) {
if (stage.windowOpenAt && now < stage.windowOpenAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting has not started yet',
@@ -225,7 +219,7 @@ export const evaluationRouter = router({
entityId: id,
detailsJson: {
projectId: evaluation.assignment.projectId,
roundId: evaluation.assignment.roundId,
stageId: evaluation.assignment.stageId,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
},
@@ -276,19 +270,19 @@ export const evaluationRouter = router({
}),
/**
* Get all evaluations for a round (admin only)
* Get all evaluations for a stage (admin only)
*/
listByRound: adminProcedure
listByStage: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
status: z.enum(['NOT_STARTED', 'DRAFT', 'SUBMITTED', 'LOCKED']).optional(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
assignment: { stageId: input.stageId },
...(input.status && { status: input.status }),
},
include: {
@@ -307,13 +301,13 @@ export const evaluationRouter = router({
* Get my past evaluations (read-only for jury)
*/
myPastEvaluations: protectedProcedure
.input(z.object({ roundId: z.string().optional() }))
.input(z.object({ stageId: z.string().optional() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluation.findMany({
where: {
assignment: {
userId: ctx.user.id,
...(input.roundId && { roundId: input.roundId }),
...(input.stageId && { stageId: input.stageId }),
},
status: 'SUBMITTED',
},
@@ -321,7 +315,7 @@ export const evaluationRouter = router({
assignment: {
include: {
project: { select: { id: true, title: true } },
round: { select: { id: true, name: true } },
stage: { select: { id: true, name: true } },
},
},
},
@@ -346,12 +340,12 @@ export const evaluationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Look up the assignment to get projectId, roundId, userId
// Look up the assignment to get projectId, stageId, userId
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: {
project: { select: { title: true } },
round: { select: { name: true } },
stage: { select: { id: true, name: true } },
},
})
@@ -367,7 +361,6 @@ export const evaluationRouter = router({
assignmentId: input.assignmentId,
userId: ctx.user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
hasConflict: input.hasConflict,
conflictType: input.hasConflict ? input.conflictType : null,
description: input.hasConflict ? input.description : null,
@@ -385,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.round.name}.`,
linkUrl: `/admin/rounds/${assignment.roundId}/coi`,
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`,
linkLabel: 'Review COI',
priority: 'high',
metadata: {
assignmentId: input.assignmentId,
userId: ctx.user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
stageId: assignment.stageId,
conflictType: input.conflictType,
},
})
@@ -409,7 +402,7 @@ export const evaluationRouter = router({
detailsJson: {
assignmentId: input.assignmentId,
projectId: assignment.projectId,
roundId: assignment.roundId,
stageId: assignment.stageId,
hasConflict: input.hasConflict,
conflictType: input.conflictType,
},
@@ -432,19 +425,19 @@ export const evaluationRouter = router({
}),
/**
* List COI declarations for a round (admin only)
* List COI declarations for a stage (admin only)
*/
listCOIByRound: adminProcedure
listCOIByStage: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
hasConflictOnly: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
assignment: { stageId: input.stageId },
...(input.hasConflictOnly && { hasConflict: true }),
},
include: {
@@ -505,19 +498,19 @@ export const evaluationRouter = router({
// =========================================================================
/**
* Manually trigger reminder check for a specific round (admin only)
* Manually trigger reminder check for a specific stage (admin only)
*/
triggerReminders: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await processEvaluationReminders(input.roundId)
const result = await processEvaluationReminders(input.stageId)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REMINDERS_TRIGGERED',
entityType: 'Round',
entityId: input.roundId,
entityType: 'Stage',
entityId: input.stageId,
detailsJson: {
sent: result.sent,
errors: result.errors,
@@ -540,13 +533,13 @@ export const evaluationRouter = router({
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
return generateSummary({
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
userId: ctx.user.id,
prisma: ctx.prisma,
})
@@ -559,64 +552,63 @@ export const evaluationRouter = router({
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluationSummary.findUnique({
where: {
projectId_roundId: {
projectId_stageId: {
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
},
})
}),
/**
* Generate summaries for all projects in a round with submitted evaluations (admin only)
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
*/
generateBulkSummaries: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Find all projects in the round with at least 1 submitted evaluation
const projects = await ctx.prisma.project.findMany({
// Find all projects with at least 1 submitted evaluation in this stage
const assignments = await ctx.prisma.assignment.findMany({
where: {
roundId: input.roundId,
assignments: {
some: {
evaluation: {
status: 'SUBMITTED',
},
},
stageId: input.stageId,
evaluation: {
status: 'SUBMITTED',
},
},
select: { id: true },
select: { projectId: true },
distinct: ['projectId'],
})
const projectIds = assignments.map((a) => a.projectId)
let generated = 0
const errors: Array<{ projectId: string; error: string }> = []
// Generate summaries sequentially to avoid rate limits
for (const project of projects) {
for (const projectId of projectIds) {
try {
await generateSummary({
projectId: project.id,
roundId: input.roundId,
projectId,
stageId: input.stageId,
userId: ctx.user.id,
prisma: ctx.prisma,
})
generated++
} catch (error) {
errors.push({
projectId: project.id,
projectId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return {
total: projects.length,
total: projectIds.length,
generated,
errors,
}
@@ -633,15 +625,15 @@ export const evaluationRouter = router({
.input(
z.object({
projectIds: z.array(z.string()).min(2).max(3),
roundId: z.string(),
stageId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Verify all projects are assigned to current user in this round
// Verify all projects are assigned to current user in this stage
const assignments = await ctx.prisma.assignment.findMany({
where: {
userId: ctx.user.id,
roundId: input.roundId,
stageId: input.stageId,
projectId: { in: input.projectIds },
},
include: {
@@ -670,13 +662,13 @@ export const evaluationRouter = router({
if (assignments.length !== input.projectIds.length) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to all requested projects in this round',
message: 'You are not assigned to all requested projects in this stage',
})
}
// Fetch the active evaluation form for this round to get criteria labels
// Fetch the active evaluation form for this stage to get criteria labels
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
where: { stageId: input.stageId, isActive: true },
select: { criteriaJson: true, scalesJson: true },
})
@@ -704,7 +696,7 @@ export const evaluationRouter = router({
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
})
)
.query(async ({ ctx, input }) => {
@@ -713,7 +705,7 @@ export const evaluationRouter = router({
where: {
userId: ctx.user.id,
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
include: { evaluation: true },
})
@@ -725,16 +717,16 @@ export const evaluationRouter = router({
})
}
// Check round settings for peer review
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
// Check stage settings for peer review
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
const settings = (stage.configJson as Record<string, unknown>) || {}
if (!settings.peer_review_enabled) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Peer review is not enabled for this round',
message: 'Peer review is not enabled for this stage',
})
}
@@ -744,7 +736,7 @@ export const evaluationRouter = router({
status: 'SUBMITTED',
assignment: {
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
},
include: {
@@ -829,16 +821,16 @@ export const evaluationRouter = router({
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Get or create discussion
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
where: {
projectId_roundId: {
projectId_stageId: {
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
},
include: {
@@ -855,7 +847,7 @@ export const evaluationRouter = router({
discussion = await ctx.prisma.evaluationDiscussion.create({
data: {
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
include: {
comments: {
@@ -868,11 +860,11 @@ export const evaluationRouter = router({
})
}
// Anonymize comments based on round settings
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
// Anonymize comments based on stage settings
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
const settings = (stage.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) => {
@@ -915,16 +907,16 @@ export const evaluationRouter = router({
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
content: z.string().min(1).max(2000),
})
)
.mutation(async ({ ctx, input }) => {
// Check max comment length from round settings
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
// Check max comment length from stage settings
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
const settings = (stage.configJson as Record<string, unknown>) || {}
const maxLength = (settings.max_comment_length as number) || 2000
if (input.content.length > maxLength) {
throw new TRPCError({
@@ -936,9 +928,9 @@ export const evaluationRouter = router({
// Get or create discussion
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
where: {
projectId_roundId: {
projectId_stageId: {
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
},
})
@@ -947,7 +939,7 @@ export const evaluationRouter = router({
discussion = await ctx.prisma.evaluationDiscussion.create({
data: {
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
})
}
@@ -978,7 +970,7 @@ export const evaluationRouter = router({
detailsJson: {
discussionId: discussion.id,
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
@@ -1015,7 +1007,7 @@ export const evaluationRouter = router({
entityId: input.discussionId,
detailsJson: {
projectId: discussion.projectId,
roundId: discussion.roundId,
stageId: discussion.stageId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
@@ -1026,4 +1018,228 @@ export const evaluationRouter = router({
return discussion
}),
// =========================================================================
// Phase 4: Stage-scoped evaluation procedures
// =========================================================================
/**
* Start a stage-scoped evaluation (create or return existing draft)
*/
startStage: protectedProcedure
.input(
z.object({
assignmentId: z.string(),
stageId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify assignment ownership and stageId match
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
})
if (assignment.userId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
if (assignment.stageId !== input.stageId) {
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 },
})
const now = new Date()
if (stage.status !== 'STAGE_ACTIVE') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Stage is not active',
})
}
// Check grace period
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
where: {
stageId: input.stageId,
userId: ctx.user.id,
OR: [
{ projectId: null },
{ projectId: assignment.projectId },
],
extendedUntil: { gte: now },
},
})
const effectiveClose = gracePeriod?.extendedUntil ?? stage.windowCloseAt
if (stage.windowOpenAt && now < stage.windowOpenAt) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Evaluation window has not opened yet',
})
}
if (effectiveClose && now > effectiveClose) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Evaluation window has closed',
})
}
// Check for existing evaluation
const existing = await ctx.prisma.evaluation.findUnique({
where: { assignmentId: input.assignmentId },
})
if (existing) return existing
// Get active evaluation form for this stage
const form = await ctx.prisma.evaluationForm.findFirst({
where: { stageId: input.stageId, isActive: true },
})
if (!form) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No active evaluation form for this stage',
})
}
return ctx.prisma.evaluation.create({
data: {
assignmentId: input.assignmentId,
formId: form.id,
status: 'DRAFT',
},
})
}),
/**
* Get the active evaluation form for a stage
*/
getStageForm: protectedProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const form = await ctx.prisma.evaluationForm.findFirst({
where: { stageId: input.stageId, isActive: true },
})
if (!form) {
return null
}
return {
id: form.id,
criteriaJson: form.criteriaJson as Array<{
id: string
label: string
description?: string
scale?: string
weight?: number
type?: string
required?: boolean
}>,
scalesJson: form.scalesJson as Record<string, { min: number; max: number; labels?: Record<string, string> }> | null,
version: form.version,
}
}),
/**
* Check the evaluation window status for a stage
*/
checkStageWindow: protectedProcedure
.input(
z.object({
stageId: z.string(),
userId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: {
id: true,
status: true,
windowOpenAt: true,
windowCloseAt: true,
},
})
const userId = input.userId ?? ctx.user.id
const now = new Date()
// Check for grace period
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
where: {
stageId: input.stageId,
userId,
extendedUntil: { gte: now },
},
orderBy: { extendedUntil: 'desc' },
})
const effectiveClose = gracePeriod?.extendedUntil ?? stage.windowCloseAt
const isOpen =
stage.status === 'STAGE_ACTIVE' &&
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
(!effectiveClose || now <= effectiveClose)
let reason = ''
if (!isOpen) {
if (stage.status !== 'STAGE_ACTIVE') {
reason = 'Stage is not active'
} else if (stage.windowOpenAt && now < stage.windowOpenAt) {
reason = 'Window has not opened yet'
} else {
reason = 'Window has closed'
}
}
return {
isOpen,
opensAt: stage.windowOpenAt,
closesAt: stage.windowCloseAt,
hasGracePeriod: !!gracePeriod,
graceExpiresAt: gracePeriod?.extendedUntil ?? null,
reason,
}
}),
/**
* List evaluations for the current user in a specific stage
*/
listStageEvaluations: protectedProcedure
.input(
z.object({
stageId: z.string(),
projectId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
assignment: {
userId: ctx.user.id,
stageId: input.stageId,
...(input.projectId ? { projectId: input.projectId } : {}),
},
}
return ctx.prisma.evaluation.findMany({
where,
include: {
assignment: {
include: {
project: { select: { id: true, title: true, teamName: true } },
},
},
form: {
select: { criteriaJson: true, scalesJson: true },
},
},
orderBy: { updatedAt: 'desc' },
})
}),
})

View File

@@ -9,7 +9,7 @@ export const exportRouter = router({
evaluations: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: 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: { roundId: input.roundId },
assignment: { stageId: input.stageId },
},
include: {
assignment: {
@@ -75,7 +75,7 @@ export const exportRouter = router({
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'Evaluation',
detailsJson: { roundId: input.roundId, count: data.length },
detailsJson: { stageId: input.stageId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -101,10 +101,12 @@ export const exportRouter = router({
* Export project scores summary
*/
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: {
assignments: { some: { stageId: input.stageId } },
},
include: {
assignments: {
include: {
@@ -159,7 +161,7 @@ export const exportRouter = router({
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'ProjectScores',
detailsJson: { roundId: input.roundId, count: data.length },
detailsJson: { stageId: input.stageId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -186,10 +188,10 @@ export const exportRouter = router({
* Export assignments
*/
assignments: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
include: {
user: { select: { name: true, email: true } },
project: { select: { title: true, teamName: true } },
@@ -232,10 +234,10 @@ export const exportRouter = router({
* Export filtering results as CSV data
*/
filteringResults: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const results = await ctx.prisma.filteringResult.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
include: {
project: {
select: {
@@ -314,7 +316,7 @@ export const exportRouter = router({
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'FilteringResult',
detailsJson: { roundId: input.roundId, count: data.length },
detailsJson: { stageId: input.stageId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -399,7 +401,7 @@ export const exportRouter = router({
getReportData: observerProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
sections: z.array(z.string()).optional(),
})
)
@@ -407,34 +409,44 @@ export const exportRouter = router({
const includeSection = (name: string) =>
!input.sections || input.sections.length === 0 || input.sections.includes(name)
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
include: {
program: { select: { name: true, year: true } },
track: {
include: {
pipeline: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
},
})
const result: Record<string, unknown> = {
roundName: round.name,
programName: round.program.name,
programYear: round.program.year,
stageName: stage.name,
programName: stage.track.pipeline.program.name,
programYear: stage.track.pipeline.program.year,
generatedAt: new Date().toISOString(),
}
// Summary stats
if (includeSection('summary')) {
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.project.count({
where: { assignments: { some: { stageId: input.stageId } } },
}),
ctx.prisma.assignment.count({ where: { stageId: input.stageId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: input.roundId },
assignment: { stageId: input.stageId },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
where: { stageId: input.stageId },
}),
])
@@ -453,7 +465,7 @@ export const exportRouter = router({
if (includeSection('scoreDistribution')) {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
assignment: { stageId: input.stageId },
status: 'SUBMITTED',
},
select: { globalScore: true },
@@ -478,7 +490,7 @@ export const exportRouter = router({
// Rankings
if (includeSection('rankings')) {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: { assignments: { some: { stageId: input.stageId } } },
select: {
id: true,
title: true,
@@ -526,7 +538,7 @@ export const exportRouter = router({
// Juror stats
if (includeSection('jurorStats')) {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
include: {
user: { select: { name: true, email: true } },
evaluation: { select: { status: true, globalScore: true } },
@@ -566,14 +578,14 @@ export const exportRouter = router({
// Criteria breakdown
if (includeSection('criteriaBreakdown')) {
const form = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
where: { stageId: input.stageId, isActive: true },
})
if (form?.criteriaJson) {
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
assignment: { stageId: input.stageId },
status: 'SUBMITTED',
},
select: { criterionScoresJson: true },
@@ -606,8 +618,8 @@ export const exportRouter = router({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REPORT_GENERATED',
entityType: 'Round',
entityId: input.roundId,
entityType: 'Stage',
entityId: input.stageId,
detailsJson: { sections: input.sections },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,

View File

@@ -20,13 +20,10 @@ export const fileRouter = router({
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
// Find the file record to get the project and round info
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: {
projectId: true,
roundId: true,
round: { select: { programId: true, sortOrder: true } },
},
})
@@ -37,11 +34,10 @@ export const fileRouter = router({
})
}
// Check if user is assigned as jury, mentor, or team member for this project
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true, roundId: true },
select: { id: true, stageId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId },
@@ -66,23 +62,47 @@ export const fileRouter = router({
})
}
// For jury members, verify round-scoped access:
// File must belong to the jury's assigned round or a prior round in the same program
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
const assignedRound = await ctx.prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { programId: true, sortOrder: true },
if (juryAssignment && !mentorAssignment && !teamMembership) {
const assignedStage = await ctx.prisma.stage.findUnique({
where: { id: juryAssignment.stageId },
select: { trackId: true, sortOrder: true },
})
if (assignedRound) {
const sameProgram = assignedRound.programId === file.round.programId
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
if (assignedStage) {
const priorOrCurrentStages = await ctx.prisma.stage.findMany({
where: {
trackId: assignedStage.trackId,
sortOrder: { lte: assignedStage.sortOrder },
},
select: { id: true },
})
if (!sameProgram || !priorOrSameRound) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
const stageIds = priorOrCurrentStages.map((s) => s.id)
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
where: {
stageId: { in: stageIds },
files: { some: { bucket: input.bucket, objectKey: input.objectKey } },
},
select: { id: true },
})
if (!hasFileRequirement) {
const fileInProject = await ctx.prisma.projectFile.findFirst({
where: {
bucket: input.bucket,
objectKey: input.objectKey,
requirementId: null,
},
select: { id: true },
})
if (!fileInProject) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
}
}
}
@@ -115,7 +135,7 @@ export const fileRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
mimeType: z.string(),
size: z.number().int().positive(),
roundId: z.string().optional(),
stageId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -129,16 +149,15 @@ export const fileRouter = router({
})
}
// Calculate isLate flag if roundId is provided
let isLate = false
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingEndAt: true },
if (input.stageId) {
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { windowCloseAt: true },
})
if (round?.votingEndAt) {
isLate = new Date() > round.votingEndAt
if (stage?.windowCloseAt) {
isLate = new Date() > stage.windowCloseAt
}
}
@@ -157,7 +176,6 @@ export const fileRouter = router({
size: input.size,
bucket,
objectKey,
roundId: input.roundId,
isLate,
},
})
@@ -173,7 +191,7 @@ export const fileRouter = router({
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
roundId: input.roundId,
stageId: input.stageId,
isLate,
},
ipAddress: ctx.ip,
@@ -244,7 +262,7 @@ export const fileRouter = router({
listByProject: protectedProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string().optional(),
stageId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
@@ -280,28 +298,36 @@ export const fileRouter = router({
}
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.roundId) {
where.roundId = input.roundId
if (input.stageId) {
where.requirement = { stageId: input.stageId }
}
return ctx.prisma.projectFile.findMany({
where,
include: {
round: { select: { id: true, name: true, sortOrder: true } },
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
requirement: {
select: {
id: true,
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
},
},
},
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
})
}),
/**
* List files for a project grouped by round
* Returns files for the specified round + all prior rounds in the same program
* List files for a project grouped by stage
* Returns files for the specified stage + all prior stages in the same track
*/
listByProjectForRound: protectedProcedure
listByProjectForStage: protectedProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
@@ -310,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, roundId: true },
select: { id: true, stageId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
@@ -336,68 +362,70 @@ export const fileRouter = router({
}
}
// Get the target round with its program and sortOrder
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true, sortOrder: true },
const targetStage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { trackId: true, sortOrder: true },
})
// Get all rounds in the same program with sortOrder <= target
const eligibleRounds = await ctx.prisma.round.findMany({
const eligibleStages = await ctx.prisma.stage.findMany({
where: {
programId: targetRound.programId,
sortOrder: { lte: targetRound.sortOrder },
trackId: targetStage.trackId,
sortOrder: { lte: targetStage.sortOrder },
},
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
const eligibleStageIds = eligibleStages.map((s) => s.id)
// Get files for these rounds (or files with no roundId)
const files = await ctx.prisma.projectFile.findMany({
where: {
projectId: input.projectId,
OR: [
{ roundId: { in: eligibleRoundIds } },
{ roundId: null },
{ requirement: { stageId: { in: eligibleStageIds } } },
{ requirementId: null },
],
},
include: {
round: { select: { id: true, name: true, sortOrder: true } },
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
requirement: {
select: {
id: true,
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
},
},
},
orderBy: [{ createdAt: 'asc' }],
})
// Group by round
const grouped: Array<{
roundId: string | null
roundName: string
stageId: string | null
stageName: string
sortOrder: number
files: typeof files
}> = []
// Add "General" group for files with no round
const generalFiles = files.filter((f) => !f.roundId)
const generalFiles = files.filter((f) => !f.requirementId)
if (generalFiles.length > 0) {
grouped.push({
roundId: null,
roundName: 'General',
stageId: null,
stageName: 'General',
sortOrder: -1,
files: generalFiles,
})
}
// Add groups for each round
for (const round of eligibleRounds) {
const roundFiles = files.filter((f) => f.roundId === round.id)
if (roundFiles.length > 0) {
for (const stage of eligibleStages) {
const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id)
if (stageFiles.length > 0) {
grouped.push({
roundId: round.id,
roundName: round.name,
sortOrder: round.sortOrder,
files: roundFiles,
stageId: stage.id,
stageName: stage.name,
sortOrder: stage.sortOrder,
files: stageFiles,
})
}
}
@@ -673,24 +701,24 @@ export const fileRouter = router({
// =========================================================================
/**
* List file requirements for a round (available to any authenticated user)
* List file requirements for a stage (available to any authenticated user)
*/
listRequirements: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.fileRequirement.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
orderBy: { sortOrder: 'asc' },
})
}),
/**
* Create a file requirement for a round (admin only)
* Create a file requirement for a stage (admin only)
*/
createRequirement: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
@@ -711,7 +739,7 @@ export const fileRouter = router({
action: 'CREATE',
entityType: 'FileRequirement',
entityId: requirement.id,
detailsJson: { name: input.name, roundId: input.roundId },
detailsJson: { name: input.name, stageId: input.stageId },
})
} catch {}
@@ -783,7 +811,7 @@ export const fileRouter = router({
reorderRequirements: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
orderedIds: z.array(z.string()),
})
)

View File

@@ -11,8 +11,7 @@ import {
NotificationTypes,
} from '../services/in-app-notification'
// Background job execution function (exported for auto-filtering on round close)
export async function runFilteringJob(jobId: string, roundId: string, userId: string) {
export async function runFilteringJob(jobId: string, stageId: string, userId: string) {
try {
// Update job to running
await prisma.filteringJob.update({
@@ -22,19 +21,28 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
// Get rules
const rules = await prisma.filteringRule.findMany({
where: { roundId },
where: { stageId },
orderBy: { priority: 'asc' },
})
// Get projects
const projects = await prisma.project.findMany({
where: { roundId },
// Get projects in this stage via ProjectStageState
const projectStates = await prisma.projectStageState.findMany({
where: {
stageId,
exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
include: {
files: {
select: { id: true, fileName: true, fileType: true },
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true },
},
},
},
},
})
const projects = projectStates.map((pss: any) => pss.project).filter(Boolean)
// Calculate batch info
const BATCH_SIZE = 20
@@ -57,7 +65,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
}
// Execute rules
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress)
const results = await executeFilteringRules(rules, projects, userId, stageId, onProgress)
// Count outcomes
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
@@ -69,13 +77,13 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
results.map((r) =>
prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId,
stageId_projectId: {
stageId,
projectId: r.projectId,
},
},
create: {
roundId,
stageId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
@@ -111,8 +119,8 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
await logAudit({
userId,
action: 'UPDATE',
entityType: 'Round',
entityId: roundId,
entityType: 'Stage',
entityId: stageId,
detailsJson: {
action: 'EXECUTE_FILTERING',
jobId,
@@ -123,9 +131,9 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
},
})
// Get round name for notification
const round = await prisma.round.findUnique({
where: { id: roundId },
// Get stage name for notification
const stage = await prisma.stage.findUnique({
where: { id: stageId },
select: { name: true },
})
@@ -133,12 +141,12 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
await notifyAdmins({
type: NotificationTypes.FILTERING_COMPLETE,
title: 'AI Filtering Complete',
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
linkUrl: `/admin/rounds/${roundId}/filtering/results`,
message: `Filtering complete for ${stage?.name || 'stage'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/filtering/results`,
linkLabel: 'View Results',
priority: 'high',
metadata: {
roundId,
stageId,
jobId,
projectCount: projects.length,
passedCount,
@@ -162,10 +170,10 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
type: NotificationTypes.FILTERING_FAILED,
title: 'AI Filtering Failed',
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
linkUrl: `/admin/rounds/${roundId}/filtering`,
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/filtering`,
linkLabel: 'View Details',
priority: 'urgent',
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
metadata: { stageId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
})
}
}
@@ -175,12 +183,11 @@ export const filteringRouter = router({
* Check if AI is configured and ready for filtering
*/
checkAIStatus: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
// Check if round has AI rules
const aiRules = await ctx.prisma.filteringRule.count({
where: {
roundId: input.roundId,
stageId: input.stageId,
ruleType: 'AI_SCREENING',
isActive: true,
},
@@ -211,13 +218,13 @@ export const filteringRouter = router({
}),
/**
* Get filtering rules for a round
* Get filtering rules for a stage
*/
getRules: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.filteringRule.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId, isActive: true },
orderBy: { priority: 'asc' },
})
}),
@@ -228,7 +235,7 @@ export const filteringRouter = router({
createRule: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
name: z.string().min(1),
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING']),
configJson: z.record(z.unknown()),
@@ -238,7 +245,7 @@ export const filteringRouter = router({
.mutation(async ({ ctx, input }) => {
const rule = await ctx.prisma.filteringRule.create({
data: {
roundId: input.roundId,
stageId: input.stageId,
name: input.name,
ruleType: input.ruleType,
configJson: input.configJson as Prisma.InputJsonValue,
@@ -251,7 +258,7 @@ export const filteringRouter = router({
action: 'CREATE',
entityType: 'FilteringRule',
entityId: rule.id,
detailsJson: { roundId: input.roundId, name: input.name, ruleType: input.ruleType },
detailsJson: { stageId: input.stageId, name: input.name, ruleType: input.ruleType },
})
return rule
@@ -335,33 +342,30 @@ export const filteringRouter = router({
* Start a filtering job (runs in background)
*/
startJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Check if there's already a running job
const existingJob = await ctx.prisma.filteringJob.findFirst({
where: { roundId: input.roundId, status: 'RUNNING' },
where: { stageId: input.stageId, status: 'RUNNING' },
})
if (existingJob) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A filtering job is already running for this round',
message: 'A filtering job is already running for this stage',
})
}
// Get rules
const rules = await ctx.prisma.filteringRule.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
orderBy: { priority: 'asc' },
})
if (rules.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No filtering rules configured for this round',
message: 'No filtering rules configured for this stage',
})
}
// Check AI config if needed
const hasAIRules = rules.some((r) => r.ruleType === 'AI_SCREENING' && r.isActive)
if (hasAIRules) {
const aiConfigured = await isOpenAIConfigured()
@@ -381,29 +385,30 @@ export const filteringRouter = router({
}
}
// Count projects
const projectCount = await ctx.prisma.project.count({
where: { roundId: input.roundId },
const projectCount = await ctx.prisma.projectStageState.count({
where: {
stageId: input.stageId,
exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
})
if (projectCount === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No projects found in this round',
message: 'No projects found in this stage',
})
}
// Create job
const job = await ctx.prisma.filteringJob.create({
data: {
roundId: input.roundId,
stageId: input.stageId,
status: 'PENDING',
totalProjects: projectCount,
},
})
// Start background execution (non-blocking)
setImmediate(() => {
runFilteringJob(job.id, input.roundId, ctx.user.id).catch(console.error)
runFilteringJob(job.id, input.stageId, ctx.user.id).catch(console.error)
})
return { jobId: job.id, message: 'Filtering job started' }
@@ -425,41 +430,38 @@ export const filteringRouter = router({
}),
/**
* Get latest job for a round
* Get latest job for a stage
*/
getLatestJob: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.filteringJob.findFirst({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
orderBy: { createdAt: 'desc' },
})
}),
/**
* Execute all filtering rules against projects in a round (synchronous, legacy)
* Execute all filtering rules against projects in a stage (synchronous)
*/
executeRules: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Get rules
const rules = await ctx.prisma.filteringRule.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
orderBy: { priority: 'asc' },
})
if (rules.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No filtering rules configured for this round',
message: 'No filtering rules configured for this stage',
})
}
// Check if any AI_SCREENING rules exist
const hasAIRules = rules.some((r) => r.ruleType === 'AI_SCREENING' && r.isActive)
if (hasAIRules) {
// Verify OpenAI is configured before proceeding
const aiConfigured = await isOpenAIConfigured()
if (!aiConfigured) {
throw new TRPCError({
@@ -469,7 +471,6 @@ export const filteringRouter = router({
})
}
// Also verify the model works
const testResult = await testOpenAIConnection()
if (!testResult.success) {
throw new TRPCError({
@@ -479,27 +480,33 @@ export const filteringRouter = router({
}
}
// Get projects in this round
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
const projectStates = await ctx.prisma.projectStageState.findMany({
where: {
stageId: input.stageId,
exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
include: {
files: {
select: { id: true, fileName: true, fileType: true },
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true },
},
},
},
},
})
const projects = projectStates.map((pss: any) => pss.project).filter(Boolean)
if (projects.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No projects found in this round',
message: 'No projects found in this stage',
})
}
// Execute rules
const results = await executeFilteringRules(rules, projects)
// Upsert results in batches to avoid long-held locks
const BATCH_SIZE = 25
for (let i = 0; i < results.length; i += BATCH_SIZE) {
const batch = results.slice(i, i + BATCH_SIZE)
@@ -507,13 +514,13 @@ export const filteringRouter = router({
batch.map((r) =>
ctx.prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId: input.roundId,
stageId_projectId: {
stageId: input.stageId,
projectId: r.projectId,
},
},
create: {
roundId: input.roundId,
stageId: input.stageId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
@@ -523,7 +530,6 @@ export const filteringRouter = router({
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
// Clear any previous override
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
@@ -537,8 +543,8 @@ export const filteringRouter = router({
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: input.roundId,
entityType: 'Stage',
entityId: input.stageId,
detailsJson: {
action: 'EXECUTE_FILTERING',
projectCount: projects.length,
@@ -557,22 +563,22 @@ export const filteringRouter = router({
}),
/**
* Get filtering results for a round (paginated)
* Get filtering results for a stage (paginated)
*/
getResults: protectedProcedure
.input(
z.object({
roundId: z.string(),
stageId: 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 { roundId, outcome, page, perPage } = input
const { stageId, outcome, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = { roundId }
const where: Record<string, unknown> = { stageId }
if (outcome) where.outcome = outcome
const [results, total] = await Promise.all([
@@ -612,20 +618,20 @@ export const filteringRouter = router({
* Get aggregate stats for filtering results
*/
getResultStats: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const [passed, filteredOut, flagged, overridden] = await Promise.all([
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'PASSED' },
where: { stageId: input.stageId, outcome: 'PASSED' },
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'FILTERED_OUT' },
where: { stageId: input.stageId, outcome: 'FILTERED_OUT' },
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'FLAGGED' },
where: { stageId: input.stageId, outcome: 'FLAGGED' },
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, overriddenBy: { not: null } },
where: { stageId: input.stageId, overriddenBy: { not: null } },
}),
])
@@ -708,33 +714,30 @@ export const filteringRouter = router({
/**
* Finalize filtering results — apply outcomes to project statuses
* PASSED → mark as ELIGIBLE and advance to next round
* PASSED → mark as ELIGIBLE
* FILTERED_OUT → mark as REJECTED (data preserved)
*/
finalizeResults: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Get current round to find the next one
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, programId: true, sortOrder: true, name: true },
const currentStage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { id: true, trackId: true, sortOrder: true, name: true },
})
// Find the next round by sortOrder
const nextRound = await ctx.prisma.round.findFirst({
const nextStage = await ctx.prisma.stage.findFirst({
where: {
programId: currentRound.programId,
sortOrder: { gt: currentRound.sortOrder },
trackId: currentStage.trackId,
sortOrder: { gt: currentStage.sortOrder },
},
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true },
})
const results = await ctx.prisma.filteringResult.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
})
// Use finalOutcome if overridden, otherwise use outcome
const filteredOutIds = results
.filter((r) => (r.finalOutcome || r.outcome) === 'FILTERED_OUT')
.map((r) => r.projectId)
@@ -743,61 +746,46 @@ export const filteringRouter = router({
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
.map((r) => r.projectId)
// Build transaction operations
const operations: Prisma.PrismaPromise<unknown>[] = []
// Filtered out projects get REJECTED status (data preserved)
if (filteredOutIds.length > 0) {
operations.push(
ctx.prisma.project.updateMany({
where: { roundId: input.roundId, id: { in: filteredOutIds } },
where: { id: { in: filteredOutIds } },
data: { status: 'REJECTED' },
})
)
}
// Passed projects get ELIGIBLE status (or advance to next round)
if (passedIds.length > 0) {
if (nextRound) {
// Advance passed projects to next round
operations.push(
ctx.prisma.project.updateMany({
where: { roundId: input.roundId, id: { in: passedIds } },
data: { roundId: nextRound.id, status: 'SUBMITTED' },
})
)
} else {
// No next round, just mark as eligible
operations.push(
ctx.prisma.project.updateMany({
where: { roundId: input.roundId, id: { in: passedIds } },
data: { status: 'ELIGIBLE' },
})
)
}
operations.push(
ctx.prisma.project.updateMany({
where: { id: { in: passedIds } },
data: { status: 'ELIGIBLE' },
})
)
}
// Execute all operations in a transaction
await ctx.prisma.$transaction(operations)
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: input.roundId,
entityType: 'Stage',
entityId: input.stageId,
detailsJson: {
action: 'FINALIZE_FILTERING',
passed: passedIds.length,
filteredOut: filteredOutIds.length,
advancedToRound: nextRound?.name || null,
advancedToStage: nextStage?.name || null,
},
})
return {
passed: passedIds.length,
filteredOut: filteredOutIds.length,
advancedToRoundId: nextRound?.id || null,
advancedToRoundName: nextRound?.name || null,
advancedToStageId: nextStage?.id || null,
advancedToStageName: nextStage?.name || null,
}
}),
@@ -807,16 +795,15 @@ export const filteringRouter = router({
reinstateProject: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
projectId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Update filtering result
await ctx.prisma.filteringResult.update({
where: {
roundId_projectId: {
roundId: input.roundId,
stageId_projectId: {
stageId: input.stageId,
projectId: input.projectId,
},
},
@@ -828,9 +815,8 @@ export const filteringRouter = router({
},
})
// Restore project status
await ctx.prisma.project.updateMany({
where: { roundId: input.roundId, id: input.projectId },
await ctx.prisma.project.update({
where: { id: input.projectId },
data: { status: 'ELIGIBLE' },
})
@@ -840,7 +826,7 @@ export const filteringRouter = router({
entityType: 'FilteringResult',
detailsJson: {
action: 'REINSTATE',
roundId: input.roundId,
stageId: input.stageId,
projectId: input.projectId,
},
})
@@ -852,7 +838,7 @@ export const filteringRouter = router({
bulkReinstate: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
projectIds: z.array(z.string()),
})
)
@@ -861,8 +847,8 @@ export const filteringRouter = router({
...input.projectIds.map((projectId) =>
ctx.prisma.filteringResult.update({
where: {
roundId_projectId: {
roundId: input.roundId,
stageId_projectId: {
stageId: input.stageId,
projectId,
},
},
@@ -875,7 +861,7 @@ export const filteringRouter = router({
})
),
ctx.prisma.project.updateMany({
where: { roundId: input.roundId, id: { in: input.projectIds } },
where: { id: { in: input.projectIds } },
data: { status: 'ELIGIBLE' },
}),
])
@@ -886,7 +872,7 @@ export const filteringRouter = router({
entityType: 'FilteringResult',
detailsJson: {
action: 'BULK_REINSTATE',
roundId: input.roundId,
stageId: input.stageId,
count: input.projectIds.length,
},
})

View File

@@ -9,9 +9,9 @@ export const gracePeriodRouter = router({
grant: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
userId: z.string(),
projectId: z.string().optional(), // Optional: specific project or all projects
projectId: z.string().optional(),
extendedUntil: z.date(),
reason: z.string().optional(),
})
@@ -32,7 +32,7 @@ export const gracePeriodRouter = router({
entityType: 'GracePeriod',
entityId: gracePeriod.id,
detailsJson: {
roundId: input.roundId,
stageId: input.stageId,
userId: input.userId,
projectId: input.projectId,
extendedUntil: input.extendedUntil.toISOString(),
@@ -45,13 +45,13 @@ export const gracePeriodRouter = router({
}),
/**
* List grace periods for a round
* List grace periods for a stage
*/
listByRound: adminProcedure
.input(z.object({ roundId: z.string() }))
listByStage: adminProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.gracePeriod.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
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 round
* List active grace periods for a stage
*/
listActiveByRound: adminProcedure
.input(z.object({ roundId: z.string() }))
listActiveByStage: adminProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.gracePeriod.findMany({
where: {
roundId: input.roundId,
stageId: input.stageId,
extendedUntil: { gte: new Date() },
},
include: {
@@ -80,19 +80,19 @@ export const gracePeriodRouter = router({
}),
/**
* Get grace periods for a specific user in a round
* Get grace periods for a specific user in a stage
*/
getByUser: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
userId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.gracePeriod.findMany({
where: {
roundId: input.roundId,
stageId: input.stageId,
userId: input.userId,
},
orderBy: { createdAt: 'desc' },
@@ -152,7 +152,7 @@ export const gracePeriodRouter = router({
entityId: input.id,
detailsJson: {
userId: gracePeriod.userId,
roundId: gracePeriod.roundId,
stageId: gracePeriod.stageId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
@@ -167,7 +167,7 @@ export const gracePeriodRouter = router({
bulkGrant: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: 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) => ({
roundId: input.roundId,
stageId: input.stageId,
userId,
extendedUntil: input.extendedUntil,
reason: input.reason,
@@ -192,7 +192,7 @@ export const gracePeriodRouter = router({
action: 'BULK_GRANT_GRACE_PERIOD',
entityType: 'GracePeriod',
detailsJson: {
roundId: input.roundId,
stageId: input.stageId,
userCount: input.userIds.length,
created: created.count,
},

View File

@@ -3,24 +3,34 @@ import { TRPCError } from '@trpc/server'
import { randomUUID } from 'crypto'
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import type { LiveVotingCriterion } from '@/types/round-settings'
interface LiveVotingCriterion {
id: string
label: string
description?: string
scale: number
weight: number
}
export const liveVotingRouter = router({
/**
* Get or create a live voting session for a round
* Get or create a live voting session for a stage
*/
getSession: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
let session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
include: {
round: {
stage: {
include: {
program: { select: { name: true, year: true } },
projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
select: { id: true, title: true, teamName: true },
track: {
include: {
pipeline: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
},
},
@@ -28,18 +38,21 @@ export const liveVotingRouter = router({
})
if (!session) {
// Create session
session = await ctx.prisma.liveVotingSession.create({
data: {
roundId: input.roundId,
stageId: input.stageId,
},
include: {
round: {
stage: {
include: {
program: { select: { name: true, year: true } },
projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
select: { id: true, title: true, teamName: true },
track: {
include: {
pipeline: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
},
},
@@ -81,15 +94,22 @@ export const liveVotingRouter = router({
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: {
round: {
stage: {
include: {
program: { select: { name: true, year: true } },
track: {
include: {
pipeline: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
},
},
},
})
// Get current project if in progress
let currentProject = null
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
currentProject = await ctx.prisma.project.findUnique({
@@ -98,7 +118,6 @@ export const liveVotingRouter = router({
})
}
// Get user's vote for current project
let userVote = null
if (session.currentProjectId) {
userVote = await ctx.prisma.liveVote.findFirst({
@@ -110,7 +129,6 @@ export const liveVotingRouter = router({
})
}
// Calculate time remaining
let timeRemaining = null
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
@@ -126,7 +144,7 @@ export const liveVotingRouter = router({
votingMode: session.votingMode,
criteriaJson: session.criteriaJson,
},
round: session.round,
stage: session.stage,
currentProject,
userVote,
timeRemaining,
@@ -142,27 +160,32 @@ export const liveVotingRouter = router({
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: {
round: {
stage: {
include: {
program: { select: { name: true, year: true } },
track: {
include: {
pipeline: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
},
},
},
})
// Get all projects in order
const projectOrder = (session.projectOrderJson as string[]) || []
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectOrder } },
select: { id: true, title: true, teamName: true },
})
// Sort by order
const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id))
.filter(Boolean)
// Get scores for each project
const scores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: session.id },
@@ -186,7 +209,7 @@ export const liveVotingRouter = router({
currentProjectId: session.currentProjectId,
votingEndsAt: session.votingEndsAt,
},
round: session.round,
stage: session.stage,
projects: projectsWithScores,
}
}),
@@ -546,9 +569,17 @@ export const liveVotingRouter = router({
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: {
round: {
stage: {
include: {
program: { select: { name: true, year: true } },
track: {
include: {
pipeline: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
},
},
},
@@ -898,10 +929,18 @@ export const liveVotingRouter = router({
audienceVotingMode: true,
audienceRequireId: true,
audienceMaxFavorites: true,
round: {
stage: {
select: {
name: true,
program: { select: { name: true, year: true } },
track: {
select: {
pipeline: {
select: {
program: { select: { name: true, year: true } },
},
},
},
},
},
},
},

822
src/server/routers/live.ts Normal file
View File

@@ -0,0 +1,822 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const liveRouter = router({
/**
* Start a live presentation session for a stage
*/
start: adminProcedure
.input(
z.object({
stageId: 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 },
})
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') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Stage must be ACTIVE to start a live session',
})
}
// Check for existing active cursor
const existingCursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { stageId: input.stageId },
})
if (existingCursor) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A live session already exists for this stage. Use jump/reorder to modify it.',
})
}
// Verify all projects exist
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectOrder } },
select: { id: true },
})
if (projects.length !== input.projectOrder.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Some project IDs are invalid',
})
}
const cursor = await ctx.prisma.$transaction(async (tx) => {
// Store the project order in stage config
await tx.stage.update({
where: { id: input.stageId },
data: {
configJson: {
...(stage.configJson as Record<string, unknown> ?? {}),
projectOrder: input.projectOrder,
} as Prisma.InputJsonValue,
},
})
const created = await tx.liveProgressCursor.create({
data: {
stageId: input.stageId,
activeProjectId: input.projectOrder[0],
activeOrderIndex: 0,
isPaused: false,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_SESSION_STARTED',
entityType: 'Stage',
entityId: input.stageId,
detailsJson: {
sessionId: created.sessionId,
projectCount: input.projectOrder.length,
firstProjectId: input.projectOrder[0],
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return cursor
}),
/**
* Set the active project in the live session
*/
setActiveProject: adminProcedure
.input(
z.object({
stageId: z.string(),
projectId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
})
// Get project order from stage config
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
})
const config = (stage.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
const index = projectOrder.indexOf(input.projectId)
if (index === -1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Project is not in the session order',
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: input.projectId,
activeOrderIndex: index,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_ACTIVE_PROJECT_SET',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectId: input.projectId,
orderIndex: index,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
return updated
}),
/**
* Jump to a specific index in the project order
*/
jump: adminProcedure
.input(
z.object({
stageId: z.string(),
index: z.number().int().min(0),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
})
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
})
const config = (stage.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
if (input.index >= projectOrder.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Index ${input.index} is out of range (0-${projectOrder.length - 1})`,
})
}
const targetProjectId = projectOrder[input.index]
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: targetProjectId,
activeOrderIndex: input.index,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_JUMP',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
fromIndex: cursor.activeOrderIndex,
toIndex: input.index,
projectId: targetProjectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
return updated
}),
/**
* Reorder the project presentation queue
*/
reorder: adminProcedure
.input(
z.object({
stageId: 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 cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
})
// Update config with new order
const updated = await ctx.prisma.$transaction(async (tx) => {
await tx.stage.update({
where: { id: input.stageId },
data: {
configJson: {
...(stage.configJson as Record<string, unknown> ?? {}),
projectOrder: input.projectOrder,
} as Prisma.InputJsonValue,
},
})
// Recalculate active index
const newIndex = cursor.activeProjectId
? input.projectOrder.indexOf(cursor.activeProjectId)
: 0
const updatedCursor = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeOrderIndex: Math.max(0, newIndex),
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_REORDER',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectCount: input.projectOrder.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updatedCursor
})
return updated
}),
/**
* Pause the live session
*/
pause: adminProcedure
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
})
if (cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session is already paused',
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: true },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_PAUSED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
return updated
}),
/**
* Resume the live session
*/
resume: adminProcedure
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
})
if (!cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session is not paused',
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: false },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_RESUMED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
return updated
}),
/**
* Get current cursor state (for all users, including audience)
*/
getCursor: protectedProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { stageId: input.stageId },
})
if (!cursor) {
return null
}
// Get stage config for project order
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
})
const config = (stage.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
// Get current project details
let activeProject = null
if (cursor.activeProjectId) {
activeProject = await ctx.prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: {
id: true,
title: true,
teamName: true,
description: true,
tags: true,
},
})
}
// Get open cohorts for this stage (if any)
const openCohorts = await ctx.prisma.cohort.findMany({
where: { stageId: input.stageId, isOpen: true },
select: {
id: true,
name: true,
votingMode: true,
windowCloseAt: true,
},
})
return {
...cursor,
activeProject,
projectOrder,
totalProjects: projectOrder.length,
openCohorts,
}
}),
/**
* Cast a vote during a live session (audience or jury)
* Checks window is open and deduplicates votes
*/
castVote: audienceProcedure
.input(
z.object({
stageId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
criterionScoresJson: z.record(z.number()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify live session exists and is not paused
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { stageId: input.stageId },
})
if (cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is paused',
})
}
// Check if there's an open cohort containing this project
const openCohort = await ctx.prisma.cohort.findFirst({
where: {
stageId: input.stageId,
isOpen: true,
projects: { some: { projectId: input.projectId } },
},
})
// Check voting window if cohort has time limits
if (openCohort?.windowCloseAt) {
const now = new Date()
if (now > openCohort.windowCloseAt) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting window has closed',
})
}
}
// 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 or check existing LiveVotingSession for this stage
// We look for any session linked to a round in this program
const session = await ctx.prisma.liveVotingSession.findFirst({
where: {
stage: {
track: { pipeline: { programId: stage.track.pipeline.programId } },
},
status: 'IN_PROGRESS',
},
})
if (!session) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'No active voting session found',
})
}
// Deduplicate: check if user already voted on this project in this session
const existingVote = await ctx.prisma.liveVote.findUnique({
where: {
sessionId_projectId_userId: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
},
},
})
if (existingVote) {
// Update existing vote
const updated = await ctx.prisma.liveVote.update({
where: { id: existingVote.id },
data: {
score: input.score,
criterionScoresJson: input.criterionScoresJson
? (input.criterionScoresJson as Prisma.InputJsonValue)
: undefined,
votedAt: new Date(),
},
})
return { vote: updated, wasUpdate: true }
}
// Create new vote
const vote = await ctx.prisma.liveVote.create({
data: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
isAudienceVote: !['JURY_MEMBER', 'SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(
ctx.user.role
),
criterionScoresJson: input.criterionScoresJson
? (input.criterionScoresJson as Prisma.InputJsonValue)
: undefined,
},
})
return { vote, wasUpdate: false }
}),
// =========================================================================
// Phase 4: Audience-native procedures
// =========================================================================
/**
* Get audience context for a live session (public-facing via sessionId)
*/
getAudienceContext: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { sessionId: input.sessionId },
})
if (!cursor) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Live session not found',
})
}
// Get stage info
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: cursor.stageId },
select: {
id: true,
name: true,
stageType: true,
windowOpenAt: true,
windowCloseAt: true,
status: true,
configJson: true,
},
})
// Get active project
let activeProject = null
if (cursor.activeProjectId) {
activeProject = await ctx.prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: {
id: true,
title: true,
teamName: true,
description: true,
tags: true,
country: true,
},
})
}
// Get open cohorts
const openCohorts = await ctx.prisma.cohort.findMany({
where: { stageId: cursor.stageId, isOpen: true },
select: {
id: true,
name: true,
votingMode: true,
windowOpenAt: true,
windowCloseAt: true,
projects: {
select: { projectId: true },
},
},
})
const config = (stage.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)
// 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 } } } } },
})
const votingSession = await ctx.prisma.liveVotingSession.findFirst({
where: {
stage: {
track: { pipeline: { programId: stageWithTrack.track.pipeline.programId } },
},
status: 'IN_PROGRESS',
},
select: { id: true },
})
// Get all cohort project IDs for this stage
const allCohortProjectIds = openCohorts.flatMap((c) =>
c.projects.map((p) => p.projectId)
)
const uniqueProjectIds = [...new Set(allCohortProjectIds)]
let projectScores: Array<{
projectId: string
title: string
teamName: string | null
averageScore: number
voteCount: number
}> = []
if (votingSession && uniqueProjectIds.length > 0) {
// Get vote aggregates
const voteAggregates = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: {
sessionId: votingSession.id,
projectId: { in: uniqueProjectIds },
},
_avg: { score: true },
_count: { score: true },
})
// Get project details
const projects = await ctx.prisma.project.findMany({
where: { id: { in: uniqueProjectIds } },
select: { id: true, title: true, teamName: true },
})
const projectMap = new Map(projects.map((p) => [p.id, p]))
projectScores = voteAggregates.map((agg) => {
const project = projectMap.get(agg.projectId)
return {
projectId: agg.projectId,
title: project?.title ?? 'Unknown',
teamName: project?.teamName ?? null,
averageScore: agg._avg.score ?? 0,
voteCount: agg._count.score,
}
})
}
return {
cursor: {
sessionId: cursor.sessionId,
activeOrderIndex: cursor.activeOrderIndex,
isPaused: cursor.isPaused,
totalProjects: projectOrder.length,
},
activeProject,
openCohorts: openCohorts.map((c) => ({
id: c.id,
name: c.name,
votingMode: c.votingMode,
windowCloseAt: c.windowCloseAt,
projectIds: c.projects.map((p) => p.projectId),
})),
projectScores,
stageInfo: {
id: stage.id,
name: stage.name,
stageType: stage.stageType,
},
windowStatus: {
isOpen: isWindowOpen,
closesAt: stage.windowCloseAt,
},
}
}),
/**
* Cast a vote in a stage-native live session
*/
castStageVote: audienceProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
dedupeKey: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Resolve cursor by sessionId
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { sessionId: input.sessionId },
})
if (!cursor) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Live session not found',
})
}
if (cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is paused',
})
}
// Check if there's an open cohort containing this project
const openCohort = await ctx.prisma.cohort.findFirst({
where: {
stageId: cursor.stageId,
isOpen: true,
projects: { some: { projectId: input.projectId } },
},
})
if (openCohort?.windowCloseAt) {
const now = new Date()
if (now > openCohort.windowCloseAt) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting window has closed',
})
}
}
// Find an active LiveVotingSession
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: cursor.stageId },
include: {
track: {
include: {
pipeline: { select: { programId: true } },
},
},
},
})
const session = await ctx.prisma.liveVotingSession.findFirst({
where: {
stage: {
track: { pipeline: { programId: stage.track.pipeline.programId } },
},
status: 'IN_PROGRESS',
},
})
if (!session) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'No active voting session found',
})
}
// Deduplicate: sessionId + projectId + userId
const existingVote = await ctx.prisma.liveVote.findUnique({
where: {
sessionId_projectId_userId: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
},
},
})
if (existingVote) {
const updated = await ctx.prisma.liveVote.update({
where: { id: existingVote.id },
data: {
score: input.score,
votedAt: new Date(),
},
})
return { vote: updated, wasUpdate: true }
}
const vote = await ctx.prisma.liveVote.create({
data: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
isAudienceVote: !['JURY_MEMBER', 'SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(
ctx.user.role
),
},
})
return { vote, wasUpdate: false }
}),
})

View File

@@ -405,7 +405,7 @@ export const mentorRouter = router({
bulkAutoAssign: adminProcedure
.input(
z.object({
roundId: z.string(),
programId: z.string(),
useAI: z.boolean().default(true),
maxAssignments: z.number().min(1).max(100).default(50),
})
@@ -414,7 +414,7 @@ export const mentorRouter = router({
// Get projects without mentors
const projects = await ctx.prisma.project.findMany({
where: {
roundId: input.roundId,
programId: input.programId,
mentorAssignment: null,
wantsMentorship: true,
},
@@ -525,8 +525,8 @@ export const mentorRouter = router({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
entityId: input.roundId,
entityType: 'Program',
entityId: input.programId,
detailsJson: {
assigned,
failed,
@@ -552,11 +552,7 @@ export const mentorRouter = router({
include: {
project: {
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
teamMembers: {
include: {
user: { select: { id: true, name: true, email: true } },
@@ -598,11 +594,7 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
round: {
include: {
program: { select: { id: true, name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
teamMembers: {
include: {
user: {
@@ -744,7 +736,7 @@ export const mentorRouter = router({
listAssignments: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
mentorId: z.string().optional(),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
@@ -752,7 +744,7 @@ export const mentorRouter = router({
)
.query(async ({ ctx, input }) => {
const where = {
...(input.roundId && { project: { roundId: input.roundId } }),
...(input.programId && { project: { programId: input.programId } }),
...(input.mentorId && { mentorId: input.mentorId }),
}
@@ -1229,12 +1221,12 @@ export const mentorRouter = router({
getActivityStats: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { project: { roundId: input.roundId } }
const where = input.programId
? { project: { programId: input.programId } }
: {}
const assignments = await ctx.prisma.mentorAssignment.findMany({

View File

@@ -12,9 +12,9 @@ export const messageRouter = router({
send: adminProcedure
.input(
z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL']),
recipientType: z.enum(['USER', 'ROLE', 'STAGE_JURY', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
stageId: 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.roundId
input.stageId
)
if (recipientUserIds.length === 0) {
@@ -47,7 +47,7 @@ export const messageRouter = router({
senderId: ctx.user.id,
recipientType: input.recipientType,
recipientFilter: input.recipientFilter ?? undefined,
roundId: input.roundId,
stageId: input.stageId,
templateId: input.templateId,
subject: input.subject,
body: input.body,
@@ -344,7 +344,7 @@ async function resolveRecipients(
prisma: PrismaClient,
recipientType: string,
recipientFilter: unknown,
roundId?: string
stageId?: 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 'ROUND_JURY': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
case 'STAGE_JURY': {
const targetStageId = stageId || (filter?.stageId as string)
if (!targetStageId) return []
const assignments = await prisma.assignment.findMany({
where: { roundId: targetRoundId },
where: { stageId: targetStageId },
select: { userId: true },
distinct: ['userId'],
})
@@ -383,7 +383,6 @@ async function resolveRecipients(
case 'PROGRAM_TEAM': {
const programId = filter?.programId as string
if (!programId) return []
// Get all applicants with projects in rounds of this program
const projects = await prisma.project.findMany({
where: { programId },
select: { submittedByUserId: true },

View File

@@ -85,23 +85,20 @@ export const notionImportRouter = router({
z.object({
apiKey: z.string().min(1),
databaseId: z.string().min(1),
roundId: z.string(),
// Column mappings: Notion property name -> Project field
programId: z.string(),
mappings: z.object({
title: z.string(), // Required
title: z.string(),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(), // Multi-select property
country: z.string().optional(), // Country name or ISO code
tags: z.string().optional(),
country: z.string().optional(),
}),
// Store unmapped columns in metadataJson
includeUnmappedInMetadata: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
})
// Fetch all records from Notion
@@ -185,8 +182,7 @@ export const notionImportRouter = router({
// Create project
await ctx.prisma.project.create({
data: {
programId: round.programId,
roundId: round.id,
programId: input.programId,
status: 'SUBMITTED',
title: title.trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
@@ -220,7 +216,6 @@ export const notionImportRouter = router({
detailsJson: {
source: 'notion',
databaseId: input.databaseId,
roundId: input.roundId,
imported: results.imported,
skipped: results.skipped,
},

File diff suppressed because it is too large Load Diff

View File

@@ -7,54 +7,138 @@ import { parseWizardConfig } from '@/lib/wizard-config'
export const programRouter = router({
/**
* List all programs with optional filtering
* List all programs with optional filtering.
* When includeStages is true, returns stages nested under
* pipelines -> tracks -> stages, flattened as `stages` for convenience.
*/
list: protectedProcedure
.input(
z.object({
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
includeRounds: z.boolean().optional(),
includeStages: z.boolean().optional(),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.prisma.program.findMany({
const includeStages = input?.includeStages || false
const programs = await ctx.prisma.program.findMany({
where: input?.status ? { status: input.status } : undefined,
orderBy: { year: 'desc' },
include: {
_count: {
select: { rounds: true },
},
rounds: {
orderBy: { createdAt: 'asc' },
include: {
_count: {
select: { projects: true, assignments: true },
include: includeStages
? {
pipelines: {
include: {
tracks: {
include: {
stages: {
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { assignments: true, projectStageStates: true },
},
},
},
},
},
},
},
},
},
},
}
: 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,
},
}))
) || []
) || [],
}))
}),
/**
* Get a single program with its rounds
* Get a single program with its stages (via pipelines)
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.program.findUniqueOrThrow({
const program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.id },
include: {
rounds: {
orderBy: { createdAt: 'asc' },
pipelines: {
include: {
_count: {
select: { projects: true, assignments: true },
tracks: {
include: {
stages: {
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { assignments: true, projectStageStates: true },
},
},
},
},
},
},
},
},
})
// 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,
},
}))
) || []
) || []
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,
})),
}
}),
/**

View File

@@ -6,13 +6,13 @@ import { logAudit } from '../utils/audit'
/**
* Project Pool Router
*
* Manages the pool of unassigned projects (projects not yet assigned to a round).
* Provides procedures for listing unassigned projects and bulk assigning them to rounds.
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
*/
export const projectPoolRouter = router({
/**
* List unassigned projects with filtering and pagination
* Projects where roundId IS NULL
* Projects not assigned to any stage
*/
listUnassigned: adminProcedure
.input(
@@ -33,7 +33,7 @@ export const projectPoolRouter = router({
// Build where clause
const where: Record<string, unknown> = {
programId,
roundId: null, // Only unassigned projects
stageStates: { none: {} }, // Only unassigned projects (not in any stage)
}
// Filter by competition category
@@ -92,47 +92,29 @@ export const projectPoolRouter = router({
}),
/**
* Bulk assign projects to a round
* Bulk assign projects to a stage
*
* Validates that:
* - All projects exist
* - All projects belong to the same program as the target round
* - Round exists and belongs to a program
* - Stage exists
*
* Updates:
* - Project.roundId
* - Project.status to 'ASSIGNED'
* - Creates ProjectStatusHistory records for each project
* - Creates audit log
* Creates:
* - ProjectStageState entries for each project
* - Project.status updated to 'ASSIGNED'
* - ProjectStatusHistory records for each project
* - Audit log
*/
assignToRound: adminProcedure
assignToStage: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
roundId: z.string(),
stageId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { projectIds, roundId } = input
const { projectIds, stageId } = input
// Step 1: Fetch round to get programId
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: {
id: true,
programId: true,
name: true,
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
})
}
// Step 2: Fetch all projects to validate
// Step 1: Fetch all projects to validate
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: projectIds },
@@ -154,28 +136,33 @@ export const projectPoolRouter = router({
})
}
// Validate all projects belong to the same program as the round
const invalidProjects = projects.filter(
(p) => p.programId !== round.programId
)
if (invalidProjects.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot assign projects from different programs. The following projects do not belong to the target program: ${invalidProjects
.map((p) => p.title)
.join(', ')}`,
})
}
// Verify stage exists and get its trackId
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: stageId },
select: { id: true, trackId: true },
})
// Step 3: Perform bulk assignment in a transaction
// Step 2: Perform bulk assignment in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Update all projects
// Create ProjectStageState entries for each project (skip existing)
const stageStateData = projectIds.map((projectId) => ({
projectId,
stageId,
trackId: stage.trackId,
state: 'PENDING' as const,
}))
await tx.projectStageState.createMany({
data: stageStateData,
skipDuplicates: true,
})
// Update project statuses
const updatedProjects = await tx.project.updateMany({
where: {
id: { in: projectIds },
},
data: {
roundId: roundId,
status: 'ASSIGNED',
},
})
@@ -193,11 +180,10 @@ export const projectPoolRouter = router({
await logAudit({
prisma: tx,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_TO_ROUND',
action: 'BULK_ASSIGN_TO_STAGE',
entityType: 'Project',
detailsJson: {
roundId,
roundName: round.name,
stageId,
projectCount: projectIds.length,
projectIds,
},
@@ -211,8 +197,7 @@ export const projectPoolRouter = router({
return {
success: true,
assignedCount: result.count,
roundId,
roundName: round.name,
stageId,
}
}),
})

View File

@@ -8,7 +8,6 @@ import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email'
@@ -34,7 +33,7 @@ export const projectRouter = router({
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
stageId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
@@ -55,8 +54,8 @@ export const projectRouter = router({
'REJECTED',
])
).optional(),
notInRoundId: z.string().optional(), // Exclude projects already in this round
unassignedOnly: z.boolean().optional(), // Projects not in any round
excludeInStageId: z.string().optional(), // Exclude projects already in this stage
unassignedOnly: z.boolean().optional(), // Projects not in any stage
search: z.string().optional(),
tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
@@ -76,7 +75,7 @@ export const projectRouter = router({
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags,
programId, stageId, excludeInStageId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
page, perPage,
@@ -89,25 +88,19 @@ export const projectRouter = router({
// Filter by program
if (programId) where.programId = programId
// Filter by round
if (roundId) {
where.roundId = roundId
// Filter by stage (via ProjectStageState join)
if (stageId) {
where.stageStates = { some: { stageId } }
}
// Exclude projects in a specific round (include unassigned projects with roundId=null)
if (notInRoundId) {
if (!where.AND) where.AND = []
;(where.AND as unknown[]).push({
OR: [
{ roundId: null },
{ roundId: { not: notInRoundId } },
],
})
// Exclude projects already in a specific stage
if (excludeInStageId) {
where.stageStates = { none: { stageId: excludeInStageId } }
}
// Filter by unassigned (no round)
// Filter by unassigned (not in any stage)
if (unassignedOnly) {
where.roundId = null
where.stageStates = { none: {} }
}
// Status filter
@@ -153,13 +146,7 @@ export const projectRouter = router({
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
round: {
select: {
id: true,
name: true,
program: { select: { id: true, name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
_count: { select: { assignments: true, files: true } },
},
}),
@@ -183,8 +170,8 @@ export const projectRouter = router({
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
notInRoundId: z.string().optional(),
stageId: z.string().optional(),
excludeInStageId: z.string().optional(),
unassignedOnly: z.boolean().optional(),
search: z.string().optional(),
statuses: z.array(
@@ -213,7 +200,7 @@ export const projectRouter = router({
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, notInRoundId, unassignedOnly,
programId, stageId, excludeInStageId, unassignedOnly,
search, statuses, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
@@ -222,17 +209,15 @@ export const projectRouter = router({
const where: Record<string, unknown> = {}
if (programId) where.programId = programId
if (roundId) where.roundId = roundId
if (notInRoundId) {
if (!where.AND) where.AND = []
;(where.AND as unknown[]).push({
OR: [
{ roundId: null },
{ roundId: { not: notInRoundId } },
],
})
if (stageId) {
where.stageStates = { some: { stageId } }
}
if (excludeInStageId) {
where.stageStates = { none: { stageId: excludeInStageId } }
}
if (unassignedOnly) {
where.stageStates = { none: {} }
}
if (unassignedOnly) where.roundId = null
if (statuses?.length) where.status = { in: statuses }
if (tags && tags.length > 0) where.tags = { hasSome: tags }
if (competitionCategory) where.competitionCategory = competitionCategory
@@ -265,11 +250,7 @@ export const projectRouter = router({
*/
getFilterOptions: protectedProcedure
.query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({
select: { id: true, name: true, program: { select: { id: true, name: true, year: true } } },
orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
}),
const [countries, categories, issues] = await Promise.all([
ctx.prisma.project.findMany({
where: { country: { not: null } },
select: { country: true },
@@ -289,7 +270,6 @@ export const projectRouter = router({
])
return {
rounds,
countries: countries.map((c) => c.country).filter(Boolean) as string[],
categories: categories.map((c) => ({
value: c.competitionCategory!,
@@ -312,7 +292,6 @@ export const projectRouter = router({
where: { id: input.id },
include: {
files: true,
round: true,
teamMembers: {
include: {
user: {
@@ -394,13 +373,12 @@ export const projectRouter = router({
/**
* Create a single project (admin only)
* Projects belong to a round.
* Projects belong to a program.
*/
create: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string().optional(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
@@ -437,15 +415,7 @@ export const projectRouter = router({
...rest
} = input
// If roundId provided, derive programId from round for validation
let resolvedProgramId = input.programId
if (input.roundId) {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true },
})
resolvedProgramId = round.programId
}
const resolvedProgramId = input.programId
// Build metadata from contact fields + any additional metadata
const fullMetadata: Record<string, unknown> = { ...metadataJson }
@@ -460,19 +430,9 @@ export const projectRouter = router({
: undefined
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
// Auto-assign to first round if no roundId provided
let resolvedRoundId = input.roundId || null
if (!resolvedRoundId) {
const firstRound = await getFirstRoundForProgram(tx, resolvedProgramId)
if (firstRound) {
resolvedRoundId = firstRound.id
}
}
const created = await tx.project.create({
data: {
programId: resolvedProgramId,
roundId: resolvedRoundId,
title: input.title,
teamName: input.teamName,
description: input.description,
@@ -545,7 +505,6 @@ export const projectRouter = router({
entityId: created.id,
detailsJson: {
title: input.title,
roundId: input.roundId,
programId: resolvedProgramId,
teamMembersCount: teamMembersInput?.length || 0,
},
@@ -599,7 +558,6 @@ export const projectRouter = router({
/**
* Update a project (admin only)
* Status updates require a roundId context since status is per-round.
*/
update: adminProcedure
.input(
@@ -609,8 +567,6 @@ export const projectRouter = router({
teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(),
country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
// Status update requires roundId
roundId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
@@ -626,7 +582,7 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { id, metadataJson, status, roundId, country, ...data } = input
const { id, metadataJson, status, country, ...data } = input
// Normalize country to ISO-2 code if provided
const normalizedCountry = country !== undefined
@@ -675,90 +631,40 @@ export const projectRouter = router({
// Send notifications if status changed
if (status) {
// Get round details for notification
const projectWithRound = await ctx.prisma.project.findUnique({
where: { id },
include: { round: { select: { name: true, entryNotificationType: true, program: { select: { name: true } } } } },
})
const round = projectWithRound?.round
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Use round's configured notification type, or fall back to status-based defaults
if (round?.entryNotificationType) {
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
} else if (round) {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
}
@@ -855,13 +761,12 @@ export const projectRouter = router({
/**
* Import projects from CSV data (admin only)
* Projects belong to a program. Optionally assign to a round.
* Projects belong to a program.
*/
importCSV: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string().optional(),
projects: z.array(
z.object({
title: z.string().min(1),
@@ -879,37 +784,13 @@ export const projectRouter = router({
where: { id: input.programId },
})
// Verify round exists and belongs to program if provided
if (input.roundId) {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
if (round.programId !== input.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round does not belong to the selected program',
})
}
}
// Auto-assign to first round if no roundId provided
let resolvedImportRoundId = input.roundId || null
if (!resolvedImportRoundId) {
const firstRound = await getFirstRoundForProgram(ctx.prisma, input.programId)
if (firstRound) {
resolvedImportRoundId = firstRound.id
}
}
// Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Create all projects with roundId and programId
const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
programId: input.programId,
roundId: resolvedImportRoundId,
status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}
@@ -929,7 +810,7 @@ export const projectRouter = router({
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
detailsJson: { programId: input.programId, count: result.imported },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -942,13 +823,11 @@ export const projectRouter = router({
*/
getTags: protectedProcedure
.input(z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId) where.round = { programId: input.programId }
if (input.roundId) where.roundId = input.roundId
if (input.programId) where.programId = input.programId
const projects = await ctx.prisma.project.findMany({
where: Object.keys(where).length > 0 ? where : undefined,
@@ -963,13 +842,11 @@ export const projectRouter = router({
/**
* Update project status in bulk (admin only)
* Status is per-round, so roundId is required.
*/
bulkUpdateStatus: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
roundId: z.string(),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
@@ -982,25 +859,18 @@ export const projectRouter = router({
)
.mutation(async ({ ctx, input }) => {
// Fetch matching projects BEFORE update so notifications match actually-updated records
const [projects, round] = await Promise.all([
ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
roundId: input.roundId,
},
select: { id: true, title: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
}),
])
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
},
select: { id: true, title: true },
})
const matchingIds = projects.map((p) => p.id)
// Validate status transitions for all projects
const projectsWithStatus = await ctx.prisma.project.findMany({
where: { id: { in: matchingIds }, roundId: input.roundId },
where: { id: { in: matchingIds } },
select: { id: true, title: true, status: true },
})
const invalidTransitions: string[] = []
@@ -1019,7 +889,7 @@ export const projectRouter = router({
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.project.updateMany({
where: { id: { in: matchingIds }, roundId: input.roundId },
where: { id: { in: matchingIds } },
data: { status: input.status },
})
@@ -1038,7 +908,7 @@ export const projectRouter = router({
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: result.count },
detailsJson: { ids: matchingIds, status: input.status, count: result.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -1046,89 +916,45 @@ export const projectRouter = router({
return result
})
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Notify project teams based on round's configured notification or status-based fallback
// Notify project teams based on status
if (projects.length > 0) {
if (round?.entryNotificationType) {
// Use round's configured notification type
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
}
} else {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
}
}
@@ -1136,7 +962,7 @@ export const projectRouter = router({
}),
/**
* List projects in a program's pool (not assigned to any round)
* List projects in a program's pool (not assigned to any stage)
*/
listPool: adminProcedure
.input(
@@ -1153,7 +979,7 @@ export const projectRouter = router({
const where: Record<string, unknown> = {
programId,
roundId: null,
stageStates: { none: {} }, // Projects not assigned to any stage
}
if (search) {
@@ -1196,7 +1022,6 @@ export const projectRouter = router({
where: { id: input.id },
include: {
files: true,
round: true,
teamMembers: {
include: {
user: {

View File

@@ -1,985 +0,0 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { Prisma } from "@prisma/client";
import { router, protectedProcedure, adminProcedure } from "../trpc";
import {
notifyRoundJury,
notifyAdmins,
NotificationTypes,
} from "../services/in-app-notification";
import { logAudit } from "@/server/utils/audit";
import { runFilteringJob } from "./filtering";
import { prisma as globalPrisma } from "@/lib/prisma";
// Valid round status transitions (state machine)
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
DRAFT: ["ACTIVE", "ARCHIVED"], // Draft can be activated or archived
ACTIVE: ["CLOSED"], // Active rounds can only be closed
CLOSED: ["ARCHIVED"], // Closed rounds can be archived
ARCHIVED: [], // Archived is terminal — no transitions out
};
export const roundRouter = router({
/**
* List rounds for a program
*/
list: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({
where: { programId: input.programId },
orderBy: { sortOrder: "asc" },
include: {
_count: {
select: { projects: true, assignments: true },
},
},
});
}),
/**
* List all rounds across all programs (admin only, for messaging/filtering)
*/
listAll: adminProcedure.query(async ({ ctx }) => {
return ctx.prisma.round.findMany({
orderBy: [{ program: { name: "asc" } }, { sortOrder: "asc" }],
select: {
id: true,
name: true,
programId: true,
program: { select: { name: true } },
},
});
}),
/**
* List rounds for a program (alias for list)
*/
listByProgram: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({
where: { programId: input.programId },
orderBy: { sortOrder: "asc" },
select: {
id: true,
name: true,
sortOrder: true,
},
});
}),
/**
* Get a single round with stats
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
include: {
program: true,
_count: {
select: { projects: true, assignments: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
});
// Get evaluation stats + progress in parallel (avoids duplicate groupBy in getProgress)
const [evaluationStats, totalAssignments, completedAssignments] =
await Promise.all([
ctx.prisma.evaluation.groupBy({
by: ["status"],
where: {
assignment: { roundId: input.id },
},
_count: true,
}),
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true },
}),
]);
const evaluationsByStatus = evaluationStats.reduce(
(acc, curr) => {
acc[curr.status] = curr._count;
return acc;
},
{} as Record<string, number>,
);
return {
...round,
evaluationStats,
// Inline progress data (eliminates need for separate getProgress call)
progress: {
totalProjects: round._count.projects,
totalAssignments,
completedAssignments,
completionPercentage:
totalAssignments > 0
? Math.round((completedAssignments / totalAssignments) * 100)
: 0,
evaluationsByStatus,
},
};
}),
/**
* Create a new round (admin only)
*/
create: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
roundType: z
.enum(["FILTERING", "EVALUATION", "LIVE_EVENT"])
.default("EVALUATION"),
requiredReviews: z.number().int().min(0).max(10).default(3),
minAssignmentsPerJuror: z.number().int().min(1).max(50).default(5),
maxAssignmentsPerJuror: z.number().int().min(1).max(100).default(20),
sortOrder: z.number().int().optional(),
settingsJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(),
votingEndAt: z.date().optional(),
submissionStartDate: z.date().optional(),
submissionEndDate: z.date().optional(),
lateSubmissionGrace: z.number().int().min(0).max(720).optional(),
entryNotificationType: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Validate assignment constraints
if (input.minAssignmentsPerJuror > input.maxAssignmentsPerJuror) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Min assignments per juror must be less than or equal to max",
});
}
// Validate dates
if (input.votingStartAt && input.votingEndAt) {
if (input.votingEndAt <= input.votingStartAt) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
}
if (input.submissionStartDate && input.submissionEndDate) {
if (input.submissionEndDate <= input.submissionStartDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Submission end date must be after start date",
});
}
}
// Auto-set sortOrder if not provided (append to end)
let sortOrder = input.sortOrder;
if (sortOrder === undefined) {
const maxOrder = await ctx.prisma.round.aggregate({
where: { programId: input.programId },
_max: { sortOrder: true },
});
sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
}
const { settingsJson, sortOrder: _so, ...rest } = input;
// Auto-activate if voting start date is in the past
const now = new Date();
const shouldAutoActivate =
input.votingStartAt && input.votingStartAt <= now;
const round = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.round.create({
data: {
...rest,
sortOrder,
status: shouldAutoActivate ? "ACTIVE" : "DRAFT",
settingsJson: (settingsJson as Prisma.InputJsonValue) ?? undefined,
},
});
// For FILTERING rounds, automatically move all projects from the program to this round
if (input.roundType === "FILTERING") {
await tx.project.updateMany({
where: {
programId: input.programId,
roundId: { not: created.id },
},
data: {
roundId: created.id,
status: "SUBMITTED",
},
});
}
// Audit log
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: "CREATE",
entityType: "Round",
entityId: created.id,
detailsJson: { ...rest, settingsJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return created;
});
return round;
}),
/**
* Update round details (admin only)
*/
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()
.nullable(),
roundType: z.enum(["FILTERING", "EVALUATION", "LIVE_EVENT"]).optional(),
requiredReviews: z.number().int().min(0).max(10).optional(),
minAssignmentsPerJuror: z.number().int().min(1).max(50).optional(),
maxAssignmentsPerJuror: z.number().int().min(1).max(100).optional(),
submissionDeadline: z.date().optional().nullable(),
votingStartAt: z.date().optional().nullable(),
votingEndAt: z.date().optional().nullable(),
settingsJson: z.record(z.unknown()).optional(),
entryNotificationType: z.string().optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, settingsJson, ...data } = input;
// Validate dates if both provided
if (data.votingStartAt && data.votingEndAt) {
if (data.votingEndAt <= data.votingStartAt) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
}
// Validate assignment constraints if either is provided
if (
data.minAssignmentsPerJuror !== undefined ||
data.maxAssignmentsPerJuror !== undefined
) {
const existingRound = await ctx.prisma.round.findUnique({
where: { id },
select: {
minAssignmentsPerJuror: true,
maxAssignmentsPerJuror: true,
status: true,
},
});
const newMin =
data.minAssignmentsPerJuror ??
existingRound?.minAssignmentsPerJuror ??
5;
const newMax =
data.maxAssignmentsPerJuror ??
existingRound?.maxAssignmentsPerJuror ??
20;
if (newMin > newMax) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Min assignments per juror must be less than or equal to max",
});
}
}
// Check if we should auto-activate (if voting start is in the past and round is DRAFT)
const now = new Date();
let autoActivate = false;
if (data.votingStartAt && data.votingStartAt <= now) {
const existingRound = await ctx.prisma.round.findUnique({
where: { id },
select: { status: true },
});
if (existingRound?.status === "DRAFT") {
autoActivate = true;
}
}
const round = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.round.update({
where: { id },
data: {
...data,
...(autoActivate && { status: "ACTIVE" }),
settingsJson: (settingsJson as Prisma.InputJsonValue) ?? undefined,
},
});
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: "UPDATE",
entityType: "Round",
entityId: id,
detailsJson: { ...data, settingsJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return updated;
});
return round;
}),
/**
* Update round status (admin only)
*/
updateStatus: adminProcedure
.input(
z.object({
id: z.string(),
status: z.enum(["DRAFT", "ACTIVE", "CLOSED", "ARCHIVED"]),
}),
)
.mutation(async ({ ctx, input }) => {
// Get previous status and voting dates for audit
const previousRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
select: { status: true, votingStartAt: true, votingEndAt: true },
});
// Validate status transition
const allowedTransitions =
VALID_ROUND_TRANSITIONS[previousRound.status] || [];
if (!allowedTransitions.includes(input.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid status transition: cannot change from ${previousRound.status} to ${input.status}. Allowed transitions: ${allowedTransitions.join(", ") || "none (terminal state)"}`,
});
}
const now = new Date();
// When activating a round, if votingStartAt is in the future, update it to now
// This ensures voting actually starts when the admin opens the round
let votingStartAtUpdated = false;
const updateData: Parameters<typeof ctx.prisma.round.update>[0]["data"] =
{
status: input.status,
};
if (input.status === "ACTIVE" && previousRound.status !== "ACTIVE") {
if (previousRound.votingStartAt && previousRound.votingStartAt > now) {
// Set to 1 minute in the past to ensure voting is immediately open
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000);
updateData.votingStartAt = oneMinuteAgo;
votingStartAtUpdated = true;
}
}
// Map status to specific action name
const statusActionMap: Record<string, string> = {
ACTIVE: "ROUND_ACTIVATED",
CLOSED: "ROUND_CLOSED",
ARCHIVED: "ROUND_ARCHIVED",
};
const action = statusActionMap[input.status] || "UPDATE_STATUS";
const round = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.round.update({
where: { id: input.id },
data: updateData,
});
await logAudit({
prisma: tx,
userId: ctx.user.id,
action,
entityType: "Round",
entityId: input.id,
detailsJson: {
status: input.status,
previousStatus: previousRound.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: previousRound.votingStartAt,
newVotingStartAt: now,
}),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return updated;
});
// Notify jury members when round is activated
if (input.status === "ACTIVE" && previousRound.status !== "ACTIVE") {
// Get round details and assignment counts per user
const roundDetails = await ctx.prisma.round.findUnique({
where: { id: input.id },
include: {
_count: { select: { assignments: true } },
},
});
// Get count of distinct jury members assigned
const juryCount = await ctx.prisma.assignment.groupBy({
by: ["userId"],
where: { roundId: input.id },
_count: true,
});
if (roundDetails && juryCount.length > 0) {
const deadline = roundDetails.votingEndAt
? new Date(roundDetails.votingEndAt).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
: undefined;
// Notify all jury members with assignments in this round
await notifyRoundJury(input.id, {
type: NotificationTypes.ROUND_NOW_OPEN,
title: `${roundDetails.name} is Now Open`,
message: `The evaluation round is now open. Please review your assigned projects and submit your evaluations before the deadline.`,
linkUrl: `/jury/assignments`,
linkLabel: "Start Evaluating",
priority: "high",
metadata: {
roundName: roundDetails.name,
projectCount: roundDetails._count.assignments,
deadline,
},
});
}
}
// Auto-run filtering when a FILTERING round is closed (if enabled in settings)
const roundSettings =
(round.settingsJson as Record<string, unknown>) || {};
const autoFilterEnabled = roundSettings.autoFilterOnClose !== false; // Default to true
if (
input.status === "CLOSED" &&
round.roundType === "FILTERING" &&
autoFilterEnabled
) {
try {
const [filteringRules, projectCount] = await Promise.all([
ctx.prisma.filteringRule.findMany({
where: { roundId: input.id, isActive: true },
}),
ctx.prisma.project.count({ where: { roundId: input.id } }),
]);
// Check for existing running job
const existingJob = await ctx.prisma.filteringJob.findFirst({
where: { roundId: input.id, status: "RUNNING" },
});
if (filteringRules.length > 0 && projectCount > 0 && !existingJob) {
// Create filtering job
const job = await globalPrisma.filteringJob.create({
data: {
roundId: input.id,
status: "PENDING",
totalProjects: projectCount,
},
});
// Start background execution (non-blocking)
setImmediate(() => {
runFilteringJob(job.id, input.id, ctx.user.id).catch(
console.error,
);
});
// Notify admins that auto-filtering has started
await notifyAdmins({
type: NotificationTypes.FILTERING_COMPLETE,
title: "Auto-Filtering Started",
message: `Filtering automatically started for "${round.name}" after closing. ${projectCount} projects will be processed.`,
linkUrl: `/admin/rounds/${input.id}/filtering`,
linkLabel: "View Progress",
metadata: {
roundId: input.id,
roundName: round.name,
projectCount,
ruleCount: filteringRules.length,
autoTriggered: true,
},
});
}
} catch (error) {
// Auto-filtering failure should not block round closure
console.error("[Auto-Filtering] Failed to start:", error);
}
}
return round;
}),
/**
* Check if voting is currently open for a round
*/
isVotingOpen: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
});
const now = new Date();
const isOpen =
round.status === "ACTIVE" &&
round.votingStartAt !== null &&
round.votingEndAt !== null &&
now >= round.votingStartAt &&
now <= round.votingEndAt;
return {
isOpen,
startsAt: round.votingStartAt,
endsAt: round.votingEndAt,
status: round.status,
};
}),
/**
* Get round progress statistics
*/
getProgress: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [totalProjects, totalAssignments, completedAssignments] =
await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true },
}),
]);
const evaluationsByStatus = await ctx.prisma.evaluation.groupBy({
by: ["status"],
where: {
assignment: { roundId: input.id },
},
_count: true,
});
return {
totalProjects,
totalAssignments,
completedAssignments,
completionPercentage:
totalAssignments > 0
? Math.round((completedAssignments / totalAssignments) * 100)
: 0,
evaluationsByStatus: evaluationsByStatus.reduce(
(acc, curr) => {
acc[curr.status] = curr._count;
return acc;
},
{} as Record<string, number>,
),
};
}),
/**
* Update or create evaluation form for a round (admin only)
*/
updateEvaluationForm: adminProcedure
.input(
z.object({
roundId: z.string(),
criteria: z.array(
z.object({
id: z.string(),
label: z.string().min(1),
description: z.string().optional(),
type: z
.enum(["numeric", "text", "boolean", "section_header"])
.default("numeric"),
// Numeric fields
scale: z.number().int().min(1).max(10).optional(),
weight: z.number().optional(),
required: z.boolean().optional(),
// Text fields
maxLength: z.number().int().min(1).max(10000).optional(),
placeholder: z.string().optional(),
// Boolean fields
trueLabel: z.string().optional(),
falseLabel: z.string().optional(),
// Conditional visibility
condition: z
.object({
criterionId: z.string(),
operator: z.enum(["equals", "greaterThan", "lessThan"]),
value: z.union([z.number(), z.string(), z.boolean()]),
})
.optional(),
// Section grouping
sectionId: z.string().optional(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
const { roundId, criteria } = input;
// Check if there are existing evaluations
const existingEvaluations = await ctx.prisma.evaluation.count({
where: {
assignment: { roundId },
status: { in: ["SUBMITTED", "LOCKED"] },
},
});
if (existingEvaluations > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot modify criteria after evaluations have been submitted",
});
}
// Get or create the active evaluation form
const existingForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId, isActive: true },
});
let form;
if (existingForm) {
// Update existing form
form = await ctx.prisma.evaluationForm.update({
where: { id: existingForm.id },
data: { criteriaJson: criteria },
});
} else {
// Create new form
form = await ctx.prisma.evaluationForm.create({
data: {
roundId,
criteriaJson: criteria,
isActive: true,
},
});
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: "UPDATE_EVALUATION_FORM",
entityType: "EvaluationForm",
entityId: form.id,
detailsJson: { roundId, criteriaCount: criteria.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return form;
}),
/**
* Get evaluation form for a round
*/
getEvaluationForm: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
});
}),
/**
* Delete a round (admin only)
* Cascades to projects, assignments, evaluations, etc.
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: { select: { projects: true, assignments: true } },
},
});
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: "DELETE",
entityType: "Round",
entityId: input.id,
detailsJson: {
name: round.name,
status: round.status,
projectsDeleted: round._count.projects,
assignmentsDeleted: round._count.assignments,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
// Reset status for projects that will lose their roundId (ON DELETE SET NULL)
await tx.project.updateMany({
where: { roundId: input.id },
data: { status: "SUBMITTED" },
});
// Delete evaluations first to avoid FK constraint on Evaluation.formId
// (formId FK may not have CASCADE in older DB schemas)
await tx.evaluation.deleteMany({
where: { form: { roundId: input.id } },
});
await tx.round.delete({
where: { id: input.id },
});
});
return round;
}),
/**
* Check if a round has any submitted evaluations
*/
hasEvaluations: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const count = await ctx.prisma.evaluation.count({
where: {
assignment: { roundId: input.roundId },
status: { in: ["SUBMITTED", "LOCKED"] },
},
});
return count > 0;
}),
/**
* Assign projects from the program pool to a round
*/
assignProjects: adminProcedure
.input(
z.object({
roundId: z.string(),
projectIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Verify round exists and get programId
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
});
// Update projects to assign them to this round
const updated = await ctx.prisma.project.updateMany({
where: {
id: { in: input.projectIds },
programId: round.programId,
},
data: {
roundId: input.roundId,
status: "SUBMITTED",
},
});
if (updated.count === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"No projects were assigned. Projects may not belong to this program.",
});
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: "ASSIGN_PROJECTS_TO_ROUND",
entityType: "Round",
entityId: input.roundId,
detailsJson: { projectCount: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return { assigned: updated.count };
}),
/**
* Remove projects from a round
*/
removeProjects: adminProcedure
.input(
z.object({
roundId: z.string(),
projectIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Set roundId to null for these projects (remove from round)
const updated = await ctx.prisma.project.updateMany({
where: {
roundId: input.roundId,
id: { in: input.projectIds },
},
data: {
roundId: null as unknown as string, // Projects need to be orphaned
},
});
const deleted = { count: updated.count };
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: "REMOVE_PROJECTS_FROM_ROUND",
entityType: "Round",
entityId: input.roundId,
detailsJson: { projectCount: deleted.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return { removed: deleted.count };
}),
/**
* Advance projects from one round to the next
* Creates new RoundProject entries in the target round (keeps them in source round too)
*/
advanceProjects: adminProcedure
.input(
z.object({
fromRoundId: z.string(),
toRoundId: z.string(),
projectIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Verify both rounds exist and belong to the same program
const [fromRound, toRound] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.fromRoundId },
}),
ctx.prisma.round.findUniqueOrThrow({ where: { id: input.toRoundId } }),
]);
if (fromRound.programId !== toRound.programId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Rounds must belong to the same program",
});
}
// Verify all projects are in the source round
const sourceProjects = await ctx.prisma.project.findMany({
where: {
roundId: input.fromRoundId,
id: { in: input.projectIds },
},
select: { id: true },
});
if (sourceProjects.length !== input.projectIds.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Some projects are not in the source round",
});
}
// Move projects to target round
const updated = await ctx.prisma.project.updateMany({
where: {
id: { in: input.projectIds },
roundId: input.fromRoundId,
},
data: {
roundId: input.toRoundId,
status: "SUBMITTED",
},
});
const created = { count: updated.count };
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: "ADVANCE_PROJECTS",
entityType: "Round",
entityId: input.toRoundId,
detailsJson: {
fromRoundId: input.fromRoundId,
toRoundId: input.toRoundId,
projectCount: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return { advanced: created.count };
}),
/**
* Reorder rounds within a program
*/
reorder: adminProcedure
.input(
z.object({
programId: z.string(),
roundIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Update sortOrder for each round based on array position
await ctx.prisma.$transaction(
input.roundIds.map((roundId, index) =>
ctx.prisma.round.update({
where: { id: roundId },
data: { sortOrder: index },
}),
),
);
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: "REORDER_ROUNDS",
entityType: "Program",
entityId: input.programId,
detailsJson: { roundIds: input.roundIds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return { success: true };
}),
});

View File

@@ -1,209 +0,0 @@
import { z } from 'zod'
import { RoundType } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const roundTemplateRouter = router({
/**
* List all round templates, optionally filtered by programId.
*/
list: adminProcedure
.input(
z.object({
programId: z.string().optional(),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.prisma.roundTemplate.findMany({
where: {
...(input?.programId ? { programId: input.programId } : {}),
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Get a single template by ID.
*/
getById: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const template = await ctx.prisma.roundTemplate.findUnique({
where: { id: input.id },
})
if (!template) {
throw new Error('Template not found')
}
return template
}),
/**
* Create a new round template from scratch.
*/
create: adminProcedure
.input(
z.object({
name: z.string().min(1).max(200),
description: z.string().optional(),
programId: z.string().optional(),
roundType: z.nativeEnum(RoundType).default('EVALUATION'),
criteriaJson: z.any(),
settingsJson: z.any().optional(),
assignmentConfig: z.any().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.roundTemplate.create({
data: {
name: input.name,
description: input.description,
programId: input.programId,
roundType: input.roundType,
criteriaJson: input.criteriaJson,
settingsJson: input.settingsJson ?? undefined,
assignmentConfig: input.assignmentConfig ?? undefined,
createdBy: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_ROUND_TEMPLATE',
entityType: 'RoundTemplate',
entityId: template.id,
detailsJson: { name: input.name },
})
} catch {}
return template
}),
/**
* Create a template from an existing round (snapshot).
*/
createFromRound: adminProcedure
.input(
z.object({
roundId: z.string(),
name: z.string().min(1).max(200),
description: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Fetch the round and its active evaluation form
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
include: {
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
})
if (!round) {
throw new Error('Round not found')
}
const form = round.evaluationForms[0]
const criteriaJson = form?.criteriaJson ?? []
const template = await ctx.prisma.roundTemplate.create({
data: {
name: input.name,
description: input.description || `Snapshot of ${round.name}`,
programId: round.programId,
roundType: round.roundType,
criteriaJson,
settingsJson: round.settingsJson ?? undefined,
createdBy: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_ROUND_TEMPLATE_FROM_ROUND',
entityType: 'RoundTemplate',
entityId: template.id,
detailsJson: { name: input.name, sourceRoundId: input.roundId },
})
} catch {}
return template
}),
/**
* Update a template.
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200).optional(),
description: z.string().optional(),
programId: z.string().nullable().optional(),
roundType: z.nativeEnum(RoundType).optional(),
criteriaJson: z.any().optional(),
settingsJson: z.any().optional(),
assignmentConfig: z.any().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const template = await ctx.prisma.roundTemplate.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.programId !== undefined ? { programId: data.programId } : {}),
...(data.roundType !== undefined ? { roundType: data.roundType } : {}),
...(data.criteriaJson !== undefined ? { criteriaJson: data.criteriaJson } : {}),
...(data.settingsJson !== undefined ? { settingsJson: data.settingsJson } : {}),
...(data.assignmentConfig !== undefined ? { assignmentConfig: data.assignmentConfig } : {}),
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_ROUND_TEMPLATE',
entityType: 'RoundTemplate',
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch {}
return template
}),
/**
* Delete a template.
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.roundTemplate.delete({
where: { id: input.id },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_ROUND_TEMPLATE',
entityType: 'RoundTemplate',
entityId: input.id,
})
} catch {}
return { success: true }
}),
})

View File

@@ -0,0 +1,291 @@
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'
import {
previewRouting,
evaluateRoutingRules,
executeRouting,
} from '@/server/services/routing-engine'
export const routingRouter = router({
/**
* Preview routing: show where projects would land without executing.
* Delegates to routing-engine service for proper predicate evaluation.
*/
preview: adminProcedure
.input(
z.object({
pipelineId: z.string(),
projectIds: z.array(z.string()).min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
const results = await previewRouting(
input.projectIds,
input.pipelineId,
ctx.prisma
)
return {
pipelineId: input.pipelineId,
totalProjects: results.length,
results: results.map((r) => ({
projectId: r.projectId,
projectTitle: r.projectTitle,
matchedRuleId: r.matchedRule?.ruleId ?? null,
matchedRuleName: r.matchedRule?.ruleName ?? null,
targetTrackId: r.matchedRule?.destinationTrackId ?? null,
targetTrackName: null as string | null,
targetStageId: r.matchedRule?.destinationStageId ?? null,
targetStageName: null as string | null,
routingMode: r.matchedRule?.routingMode ?? null,
reason: r.reason,
})),
}
}),
/**
* Execute routing: evaluate rules and move projects into tracks/stages.
* Delegates to routing-engine service which enforces PARALLEL/EXCLUSIVE/POST_MAIN modes.
*/
execute: adminProcedure
.input(
z.object({
pipelineId: z.string(),
projectIds: z.array(z.string()).min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
// Verify pipeline is ACTIVE
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
where: { id: input.pipelineId },
})
if (pipeline.status !== 'ACTIVE') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Pipeline must be ACTIVE to route projects',
})
}
// Load projects to get their current active stage states
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds } },
select: {
id: true,
title: true,
projectStageStates: {
where: { exitedAt: null },
select: { stageId: true },
take: 1,
},
},
})
if (projects.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No matching projects found',
})
}
let routedCount = 0
let skippedCount = 0
const errors: Array<{ projectId: string; error: string }> = []
for (const project of projects) {
const activePSS = project.projectStageStates[0]
if (!activePSS) {
skippedCount++
continue
}
// Evaluate routing rules using the service
const matchedRule = await evaluateRoutingRules(
project.id,
activePSS.stageId,
input.pipelineId,
ctx.prisma
)
if (!matchedRule) {
skippedCount++
continue
}
// Execute routing using the service (handles PARALLEL/EXCLUSIVE/POST_MAIN)
const result = await executeRouting(
project.id,
matchedRule,
ctx.user.id,
ctx.prisma
)
if (result.success) {
routedCount++
} else {
skippedCount++
if (result.errors?.length) {
errors.push({ projectId: project.id, error: result.errors[0] })
}
}
}
// Record batch-level audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ROUTING_EXECUTED',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
projectCount: projects.length,
routedCount,
skippedCount,
errors: errors.length > 0 ? errors : undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { routedCount, skippedCount, totalProjects: projects.length }
}),
/**
* List routing rules for a pipeline
*/
listRules: adminProcedure
.input(z.object({ pipelineId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
orderBy: [{ isActive: 'desc' }, { priority: 'desc' }],
include: {
sourceTrack: { select: { id: true, name: true } },
destinationTrack: { select: { id: true, name: true } },
},
})
}),
/**
* Create or update a routing rule
*/
upsertRule: adminProcedure
.input(
z.object({
id: z.string().optional(), // If provided, update existing
pipelineId: z.string(),
name: z.string().min(1).max(255),
scope: z.enum(['global', 'track', 'stage']).default('global'),
sourceTrackId: z.string().optional().nullable(),
destinationTrackId: z.string(),
destinationStageId: z.string().optional().nullable(),
predicateJson: z.record(z.unknown()),
priority: z.number().int().min(0).max(1000).default(0),
isActive: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { id, predicateJson, ...data } = input
// Verify destination track exists in this pipeline
const destTrack = await ctx.prisma.track.findFirst({
where: { id: input.destinationTrackId, pipelineId: input.pipelineId },
})
if (!destTrack) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Destination track must belong to the same pipeline',
})
}
if (id) {
// Update existing rule
const rule = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.routingRule.update({
where: { id },
data: {
...data,
predicateJson: predicateJson as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'RoutingRule',
entityId: id,
detailsJson: { name: input.name, priority: input.priority },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return rule
} else {
// Create new rule
const rule = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.routingRule.create({
data: {
...data,
predicateJson: predicateJson as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'RoutingRule',
entityId: created.id,
detailsJson: { name: input.name, priority: input.priority },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return rule
}
}),
/**
* Toggle a routing rule on/off
*/
toggleRule: adminProcedure
.input(
z.object({
id: z.string(),
isActive: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const rule = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.routingRule.update({
where: { id: input.id },
data: { isActive: input.isActive },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: input.isActive ? 'ROUTING_RULE_ENABLED' : 'ROUTING_RULE_DISABLED',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { isActive: input.isActive, name: updated.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return rule
}),
})

659
src/server/routers/stage.ts Normal file
View File

@@ -0,0 +1,659 @@
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'
// 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
const stage = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.stage.create({
data: {
...rest,
sortOrder,
configJson: (configJson as Prisma.InputJsonValue) ?? undefined,
},
})
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
// 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',
})
}
}
const stage = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.stage.update({
where: { id },
data: {
...data,
configJson: (configJson as Prisma.InputJsonValue) ?? undefined,
},
})
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>
),
}
}),
/**
* 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,
},
},
},
})
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.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

@@ -0,0 +1,608 @@
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,
})
const overLoaded = jurorLoads.filter(
(j) => j._count > input.targetPerJuror
)
const underLoaded = jurorLoads.filter(
(j) => j._count < input.targetPerJuror
)
// Calculate how many can be moved
const excessTotal = overLoaded.reduce(
(sum, j) => sum + (j._count - input.targetPerJuror),
0
)
const capacityTotal = underLoaded.reduce(
(sum, j) => sum + (input.targetPerJuror - 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) {
if (under._count >= input.targetPerJuror) 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

@@ -0,0 +1,514 @@
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

@@ -48,10 +48,11 @@ async function runTaggingJob(jobId: string, userId: string) {
throw new Error('No expertise tags configured')
}
// Get projects to tag
const whereClause = job.programId
? { round: { programId: job.programId } }
: { roundId: job.roundId! }
// Get projects to tag (always by programId)
if (!job.programId) {
throw new Error('Job must have a programId')
}
const whereClause = { programId: job.programId }
const allProjects = await prisma.project.findMany({
where: whereClause,
@@ -627,24 +628,14 @@ export const tagRouter = router({
*/
startTaggingJob: adminProcedure
.input(z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
programId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
if (!input.roundId && !input.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Either roundId or programId is required',
})
}
// Check for existing running job
const existingJob = await ctx.prisma.taggingJob.findFirst({
where: {
OR: [
{ roundId: input.roundId, status: { in: ['PENDING', 'RUNNING'] } },
{ programId: input.programId, status: { in: ['PENDING', 'RUNNING'] } },
],
programId: input.programId,
status: { in: ['PENDING', 'RUNNING'] },
},
})
@@ -658,7 +649,6 @@ export const tagRouter = router({
// Create the job
const job = await ctx.prisma.taggingJob.create({
data: {
roundId: input.roundId,
programId: input.programId,
status: 'PENDING',
},
@@ -669,8 +659,8 @@ export const tagRouter = router({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'START_AI_TAG_JOB',
entityType: input.programId ? 'Program' : 'Round',
entityId: input.programId || input.roundId!,
entityType: 'Program',
entityId: input.programId,
detailsJson: { jobId: job.id },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
@@ -721,16 +711,12 @@ export const tagRouter = router({
*/
getLatestTaggingJob: adminProcedure
.input(z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
programId: z.string(),
}))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.taggingJob.findFirst({
where: {
OR: [
input.roundId ? { roundId: input.roundId } : {},
input.programId ? { programId: input.programId } : {},
].filter(o => Object.keys(o).length > 0),
programId: input.programId,
},
orderBy: { createdAt: 'desc' },
})

View File

@@ -95,24 +95,21 @@ export const typeformImportRouter = router({
z.object({
apiKey: z.string().min(1),
formId: z.string().min(1),
roundId: z.string(),
// Field mappings: Typeform field title -> Project field
programId: z.string(),
mappings: z.object({
title: z.string(), // Required
title: z.string(),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(), // Multi-select or text field
email: z.string().optional(), // For tracking submission email
country: z.string().optional(), // Country name or ISO code
tags: z.string().optional(),
email: z.string().optional(),
country: z.string().optional(),
}),
// Store unmapped columns in metadataJson
includeUnmappedInMetadata: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
})
// Fetch form schema and all responses
@@ -213,8 +210,7 @@ export const typeformImportRouter = router({
// Create project
await ctx.prisma.project.create({
data: {
programId: round.programId,
roundId: round.id,
programId: input.programId,
status: 'SUBMITTED',
title: String(title).trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
@@ -248,7 +244,6 @@ export const typeformImportRouter = router({
detailsJson: {
source: 'typeform',
formId: input.formId,
roundId: input.roundId,
imported: results.imported,
skipped: results.skipped,
},

View File

@@ -479,7 +479,7 @@ export const userRouter = router({
.array(
z.object({
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
})
)
.optional(),
@@ -531,8 +531,7 @@ export const userRouter = router({
return { created: 0, skipped }
}
// Build map of email -> assignments before createMany (since createMany removes extra fields)
const emailToAssignments = new Map<string, Array<{ projectId: string; roundId: string }>>()
const emailToAssignments = new Map<string, Array<{ projectId: string; stageId: string }>>()
for (const u of newUsers) {
if (u.assignments && u.assignments.length > 0) {
emailToAssignments.set(u.email.toLowerCase(), u.assignments)
@@ -577,7 +576,7 @@ export const userRouter = router({
data: {
userId: user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
stageId: assignment.stageId,
method: 'MANUAL',
createdBy: ctx.user.id,
},
@@ -659,7 +658,7 @@ export const userRouter = router({
getJuryMembers: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
stageId: z.string().optional(),
search: z.string().optional(),
})
)
@@ -690,8 +689,8 @@ export const userRouter = router({
profileImageProvider: true,
_count: {
select: {
assignments: input.roundId
? { where: { roundId: input.roundId } }
assignments: input.stageId
? { where: { stageId: input.stageId } }
: true,
},
},

View File

@@ -13,6 +13,8 @@ export const WEBHOOK_EVENTS = [
'project.statusChanged',
'round.activated',
'round.closed',
'stage.activated',
'stage.closed',
'assignment.created',
'assignment.completed',
'user.invited',