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
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AIAction =
|
||||
| 'MENTOR_MATCHING'
|
||||
| 'PROJECT_TAGGING'
|
||||
| 'EVALUATION_SUMMARY'
|
||||
| 'ROUTING'
|
||||
|
||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user