diff --git a/prisma/migrations/20260331000000_add_evaluation_form_category/migration.sql b/prisma/migrations/20260331000000_add_evaluation_form_category/migration.sql new file mode 100644 index 0000000..c179ae1 --- /dev/null +++ b/prisma/migrations/20260331000000_add_evaluation_form_category/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable: add nullable category column to EvaluationForm +ALTER TABLE "EvaluationForm" ADD COLUMN "category" "CompetitionCategory"; + +-- Drop old unique constraint +ALTER TABLE "EvaluationForm" DROP CONSTRAINT "EvaluationForm_roundId_version_key"; + +-- Add new unique constraint including category +ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_version_category_key" UNIQUE ("roundId", "version", "category"); + +-- Partial unique index: prevent duplicate shared forms at the same version +-- (PostgreSQL treats NULLs as distinct in unique constraints, so we need this) +CREATE UNIQUE INDEX "EvaluationForm_roundId_version_null_category" + ON "EvaluationForm" ("roundId", "version") WHERE "category" IS NULL; + +-- Compound index for category-aware active form lookups +CREATE INDEX "EvaluationForm_roundId_isActive_category_idx" + ON "EvaluationForm" ("roundId", "isActive", "category"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc5ae76..cc20099 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -519,9 +519,10 @@ model WizardTemplate { // ============================================================================= model EvaluationForm { - id String @id @default(cuid()) - roundId String - version Int @default(1) + id String @id @default(cuid()) + roundId String + version Int @default(1) + category CompetitionCategory? // null=shared form, STARTUP or BUSINESS_CONCEPT=category-specific // Form configuration // criteriaJson: Array of { id, label, description, scale, weight, required } @@ -537,8 +538,9 @@ model EvaluationForm { round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) evaluations Evaluation[] - @@unique([roundId, version]) + @@unique([roundId, version, category]) @@index([roundId, isActive]) + @@index([roundId, isActive, category]) } // ============================================================================= diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 51625bd..d43e0a3 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -1006,6 +1006,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { open={!!selectedEvalAssignment} onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }} onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })} + category={project?.competitionCategory} /> {/* AI Evaluation Summary */} diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 1886554..d00a9fb 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -2291,7 +2291,7 @@ export default function RoundDetailPage() { /> {/* Evaluation Criteria Editor (EVALUATION rounds only) */} - {isEvaluation && } + {isEvaluation && } {/* Document Requirements — hidden for EVALUATION rounds unless requireDocumentUpload is on */} {(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && ( diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx index 8417b03..d575ba5 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx @@ -80,10 +80,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { const [coiCompleted, setCOICompleted] = useState(false) const [coiHasConflict, setCOIHasConflict] = useState(false) - // Fetch the active evaluation form for this round + // Fetch the active evaluation form for this round (category-aware) const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( - { roundId }, - { enabled: !!roundId } + { roundId, category: project?.competitionCategory }, + { enabled: !!roundId && !!project } ) // Start evaluation mutation (creates draft) diff --git a/src/components/admin/evaluation-edit-sheet.tsx b/src/components/admin/evaluation-edit-sheet.tsx index 1d67cb1..0bebb58 100644 --- a/src/components/admin/evaluation-edit-sheet.tsx +++ b/src/components/admin/evaluation-edit-sheet.tsx @@ -34,6 +34,8 @@ type EvaluationEditSheetProps = { onOpenChange: (open: boolean) => void /** Called after a successful feedback edit */ onSaved?: () => void + /** Optional project competition category for category-aware form lookup */ + category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null } export function EvaluationEditSheet({ @@ -41,6 +43,7 @@ export function EvaluationEditSheet({ open, onOpenChange, onSaved, + category, }: EvaluationEditSheetProps) { const [isEditing, setIsEditing] = useState(false) const [editedFeedback, setEditedFeedback] = useState('') @@ -115,7 +118,7 @@ export function EvaluationEditSheet({ {/* Criterion Scores */} {hasScores && ( - + )} {/* Feedback Text — editable */} @@ -147,12 +150,14 @@ export function EvaluationEditSheet({ function CriterionScoresSection({ criterionScores, roundId, + category, }: { criterionScores: Record roundId?: string + category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null }) { const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( - { roundId: roundId ?? '' }, + { roundId: roundId ?? '', category }, { enabled: !!roundId } ) diff --git a/src/components/admin/round/evaluation-criteria-editor.tsx b/src/components/admin/round/evaluation-criteria-editor.tsx index ec52403..d985de1 100644 --- a/src/components/admin/round/evaluation-criteria-editor.tsx +++ b/src/components/admin/round/evaluation-criteria-editor.tsx @@ -6,15 +6,165 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Badge } from '@/components/ui/badge' import { Loader2 } from 'lucide-react' import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder' import type { Criterion } from '@/components/forms/evaluation-form-builder' export type EvaluationCriteriaEditorProps = { roundId: string + perCategoryCriteria?: boolean } -export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) { +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function parseCriteria(criteriaJson: unknown): Criterion[] { + if (!criteriaJson) return [] + return (criteriaJson as Criterion[]).map((c) => { + const type = c.type || 'numeric' + if (type === 'numeric' && typeof c.scale === 'string') { + const parts = (c.scale as string).split('-').map(Number) + if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion + } + } + return { ...c, type } as Criterion + }) +} + +function buildUpsertPayload(criteria: Criterion[]) { + return criteria.map((c) => ({ + id: c.id, + label: c.label, + description: c.description, + type: c.type || 'numeric', + weight: c.weight, + scale: typeof c.scale === 'number' ? c.scale : undefined, + minScore: (c as any).minScore, + maxScore: (c as any).maxScore, + required: c.required, + maxLength: c.maxLength, + placeholder: c.placeholder, + trueLabel: c.trueLabel, + falseLabel: c.falseLabel, + condition: c.condition, + sectionId: c.sectionId, + })) +} + +// --------------------------------------------------------------------------- +// Single-category panel (used both standalone and inside tabs) +// --------------------------------------------------------------------------- + +function SingleCriteriaPanel({ + roundId, + category, +}: { + roundId: string + category?: 'STARTUP' | 'BUSINESS_CONCEPT' +}) { + const [pendingCriteria, setPendingCriteria] = useState(null) + const utils = trpc.useUtils() + + const { data: form, isLoading } = trpc.evaluation.getForm.useQuery( + { roundId, category: category ?? null }, + { refetchInterval: 30_000 }, + ) + + const upsertMutation = trpc.evaluation.upsertForm.useMutation({ + onSuccess: () => { + utils.evaluation.getForm.invalidate({ roundId, category: category ?? null }) + toast.success('Evaluation criteria saved') + setPendingCriteria(null) + }, + onError: (err) => toast.error(err.message), + }) + + const serverCriteria: Criterion[] = useMemo( + () => parseCriteria(form?.criteriaJson), + [form?.criteriaJson], + ) + + const handleChange = useCallback((criteria: Criterion[]) => { + setPendingCriteria(criteria) + }, []) + + const handleSave = () => { + const criteria = pendingCriteria ?? serverCriteria + const validCriteria = criteria.filter((c) => c.label.trim()) + if (validCriteria.length === 0) { + toast.error('Add at least one criterion') + return + } + upsertMutation.mutate({ + roundId, + category: category ?? undefined, + criteria: buildUpsertPayload(validCriteria), + }) + } + + return ( +
+
+ {form && ( + + Version {form.version} —{' '} + {(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria + + )} + {!form && !isLoading && ( + + No criteria defined yet. Add numeric scores, yes/no questions, and text fields. + + )} + {pendingCriteria && ( +
+ + +
+ )} +
+ {isLoading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : ( + + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export function EvaluationCriteriaEditor({ roundId, perCategoryCriteria }: EvaluationCriteriaEditorProps) { + if (!perCategoryCriteria) { + // Original single-form layout + return + } + + // Per-category tabbed layout + return +} + +// --------------------------------------------------------------------------- +// Single form editor — preserves original Card layout exactly +// --------------------------------------------------------------------------- + +function SingleFormEditor({ roundId }: { roundId: string }) { const [pendingCriteria, setPendingCriteria] = useState(null) const utils = trpc.useUtils() @@ -32,21 +182,10 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr onError: (err) => toast.error(err.message), }) - // Convert server criteriaJson to Criterion[] format - const serverCriteria: Criterion[] = useMemo(() => { - if (!form?.criteriaJson) return [] - return (form.criteriaJson as Criterion[]).map((c) => { - // Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore - const type = c.type || 'numeric' - if (type === 'numeric' && typeof c.scale === 'string') { - const parts = (c.scale as string).split('-').map(Number) - if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { - return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion - } - } - return { ...c, type } as Criterion - }) - }, [form?.criteriaJson]) + const serverCriteria: Criterion[] = useMemo( + () => parseCriteria(form?.criteriaJson), + [form?.criteriaJson], + ) const handleChange = useCallback((criteria: Criterion[]) => { setPendingCriteria(criteria) @@ -59,26 +198,9 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr toast.error('Add at least one criterion') return } - // Map to upsertForm format upsertMutation.mutate({ roundId, - criteria: validCriteria.map((c) => ({ - id: c.id, - label: c.label, - description: c.description, - type: c.type || 'numeric', - weight: c.weight, - scale: typeof c.scale === 'number' ? c.scale : undefined, - minScore: (c as any).minScore, - maxScore: (c as any).maxScore, - required: c.required, - maxLength: c.maxLength, - placeholder: c.placeholder, - trueLabel: c.trueLabel, - falseLabel: c.falseLabel, - condition: c.condition, - sectionId: c.sectionId, - })), + criteria: buildUpsertPayload(validCriteria), }) } @@ -122,3 +244,59 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr ) } + +// --------------------------------------------------------------------------- +// Tabbed criteria editor — separate forms per category +// --------------------------------------------------------------------------- + +function TabbedCriteriaEditor({ roundId }: { roundId: string }) { + const { data: startupForm } = trpc.evaluation.getForm.useQuery( + { roundId, category: 'STARTUP' }, + { refetchInterval: 30_000 }, + ) + const { data: conceptForm } = trpc.evaluation.getForm.useQuery( + { roundId, category: 'BUSINESS_CONCEPT' }, + { refetchInterval: 30_000 }, + ) + + return ( + + + Evaluation Criteria + + Separate criteria for each project category. Configure each tab independently. + + + + + + + Startup Criteria + {!startupForm && ( + + No form + + )} + + + Business Concept Criteria + {!conceptForm && ( + + No form + + )} + + + + + + + + + + + + + + ) +} diff --git a/src/components/admin/rounds/config/evaluation-config.tsx b/src/components/admin/rounds/config/evaluation-config.tsx index 489dfbc..bba65ab 100644 --- a/src/components/admin/rounds/config/evaluation-config.tsx +++ b/src/components/admin/rounds/config/evaluation-config.tsx @@ -73,6 +73,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) { +
+
+ +

Define different evaluation criteria for Startup and Business Concept projects

+
+ update('perCategoryCriteria', v)} + /> +
+

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),