How much of other jurors' identities are revealed
diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx
index 248534c..bc611dc 100644
--- a/src/components/observer/observer-project-detail.tsx
+++ b/src/components/observer/observer-project-detail.tsx
@@ -54,7 +54,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
const roundId = data?.assignments?.[0]?.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
- { roundId: roundId ?? '' },
+ { roundId: roundId ?? '', category: data?.project?.competitionCategory },
{ enabled: !!roundId },
)
diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts
index 828e119..5d1057c 100644
--- a/src/server/routers/evaluation.ts
+++ b/src/server/routers/evaluation.ts
@@ -9,6 +9,7 @@ import { generateSummary } from '@/server/services/ai-evaluation-summary'
import { quickRank as aiQuickRank } from '../services/ai-ranking'
import type { EvaluationConfig } from '@/types/competition-configs'
import type { PrismaClient } from '@prisma/client'
+import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
/**
@@ -1298,11 +1299,9 @@ export const evaluationRouter = router({
* Get active evaluation form for a round (admin view with full details)
*/
getForm: adminProcedure
- .input(z.object({ roundId: z.string() }))
+ .input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
.query(async ({ ctx, input }) => {
- const form = await ctx.prisma.evaluationForm.findFirst({
- where: { roundId: input.roundId, isActive: true },
- })
+ const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
if (!form) return null
@@ -1333,6 +1332,7 @@ export const evaluationRouter = router({
.input(
z.object({
roundId: z.string(),
+ category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish(),
criteria: z.array(
z.object({
id: z.string(),
@@ -1364,7 +1364,7 @@ export const evaluationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
- const { roundId, criteria } = input
+ const { roundId, category, criteria } = input
// Enforce max one advance criterion per form
const advanceCount = criteria.filter((c) => c.type === 'advance').length
@@ -1378,9 +1378,9 @@ export const evaluationRouter = router({
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
- // Get current max version for this round
+ // Get current max version for this round + category
const latestForm = await ctx.prisma.evaluationForm.findFirst({
- where: { roundId },
+ where: { roundId, category: category ?? null },
orderBy: { version: 'desc' },
select: { version: true },
})
@@ -1440,10 +1440,10 @@ export const evaluationRouter = router({
scalesJson[scale] = { min, max }
}
- // Transaction: deactivate old → create new
+ // Transaction: deactivate old → create new (scoped to category)
const form = await ctx.prisma.$transaction(async (tx) => {
await tx.evaluationForm.updateMany({
- where: { roundId, isActive: true },
+ where: { roundId, isActive: true, category: category ?? null },
data: { isActive: false },
})
@@ -1454,6 +1454,7 @@ export const evaluationRouter = router({
criteriaJson,
scalesJson,
isActive: true,
+ category: category ?? null,
},
})
})
@@ -1553,10 +1554,14 @@ export const evaluationRouter = router({
})
if (existing) return existing
- // Get active evaluation form for this stage
- const form = await ctx.prisma.evaluationForm.findFirst({
- where: { roundId: input.roundId, isActive: true },
+ // Fetch project's competition category for category-aware form lookup
+ const project = await ctx.prisma.project.findUniqueOrThrow({
+ where: { id: assignment.projectId },
+ select: { competitionCategory: true },
})
+
+ // Get active evaluation form for this stage (category-aware)
+ const form = await findActiveForm(ctx.prisma, input.roundId, project.competitionCategory)
if (!form) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -1577,11 +1582,9 @@ export const evaluationRouter = router({
* Get the active evaluation form for a stage
*/
getStageForm: protectedProcedure
- .input(z.object({ roundId: z.string() }))
+ .input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
.query(async ({ ctx, input }) => {
- const form = await ctx.prisma.evaluationForm.findFirst({
- where: { roundId: input.roundId, isActive: true },
- })
+ const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
if (!form) {
return null
@@ -1846,7 +1849,7 @@ export const evaluationRouter = router({
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: input.userId },
include: {
- project: { select: { id: true, title: true } },
+ project: { select: { id: true, title: true, competitionCategory: true } },
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
evaluation: {
select: {
diff --git a/src/server/routers/export.ts b/src/server/routers/export.ts
index 54d83bc..7ae4252 100644
--- a/src/server/routers/export.ts
+++ b/src/server/routers/export.ts
@@ -103,14 +103,23 @@ export const exportRouter = router({
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
- // Fetch evaluation form to get criteria labels
- const evalForm = await ctx.prisma.evaluationForm.findFirst({
+ // Fetch all active evaluation forms for this round (shared + category-specific)
+ const activeForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
select: { criteriaJson: true },
})
- const criteria = (evalForm?.criteriaJson as Array<{
- id: string; label: string; type?: string
- }> | null) ?? []
+ // Merge criteria across all forms, deduplicating by criterion id
+ const seenCriterionIds = new Set
()
+ const criteria: Array<{ id: string; label: string; type?: string }> = []
+ for (const f of activeForms) {
+ const fc = (f.criteriaJson as Array<{ id: string; label: string; type?: string }> | null) ?? []
+ for (const c of fc) {
+ if (!seenCriterionIds.has(c.id)) {
+ seenCriterionIds.add(c.id)
+ criteria.push(c)
+ }
+ }
+ }
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
const projects = await ctx.prisma.project.findMany({
@@ -632,12 +641,24 @@ export const exportRouter = router({
// Criteria breakdown
if (includeSection('criteriaBreakdown')) {
- const form = await ctx.prisma.evaluationForm.findFirst({
+ // Fetch all active forms (shared + category-specific) and merge criteria
+ const allForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
})
- if (form?.criteriaJson) {
- const criteria = form.criteriaJson as Array<{ id: string; label: string }>
+ const seenIds = new Set()
+ const allCriteria: Array<{ id: string; label: string }> = []
+ for (const f of allForms) {
+ const fc = (f.criteriaJson as Array<{ id: string; label: string }> | null) ?? []
+ for (const c of fc) {
+ if (!seenIds.has(c.id)) {
+ seenIds.add(c.id)
+ allCriteria.push(c)
+ }
+ }
+ }
+
+ if (allCriteria.length > 0) {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
@@ -646,7 +667,7 @@ export const exportRouter = router({
select: { criterionScoresJson: true },
})
- result.criteriaBreakdown = criteria.map((c) => {
+ result.criteriaBreakdown = allCriteria.map((c) => {
const scores: number[] = []
evaluations.forEach((e) => {
const cs = e.criterionScoresJson as Record | null
diff --git a/src/server/routers/ranking.ts b/src/server/routers/ranking.ts
index ef61fce..bdb46d7 100644
--- a/src/server/routers/ranking.ts
+++ b/src/server/routers/ranking.ts
@@ -443,17 +443,24 @@ export const rankingRouter = router({
roundEvaluationScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
- // Find the boolean criterion ID from the EvaluationForm (not round configJson)
- const evalForm = await ctx.prisma.evaluationForm.findFirst({
+ // Find the boolean criterion ID across all active forms (shared + category-specific)
+ const activeForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
select: { criteriaJson: true },
})
- const formCriteria = (evalForm?.criteriaJson as Array<{
- id: string; label: string; type?: string
- }> | null) ?? []
- const boolCriterionId = formCriteria.find(
- (c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
- )?.id ?? null
+ let boolCriterionId: string | null = null
+ for (const f of activeForms) {
+ const fc = (f.criteriaJson as Array<{
+ id: string; label: string; type?: string
+ }> | null) ?? []
+ const found = fc.find(
+ (c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
+ )
+ if (found) {
+ boolCriterionId = found.id
+ break
+ }
+ }
const assignments = await ctx.prisma.assignment.findMany({
where: {
diff --git a/src/server/services/ai-evaluation-summary.ts b/src/server/services/ai-evaluation-summary.ts
index 620a81e..7b51a2c 100644
--- a/src/server/services/ai-evaluation-summary.ts
+++ b/src/server/services/ai-evaluation-summary.ts
@@ -16,6 +16,7 @@ import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { sanitizeText } from './anonymization'
import type { PrismaClient, Prisma } from '@prisma/client'
+import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -290,6 +291,7 @@ export async function generateSummary({
select: {
id: true,
title: true,
+ competitionCategory: true,
},
})
@@ -329,11 +331,8 @@ export async function generateSummary({
})
}
- // Get evaluation form criteria for this round
- const form = await prisma.evaluationForm.findFirst({
- where: { roundId, isActive: true },
- select: { criteriaJson: true },
- })
+ // Get evaluation form criteria for this round (category-aware)
+ const form = await findActiveForm(prisma, roundId, project.competitionCategory)
const criteria: CriterionDef[] = form?.criteriaJson
? (form.criteriaJson as unknown as CriterionDef[])
diff --git a/src/server/services/ai-ranking.ts b/src/server/services/ai-ranking.ts
index 29a5f17..527c0e1 100644
--- a/src/server/services/ai-ranking.ts
+++ b/src/server/services/ai-ranking.ts
@@ -18,6 +18,7 @@
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, logAIError } from './ai-errors'
+import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
import { TRPCError } from '@trpc/server'
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
@@ -625,16 +626,13 @@ async function fetchCategoryProjects(
roundId: string,
prisma: PrismaClient,
): Promise {
- // Fetch the round config and evaluation form in parallel
+ // Fetch the round config and evaluation form in parallel (category-aware)
const [round, evalForm] = await Promise.all([
prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { configJson: true },
}),
- prisma.evaluationForm.findFirst({
- where: { roundId, isActive: true },
- select: { criteriaJson: true },
- }),
+ findActiveForm(prisma, roundId, category),
])
const roundConfig = round.configJson as Record | null
diff --git a/src/server/utils/evaluation-form-lookup.ts b/src/server/utils/evaluation-form-lookup.ts
new file mode 100644
index 0000000..c703d6f
--- /dev/null
+++ b/src/server/utils/evaluation-form-lookup.ts
@@ -0,0 +1,26 @@
+import type { PrismaClient, CompetitionCategory, EvaluationForm } from '@prisma/client'
+
+/**
+ * Find the active EvaluationForm for a round, with category-aware resolution.
+ *
+ * Resolution order:
+ * 1. If `category` is provided, try the category-specific active form first.
+ * 2. Fall back to the shared form (category = null).
+ * 3. If no `category` provided, return the shared form directly.
+ */
+export async function findActiveForm(
+ prisma: PrismaClient | Pick,
+ roundId: string,
+ category?: CompetitionCategory | null,
+): Promise {
+ if (category) {
+ const specific = await prisma.evaluationForm.findFirst({
+ where: { roundId, isActive: true, category },
+ })
+ if (specific) return specific
+ }
+ // Fallback to shared form (category = null)
+ return prisma.evaluationForm.findFirst({
+ where: { roundId, isActive: true, category: null },
+ })
+}
diff --git a/src/types/competition-configs.ts b/src/types/competition-configs.ts
index f08468a..b250934 100644
--- a/src/types/competition-configs.ts
+++ b/src/types/competition-configs.ts
@@ -93,6 +93,7 @@ export const EvaluationConfigSchema = z.object({
requiredReviewsPerProject: z.number().int().positive().default(3),
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
+ perCategoryCriteria: z.boolean().default(false),
requireFeedback: z.boolean().default(true),
feedbackMinLength: z.number().int().nonnegative().default(0),
requireAllCriteriaScored: z.boolean().default(true),