2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
include: { round: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
evaluationForms: { where: { isActive: true }, take: 1 },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (assignment.userId !== ctx.user.id) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get active form
|
|
|
|
|
const form = assignment.round.evaluationForms[0]
|
|
|
|
|
if (!form) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No active evaluation form for this round',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.number()).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.number()),
|
|
|
|
|
globalScore: z.number().int().min(1).max(10),
|
|
|
|
|
binaryDecision: z.boolean(),
|
|
|
|
|
feedbackText: z.string().min(10),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const { id, ...data } = input
|
|
|
|
|
|
|
|
|
|
// Verify ownership
|
|
|
|
|
const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({
|
|
|
|
|
where: { id },
|
|
|
|
|
include: {
|
|
|
|
|
assignment: {
|
|
|
|
|
include: { round: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (evaluation.assignment.userId !== ctx.user.id) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check voting window
|
|
|
|
|
const round = evaluation.assignment.round
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
|
|
|
|
if (round.status !== '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.votingEndAt
|
|
|
|
|
|
|
|
|
|
if (round.votingStartAt && now < round.votingStartAt) {
|
|
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Submit
|
|
|
|
|
const updated = await ctx.prisma.evaluation.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data: {
|
|
|
|
|
...data,
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
submittedAt: now,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mark assignment as completed
|
|
|
|
|
await ctx.prisma.assignment.update({
|
|
|
|
|
where: { id: evaluation.assignmentId },
|
|
|
|
|
data: { isCompleted: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
action: 'EVALUATION_SUBMITTED',
|
2026-01-30 13:41:32 +01:00
|
|
|
entityType: 'Evaluation',
|
|
|
|
|
entityId: id,
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
detailsJson: {
|
|
|
|
|
projectId: evaluation.assignment.projectId,
|
|
|
|
|
roundId: evaluation.assignment.roundId,
|
|
|
|
|
globalScore: data.globalScore,
|
|
|
|
|
binaryDecision: data.binaryDecision,
|
|
|
|
|
},
|
2026-01-30 13:41:32 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return updated
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 round (admin only)
|
|
|
|
|
*/
|
|
|
|
|
listByRound: 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' },
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
})
|