Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
628
src/server/routers/assignment.ts
Normal file
628
src/server/routers/assignment.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
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 }) => {
|
||||
return ctx.prisma.assignment.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
||||
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
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(
|
||||
(p) => p._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 projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// 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 projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// 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 }
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user