MOPC-App/src/server/routers/assignment.ts

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