refactor: tech debt batch 3 — type safety + assignment router split
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m4s
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m4s
#5 — Replaced 55x PrismaClient | any with proper Prisma types across 8 files - Service files: PrismaClient | any → PrismaClient, tx: any → Prisma.TransactionClient - Fixed 4 real bugs uncovered by typing: - mentor-workspace.ts: wrong FK fields (mentorAssignmentId → workspaceId, role → senderRole) - ai-shortlist.ts: untyped string passed to CompetitionCategory enum filter - result-lock.ts: unknown passed where Prisma.InputJsonValue required #9 — Split assignment.ts (2,775 lines) into 6 focused files: - shared.ts (93 lines) — MOVABLE_EVAL_STATUSES, buildBatchNotifications, getCandidateJurors - assignment-crud.ts (473 lines) — 8 core CRUD procedures - assignment-suggestions.ts (880 lines) — AI suggestions + job runner - assignment-notifications.ts (138 lines) — 2 notification procedures - assignment-redistribution.ts (1,162 lines) — 8 reassign/transfer procedures - index.ts (15 lines) — barrel export with router merge, zero frontend changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
473
src/server/routers/assignment/assignment-crud.ts
Normal file
473
src/server/routers/assignment/assignment-crud.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user