Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins: - F1: Evaluation progress indicator with touch tracking in sticky status bar - F2: Export filtering results as CSV with dynamic AI column flattening - F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure) Batch 2 - Jury Experience: - F4: Countdown timer component with urgency colors + email reminder service with cron endpoint - F5: Conflict of interest declaration system (dialog, admin management, review workflow) Batch 3 - Admin & AI Enhancements: - F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording - F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns - F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking) Batch 4 - Form Flexibility & Applicant Portal: - F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility) - F10: Applicant portal (status timeline, per-round documents, mentor messaging) Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
||||
import { processEvaluationReminders } from '../services/evaluation-reminders'
|
||||
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||
|
||||
export const evaluationRouter = router({
|
||||
/**
|
||||
@@ -89,7 +92,7 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
criterionScoresJson: z.record(z.number()).optional(),
|
||||
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(),
|
||||
@@ -134,7 +137,7 @@ export const evaluationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
criterionScoresJson: z.record(z.number()),
|
||||
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
||||
globalScore: z.number().int().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10),
|
||||
@@ -325,4 +328,297 @@ export const evaluationRouter = router({
|
||||
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: { 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,
|
||||
roundId: assignment.roundId,
|
||||
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/rounds/${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: 'COI_DECLARED',
|
||||
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,
|
||||
})
|
||||
|
||||
return coi
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 round (admin only)
|
||||
*/
|
||||
listCOIByRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
hasConflictOnly: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
// 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,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return coi
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Reminder Triggers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Manually trigger reminder check for a specific round (admin only)
|
||||
*/
|
||||
triggerReminders: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await processEvaluationReminders(input.roundId)
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REMINDERS_TRIGGERED',
|
||||
entityType: 'Round',
|
||||
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 round with submitted evaluations (admin only)
|
||||
*/
|
||||
generateBulkSummaries: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Find all projects in the round with at least 1 submitted evaluation
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
assignments: {
|
||||
some: {
|
||||
evaluation: {
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
let generated = 0
|
||||
const errors: Array<{ projectId: string; error: string }> = []
|
||||
|
||||
// Generate summaries sequentially to avoid rate limits
|
||||
for (const project of projects) {
|
||||
try {
|
||||
await generateSummary({
|
||||
projectId: project.id,
|
||||
roundId: input.roundId,
|
||||
userId: ctx.user.id,
|
||||
prisma: ctx.prisma,
|
||||
})
|
||||
generated++
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
projectId: project.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: projects.length,
|
||||
generated,
|
||||
errors,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user