Pipeline UX: clickable cards, wizard edit, routing rules redesign, category quotas
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s

- Simplify pipeline list cards: whole card is clickable, remove clutter
- Add wizard edit page for existing pipelines with full state pre-population
- Extract toWizardTrackConfig to shared utility for reuse
- Rewrite predicate builder with 3 modes: Simple (sentence-style), AI (NLP), Advanced (JSON)
- Fix routing operators to match backend (eq/neq/in/contains/gt/lt)
- Rewrite routing rules editor with collapsible cards and natural language summaries
- Add parseNaturalLanguageRule AI procedure for routing rules
- Add per-category quotas to SelectionConfig and EvaluationConfig
- Add category quota UI toggles to selection and assignment sections
- Add category breakdown display to selection panel
- Add category-aware scoring to smart assignment (penalty/bonus)
- Add category-aware filtering targets with excess demotion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-14 20:10:24 +01:00
parent c634982835
commit 382570cebd
17 changed files with 2577 additions and 1095 deletions

View File

@@ -34,14 +34,14 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const jurors = await prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
@@ -339,10 +339,10 @@ export const assignmentRouter = router({
])
const config = (stage.configJson ?? {}) as Record<string, unknown>
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
const currentCount = await ctx.prisma.assignment.count({
@@ -461,10 +461,10 @@ export const assignmentRouter = router({
select: { configJson: true, name: true, windowCloseAt: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Track running counts to handle multiple assignments to the same juror in one batch
const runningCounts = new Map<string, number>()
@@ -680,14 +680,20 @@ export const assignmentRouter = router({
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const minAssignmentsPerJuror =
(config.minLoadPerJuror as number) ??
(config.minAssignmentsPerJuror as number) ??
1
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Extract category quotas if enabled
const categoryQuotasEnabled = config.categoryQuotasEnabled === true
const categoryQuotas = categoryQuotasEnabled
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
: undefined
const jurors = await ctx.prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
@@ -717,6 +723,7 @@ export const assignmentRouter = router({
id: true,
title: true,
tags: true,
competitionCategory: true,
projectTags: {
include: { tag: { select: { name: true } } },
},
@@ -732,6 +739,28 @@ export const assignmentRouter = router({
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
)
// Build per-juror category distribution for quota scoring
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
if (categoryQuotas) {
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
where: { stageId: input.stageId },
select: {
userId: true,
project: { select: { competitionCategory: true } },
},
})
for (const a of assignmentsWithCategory) {
const cat = a.project.competitionCategory?.toLowerCase().trim()
if (!cat) continue
let catMap = jurorCategoryDistribution.get(a.userId)
if (!catMap) {
catMap = {}
jurorCategoryDistribution.set(a.userId, catMap)
}
catMap[cat] = (catMap[cat] || 0) + 1
}
}
const suggestions: Array<{
userId: string
jurorName: string
@@ -796,6 +825,34 @@ export const assignmentRouter = router({
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
)
// Category quota scoring
if (categoryQuotas) {
const jurorCategoryCounts = jurorCategoryDistribution.get(juror.id) || {}
const normalizedCat = project.competitionCategory?.toLowerCase().trim()
if (normalizedCat) {
const quota = Object.entries(categoryQuotas).find(
([key]) => key.toLowerCase().trim() === normalizedCat
)
if (quota) {
const [, { min, max }] = quota
const currentCount = jurorCategoryCounts[normalizedCat] || 0
if (currentCount >= max) {
score -= 25
reasoning.push(`Category quota exceeded (-25)`)
} else if (currentCount < min) {
const otherAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
if (key.toLowerCase().trim() === normalizedCat) return false
return (jurorCategoryCounts[key.toLowerCase().trim()] || 0) >= q.min
})
if (otherAboveMin) {
score += 10
reasoning.push(`Category quota bonus (+10)`)
}
}
}
}
}
return {
userId: juror.id,
jurorName: juror.name || juror.email || 'Unknown',
@@ -932,10 +989,10 @@ export const assignmentRouter = router({
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>()
for (const u of users) {
@@ -1087,10 +1144,10 @@ export const assignmentRouter = router({
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const stageMaxPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const runningCounts = new Map<string, number>()
for (const u of users) {

View File

@@ -11,6 +11,24 @@ import {
NotificationTypes,
} from '../services/in-app-notification'
/**
* Extract a numeric confidence/quality score from aiScreeningJson.
* Looks for common keys: overallScore, confidenceScore, score, qualityScore.
* Returns 0 if no score found.
*/
function getAIConfidenceScore(aiScreeningJson: Prisma.JsonValue | null): number {
if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) {
return 0
}
const obj = aiScreeningJson as Record<string, unknown>
for (const key of ['overallScore', 'confidenceScore', 'score', 'qualityScore']) {
if (typeof obj[key] === 'number') {
return obj[key] as number
}
}
return 0
}
export async function runFilteringJob(jobId: string, stageId: string, userId: string) {
try {
// Update job to running
@@ -267,17 +285,17 @@ export const filteringRouter = router({
/**
* Update a filtering rule
*/
updateRule: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).optional(),
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING']).optional(),
configJson: z.record(z.unknown()).optional(),
priority: z.number().int().optional(),
isActive: z.boolean().optional(),
})
)
updateRule: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).optional(),
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING']).optional(),
configJson: z.record(z.unknown()).optional(),
priority: z.number().int().optional(),
isActive: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, configJson, ...rest } = input
const rule = await ctx.prisma.filteringRule.update({
@@ -719,7 +737,12 @@ export const filteringRouter = router({
* FILTERED_OUT → mark as REJECTED (data preserved)
*/
finalizeResults: adminProcedure
.input(z.object({ stageId: z.string() }))
.input(
z.object({
stageId: z.string(),
categoryTargets: z.record(z.number().int().min(0)).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const currentStage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
@@ -737,15 +760,80 @@ export const filteringRouter = router({
const results = await ctx.prisma.filteringResult.findMany({
where: { stageId: input.stageId },
include: {
project: {
select: { competitionCategory: true },
},
},
})
const filteredOutIds = results
.filter((r) => (r.finalOutcome || r.outcome) === 'FILTERED_OUT')
.map((r) => r.projectId)
const passedIds = results
let passedResults = results
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
.map((r) => r.projectId)
// Apply category targets if provided
const categoryWarnings: string[] = []
const categoryCounts: Record<string, number> = {}
const demotedIds: string[] = []
if (input.categoryTargets && Object.keys(input.categoryTargets).length > 0) {
// Group passing projects by category
const passedByCategory = new Map<string, typeof passedResults>()
for (const r of passedResults) {
const cat = r.project.competitionCategory?.toLowerCase().trim() || '_uncategorized'
const existing = passedByCategory.get(cat) || []
existing.push(r)
passedByCategory.set(cat, existing)
}
// Check each category against its target
for (const [cat, target] of Object.entries(input.categoryTargets)) {
const normalizedCat = cat.toLowerCase().trim()
const catResults = passedByCategory.get(normalizedCat) || []
categoryCounts[cat] = catResults.length
if (catResults.length > target) {
// Sort by AI confidence score (descending) to keep the best
const sorted = catResults.sort((a, b) => {
const scoreA = getAIConfidenceScore(a.aiScreeningJson)
const scoreB = getAIConfidenceScore(b.aiScreeningJson)
return scoreB - scoreA
})
// Demote the lowest-ranked excess to FLAGGED
const excess = sorted.slice(target)
for (const r of excess) {
demotedIds.push(r.id)
}
} else if (catResults.length < target) {
categoryWarnings.push(
`Category "${cat}" is below target: ${catResults.length}/${target}`
)
}
}
// Also count categories not in targets
for (const [cat, catResults] of passedByCategory) {
if (!Object.keys(input.categoryTargets).some((k) => k.toLowerCase().trim() === cat)) {
categoryCounts[cat] = catResults.length
}
}
// Remove demoted from passedResults
const demotedIdSet = new Set(demotedIds)
passedResults = passedResults.filter((r) => !demotedIdSet.has(r.id))
} else {
// Build category counts even without targets
for (const r of passedResults) {
const cat = r.project.competitionCategory || '_uncategorized'
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1
}
}
const passedIds = passedResults.map((r) => r.projectId)
const operations: Prisma.PrismaPromise<unknown>[] = []
@@ -767,6 +855,21 @@ export const filteringRouter = router({
)
}
// Update demoted results to FLAGGED outcome
if (demotedIds.length > 0) {
operations.push(
ctx.prisma.filteringResult.updateMany({
where: { id: { in: demotedIds } },
data: {
finalOutcome: 'FLAGGED',
overriddenBy: ctx.user.id,
overriddenAt: new Date(),
overrideReason: 'Demoted by category target enforcement',
},
})
)
}
await ctx.prisma.$transaction(operations)
await logAudit({
@@ -778,6 +881,9 @@ export const filteringRouter = router({
action: 'FINALIZE_FILTERING',
passed: passedIds.length,
filteredOut: filteredOutIds.length,
demotedToFlagged: demotedIds.length,
categoryTargets: input.categoryTargets || null,
categoryWarnings,
advancedToStage: nextStage?.name || null,
},
})
@@ -785,6 +891,9 @@ export const filteringRouter = router({
return {
passed: passedIds.length,
filteredOut: filteredOutIds.length,
demotedToFlagged: demotedIds.length,
categoryCounts,
categoryWarnings,
advancedToStageId: nextStage?.id || null,
advancedToStageName: nextStage?.name || null,
}

View File

@@ -3,6 +3,8 @@ import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import {
previewRouting,
evaluateRoutingRules,
@@ -172,7 +174,7 @@ export const routingRouter = router({
/**
* Create or update a routing rule
*/
upsertRule: adminProcedure
upsertRule: adminProcedure
.input(
z.object({
id: z.string().optional(), // If provided, update existing
@@ -251,105 +253,105 @@ export const routingRouter = router({
return created
})
return rule
}
}),
/**
* Delete a routing rule
*/
deleteRule: adminProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.routingRule.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, pipelineId: true },
})
await ctx.prisma.$transaction(async (tx) => {
await tx.routingRule.delete({
where: { id: input.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { name: existing.name, pipelineId: existing.pipelineId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Reorder routing rules by priority (highest first)
*/
reorderRules: adminProcedure
.input(
z.object({
pipelineId: z.string(),
orderedIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const rules = await ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true },
})
const ruleIds = new Set(rules.map((rule) => rule.id))
for (const id of input.orderedIds) {
if (!ruleIds.has(id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Routing rule ${id} does not belong to this pipeline`,
})
}
}
await ctx.prisma.$transaction(async (tx) => {
const maxPriority = input.orderedIds.length
await Promise.all(
input.orderedIds.map((id, index) =>
tx.routingRule.update({
where: { id },
data: {
priority: maxPriority - index,
},
})
)
)
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
action: 'ROUTING_RULES_REORDERED',
ruleCount: input.orderedIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Toggle a routing rule on/off
*/
return rule
}
}),
/**
* Delete a routing rule
*/
deleteRule: adminProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.routingRule.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, pipelineId: true },
})
await ctx.prisma.$transaction(async (tx) => {
await tx.routingRule.delete({
where: { id: input.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'RoutingRule',
entityId: input.id,
detailsJson: { name: existing.name, pipelineId: existing.pipelineId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Reorder routing rules by priority (highest first)
*/
reorderRules: adminProcedure
.input(
z.object({
pipelineId: z.string(),
orderedIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const rules = await ctx.prisma.routingRule.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true },
})
const ruleIds = new Set(rules.map((rule) => rule.id))
for (const id of input.orderedIds) {
if (!ruleIds.has(id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Routing rule ${id} does not belong to this pipeline`,
})
}
}
await ctx.prisma.$transaction(async (tx) => {
const maxPriority = input.orderedIds.length
await Promise.all(
input.orderedIds.map((id, index) =>
tx.routingRule.update({
where: { id },
data: {
priority: maxPriority - index,
},
})
)
)
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Pipeline',
entityId: input.pipelineId,
detailsJson: {
action: 'ROUTING_RULES_REORDERED',
ruleCount: input.orderedIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Toggle a routing rule on/off
*/
toggleRule: adminProcedure
.input(
z.object({
@@ -380,4 +382,138 @@ export const routingRouter = router({
return rule
}),
/**
* Parse natural language into a routing rule predicate using AI
*/
parseNaturalLanguageRule: adminProcedure
.input(
z.object({
text: z.string().min(1).max(500),
pipelineId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const openai = await getOpenAI()
if (!openai) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'OpenAI is not configured. Go to Settings to set up the API key.',
})
}
// Load pipeline tracks for context
const tracks = await ctx.prisma.track.findMany({
where: { pipelineId: input.pipelineId },
select: { id: true, name: true },
orderBy: { sortOrder: 'asc' },
})
const trackNames = tracks.map((t) => t.name).join(', ')
const model = await getConfiguredModel()
const systemPrompt = `You are a routing rule parser for a project management pipeline.
Convert the user's natural language description into a structured predicate JSON.
Available fields:
- competitionCategory: The project's competition category (string values like "STARTUP", "BUSINESS_CONCEPT")
- oceanIssue: The ocean issue the project addresses (string)
- country: The project's country of origin (string)
- geographicZone: The geographic zone (string)
- wantsMentorship: Whether the project wants mentorship (boolean: true/false)
- tags: Project tags (array of strings)
Available operators:
- eq: equals (exact match)
- neq: not equals
- in: value is in a list
- contains: string contains substring
- gt: greater than (numeric)
- lt: less than (numeric)
Predicate format:
- Simple condition: { "field": "<field>", "operator": "<op>", "value": "<value>" }
- Compound (AND): { "logic": "and", "conditions": [<condition>, ...] }
- Compound (OR): { "logic": "or", "conditions": [<condition>, ...] }
For boolean fields (wantsMentorship), use value: true or value: false (not strings).
For "in" operator, value should be an array: ["VALUE1", "VALUE2"].
Pipeline tracks: ${trackNames || 'None configured yet'}
Return a JSON object with two keys:
- "predicate": the predicate JSON object
- "explanation": a brief human-readable explanation of what the rule matches
Example input: "projects from France or Monaco that are startups"
Example output:
{
"predicate": {
"logic": "and",
"conditions": [
{ "field": "country", "operator": "in", "value": ["France", "Monaco"] },
{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }
]
},
"explanation": "Matches projects from France or Monaco with competition category STARTUP"
}`
const params = buildCompletionParams(model, {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: input.text },
],
maxTokens: 1000,
temperature: 0.1,
jsonMode: true,
})
const response = await openai.chat.completions.create(params)
const content = response.choices[0]?.message?.content
if (!content) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI returned an empty response',
})
}
// Log AI usage
const tokenUsage = extractTokenUsage(response)
await logAIUsage({
userId: ctx.user.id,
action: 'ROUTING',
entityType: 'Pipeline',
entityId: input.pipelineId,
model,
...tokenUsage,
itemsProcessed: 1,
status: 'SUCCESS',
detailsJson: { input: input.text },
})
// Parse the response
let parsed: { predicate: Record<string, unknown>; explanation: string }
try {
parsed = JSON.parse(content) as { predicate: Record<string, unknown>; explanation: string }
} catch {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI returned invalid JSON. Try rephrasing your rule.',
})
}
if (!parsed.predicate || typeof parsed.predicate !== 'object') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'AI response missing predicate. Try rephrasing your rule.',
})
}
return {
predicateJson: parsed.predicate,
explanation: parsed.explanation || 'Parsed routing rule',
}
}),
})

View File

@@ -1,9 +1,9 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
// Valid stage status transitions
const VALID_STAGE_TRANSITIONS: Record<string, string[]> = {
@@ -67,36 +67,36 @@ export const stageRouter = router({
})
}
const { configJson, sortOrder: _so, ...rest } = input
let parsedConfigJson: Prisma.InputJsonValue | undefined
if (configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
input.stageType,
configJson,
{ strictUnknownKeys: true }
)
parsedConfigJson = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Invalid stage configuration payload',
})
}
}
const stage = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.stage.create({
data: {
...rest,
sortOrder,
configJson: parsedConfigJson,
},
})
const { configJson, sortOrder: _so, ...rest } = input
let parsedConfigJson: Prisma.InputJsonValue | undefined
if (configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
input.stageType,
configJson,
{ strictUnknownKeys: true }
)
parsedConfigJson = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Invalid stage configuration payload',
})
}
}
const stage = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.stage.create({
data: {
...rest,
sortOrder,
configJson: parsedConfigJson,
},
})
await logAudit({
prisma: tx,
@@ -133,52 +133,52 @@ export const stageRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { id, configJson, ...data } = input
const existing = await ctx.prisma.stage.findUniqueOrThrow({
where: { id },
select: {
stageType: true,
},
})
let parsedConfigJson: Prisma.InputJsonValue | undefined
// Validate window dates if both provided
if (data.windowOpenAt && data.windowCloseAt) {
const { id, configJson, ...data } = input
const existing = await ctx.prisma.stage.findUniqueOrThrow({
where: { id },
select: {
stageType: true,
},
})
let parsedConfigJson: Prisma.InputJsonValue | undefined
// Validate window dates if both provided
if (data.windowOpenAt && data.windowCloseAt) {
if (data.windowCloseAt <= data.windowOpenAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Window close date must be after open date',
})
}
}
if (configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
existing.stageType,
configJson,
{ strictUnknownKeys: true }
)
parsedConfigJson = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Invalid stage configuration payload',
})
}
}
const stage = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.stage.update({
where: { id },
data: {
...data,
configJson: parsedConfigJson,
},
})
})
}
}
if (configJson !== undefined) {
try {
const { config } = parseAndValidateStageConfig(
existing.stageType,
configJson,
{ strictUnknownKeys: true }
)
parsedConfigJson = config as Prisma.InputJsonValue
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Invalid stage configuration payload',
})
}
}
const stage = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.stage.update({
where: { id },
data: {
...data,
configJson: parsedConfigJson,
},
})
await logAudit({
prisma: tx,
@@ -221,7 +221,7 @@ export const stageRouter = router({
/**
* Get a single stage with details
*/
get: protectedProcedure
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
@@ -259,277 +259,277 @@ export const stageRouter = router({
_count: true,
})
return {
...stage,
stateDistribution: stateDistribution.reduce(
return {
...stage,
stateDistribution: stateDistribution.reduce(
(acc, curr) => {
acc[curr.state] = curr._count
return acc
},
{} as Record<string, number>
),
}
}),
/**
* List transitions for a track
*/
listTransitions: protectedProcedure
.input(z.object({ trackId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.stageTransition.findMany({
where: {
fromStage: {
trackId: input.trackId,
},
},
include: {
fromStage: {
select: { id: true, name: true, slug: true, trackId: true },
},
toStage: {
select: { id: true, name: true, slug: true, trackId: true },
},
},
orderBy: [{ fromStage: { sortOrder: 'asc' } }, { toStage: { sortOrder: 'asc' } }],
})
}),
/**
* Create a transition between stages
*/
createTransition: adminProcedure
.input(
z.object({
fromStageId: z.string(),
toStageId: z.string(),
isDefault: z.boolean().optional(),
guardJson: z.record(z.unknown()).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
if (input.fromStageId === input.toStageId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'fromStageId and toStageId must be different',
})
}
const [fromStage, toStage] = await Promise.all([
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.fromStageId },
select: { id: true, name: true, trackId: true },
}),
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.toStageId },
select: { id: true, name: true, trackId: true },
}),
])
if (fromStage.trackId !== toStage.trackId) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Transitions can only connect stages within the same track',
})
}
const existing = await ctx.prisma.stageTransition.findUnique({
where: {
fromStageId_toStageId: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
},
},
select: { id: true },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Transition already exists',
})
}
const transition = await ctx.prisma.$transaction(async (tx) => {
if (input.isDefault) {
await tx.stageTransition.updateMany({
where: { fromStageId: input.fromStageId },
data: { isDefault: false },
})
}
const created = await tx.stageTransition.create({
data: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
isDefault: input.isDefault ?? false,
guardJson:
input.guardJson === undefined
? undefined
: (input.guardJson as Prisma.InputJsonValue),
},
include: {
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'StageTransition',
entityId: created.id,
detailsJson: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
isDefault: created.isDefault,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return transition
}),
/**
* Update transition properties
*/
updateTransition: adminProcedure
.input(
z.object({
id: z.string(),
isDefault: z.boolean().optional(),
guardJson: z.record(z.unknown()).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
where: { id: input.id },
select: {
id: true,
fromStageId: true,
toStageId: true,
isDefault: true,
},
})
const updated = await ctx.prisma.$transaction(async (tx) => {
if (input.isDefault) {
await tx.stageTransition.updateMany({
where: { fromStageId: transition.fromStageId },
data: { isDefault: false },
})
}
const next = await tx.stageTransition.update({
where: { id: input.id },
data: {
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
...(input.guardJson !== undefined
? {
guardJson:
input.guardJson === null
? Prisma.JsonNull
: (input.guardJson as Prisma.InputJsonValue),
}
: {}),
},
include: {
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'StageTransition',
entityId: input.id,
detailsJson: {
isDefault: input.isDefault,
guardUpdated: input.guardJson !== undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return next
})
return updated
}),
/**
* Delete a transition
*/
deleteTransition: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
where: { id: input.id },
select: {
id: true,
fromStageId: true,
isDefault: true,
},
})
const fromTransitionCount = await ctx.prisma.stageTransition.count({
where: { fromStageId: transition.fromStageId },
})
if (fromTransitionCount <= 1) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Cannot delete the last transition from a stage',
})
}
await ctx.prisma.$transaction(async (tx) => {
await tx.stageTransition.delete({
where: { id: input.id },
})
if (transition.isDefault) {
const replacement = await tx.stageTransition.findFirst({
where: { fromStageId: transition.fromStageId },
orderBy: { createdAt: 'asc' },
select: { id: true },
})
if (replacement) {
await tx.stageTransition.update({
where: { id: replacement.id },
data: { isDefault: true },
})
}
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'StageTransition',
entityId: input.id,
detailsJson: {
fromStageId: transition.fromStageId,
wasDefault: transition.isDefault,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Transition a stage status (state machine)
*/
}
}),
/**
* List transitions for a track
*/
listTransitions: protectedProcedure
.input(z.object({ trackId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.stageTransition.findMany({
where: {
fromStage: {
trackId: input.trackId,
},
},
include: {
fromStage: {
select: { id: true, name: true, slug: true, trackId: true },
},
toStage: {
select: { id: true, name: true, slug: true, trackId: true },
},
},
orderBy: [{ fromStage: { sortOrder: 'asc' } }, { toStage: { sortOrder: 'asc' } }],
})
}),
/**
* Create a transition between stages
*/
createTransition: adminProcedure
.input(
z.object({
fromStageId: z.string(),
toStageId: z.string(),
isDefault: z.boolean().optional(),
guardJson: z.record(z.unknown()).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
if (input.fromStageId === input.toStageId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'fromStageId and toStageId must be different',
})
}
const [fromStage, toStage] = await Promise.all([
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.fromStageId },
select: { id: true, name: true, trackId: true },
}),
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.toStageId },
select: { id: true, name: true, trackId: true },
}),
])
if (fromStage.trackId !== toStage.trackId) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Transitions can only connect stages within the same track',
})
}
const existing = await ctx.prisma.stageTransition.findUnique({
where: {
fromStageId_toStageId: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
},
},
select: { id: true },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Transition already exists',
})
}
const transition = await ctx.prisma.$transaction(async (tx) => {
if (input.isDefault) {
await tx.stageTransition.updateMany({
where: { fromStageId: input.fromStageId },
data: { isDefault: false },
})
}
const created = await tx.stageTransition.create({
data: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
isDefault: input.isDefault ?? false,
guardJson:
input.guardJson === undefined
? undefined
: (input.guardJson as Prisma.InputJsonValue),
},
include: {
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'StageTransition',
entityId: created.id,
detailsJson: {
fromStageId: input.fromStageId,
toStageId: input.toStageId,
isDefault: created.isDefault,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return transition
}),
/**
* Update transition properties
*/
updateTransition: adminProcedure
.input(
z.object({
id: z.string(),
isDefault: z.boolean().optional(),
guardJson: z.record(z.unknown()).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
where: { id: input.id },
select: {
id: true,
fromStageId: true,
toStageId: true,
isDefault: true,
},
})
const updated = await ctx.prisma.$transaction(async (tx) => {
if (input.isDefault) {
await tx.stageTransition.updateMany({
where: { fromStageId: transition.fromStageId },
data: { isDefault: false },
})
}
const next = await tx.stageTransition.update({
where: { id: input.id },
data: {
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
...(input.guardJson !== undefined
? {
guardJson:
input.guardJson === null
? Prisma.JsonNull
: (input.guardJson as Prisma.InputJsonValue),
}
: {}),
},
include: {
fromStage: { select: { id: true, name: true, slug: true, trackId: true } },
toStage: { select: { id: true, name: true, slug: true, trackId: true } },
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'StageTransition',
entityId: input.id,
detailsJson: {
isDefault: input.isDefault,
guardUpdated: input.guardJson !== undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return next
})
return updated
}),
/**
* Delete a transition
*/
deleteTransition: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const transition = await ctx.prisma.stageTransition.findUniqueOrThrow({
where: { id: input.id },
select: {
id: true,
fromStageId: true,
isDefault: true,
},
})
const fromTransitionCount = await ctx.prisma.stageTransition.count({
where: { fromStageId: transition.fromStageId },
})
if (fromTransitionCount <= 1) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Cannot delete the last transition from a stage',
})
}
await ctx.prisma.$transaction(async (tx) => {
await tx.stageTransition.delete({
where: { id: input.id },
})
if (transition.isDefault) {
const replacement = await tx.stageTransition.findFirst({
where: { fromStageId: transition.fromStageId },
orderBy: { createdAt: 'asc' },
select: { id: true },
})
if (replacement) {
await tx.stageTransition.update({
where: { id: replacement.id },
data: { isDefault: true },
})
}
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'StageTransition',
entityId: input.id,
detailsJson: {
fromStageId: transition.fromStageId,
wasDefault: transition.isDefault,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
})
return { success: true }
}),
/**
* Transition a stage status (state machine)
*/
transition: adminProcedure
.input(
z.object({
@@ -723,6 +723,7 @@ export const stageRouter = router({
status: true,
tags: true,
teamName: true,
competitionCategory: true,
},
},
},
@@ -935,11 +936,11 @@ export const stageRouter = router({
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
(!stage.windowCloseAt || now <= stage.windowCloseAt)
const config = (stage.configJson as Record<string, unknown>) ?? {}
const lateGraceHours =
(config.lateGraceHours as number) ??
(config.lateSubmissionGrace as number) ??
0
const config = (stage.configJson as Record<string, unknown>) ?? {}
const lateGraceHours =
(config.lateGraceHours as number) ??
(config.lateSubmissionGrace as number) ??
0
const isLateWindow =
!isOpen &&
stage.windowCloseAt &&

View File

@@ -35,6 +35,7 @@ export interface ScoreBreakdown {
previousRoundFamiliarity: number
coiPenalty: number
availabilityPenalty: number
categoryQuotaPenalty: number
}
export interface AssignmentScore {
@@ -69,6 +70,8 @@ const GEO_DIVERSITY_PENALTY_PER_EXCESS = -15
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
const AVAILABILITY_PENALTY = -30 // Heavy penalty for unavailable jurors
const CATEGORY_QUOTA_PENALTY = -25 // Heavy penalty when juror exceeds category max
const CATEGORY_QUOTA_BONUS = 10 // Bonus when juror is below category min
// Common words to exclude from bio matching
const STOP_WORDS = new Set([
@@ -267,6 +270,50 @@ export function calculateAvailabilityPenalty(
return AVAILABILITY_PENALTY
}
/**
* Calculate category quota penalty/bonus for a juror-project pair.
* - If the juror's count for the project's category >= max quota, apply heavy penalty (-25)
* - If the juror's count is below min and other categories are above their min, apply bonus (+10)
* - Otherwise return 0
*/
export function calculateCategoryQuotaPenalty(
categoryQuotas: Record<string, { min: number; max: number }>,
jurorCategoryCounts: Record<string, number>,
projectCategory: string | null | undefined
): number {
if (!projectCategory) return 0
const normalizedCategory = projectCategory.toLowerCase().trim()
const quota = Object.entries(categoryQuotas).find(
([key]) => key.toLowerCase().trim() === normalizedCategory
)
if (!quota) return 0
const [, { min, max }] = quota
const currentCount = jurorCategoryCounts[normalizedCategory] || 0
// If at or over max, heavy penalty
if (currentCount >= max) {
return CATEGORY_QUOTA_PENALTY
}
// If below min and other categories are above their min, give bonus
if (currentCount < min) {
const otherCategoriesAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
if (key.toLowerCase().trim() === normalizedCategory) return false
const count = jurorCategoryCounts[key.toLowerCase().trim()] || 0
return count >= q.min
})
if (otherCategoriesAboveMin) {
return CATEGORY_QUOTA_BONUS
}
}
return 0
}
// ─── Main Scoring Function ───────────────────────────────────────────────────
/**
@@ -277,8 +324,9 @@ export async function getSmartSuggestions(options: {
type: 'jury' | 'mentor'
limit?: number
aiMaxPerJudge?: number
categoryQuotas?: Record<string, { min: number; max: number }>
}): Promise<AssignmentScore[]> {
const { stageId, type, limit = 50, aiMaxPerJudge = 20 } = options
const { stageId, type, limit = 50, aiMaxPerJudge = 20, categoryQuotas } = options
const projectStageStates = await prisma.projectStageState.findMany({
where: { stageId },
@@ -297,6 +345,7 @@ export async function getSmartSuggestions(options: {
teamName: true,
description: true,
country: true,
competitionCategory: true,
status: true,
projectTags: {
include: { tag: true },
@@ -372,6 +421,28 @@ export async function getSmartSuggestions(options: {
countryMap.set(country, (countryMap.get(country) || 0) + 1)
}
// Build map: userId -> { category -> count } for category quota scoring
const userCategoryDistribution = new Map<string, Record<string, number>>()
if (categoryQuotas) {
const assignmentsWithCategory = await prisma.assignment.findMany({
where: { stageId },
select: {
userId: true,
project: { select: { competitionCategory: true } },
},
})
for (const a of assignmentsWithCategory) {
const category = a.project.competitionCategory?.toLowerCase().trim()
if (!category) continue
let categoryMap = userCategoryDistribution.get(a.userId)
if (!categoryMap) {
categoryMap = {}
userCategoryDistribution.set(a.userId, categoryMap)
}
categoryMap[category] = (categoryMap[category] || 0) + 1
}
}
const currentStage = await prisma.stage.findUnique({
where: { id: stageId },
select: { trackId: true, sortOrder: true },
@@ -485,6 +556,17 @@ export async function getSmartSuggestions(options: {
// ── New scoring factors ─────────────────────────────────────────────
// Category quota penalty/bonus
let categoryQuotaPenalty = 0
if (categoryQuotas) {
const jurorCategoryCounts = userCategoryDistribution.get(user.id) || {}
categoryQuotaPenalty = calculateCategoryQuotaPenalty(
categoryQuotas,
jurorCategoryCounts,
project.competitionCategory
)
}
// Geographic diversity penalty
let geoDiversityPenalty = 0
const projectCountry = project.country?.toLowerCase().trim()
@@ -510,7 +592,8 @@ export async function getSmartSuggestions(options: {
countryScore +
geoDiversityPenalty +
previousRoundFamiliarity +
availabilityPenalty
availabilityPenalty +
categoryQuotaPenalty
// Build reasoning
const reasoning: string[] = []
@@ -540,6 +623,11 @@ export async function getSmartSuggestions(options: {
if (availabilityPenalty < 0) {
reasoning.push(`Unavailable during voting window (${availabilityPenalty})`)
}
if (categoryQuotaPenalty < 0) {
reasoning.push(`Category quota exceeded (${categoryQuotaPenalty})`)
} else if (categoryQuotaPenalty > 0) {
reasoning.push(`Category quota bonus (+${categoryQuotaPenalty})`)
}
suggestions.push({
userId: user.id,
@@ -557,6 +645,7 @@ export async function getSmartSuggestions(options: {
previousRoundFamiliarity,
coiPenalty: 0, // COI jurors are skipped entirely
availabilityPenalty,
categoryQuotaPenalty,
},
reasoning,
matchingTags,
@@ -690,6 +779,7 @@ export async function getMentorSuggestionsForProject(
previousRoundFamiliarity: 0,
coiPenalty: 0,
availabilityPenalty: 0,
categoryQuotaPenalty: 0,
},
reasoning,
matchingTags,

View File

@@ -18,6 +18,7 @@ export type AIAction =
| 'MENTOR_MATCHING'
| 'PROJECT_TAGGING'
| 'EVALUATION_SUMMARY'
| 'ROUTING'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'