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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'}!`,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -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
561
src/server/routers/award.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
331
src/server/routers/cohort.ts
Normal file
331
src/server/routers/cohort.ts
Normal 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 },
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
353
src/server/routers/decision.ts
Normal file
353
src/server/routers/decision.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
822
src/server/routers/live.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
1125
src/server/routers/pipeline.ts
Normal file
1125
src/server/routers/pipeline.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
291
src/server/routers/routing.ts
Normal file
291
src/server/routers/routing.ts
Normal 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
659
src/server/routers/stage.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
608
src/server/routers/stageAssignment.ts
Normal file
608
src/server/routers/stageAssignment.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
514
src/server/routers/stageFiltering.ts
Normal file
514
src/server/routers/stageFiltering.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,6 +13,8 @@ export const WEBHOOK_EVENTS = [
|
||||
'project.statusChanged',
|
||||
'round.activated',
|
||||
'round.closed',
|
||||
'stage.activated',
|
||||
'stage.closed',
|
||||
'assignment.created',
|
||||
'assignment.completed',
|
||||
'user.invited',
|
||||
|
||||
Reference in New Issue
Block a user