655 lines
18 KiB
TypeScript
655 lines
18 KiB
TypeScript
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
|
import {
|
|
generateAIAssignments,
|
|
generateFallbackAssignments,
|
|
} from '../services/ai-assignment'
|
|
import { isOpenAIConfigured } from '@/lib/openai'
|
|
|
|
export const assignmentRouter = router({
|
|
/**
|
|
* List assignments for a round (admin only)
|
|
*/
|
|
listByRound: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
|
project: { select: { id: true, title: true, tags: true } },
|
|
evaluation: { select: { status: true, submittedAt: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* List assignments for a project (admin only)
|
|
*/
|
|
listByProject: adminProcedure
|
|
.input(z.object({ projectId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
where: { projectId: input.projectId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
|
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
|
|
// Attach avatar URLs
|
|
return Promise.all(
|
|
assignments.map(async (a) => ({
|
|
...a,
|
|
user: {
|
|
...a.user,
|
|
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
|
|
},
|
|
}))
|
|
)
|
|
}),
|
|
|
|
/**
|
|
* Get my assignments (for jury members)
|
|
*/
|
|
myAssignments: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string().optional(),
|
|
status: z.enum(['all', 'pending', 'completed']).default('all'),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const where: Record<string, unknown> = {
|
|
userId: ctx.user.id,
|
|
round: { status: 'ACTIVE' },
|
|
}
|
|
|
|
if (input.roundId) {
|
|
where.roundId = input.roundId
|
|
}
|
|
|
|
if (input.status === 'pending') {
|
|
where.isCompleted = false
|
|
} else if (input.status === 'completed') {
|
|
where.isCompleted = true
|
|
}
|
|
|
|
return ctx.prisma.assignment.findMany({
|
|
where,
|
|
include: {
|
|
project: {
|
|
include: { files: true },
|
|
},
|
|
round: true,
|
|
evaluation: true,
|
|
},
|
|
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Get assignment by ID
|
|
*/
|
|
get: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
project: { include: { files: true } },
|
|
round: { include: { evaluationForms: { where: { isActive: true } } } },
|
|
evaluation: true,
|
|
},
|
|
})
|
|
|
|
// Verify access
|
|
if (
|
|
ctx.user.role === 'JURY_MEMBER' &&
|
|
assignment.userId !== ctx.user.id
|
|
) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You do not have access to this assignment',
|
|
})
|
|
}
|
|
|
|
return assignment
|
|
}),
|
|
|
|
/**
|
|
* Create a single assignment (admin only)
|
|
*/
|
|
create: adminProcedure
|
|
.input(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
isRequired: z.boolean().default(true),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Check if assignment already exists
|
|
const existing = await ctx.prisma.assignment.findUnique({
|
|
where: {
|
|
userId_projectId_roundId: {
|
|
userId: input.userId,
|
|
projectId: input.projectId,
|
|
roundId: input.roundId,
|
|
},
|
|
},
|
|
})
|
|
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'This assignment already exists',
|
|
})
|
|
}
|
|
|
|
// Check user's assignment limit
|
|
const user = await ctx.prisma.user.findUniqueOrThrow({
|
|
where: { id: input.userId },
|
|
select: { maxAssignments: true },
|
|
})
|
|
|
|
if (user.maxAssignments !== null) {
|
|
const currentCount = await ctx.prisma.assignment.count({
|
|
where: { userId: input.userId, roundId: input.roundId },
|
|
})
|
|
|
|
if (currentCount >= user.maxAssignments) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `User has reached their maximum assignment limit of ${user.maxAssignments}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
const assignment = await ctx.prisma.assignment.create({
|
|
data: {
|
|
...input,
|
|
method: 'MANUAL',
|
|
createdBy: ctx.user.id,
|
|
},
|
|
})
|
|
|
|
// Audit log
|
|
await ctx.prisma.auditLog.create({
|
|
data: {
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'Assignment',
|
|
entityId: assignment.id,
|
|
detailsJson: input,
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
},
|
|
})
|
|
|
|
return assignment
|
|
}),
|
|
|
|
/**
|
|
* Bulk create assignments (admin only)
|
|
*/
|
|
bulkCreate: adminProcedure
|
|
.input(
|
|
z.object({
|
|
assignments: z.array(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
roundId: z.string(),
|
|
})
|
|
),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await ctx.prisma.assignment.createMany({
|
|
data: input.assignments.map((a) => ({
|
|
...a,
|
|
method: 'BULK',
|
|
createdBy: ctx.user.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
|
|
// Audit log
|
|
await ctx.prisma.auditLog.create({
|
|
data: {
|
|
userId: ctx.user.id,
|
|
action: 'BULK_CREATE',
|
|
entityType: 'Assignment',
|
|
detailsJson: { count: result.count },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
},
|
|
})
|
|
|
|
return { created: result.count }
|
|
}),
|
|
|
|
/**
|
|
* Delete an assignment (admin only)
|
|
*/
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const assignment = await ctx.prisma.assignment.delete({
|
|
where: { id: input.id },
|
|
})
|
|
|
|
// Audit log
|
|
await ctx.prisma.auditLog.create({
|
|
data: {
|
|
userId: ctx.user.id,
|
|
action: 'DELETE',
|
|
entityType: 'Assignment',
|
|
entityId: input.id,
|
|
detailsJson: {
|
|
userId: assignment.userId,
|
|
projectId: assignment.projectId,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
},
|
|
})
|
|
|
|
return assignment
|
|
}),
|
|
|
|
/**
|
|
* Get assignment statistics for a round
|
|
*/
|
|
getStats: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const [
|
|
totalAssignments,
|
|
completedAssignments,
|
|
assignmentsByUser,
|
|
projectCoverage,
|
|
] = await Promise.all([
|
|
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
|
ctx.prisma.assignment.count({
|
|
where: { roundId: input.roundId, isCompleted: true },
|
|
}),
|
|
ctx.prisma.assignment.groupBy({
|
|
by: ['userId'],
|
|
where: { roundId: input.roundId },
|
|
_count: true,
|
|
}),
|
|
ctx.prisma.roundProject.findMany({
|
|
where: { roundId: input.roundId },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
_count: { select: { assignments: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
])
|
|
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { requiredReviews: true },
|
|
})
|
|
|
|
const projectsWithFullCoverage = projectCoverage.filter(
|
|
(rp) => rp.project._count.assignments >= round.requiredReviews
|
|
).length
|
|
|
|
return {
|
|
totalAssignments,
|
|
completedAssignments,
|
|
completionPercentage:
|
|
totalAssignments > 0
|
|
? Math.round((completedAssignments / totalAssignments) * 100)
|
|
: 0,
|
|
juryMembersAssigned: assignmentsByUser.length,
|
|
projectsWithFullCoverage,
|
|
totalProjects: projectCoverage.length,
|
|
coveragePercentage:
|
|
projectCoverage.length > 0
|
|
? Math.round(
|
|
(projectsWithFullCoverage / projectCoverage.length) * 100
|
|
)
|
|
: 0,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get smart assignment suggestions using algorithm
|
|
*/
|
|
getSuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
maxPerJuror: z.number().int().min(1).max(50).default(10),
|
|
minPerProject: z.number().int().min(1).max(10).default(3),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
// Get all active jury members with their expertise and current load
|
|
const jurors = await ctx.prisma.user.findMany({
|
|
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
expertiseTags: true,
|
|
maxAssignments: true,
|
|
_count: {
|
|
select: {
|
|
assignments: { where: { roundId: input.roundId } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Get all projects that need more assignments
|
|
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
|
where: { roundId: input.roundId },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
tags: true,
|
|
_count: { select: { assignments: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const projects = roundProjectEntries.map((rp) => rp.project)
|
|
|
|
// Get existing assignments to avoid duplicates
|
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
const assignmentSet = new Set(
|
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
|
)
|
|
|
|
// Simple scoring algorithm
|
|
const suggestions: Array<{
|
|
userId: string
|
|
projectId: string
|
|
score: number
|
|
reasoning: string[]
|
|
}> = []
|
|
|
|
for (const project of projects) {
|
|
// Skip if project has enough assignments
|
|
if (project._count.assignments >= input.minPerProject) continue
|
|
|
|
const neededAssignments = input.minPerProject - project._count.assignments
|
|
|
|
// Score each juror for this project
|
|
const jurorScores = jurors
|
|
.filter((j) => {
|
|
// Skip if already assigned
|
|
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
|
|
// Skip if at max capacity
|
|
const maxAllowed = j.maxAssignments ?? input.maxPerJuror
|
|
if (j._count.assignments >= maxAllowed) return false
|
|
return true
|
|
})
|
|
.map((juror) => {
|
|
const reasoning: string[] = []
|
|
let score = 0
|
|
|
|
// Expertise match (40% weight)
|
|
const matchingTags = juror.expertiseTags.filter((tag) =>
|
|
project.tags.includes(tag)
|
|
)
|
|
const expertiseScore =
|
|
matchingTags.length > 0
|
|
? matchingTags.length / Math.max(project.tags.length, 1)
|
|
: 0
|
|
score += expertiseScore * 40
|
|
if (matchingTags.length > 0) {
|
|
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
|
|
}
|
|
|
|
// Load balancing (25% weight)
|
|
const maxAllowed = juror.maxAssignments ?? input.maxPerJuror
|
|
const loadScore = 1 - juror._count.assignments / maxAllowed
|
|
score += loadScore * 25
|
|
reasoning.push(
|
|
`Workload: ${juror._count.assignments}/${maxAllowed} assigned`
|
|
)
|
|
|
|
return {
|
|
userId: juror.id,
|
|
projectId: project.id,
|
|
score,
|
|
reasoning,
|
|
}
|
|
})
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, neededAssignments)
|
|
|
|
suggestions.push(...jurorScores)
|
|
}
|
|
|
|
// Sort by score and return
|
|
return suggestions.sort((a, b) => b.score - a.score)
|
|
}),
|
|
|
|
/**
|
|
* Check if AI assignment is available
|
|
*/
|
|
isAIAvailable: adminProcedure.query(async () => {
|
|
return isOpenAIConfigured()
|
|
}),
|
|
|
|
/**
|
|
* Get AI-powered assignment suggestions
|
|
*/
|
|
getAISuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
useAI: z.boolean().default(true),
|
|
maxPerJuror: z.number().int().min(1).max(50).default(10),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
// Get round info
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { requiredReviews: true },
|
|
})
|
|
|
|
// Get all active jury members with their expertise and current load
|
|
const jurors = await ctx.prisma.user.findMany({
|
|
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
expertiseTags: true,
|
|
maxAssignments: true,
|
|
_count: {
|
|
select: {
|
|
assignments: { where: { roundId: input.roundId } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Get all projects in the round
|
|
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
|
where: { roundId: input.roundId },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
description: true,
|
|
tags: true,
|
|
teamName: true,
|
|
_count: { select: { assignments: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const projects = roundProjectEntries.map((rp) => rp.project)
|
|
|
|
// Get existing assignments
|
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
|
where: { roundId: input.roundId },
|
|
select: { userId: true, projectId: true },
|
|
})
|
|
|
|
const constraints = {
|
|
requiredReviewsPerProject: round.requiredReviews,
|
|
maxAssignmentsPerJuror: input.maxPerJuror,
|
|
existingAssignments: existingAssignments.map((a) => ({
|
|
jurorId: a.userId,
|
|
projectId: a.projectId,
|
|
})),
|
|
}
|
|
|
|
// Use AI or fallback based on input and availability
|
|
let result
|
|
if (input.useAI) {
|
|
result = await generateAIAssignments(jurors, projects, constraints)
|
|
} else {
|
|
result = generateFallbackAssignments(jurors, projects, constraints)
|
|
}
|
|
|
|
// Enrich suggestions with user and project names for display
|
|
const enrichedSuggestions = await Promise.all(
|
|
result.suggestions.map(async (s) => {
|
|
const juror = jurors.find((j) => j.id === s.jurorId)
|
|
const project = projects.find((p) => p.id === s.projectId)
|
|
return {
|
|
...s,
|
|
jurorName: juror?.name || juror?.email || 'Unknown',
|
|
projectTitle: project?.title || 'Unknown',
|
|
}
|
|
})
|
|
)
|
|
|
|
return {
|
|
success: result.success,
|
|
suggestions: enrichedSuggestions,
|
|
fallbackUsed: result.fallbackUsed,
|
|
error: result.error,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Apply AI-suggested assignments
|
|
*/
|
|
applyAISuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
assignments: z.array(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
confidenceScore: z.number().optional(),
|
|
expertiseMatchScore: z.number().optional(),
|
|
reasoning: z.string().optional(),
|
|
})
|
|
),
|
|
usedAI: z.boolean().default(false),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const created = await ctx.prisma.assignment.createMany({
|
|
data: input.assignments.map((a) => ({
|
|
userId: a.userId,
|
|
projectId: a.projectId,
|
|
roundId: input.roundId,
|
|
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
|
|
aiConfidenceScore: a.confidenceScore,
|
|
expertiseMatchScore: a.expertiseMatchScore,
|
|
aiReasoning: a.reasoning,
|
|
createdBy: ctx.user.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
|
|
// Audit log
|
|
await ctx.prisma.auditLog.create({
|
|
data: {
|
|
userId: ctx.user.id,
|
|
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
|
entityType: 'Assignment',
|
|
detailsJson: {
|
|
roundId: input.roundId,
|
|
count: created.count,
|
|
usedAI: input.usedAI,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
},
|
|
})
|
|
|
|
return { created: created.count }
|
|
}),
|
|
|
|
/**
|
|
* Apply suggested assignments
|
|
*/
|
|
applySuggestions: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
assignments: z.array(
|
|
z.object({
|
|
userId: z.string(),
|
|
projectId: z.string(),
|
|
reasoning: z.string().optional(),
|
|
})
|
|
),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const created = await ctx.prisma.assignment.createMany({
|
|
data: input.assignments.map((a) => ({
|
|
userId: a.userId,
|
|
projectId: a.projectId,
|
|
roundId: input.roundId,
|
|
method: 'ALGORITHM',
|
|
aiReasoning: a.reasoning,
|
|
createdBy: ctx.user.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
|
|
// Audit log
|
|
await ctx.prisma.auditLog.create({
|
|
data: {
|
|
userId: ctx.user.id,
|
|
action: 'APPLY_SUGGESTIONS',
|
|
entityType: 'Assignment',
|
|
detailsJson: {
|
|
roundId: input.roundId,
|
|
count: created.count,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
},
|
|
})
|
|
|
|
return { created: created.count }
|
|
}),
|
|
})
|