474 lines
14 KiB
TypeScript
474 lines
14 KiB
TypeScript
|
|
import { z } from 'zod'
|
||
|
|
import { TRPCError } from '@trpc/server'
|
||
|
|
import { router, protectedProcedure, adminProcedure, userHasRole } from '../../trpc'
|
||
|
|
import { getUserAvatarUrl } from '../../utils/avatar-url'
|
||
|
|
import { createNotification, NotificationTypes } from '../../services/in-app-notification'
|
||
|
|
import { logAudit } from '@/server/utils/audit'
|
||
|
|
import { buildBatchNotifications } from './shared'
|
||
|
|
|
||
|
|
export const assignmentCrudRouter = router({
|
||
|
|
listByStage: 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,
|
||
|
|
criterionScoresJson: true,
|
||
|
|
form: { select: { criteriaJson: true } },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: 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,
|
||
|
|
}
|
||
|
|
|
||
|
|
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 (
|
||
|
|
userHasRole(ctx.user, 'JURY_MEMBER') &&
|
||
|
|
!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') &&
|
||
|
|
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),
|
||
|
|
forceOverride: z.boolean().default(false),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
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',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const [stage, user] = await Promise.all([
|
||
|
|
ctx.prisma.round.findUniqueOrThrow({
|
||
|
|
where: { id: input.roundId },
|
||
|
|
select: { configJson: true },
|
||
|
|
}),
|
||
|
|
ctx.prisma.user.findUniqueOrThrow({
|
||
|
|
where: { id: input.userId },
|
||
|
|
select: { maxAssignments: true, name: true },
|
||
|
|
}),
|
||
|
|
])
|
||
|
|
|
||
|
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||
|
|
const maxAssignmentsPerJuror =
|
||
|
|
(config.maxLoadPerJuror as number) ??
|
||
|
|
(config.maxAssignmentsPerJuror as number) ??
|
||
|
|
20
|
||
|
|
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
|
||
|
|
|
||
|
|
const currentCount = await ctx.prisma.assignment.count({
|
||
|
|
where: { userId: input.userId, roundId: input.roundId },
|
||
|
|
})
|
||
|
|
|
||
|
|
// Check if at or over limit
|
||
|
|
if (currentCount >= effectiveMax) {
|
||
|
|
if (!input.forceOverride) {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'BAD_REQUEST',
|
||
|
|
message: `${user.name || 'Judge'} has reached their maximum limit of ${effectiveMax} projects. Use manual override to proceed.`,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
// Log the override in audit
|
||
|
|
console.log(`[Assignment] Manual override: Assigning ${user.name} beyond limit (${currentCount}/${effectiveMax})`)
|
||
|
|
}
|
||
|
|
|
||
|
|
const { forceOverride: _override, ...assignmentData } = input
|
||
|
|
const assignment = await ctx.prisma.assignment.create({
|
||
|
|
data: {
|
||
|
|
...assignmentData,
|
||
|
|
method: 'MANUAL',
|
||
|
|
createdBy: ctx.user.id,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await logAudit({
|
||
|
|
prisma: ctx.prisma,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'CREATE',
|
||
|
|
entityType: 'Assignment',
|
||
|
|
entityId: assignment.id,
|
||
|
|
detailsJson: input,
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
const [project, stageInfo] = await Promise.all([
|
||
|
|
ctx.prisma.project.findUnique({
|
||
|
|
where: { id: input.projectId },
|
||
|
|
select: { title: true },
|
||
|
|
}),
|
||
|
|
ctx.prisma.round.findUnique({
|
||
|
|
where: { id: input.roundId },
|
||
|
|
select: { name: true, windowCloseAt: true },
|
||
|
|
}),
|
||
|
|
])
|
||
|
|
|
||
|
|
if (project && stageInfo) {
|
||
|
|
const deadline = stageInfo.windowCloseAt
|
||
|
|
? new Date(stageInfo.windowCloseAt).toLocaleDateString('en-US', {
|
||
|
|
weekday: 'long',
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric',
|
||
|
|
})
|
||
|
|
: undefined
|
||
|
|
|
||
|
|
await createNotification({
|
||
|
|
userId: input.userId,
|
||
|
|
type: NotificationTypes.ASSIGNED_TO_PROJECT,
|
||
|
|
title: 'New Project Assignment',
|
||
|
|
message: `You have been assigned to evaluate "${project.title}" for ${stageInfo.name}.`,
|
||
|
|
linkUrl: `/jury/competitions`,
|
||
|
|
linkLabel: 'View Assignment',
|
||
|
|
metadata: {
|
||
|
|
projectName: project.title,
|
||
|
|
roundName: stageInfo.name,
|
||
|
|
deadline,
|
||
|
|
assignmentId: assignment.id,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return assignment
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Bulk create assignments (admin only)
|
||
|
|
*/
|
||
|
|
bulkCreate: adminProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
roundId: z.string(),
|
||
|
|
assignments: z.array(
|
||
|
|
z.object({
|
||
|
|
userId: z.string(),
|
||
|
|
projectId: z.string(),
|
||
|
|
})
|
||
|
|
),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
// Fetch per-juror maxAssignments and current counts for capacity checking
|
||
|
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||
|
|
const users = await ctx.prisma.user.findMany({
|
||
|
|
where: { id: { in: uniqueUserIds } },
|
||
|
|
select: {
|
||
|
|
id: true,
|
||
|
|
name: true,
|
||
|
|
maxAssignments: true,
|
||
|
|
_count: {
|
||
|
|
select: {
|
||
|
|
assignments: { where: { roundId: input.roundId } },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||
|
|
|
||
|
|
// Get stage default max
|
||
|
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||
|
|
where: { id: input.roundId },
|
||
|
|
select: { configJson: true, name: true, windowCloseAt: true },
|
||
|
|
})
|
||
|
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||
|
|
const stageMaxPerJuror =
|
||
|
|
(config.maxLoadPerJuror as number) ??
|
||
|
|
(config.maxAssignmentsPerJuror as number) ??
|
||
|
|
20
|
||
|
|
|
||
|
|
// Track running counts to handle multiple assignments to the same juror in one batch
|
||
|
|
const runningCounts = new Map<string, number>()
|
||
|
|
for (const u of users) {
|
||
|
|
runningCounts.set(u.id, u._count.assignments)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter out assignments that would exceed a juror's limit
|
||
|
|
let skippedDueToCapacity = 0
|
||
|
|
const allowedAssignments = input.assignments.filter((a) => {
|
||
|
|
const user = userMap.get(a.userId)
|
||
|
|
if (!user) return true // unknown user, let createMany handle it
|
||
|
|
|
||
|
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||
|
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||
|
|
|
||
|
|
if (currentCount >= effectiveMax) {
|
||
|
|
skippedDueToCapacity++
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Increment running count for subsequent assignments to same user
|
||
|
|
runningCounts.set(a.userId, currentCount + 1)
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
|
||
|
|
const result = await ctx.prisma.assignment.createMany({
|
||
|
|
data: allowedAssignments.map((a) => ({
|
||
|
|
...a,
|
||
|
|
roundId: input.roundId,
|
||
|
|
method: 'BULK',
|
||
|
|
createdBy: ctx.user.id,
|
||
|
|
})),
|
||
|
|
skipDuplicates: true,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await logAudit({
|
||
|
|
prisma: ctx.prisma,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'BULK_CREATE',
|
||
|
|
entityType: 'Assignment',
|
||
|
|
detailsJson: {
|
||
|
|
count: result.count,
|
||
|
|
requested: input.assignments.length,
|
||
|
|
skippedDueToCapacity,
|
||
|
|
},
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Send notifications to assigned jury members (grouped by user)
|
||
|
|
if (result.count > 0 && allowedAssignments.length > 0) {
|
||
|
|
// Group assignments by user to get counts
|
||
|
|
const userAssignmentCounts = allowedAssignments.reduce(
|
||
|
|
(acc, a) => {
|
||
|
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||
|
|
return acc
|
||
|
|
},
|
||
|
|
{} as Record<string, number>
|
||
|
|
)
|
||
|
|
|
||
|
|
const deadline = stage?.windowCloseAt
|
||
|
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
||
|
|
weekday: 'long',
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric',
|
||
|
|
})
|
||
|
|
: undefined
|
||
|
|
|
||
|
|
await buildBatchNotifications(userAssignmentCounts, stage?.name, deadline)
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
created: result.count,
|
||
|
|
requested: input.assignments.length,
|
||
|
|
skipped: input.assignments.length - result.count,
|
||
|
|
skippedDueToCapacity,
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 logAudit({
|
||
|
|
prisma: ctx.prisma,
|
||
|
|
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 stage = await ctx.prisma.round.findUniqueOrThrow({
|
||
|
|
where: { id: input.roundId },
|
||
|
|
select: { configJson: true },
|
||
|
|
})
|
||
|
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||
|
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||
|
|
|
||
|
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||
|
|
where: { roundId: input.roundId },
|
||
|
|
select: { projectId: true },
|
||
|
|
})
|
||
|
|
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
||
|
|
|
||
|
|
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: { id: { in: projectIds } },
|
||
|
|
select: {
|
||
|
|
id: true,
|
||
|
|
title: true,
|
||
|
|
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
])
|
||
|
|
|
||
|
|
const projectsWithFullCoverage = projectCoverage.filter(
|
||
|
|
(p) => p._count.assignments >= 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,
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
})
|