All checks were successful
Build and Push Docker Image / build (push) Successful in 8m38s
1. Evaluation submit: The requireAllCriteriaScored validation was
querying findFirst({ roundId, isActive: true }) to get the form
criteria, instead of using the evaluation's stored formId. If an
admin ever re-saved the evaluation form (creating a new version
with new criterion IDs), jurors who started evaluating before the
re-save had scores keyed to old IDs that didn't match the new
form. Now uses evaluation.form (the form assigned at start time).
2. Observer reports page: Two .map() calls on p.stages lacked null
guards, causing "Cannot read properties of null (reading 'map')"
crash. Added (p.stages || []) guards matching the pattern already
used in CrossStageTab.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1575 lines
48 KiB
TypeScript
1575 lines
48 KiB
TypeScript
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc'
|
|
import { logAudit } from '@/server/utils/audit'
|
|
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
|
import { reassignAfterCOI } from './assignment'
|
|
import { sendManualReminders } from '../services/evaluation-reminders'
|
|
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
|
|
|
export const evaluationRouter = router({
|
|
/**
|
|
* Get evaluation for an assignment
|
|
*/
|
|
get: protectedProcedure
|
|
.input(z.object({ assignmentId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
// Verify ownership or admin
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
where: { id: input.assignmentId },
|
|
})
|
|
|
|
if (
|
|
ctx.user.role === 'JURY_MEMBER' &&
|
|
assignment.userId !== ctx.user.id
|
|
) {
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
}
|
|
|
|
return ctx.prisma.evaluation.findUnique({
|
|
where: { assignmentId: input.assignmentId },
|
|
include: {
|
|
form: true,
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Start an evaluation (creates draft)
|
|
*/
|
|
start: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
assignmentId: z.string(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Verify assignment ownership
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
where: { id: input.assignmentId },
|
|
})
|
|
|
|
if (assignment.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
}
|
|
|
|
// Get active form for this stage
|
|
const form = await ctx.prisma.evaluationForm.findFirst({
|
|
where: { roundId: assignment.roundId, isActive: true },
|
|
})
|
|
if (!form) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'No active evaluation form for this stage',
|
|
})
|
|
}
|
|
|
|
// Check if evaluation exists
|
|
const existing = await ctx.prisma.evaluation.findUnique({
|
|
where: { assignmentId: input.assignmentId },
|
|
})
|
|
|
|
if (existing) return existing
|
|
|
|
return ctx.prisma.evaluation.create({
|
|
data: {
|
|
assignmentId: input.assignmentId,
|
|
formId: form.id,
|
|
status: 'DRAFT',
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Autosave evaluation (debounced on client)
|
|
*/
|
|
autosave: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
|
|
globalScore: z.number().int().min(1).max(10).optional().nullable(),
|
|
binaryDecision: z.boolean().optional().nullable(),
|
|
feedbackText: z.string().optional().nullable(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { id, ...data } = input
|
|
|
|
// Verify ownership and status
|
|
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
|
|
where: { id },
|
|
include: { assignment: true },
|
|
})
|
|
|
|
if (evaluation.assignment.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
}
|
|
|
|
if (
|
|
evaluation.status === 'SUBMITTED' ||
|
|
evaluation.status === 'LOCKED'
|
|
) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Cannot edit submitted evaluation',
|
|
})
|
|
}
|
|
|
|
return ctx.prisma.evaluation.update({
|
|
where: { id },
|
|
data: {
|
|
...data,
|
|
status: 'DRAFT',
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Submit evaluation (final)
|
|
*/
|
|
submit: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
|
globalScore: z.number().int().min(1).max(10).optional(),
|
|
binaryDecision: z.boolean().optional(),
|
|
feedbackText: z.string().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { id, ...data } = input
|
|
|
|
// Verify ownership
|
|
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
|
|
where: { id },
|
|
include: {
|
|
assignment: true,
|
|
form: { select: { criteriaJson: true } },
|
|
},
|
|
})
|
|
|
|
if (evaluation.assignment.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
}
|
|
|
|
// Server-side COI check
|
|
const coi = await ctx.prisma.conflictOfInterest.findFirst({
|
|
where: { assignmentId: evaluation.assignmentId, hasConflict: true },
|
|
})
|
|
if (coi) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Cannot submit evaluation — conflict of interest declared',
|
|
})
|
|
}
|
|
|
|
// Check voting window via round
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: evaluation.assignment.roundId },
|
|
})
|
|
const now = new Date()
|
|
|
|
if (round.status !== 'ROUND_ACTIVE') {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Round is not active',
|
|
})
|
|
}
|
|
|
|
// Check for grace period
|
|
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
|
|
where: {
|
|
roundId: round.id,
|
|
userId: ctx.user.id,
|
|
OR: [
|
|
{ projectId: null },
|
|
{ projectId: evaluation.assignment.projectId },
|
|
],
|
|
extendedUntil: { gte: now },
|
|
},
|
|
})
|
|
|
|
const effectiveEndDate = gracePeriod?.extendedUntil ?? round.windowCloseAt
|
|
|
|
if (round.windowOpenAt && now < round.windowOpenAt) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Voting has not started yet',
|
|
})
|
|
}
|
|
|
|
if (effectiveEndDate && now > effectiveEndDate) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Voting window has closed',
|
|
})
|
|
}
|
|
|
|
// Load round config for validation
|
|
const config = (round.configJson as Record<string, unknown>) || {}
|
|
const scoringMode = (config.scoringMode as string) || 'criteria'
|
|
|
|
// Fix 3: Dynamic feedback validation based on config
|
|
const requireFeedback = config.requireFeedback !== false
|
|
if (requireFeedback) {
|
|
const feedbackMinLength = (config.feedbackMinLength as number) || 10
|
|
if (!data.feedbackText || data.feedbackText.length < feedbackMinLength) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Feedback must be at least ${feedbackMinLength} characters`,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fix 4: Normalize binaryDecision and globalScore based on scoringMode
|
|
if (scoringMode !== 'binary') {
|
|
data.binaryDecision = undefined
|
|
}
|
|
if (scoringMode === 'binary') {
|
|
data.globalScore = undefined
|
|
}
|
|
|
|
// Fix 5: requireAllCriteriaScored validation
|
|
// Use the form the juror was assigned (evaluation.form), NOT the current active form.
|
|
// If the admin re-saves the form, criterion IDs change — jurors who started before
|
|
// the re-save would have scores keyed to old IDs that don't match the new form.
|
|
if (config.requireAllCriteriaScored && scoringMode === 'criteria') {
|
|
const evalForm = evaluation.form
|
|
if (evalForm?.criteriaJson) {
|
|
const criteria = evalForm.criteriaJson as Array<{ id: string; label?: string; type?: string; required?: boolean }>
|
|
const scorableCriteria = criteria.filter(
|
|
(c) => c.type !== 'section_header' && c.type !== 'text' && c.required !== false
|
|
)
|
|
const scores = data.criterionScoresJson as Record<string, unknown> | undefined
|
|
const missingCriteria = scorableCriteria.filter((c) => {
|
|
if (!scores) return true
|
|
const val = scores[c.id]
|
|
// Boolean criteria store true/false, numeric criteria store numbers
|
|
if (c.type === 'boolean') return typeof val !== 'boolean'
|
|
return typeof val !== 'number'
|
|
})
|
|
if (missingCriteria.length > 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Missing scores for criteria: ${missingCriteria.map((c) => c.label || c.id).join(', ')}`,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Submit evaluation and mark assignment as completed atomically
|
|
const saveData = {
|
|
criterionScoresJson: data.criterionScoresJson,
|
|
globalScore: data.globalScore ?? null,
|
|
binaryDecision: data.binaryDecision ?? null,
|
|
feedbackText: data.feedbackText ?? null,
|
|
}
|
|
const [updated] = await ctx.prisma.$transaction([
|
|
ctx.prisma.evaluation.update({
|
|
where: { id },
|
|
data: {
|
|
...saveData,
|
|
status: 'SUBMITTED',
|
|
submittedAt: now,
|
|
},
|
|
}),
|
|
ctx.prisma.assignment.update({
|
|
where: { id: evaluation.assignmentId },
|
|
data: { isCompleted: true },
|
|
}),
|
|
])
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'EVALUATION_SUBMITTED',
|
|
entityType: 'Evaluation',
|
|
entityId: id,
|
|
detailsJson: {
|
|
projectId: evaluation.assignment.projectId,
|
|
roundId: evaluation.assignment.roundId,
|
|
globalScore: data.globalScore,
|
|
binaryDecision: data.binaryDecision,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return updated
|
|
}),
|
|
|
|
/**
|
|
* Reset (erase) an evaluation so a juror can start over (admin only)
|
|
* Deletes the evaluation record and resets the assignment's isCompleted flag.
|
|
*/
|
|
resetEvaluation: adminProcedure
|
|
.input(
|
|
z.object({
|
|
assignmentId: z.string(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
where: { id: input.assignmentId },
|
|
include: {
|
|
evaluation: true,
|
|
user: { select: { id: true, name: true, email: true } },
|
|
project: { select: { id: true, title: true } },
|
|
},
|
|
})
|
|
|
|
if (!assignment.evaluation) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'No evaluation found for this assignment',
|
|
})
|
|
}
|
|
|
|
// Delete the evaluation and reset assignment completion in a transaction
|
|
await ctx.prisma.$transaction([
|
|
ctx.prisma.evaluation.delete({
|
|
where: { id: assignment.evaluation.id },
|
|
}),
|
|
ctx.prisma.assignment.update({
|
|
where: { id: input.assignmentId },
|
|
data: { isCompleted: false },
|
|
}),
|
|
])
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'EVALUATION_RESET',
|
|
entityType: 'Evaluation',
|
|
entityId: assignment.evaluation.id,
|
|
detailsJson: {
|
|
assignmentId: input.assignmentId,
|
|
jurorId: assignment.user.id,
|
|
jurorName: assignment.user.name || assignment.user.email,
|
|
projectId: assignment.project.id,
|
|
projectTitle: assignment.project.title,
|
|
previousStatus: assignment.evaluation.status,
|
|
previousGlobalScore: assignment.evaluation.globalScore,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return { success: true }
|
|
}),
|
|
|
|
/**
|
|
* Get aggregated stats for a project (admin only)
|
|
*/
|
|
getProjectStats: adminProcedure
|
|
.input(z.object({ projectId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
status: 'SUBMITTED',
|
|
assignment: { projectId: input.projectId },
|
|
},
|
|
})
|
|
|
|
if (evaluations.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const globalScores = evaluations
|
|
.map((e) => e.globalScore)
|
|
.filter((s): s is number => s !== null)
|
|
|
|
const yesVotes = evaluations.filter(
|
|
(e) => e.binaryDecision === true
|
|
).length
|
|
|
|
return {
|
|
totalEvaluations: evaluations.length,
|
|
averageGlobalScore:
|
|
globalScores.length > 0
|
|
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
|
|
: null,
|
|
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
|
|
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
|
|
yesVotes,
|
|
noVotes: evaluations.length - yesVotes,
|
|
yesPercentage: (yesVotes / evaluations.length) * 100,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get all evaluations for a stage (admin only)
|
|
*/
|
|
listByStage: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
status: z.enum(['NOT_STARTED', 'DRAFT', 'SUBMITTED', 'LOCKED']).optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: { roundId: input.roundId },
|
|
...(input.status && { status: input.status }),
|
|
},
|
|
include: {
|
|
assignment: {
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
project: { select: { id: true, title: true } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Get my past evaluations (read-only for jury)
|
|
*/
|
|
myPastEvaluations: protectedProcedure
|
|
.input(z.object({ roundId: z.string().optional() }))
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
assignment: {
|
|
userId: ctx.user.id,
|
|
...(input.roundId && { roundId: input.roundId }),
|
|
},
|
|
status: 'SUBMITTED',
|
|
},
|
|
include: {
|
|
assignment: {
|
|
include: {
|
|
project: { select: { id: true, title: true } },
|
|
round: { select: { id: true, name: true } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: { submittedAt: 'desc' },
|
|
})
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Conflict of Interest (COI) Endpoints
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Declare a conflict of interest for an assignment
|
|
*/
|
|
declareCOI: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
assignmentId: z.string(),
|
|
hasConflict: z.boolean(),
|
|
conflictType: z.string().optional(),
|
|
description: z.string().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Look up the assignment to get projectId, roundId, userId
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
where: { id: input.assignmentId },
|
|
include: {
|
|
project: { select: { title: true } },
|
|
round: { select: { id: true, name: true } },
|
|
},
|
|
})
|
|
|
|
// Verify ownership
|
|
if (assignment.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
}
|
|
|
|
// Upsert COI record
|
|
const coi = await ctx.prisma.conflictOfInterest.upsert({
|
|
where: { assignmentId: input.assignmentId },
|
|
create: {
|
|
assignmentId: input.assignmentId,
|
|
userId: ctx.user.id,
|
|
projectId: assignment.projectId,
|
|
hasConflict: input.hasConflict,
|
|
conflictType: input.hasConflict ? input.conflictType : null,
|
|
description: input.hasConflict ? input.description : null,
|
|
},
|
|
update: {
|
|
hasConflict: input.hasConflict,
|
|
conflictType: input.hasConflict ? input.conflictType : null,
|
|
description: input.hasConflict ? input.description : null,
|
|
declaredAt: new Date(),
|
|
},
|
|
})
|
|
|
|
// Notify admins if conflict declared
|
|
if (input.hasConflict) {
|
|
await notifyAdmins({
|
|
type: NotificationTypes.JURY_INACTIVE,
|
|
title: 'Conflict of Interest Declared',
|
|
message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.round.name}.`,
|
|
linkUrl: `/admin/stages/${assignment.roundId}/coi`,
|
|
linkLabel: 'Review COI',
|
|
priority: 'high',
|
|
metadata: {
|
|
assignmentId: input.assignmentId,
|
|
userId: ctx.user.id,
|
|
projectId: assignment.projectId,
|
|
roundId: assignment.roundId,
|
|
conflictType: input.conflictType,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: input.hasConflict ? 'COI_DECLARED' : 'COI_NO_CONFLICT',
|
|
entityType: 'ConflictOfInterest',
|
|
entityId: coi.id,
|
|
detailsJson: {
|
|
assignmentId: input.assignmentId,
|
|
projectId: assignment.projectId,
|
|
roundId: assignment.roundId,
|
|
hasConflict: input.hasConflict,
|
|
conflictType: input.conflictType,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
// Auto-reassign the project to another eligible juror
|
|
let reassignment: { newJurorId: string; newJurorName: string } | null = null
|
|
if (input.hasConflict) {
|
|
try {
|
|
reassignment = await reassignAfterCOI({
|
|
assignmentId: input.assignmentId,
|
|
auditUserId: ctx.user.id,
|
|
auditIp: ctx.ip,
|
|
auditUserAgent: ctx.userAgent,
|
|
})
|
|
} catch (err) {
|
|
// Don't fail the COI declaration if reassignment fails
|
|
console.error('[COI] Auto-reassignment failed:', err)
|
|
}
|
|
}
|
|
|
|
return { ...coi, reassignment }
|
|
}),
|
|
|
|
/**
|
|
* Get COI status for an assignment
|
|
*/
|
|
getCOIStatus: protectedProcedure
|
|
.input(z.object({ assignmentId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.conflictOfInterest.findUnique({
|
|
where: { assignmentId: input.assignmentId },
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* List COI declarations for a stage (admin only)
|
|
*/
|
|
listCOIByStage: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
hasConflictOnly: z.boolean().optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.conflictOfInterest.findMany({
|
|
where: {
|
|
assignment: { roundId: input.roundId },
|
|
...(input.hasConflictOnly && { hasConflict: true }),
|
|
},
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
assignment: {
|
|
include: {
|
|
project: { select: { id: true, title: true } },
|
|
},
|
|
},
|
|
reviewedBy: { select: { id: true, name: true, email: true } },
|
|
},
|
|
orderBy: { declaredAt: 'desc' },
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Review a COI declaration (admin only)
|
|
*/
|
|
reviewCOI: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
reviewAction: z.enum(['cleared', 'reassigned', 'noted']),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const coi = await ctx.prisma.conflictOfInterest.update({
|
|
where: { id: input.id },
|
|
data: {
|
|
reviewedById: ctx.user.id,
|
|
reviewedAt: new Date(),
|
|
reviewAction: input.reviewAction,
|
|
},
|
|
})
|
|
|
|
// If admin chose "reassigned", trigger actual reassignment
|
|
let reassignment: { newJurorId: string; newJurorName: string } | null = null
|
|
if (input.reviewAction === 'reassigned') {
|
|
reassignment = await reassignAfterCOI({
|
|
assignmentId: coi.assignmentId,
|
|
auditUserId: ctx.user.id,
|
|
auditIp: ctx.ip,
|
|
auditUserAgent: ctx.userAgent,
|
|
})
|
|
}
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'COI_REVIEWED',
|
|
entityType: 'ConflictOfInterest',
|
|
entityId: input.id,
|
|
detailsJson: {
|
|
reviewAction: input.reviewAction,
|
|
assignmentId: coi.assignmentId,
|
|
userId: coi.userId,
|
|
projectId: coi.projectId,
|
|
reassignedTo: reassignment?.newJurorId,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return { ...coi, reassignment }
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Reminder Triggers
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Manually trigger reminder check for a specific stage (admin only)
|
|
*/
|
|
triggerReminders: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await sendManualReminders(input.roundId)
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'REMINDERS_TRIGGERED',
|
|
entityType: 'Stage',
|
|
entityId: input.roundId,
|
|
detailsJson: {
|
|
sent: result.sent,
|
|
errors: result.errors,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return result
|
|
}),
|
|
|
|
// =========================================================================
|
|
// AI Evaluation Summary Endpoints
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Generate an AI-powered evaluation summary for a project (admin only)
|
|
*/
|
|
generateSummary: adminProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
return generateSummary({
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
userId: ctx.user.id,
|
|
prisma: ctx.prisma,
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Get an existing evaluation summary for a project (admin only)
|
|
*/
|
|
getSummary: adminProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.evaluationSummary.findUnique({
|
|
where: {
|
|
projectId_roundId: {
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
|
|
*/
|
|
generateBulkSummaries: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Find all projects with at least 1 submitted evaluation in this stage
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
where: {
|
|
roundId: input.roundId,
|
|
evaluation: {
|
|
status: 'SUBMITTED',
|
|
},
|
|
},
|
|
select: { projectId: true },
|
|
distinct: ['projectId'],
|
|
})
|
|
|
|
const projectIds = assignments.map((a) => a.projectId)
|
|
|
|
let generated = 0
|
|
const errors: Array<{ projectId: string; error: string }> = []
|
|
|
|
// Generate summaries sequentially to avoid rate limits
|
|
for (const projectId of projectIds) {
|
|
try {
|
|
await generateSummary({
|
|
projectId,
|
|
roundId: input.roundId,
|
|
userId: ctx.user.id,
|
|
prisma: ctx.prisma,
|
|
})
|
|
generated++
|
|
} catch (error) {
|
|
errors.push({
|
|
projectId,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: projectIds.length,
|
|
generated,
|
|
errors,
|
|
}
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Side-by-Side Comparison (F4)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Get multiple projects with evaluations for side-by-side comparison
|
|
*/
|
|
getMultipleForComparison: juryProcedure
|
|
.input(
|
|
z.object({
|
|
projectIds: z.array(z.string()).min(2).max(3),
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
// Verify all projects are assigned to current user in this stage
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
roundId: input.roundId,
|
|
projectId: { in: input.projectIds },
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
description: true,
|
|
country: true,
|
|
tags: true,
|
|
files: {
|
|
select: {
|
|
id: true,
|
|
fileName: true,
|
|
fileType: true,
|
|
size: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
evaluation: true,
|
|
},
|
|
})
|
|
|
|
if (assignments.length !== input.projectIds.length) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You are not assigned to all requested projects in this stage',
|
|
})
|
|
}
|
|
|
|
// Fetch the active evaluation form for this stage to get criteria labels
|
|
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
|
|
where: { roundId: input.roundId, isActive: true },
|
|
select: { criteriaJson: true, scalesJson: true },
|
|
})
|
|
|
|
return {
|
|
items: assignments.map((a) => ({
|
|
project: a.project,
|
|
evaluation: a.evaluation,
|
|
assignmentId: a.id,
|
|
})),
|
|
criteria: evaluationForm?.criteriaJson as Array<{
|
|
id: string; label: string; description?: string; scale?: string; weight?: number; type?: string
|
|
}> | null,
|
|
scales: evaluationForm?.scalesJson as Record<string, { min: number; max: number }> | null,
|
|
}
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Peer Review & Discussion (F13)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Get anonymized peer evaluation summary for a project
|
|
*/
|
|
getPeerSummary: juryProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
// Verify user has submitted their own evaluation first
|
|
const userAssignment = await ctx.prisma.assignment.findFirst({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
include: { evaluation: true },
|
|
})
|
|
|
|
if (!userAssignment || userAssignment.evaluation?.status !== 'SUBMITTED') {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'You must submit your own evaluation before viewing peer summaries',
|
|
})
|
|
}
|
|
|
|
// Check stage settings for peer review
|
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
})
|
|
|
|
const settings = (stage.configJson as Record<string, unknown>) || {}
|
|
if (!settings.peerReviewEnabled) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Peer review is not enabled for this stage',
|
|
})
|
|
}
|
|
|
|
// Get all submitted evaluations for this project
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
status: 'SUBMITTED',
|
|
assignment: {
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
},
|
|
include: {
|
|
assignment: {
|
|
include: {
|
|
user: { select: { id: true, name: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (evaluations.length === 0) {
|
|
return { aggregated: null, individualScores: [], totalEvaluations: 0 }
|
|
}
|
|
|
|
// Calculate average and stddev per criterion
|
|
const criterionData: Record<string, number[]> = {}
|
|
evaluations.forEach((e) => {
|
|
const scores = e.criterionScoresJson as Record<string, number> | null
|
|
if (scores) {
|
|
Object.entries(scores).forEach(([key, val]) => {
|
|
if (typeof val === 'number') {
|
|
if (!criterionData[key]) criterionData[key] = []
|
|
criterionData[key].push(val)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
const aggregated: Record<string, { average: number; stddev: number; count: number; distribution: Record<number, number> }> = {}
|
|
Object.entries(criterionData).forEach(([key, scores]) => {
|
|
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
|
|
const variance = scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / scores.length
|
|
const stddev = Math.sqrt(variance)
|
|
|
|
const distribution: Record<number, number> = {}
|
|
scores.forEach((s) => {
|
|
const bucket = Math.round(s)
|
|
distribution[bucket] = (distribution[bucket] || 0) + 1
|
|
})
|
|
|
|
aggregated[key] = { average: avg, stddev, count: scores.length, distribution }
|
|
})
|
|
|
|
// Anonymize individual scores based on round settings
|
|
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
|
|
|
const individualScores = evaluations.map((e) => {
|
|
let jurorLabel: string
|
|
if (anonymizationLevel === 'named') {
|
|
jurorLabel = e.assignment.user.name || 'Juror'
|
|
} else if (anonymizationLevel === 'show_initials') {
|
|
const name = e.assignment.user.name || ''
|
|
jurorLabel = name
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('')
|
|
.toUpperCase() || 'J'
|
|
} else {
|
|
jurorLabel = `Juror ${evaluations.indexOf(e) + 1}`
|
|
}
|
|
|
|
return {
|
|
jurorLabel,
|
|
globalScore: e.globalScore,
|
|
binaryDecision: e.binaryDecision,
|
|
criterionScoresJson: e.criterionScoresJson,
|
|
}
|
|
})
|
|
|
|
return {
|
|
aggregated,
|
|
individualScores,
|
|
totalEvaluations: evaluations.length,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get or create a discussion for a project evaluation
|
|
*/
|
|
getDiscussion: juryProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
// Get or create discussion
|
|
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
|
|
where: {
|
|
projectId_roundId: {
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
},
|
|
include: {
|
|
comments: {
|
|
include: {
|
|
user: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!discussion) {
|
|
discussion = await ctx.prisma.evaluationDiscussion.create({
|
|
data: {
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
include: {
|
|
comments: {
|
|
include: {
|
|
user: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// Anonymize comments based on round settings
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
})
|
|
const settings = (round.configJson as Record<string, unknown>) || {}
|
|
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
|
|
|
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
|
|
let authorLabel: string
|
|
if (anonymizationLevel === 'named' || c.userId === ctx.user.id) {
|
|
authorLabel = c.user.name || 'Juror'
|
|
} else if (anonymizationLevel === 'show_initials') {
|
|
const name = c.user.name || ''
|
|
authorLabel = name
|
|
.split(' ')
|
|
.map((n: string) => n[0])
|
|
.join('')
|
|
.toUpperCase() || 'J'
|
|
} else {
|
|
authorLabel = `Juror ${idx + 1}`
|
|
}
|
|
|
|
return {
|
|
id: c.id,
|
|
authorLabel,
|
|
isOwn: c.userId === ctx.user.id,
|
|
content: c.content,
|
|
createdAt: c.createdAt,
|
|
}
|
|
})
|
|
|
|
return {
|
|
id: discussion.id,
|
|
status: discussion.status,
|
|
createdAt: discussion.createdAt,
|
|
closedAt: discussion.closedAt,
|
|
comments: anonymizedComments,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Add a comment to a project evaluation discussion
|
|
*/
|
|
addComment: juryProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
content: z.string().min(1).max(2000),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Check max comment length from round settings
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
})
|
|
const settings = (round.configJson as Record<string, unknown>) || {}
|
|
const maxLength = (settings.maxCommentLength as number) || 2000
|
|
if (input.content.length > maxLength) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Comment exceeds maximum length of ${maxLength} characters`,
|
|
})
|
|
}
|
|
|
|
// Get or create discussion
|
|
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
|
|
where: {
|
|
projectId_roundId: {
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!discussion) {
|
|
discussion = await ctx.prisma.evaluationDiscussion.create({
|
|
data: {
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
})
|
|
}
|
|
|
|
if (discussion.status === 'closed') {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'This discussion has been closed',
|
|
})
|
|
}
|
|
|
|
const comment = await ctx.prisma.discussionComment.create({
|
|
data: {
|
|
discussionId: discussion.id,
|
|
userId: ctx.user.id,
|
|
content: input.content,
|
|
},
|
|
})
|
|
|
|
// Audit log
|
|
try {
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'DISCUSSION_COMMENT_ADDED',
|
|
entityType: 'DiscussionComment',
|
|
entityId: comment.id,
|
|
detailsJson: {
|
|
discussionId: discussion.id,
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
} catch {
|
|
// Never throw on audit failure
|
|
}
|
|
|
|
return comment
|
|
}),
|
|
|
|
/**
|
|
* Close a discussion (admin only)
|
|
*/
|
|
closeDiscussion: adminProcedure
|
|
.input(z.object({ discussionId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const discussion = await ctx.prisma.evaluationDiscussion.update({
|
|
where: { id: input.discussionId },
|
|
data: {
|
|
status: 'closed',
|
|
closedAt: new Date(),
|
|
closedById: ctx.user.id,
|
|
},
|
|
})
|
|
|
|
// Audit log
|
|
try {
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'DISCUSSION_CLOSED',
|
|
entityType: 'EvaluationDiscussion',
|
|
entityId: input.discussionId,
|
|
detailsJson: {
|
|
projectId: discussion.projectId,
|
|
roundId: discussion.roundId,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
} catch {
|
|
// Never throw on audit failure
|
|
}
|
|
|
|
return discussion
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Evaluation Form CRUD (Admin)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Get active evaluation form for a round (admin view with full details)
|
|
*/
|
|
getForm: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const form = await ctx.prisma.evaluationForm.findFirst({
|
|
where: { roundId: input.roundId, isActive: true },
|
|
})
|
|
|
|
if (!form) return null
|
|
|
|
return {
|
|
id: form.id,
|
|
roundId: form.roundId,
|
|
version: form.version,
|
|
isActive: form.isActive,
|
|
criteriaJson: form.criteriaJson as Array<{
|
|
id: string
|
|
label: string
|
|
description?: string
|
|
weight?: number
|
|
minScore?: number
|
|
maxScore?: number
|
|
}>,
|
|
scalesJson: form.scalesJson as Record<string, unknown> | null,
|
|
createdAt: form.createdAt,
|
|
updatedAt: form.updatedAt,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Create or update the evaluation form for a round.
|
|
* Deactivates any existing active form and creates a new versioned one.
|
|
*/
|
|
upsertForm: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
criteria: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
label: z.string().min(1).max(255),
|
|
description: z.string().max(2000).optional(),
|
|
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),
|
|
// Numeric fields
|
|
weight: z.number().min(0).max(100).optional(),
|
|
minScore: z.number().int().min(0).optional(),
|
|
maxScore: z.number().int().min(1).optional(),
|
|
scale: z.number().int().min(1).max(10).optional(),
|
|
required: z.boolean().optional(),
|
|
// Text fields
|
|
maxLength: z.number().int().min(1).max(10000).optional(),
|
|
placeholder: z.string().max(500).optional(),
|
|
// Boolean fields
|
|
trueLabel: z.string().max(100).optional(),
|
|
falseLabel: z.string().max(100).optional(),
|
|
// Conditional visibility
|
|
condition: z.object({
|
|
criterionId: z.string(),
|
|
operator: z.enum(['equals', 'greaterThan', 'lessThan']),
|
|
value: z.union([z.number(), z.string(), z.boolean()]),
|
|
}).optional(),
|
|
// Section grouping
|
|
sectionId: z.string().optional(),
|
|
})
|
|
).min(1),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { roundId, criteria } = input
|
|
|
|
// Verify round exists
|
|
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
|
|
|
// Get current max version for this round
|
|
const latestForm = await ctx.prisma.evaluationForm.findFirst({
|
|
where: { roundId },
|
|
orderBy: { version: 'desc' },
|
|
select: { version: true },
|
|
})
|
|
const nextVersion = (latestForm?.version ?? 0) + 1
|
|
|
|
// Build criteriaJson preserving all fields
|
|
const criteriaJson = criteria.map((c) => {
|
|
const type = c.type || 'numeric'
|
|
const base = {
|
|
id: c.id,
|
|
label: c.label,
|
|
description: c.description || '',
|
|
type,
|
|
required: c.required ?? (type !== 'section_header'),
|
|
}
|
|
|
|
if (type === 'numeric') {
|
|
const scaleVal = c.scale ?? 10
|
|
return {
|
|
...base,
|
|
weight: c.weight ?? 1,
|
|
scale: `${c.minScore ?? 1}-${c.maxScore ?? scaleVal}`,
|
|
}
|
|
}
|
|
if (type === 'text') {
|
|
return {
|
|
...base,
|
|
maxLength: c.maxLength ?? 1000,
|
|
placeholder: c.placeholder || '',
|
|
}
|
|
}
|
|
if (type === 'boolean') {
|
|
return {
|
|
...base,
|
|
trueLabel: c.trueLabel || 'Yes',
|
|
falseLabel: c.falseLabel || 'No',
|
|
}
|
|
}
|
|
// section_header
|
|
return base
|
|
})
|
|
|
|
// Auto-generate scalesJson from numeric criteria
|
|
const numericCriteria = criteriaJson.filter((c) => c.type === 'numeric')
|
|
const scaleSet = new Set(numericCriteria.map((c) => (c as { scale: string }).scale))
|
|
const scalesJson: Record<string, { min: number; max: number }> = {}
|
|
for (const scale of scaleSet) {
|
|
const [min, max] = scale.split('-').map(Number)
|
|
scalesJson[scale] = { min, max }
|
|
}
|
|
|
|
// Transaction: deactivate old → create new
|
|
const form = await ctx.prisma.$transaction(async (tx) => {
|
|
await tx.evaluationForm.updateMany({
|
|
where: { roundId, isActive: true },
|
|
data: { isActive: false },
|
|
})
|
|
|
|
return tx.evaluationForm.create({
|
|
data: {
|
|
roundId,
|
|
version: nextVersion,
|
|
criteriaJson,
|
|
scalesJson,
|
|
isActive: true,
|
|
},
|
|
})
|
|
})
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'UPSERT_EVALUATION_FORM',
|
|
entityType: 'EvaluationForm',
|
|
entityId: form.id,
|
|
detailsJson: {
|
|
roundId,
|
|
version: nextVersion,
|
|
criteriaCount: criteria.length,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return form
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Phase 4: Stage-scoped evaluation procedures
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Start a stage-scoped evaluation (create or return existing draft)
|
|
*/
|
|
startStage: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
assignmentId: z.string(),
|
|
roundId: z.string(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Verify assignment ownership and roundId match
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
where: { id: input.assignmentId },
|
|
})
|
|
|
|
if (assignment.userId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
}
|
|
|
|
if (assignment.roundId !== input.roundId) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Assignment does not belong to this stage',
|
|
})
|
|
}
|
|
|
|
// Check round window
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
})
|
|
|
|
const now = new Date()
|
|
if (round.status !== 'ROUND_ACTIVE') {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'Round is not active',
|
|
})
|
|
}
|
|
|
|
// Check grace period
|
|
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
|
|
where: {
|
|
roundId: input.roundId,
|
|
userId: ctx.user.id,
|
|
OR: [
|
|
{ projectId: null },
|
|
{ projectId: assignment.projectId },
|
|
],
|
|
extendedUntil: { gte: now },
|
|
},
|
|
})
|
|
|
|
const effectiveClose = gracePeriod?.extendedUntil ?? round.windowCloseAt
|
|
if (round.windowOpenAt && now < round.windowOpenAt) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'Evaluation window has not opened yet',
|
|
})
|
|
}
|
|
if (effectiveClose && now > effectiveClose) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'Evaluation window has closed',
|
|
})
|
|
}
|
|
|
|
// Check for existing evaluation
|
|
const existing = await ctx.prisma.evaluation.findUnique({
|
|
where: { assignmentId: input.assignmentId },
|
|
})
|
|
if (existing) return existing
|
|
|
|
// Get active evaluation form for this stage
|
|
const form = await ctx.prisma.evaluationForm.findFirst({
|
|
where: { roundId: input.roundId, isActive: true },
|
|
})
|
|
if (!form) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'No active evaluation form for this stage',
|
|
})
|
|
}
|
|
|
|
return ctx.prisma.evaluation.create({
|
|
data: {
|
|
assignmentId: input.assignmentId,
|
|
formId: form.id,
|
|
status: 'DRAFT',
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Get the active evaluation form for a stage
|
|
*/
|
|
getStageForm: protectedProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const form = await ctx.prisma.evaluationForm.findFirst({
|
|
where: { roundId: input.roundId, isActive: true },
|
|
})
|
|
|
|
if (!form) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
id: form.id,
|
|
criteriaJson: form.criteriaJson as Array<{
|
|
id: string
|
|
label: string
|
|
description?: string
|
|
scale?: string
|
|
weight?: number
|
|
type?: string
|
|
required?: boolean
|
|
}>,
|
|
scalesJson: form.scalesJson as Record<string, { min: number; max: number; labels?: Record<string, string> }> | null,
|
|
version: form.version,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Check the evaluation window status for a stage
|
|
*/
|
|
checkStageWindow: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
userId: z.string().optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
windowOpenAt: true,
|
|
windowCloseAt: true,
|
|
},
|
|
})
|
|
|
|
const userId = input.userId ?? ctx.user.id
|
|
const now = new Date()
|
|
|
|
// Check for grace period
|
|
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
|
|
where: {
|
|
roundId: input.roundId,
|
|
userId,
|
|
extendedUntil: { gte: now },
|
|
},
|
|
orderBy: { extendedUntil: 'desc' },
|
|
})
|
|
|
|
const effectiveClose = gracePeriod?.extendedUntil ?? stage.windowCloseAt
|
|
|
|
const isOpen =
|
|
stage.status === 'ROUND_ACTIVE' &&
|
|
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
|
|
(!effectiveClose || now <= effectiveClose)
|
|
|
|
let reason = ''
|
|
if (!isOpen) {
|
|
if (stage.status !== 'ROUND_ACTIVE') {
|
|
reason = 'Stage is not active'
|
|
} else if (stage.windowOpenAt && now < stage.windowOpenAt) {
|
|
reason = 'Window has not opened yet'
|
|
} else {
|
|
reason = 'Window has closed'
|
|
}
|
|
}
|
|
|
|
return {
|
|
isOpen,
|
|
opensAt: stage.windowOpenAt,
|
|
closesAt: stage.windowCloseAt,
|
|
hasGracePeriod: !!gracePeriod,
|
|
graceExpiresAt: gracePeriod?.extendedUntil ?? null,
|
|
reason,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* List evaluations for the current user in a specific stage
|
|
*/
|
|
listStageEvaluations: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
projectId: z.string().optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const where: Record<string, unknown> = {
|
|
assignment: {
|
|
userId: ctx.user.id,
|
|
roundId: input.roundId,
|
|
...(input.projectId ? { projectId: input.projectId } : {}),
|
|
},
|
|
}
|
|
|
|
return ctx.prisma.evaluation.findMany({
|
|
where,
|
|
include: {
|
|
assignment: {
|
|
include: {
|
|
project: { select: { id: true, title: true, teamName: true } },
|
|
},
|
|
},
|
|
form: {
|
|
select: { criteriaJson: true, scalesJson: true },
|
|
},
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
})
|
|
}),
|
|
})
|