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:
@@ -10,7 +10,7 @@ import { logAudit } from '@/server/utils/audit'
|
|||||||
import { createNotification } from '../services/in-app-notification'
|
import { createNotification } from '../services/in-app-notification'
|
||||||
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
||||||
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
||||||
import type { Prisma, RoundType } from '@prisma/client'
|
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
||||||
|
|
||||||
// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
|
// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
|
||||||
// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions,
|
// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions,
|
||||||
@@ -22,8 +22,7 @@ function generateInviteToken(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */
|
/** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
async function isProjectRejected(prisma: PrismaClient, projectId: string): Promise<boolean> {
|
||||||
async function isProjectRejected(prisma: any, projectId: string): Promise<boolean> {
|
|
||||||
const rejected = await prisma.projectRoundState.findFirst({
|
const rejected = await prisma.projectRoundState.findFirst({
|
||||||
where: { projectId, state: 'REJECTED' },
|
where: { projectId, state: 'REJECTED' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
138
src/server/routers/assignment/assignment-notifications.ts
Normal file
138
src/server/routers/assignment/assignment-notifications.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { router, adminProcedure } from '../../trpc'
|
||||||
|
import { createBulkNotifications, NotificationTypes } from '../../services/in-app-notification'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
|
export const assignmentNotificationsRouter = router({
|
||||||
|
/**
|
||||||
|
* Notify all jurors of their current assignments for a round (admin only).
|
||||||
|
* Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications).
|
||||||
|
*/
|
||||||
|
notifyJurorsOfAssignments: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all assignments grouped by user
|
||||||
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
return { sent: 0, jurorCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count assignments per user
|
||||||
|
const userCounts: Record<string, number> = {}
|
||||||
|
for (const a of assignments) {
|
||||||
|
userCounts[a.userId] = (userCounts[a.userId] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = round.windowCloseAt
|
||||||
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Create in-app notifications grouped by project count
|
||||||
|
const usersByProjectCount = new Map<number, string[]>()
|
||||||
|
for (const [userId, projectCount] of Object.entries(userCounts)) {
|
||||||
|
const existing = usersByProjectCount.get(projectCount) || []
|
||||||
|
existing.push(userId)
|
||||||
|
usersByProjectCount.set(projectCount, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSent = 0
|
||||||
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
||||||
|
if (userIds.length === 0) continue
|
||||||
|
await createBulkNotifications({
|
||||||
|
userIds,
|
||||||
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||||||
|
title: `${projectCount} Projects Assigned`,
|
||||||
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { projectCount, roundName: round.name, deadline },
|
||||||
|
})
|
||||||
|
totalSent += userIds.length
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
jurorCount: Object.keys(userCounts).length,
|
||||||
|
totalAssignments: assignments.length,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sent: totalSent, jurorCount: Object.keys(userCounts).length }
|
||||||
|
}),
|
||||||
|
|
||||||
|
notifySingleJurorOfAssignments: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), userId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId, userId: input.userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'No assignments found for this juror in this round' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCount = assignments.length
|
||||||
|
const deadline = round.windowCloseAt
|
||||||
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
await createBulkNotifications({
|
||||||
|
userIds: [input.userId],
|
||||||
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||||||
|
title: `${projectCount} Projects Assigned`,
|
||||||
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { projectCount, roundName: round.name, deadline },
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'NOTIFY_SINGLE_JUROR_OF_ASSIGNMENTS',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
targetUserId: input.userId,
|
||||||
|
assignmentCount: projectCount,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sent: 1, projectCount }
|
||||||
|
}),
|
||||||
|
})
|
||||||
1162
src/server/routers/assignment/assignment-redistribution.ts
Normal file
1162
src/server/routers/assignment/assignment-redistribution.ts
Normal file
File diff suppressed because it is too large
Load Diff
880
src/server/routers/assignment/assignment-suggestions.ts
Normal file
880
src/server/routers/assignment/assignment-suggestions.ts
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { router, adminProcedure, withAIRateLimit } from '../../trpc'
|
||||||
|
import {
|
||||||
|
generateAIAssignments,
|
||||||
|
type AssignmentProgressCallback,
|
||||||
|
} from '../../services/ai-assignment'
|
||||||
|
import { isOpenAIConfigured } from '@/lib/openai'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { notifyAdmins, NotificationTypes } from '../../services/in-app-notification'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
import { buildBatchNotifications } from './shared'
|
||||||
|
|
||||||
|
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||||
|
try {
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: 'RUNNING', startedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
configJson: true,
|
||||||
|
competitionId: true,
|
||||||
|
juryGroupId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
|
const minAssignmentsPerJuror =
|
||||||
|
(config.minLoadPerJuror as number) ??
|
||||||
|
(config.minAssignmentsPerJuror as number) ??
|
||||||
|
1
|
||||||
|
const maxAssignmentsPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// Scope jurors to jury group if the round has one assigned
|
||||||
|
let scopedJurorIds: string[] | undefined
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const groupMembers = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: round.juryGroupId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
scopedJurorIds = groupMembers.map((m) => m.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jurors = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
expertiseTags: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectRoundStates = await prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const projectIds = projectRoundStates.map((prs) => prs.projectId)
|
||||||
|
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
tags: true,
|
||||||
|
teamName: true,
|
||||||
|
projectTags: {
|
||||||
|
select: { tag: { select: { name: true } }, confidence: true },
|
||||||
|
},
|
||||||
|
_count: { select: { assignments: { where: { roundId } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enrich projects with tag confidence data for AI matching
|
||||||
|
const projectsWithConfidence = projects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
tagConfidences: p.projectTags.map((pt) => ({
|
||||||
|
name: pt.tag.name,
|
||||||
|
confidence: pt.confidence,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const existingAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Query COI records for this round to exclude conflicted juror-project pairs
|
||||||
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
|
where: {
|
||||||
|
assignment: { roundId },
|
||||||
|
hasConflict: true,
|
||||||
|
},
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const coiExclusions = new Set(
|
||||||
|
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate batch info
|
||||||
|
const BATCH_SIZE = 15
|
||||||
|
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
|
||||||
|
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { totalProjects: projects.length, totalBatches },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Progress callback
|
||||||
|
const onProgress: AssignmentProgressCallback = async (progress) => {
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
currentBatch: progress.currentBatch,
|
||||||
|
processedCount: progress.processedCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-juror limits map for jurors with personal maxAssignments
|
||||||
|
const jurorLimits: Record<string, number> = {}
|
||||||
|
for (const juror of jurors) {
|
||||||
|
if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
|
||||||
|
jurorLimits[juror.id] = juror.maxAssignments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
requiredReviewsPerProject: requiredReviews,
|
||||||
|
minAssignmentsPerJuror,
|
||||||
|
maxAssignmentsPerJuror,
|
||||||
|
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
|
||||||
|
existingAssignments: existingAssignments.map((a) => ({
|
||||||
|
jurorId: a.userId,
|
||||||
|
projectId: a.projectId,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateAIAssignments(
|
||||||
|
jurors,
|
||||||
|
projectsWithConfidence,
|
||||||
|
constraints,
|
||||||
|
userId,
|
||||||
|
roundId,
|
||||||
|
onProgress
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter out suggestions that conflict with COI declarations
|
||||||
|
const filteredSuggestions = coiExclusions.size > 0
|
||||||
|
? result.suggestions.filter((s) => !coiExclusions.has(`${s.jurorId}:${s.projectId}`))
|
||||||
|
: result.suggestions
|
||||||
|
|
||||||
|
// Enrich suggestions with names for storage
|
||||||
|
const enrichedSuggestions = filteredSuggestions.map((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',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark job as completed and store suggestions
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completedAt: new Date(),
|
||||||
|
processedCount: projects.length,
|
||||||
|
suggestionsCount: filteredSuggestions.length,
|
||||||
|
suggestionsJson: enrichedSuggestions,
|
||||||
|
fallbackUsed: result.fallbackUsed ?? false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
||||||
|
title: 'AI Assignment Suggestions Ready',
|
||||||
|
message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||||
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
|
linkLabel: 'View Suggestions',
|
||||||
|
priority: 'high',
|
||||||
|
metadata: {
|
||||||
|
roundId,
|
||||||
|
jobId,
|
||||||
|
projectCount: projects.length,
|
||||||
|
suggestionsCount: filteredSuggestions.length,
|
||||||
|
fallbackUsed: result.fallbackUsed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AI Assignment Job] Error:', error)
|
||||||
|
|
||||||
|
// Mark job as failed
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assignmentSuggestionsRouter = router({
|
||||||
|
/**
|
||||||
|
* Get smart assignment suggestions using algorithm
|
||||||
|
*/
|
||||||
|
getSuggestions: 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, juryGroupId: true },
|
||||||
|
})
|
||||||
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
|
const minAssignmentsPerJuror =
|
||||||
|
(config.minLoadPerJuror as number) ??
|
||||||
|
(config.minAssignmentsPerJuror as number) ??
|
||||||
|
1
|
||||||
|
const maxAssignmentsPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// Extract category quotas if enabled
|
||||||
|
const categoryQuotasEnabled = config.categoryQuotasEnabled === true
|
||||||
|
const categoryQuotas = categoryQuotasEnabled
|
||||||
|
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Scope jurors to jury group if the round has one assigned
|
||||||
|
let scopedJurorIds: string[] | undefined
|
||||||
|
if (stage.juryGroupId) {
|
||||||
|
const groupMembers = await ctx.prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: stage.juryGroupId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
scopedJurorIds = groupMembers.map((m) => m.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jurors = await ctx.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
expertiseTags: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId: input.roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
||||||
|
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
tags: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
projectTags: {
|
||||||
|
include: { tag: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build per-juror category distribution for quota scoring
|
||||||
|
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
|
||||||
|
if (categoryQuotas) {
|
||||||
|
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
project: { select: { competitionCategory: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (const a of assignmentsWithCategory) {
|
||||||
|
const cat = a.project.competitionCategory?.toLowerCase().trim()
|
||||||
|
if (!cat) continue
|
||||||
|
let catMap = jurorCategoryDistribution.get(a.userId)
|
||||||
|
if (!catMap) {
|
||||||
|
catMap = {}
|
||||||
|
jurorCategoryDistribution.set(a.userId, catMap)
|
||||||
|
}
|
||||||
|
catMap[cat] = (catMap[cat] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: Array<{
|
||||||
|
userId: string
|
||||||
|
jurorName: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
score: number
|
||||||
|
reasoning: string[]
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (project._count.assignments >= requiredReviews) continue
|
||||||
|
|
||||||
|
const neededAssignments = requiredReviews - project._count.assignments
|
||||||
|
|
||||||
|
const jurorScores = jurors
|
||||||
|
.filter((j) => {
|
||||||
|
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
|
||||||
|
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
if (j._count.assignments >= effectiveMax) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((juror) => {
|
||||||
|
const reasoning: string[] = []
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
const projectTagNames = project.projectTags.map((pt) => pt.tag.name.toLowerCase())
|
||||||
|
|
||||||
|
const matchingTags = projectTagNames.length > 0
|
||||||
|
? juror.expertiseTags.filter((tag) =>
|
||||||
|
projectTagNames.includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
: juror.expertiseTags.filter((tag) =>
|
||||||
|
project.tags.map((t) => t.toLowerCase()).includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalTags = projectTagNames.length > 0 ? projectTagNames.length : project.tags.length
|
||||||
|
const expertiseScore =
|
||||||
|
matchingTags.length > 0
|
||||||
|
? matchingTags.length / Math.max(totalTags, 1)
|
||||||
|
: 0
|
||||||
|
score += expertiseScore * 35
|
||||||
|
if (matchingTags.length > 0) {
|
||||||
|
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMax = juror.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
const loadScore = 1 - juror._count.assignments / effectiveMax
|
||||||
|
score += loadScore * 20
|
||||||
|
|
||||||
|
const underMinBonus =
|
||||||
|
juror._count.assignments < minAssignmentsPerJuror
|
||||||
|
? (minAssignmentsPerJuror - juror._count.assignments) * 3
|
||||||
|
: 0
|
||||||
|
score += Math.min(15, underMinBonus)
|
||||||
|
|
||||||
|
if (juror._count.assignments < minAssignmentsPerJuror) {
|
||||||
|
reasoning.push(
|
||||||
|
`Under target: ${juror._count.assignments}/${minAssignmentsPerJuror} min`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reasoning.push(
|
||||||
|
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Category quota scoring
|
||||||
|
if (categoryQuotas) {
|
||||||
|
const jurorCategoryCounts = jurorCategoryDistribution.get(juror.id) || {}
|
||||||
|
const normalizedCat = project.competitionCategory?.toLowerCase().trim()
|
||||||
|
if (normalizedCat) {
|
||||||
|
const quota = Object.entries(categoryQuotas).find(
|
||||||
|
([key]) => key.toLowerCase().trim() === normalizedCat
|
||||||
|
)
|
||||||
|
if (quota) {
|
||||||
|
const [, { min, max }] = quota
|
||||||
|
const currentCount = jurorCategoryCounts[normalizedCat] || 0
|
||||||
|
if (currentCount >= max) {
|
||||||
|
score -= 25
|
||||||
|
reasoning.push(`Category quota exceeded (-25)`)
|
||||||
|
} else if (currentCount < min) {
|
||||||
|
const otherAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
|
||||||
|
if (key.toLowerCase().trim() === normalizedCat) return false
|
||||||
|
return (jurorCategoryCounts[key.toLowerCase().trim()] || 0) >= q.min
|
||||||
|
})
|
||||||
|
if (otherAboveMin) {
|
||||||
|
score += 10
|
||||||
|
reasoning.push(`Category quota bonus (+10)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: juror.id,
|
||||||
|
jurorName: juror.name || juror.email || 'Unknown',
|
||||||
|
projectId: project.id,
|
||||||
|
projectTitle: project.title || 'Unknown',
|
||||||
|
score,
|
||||||
|
reasoning,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, neededAssignments)
|
||||||
|
|
||||||
|
suggestions.push(...jurorScores)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (retrieves from completed job)
|
||||||
|
*/
|
||||||
|
getAISuggestions: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
useAI: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const completedJob = await ctx.prisma.assignmentJob.findFirst({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
},
|
||||||
|
orderBy: { completedAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
suggestionsJson: true,
|
||||||
|
fallbackUsed: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (completedJob?.suggestionsJson) {
|
||||||
|
const suggestions = completedJob.suggestionsJson as Array<{
|
||||||
|
jurorId: string
|
||||||
|
jurorName: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
confidenceScore: number
|
||||||
|
expertiseMatchScore: number
|
||||||
|
reasoning: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredSuggestions = suggestions.filter(
|
||||||
|
(s) => !assignmentSet.has(`${s.jurorId}-${s.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
suggestions: filteredSuggestions,
|
||||||
|
fallbackUsed: completedJob.fallbackUsed,
|
||||||
|
error: null,
|
||||||
|
generatedAt: completedJob.completedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
suggestions: [],
|
||||||
|
fallbackUsed: false,
|
||||||
|
error: null,
|
||||||
|
generatedAt: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
forceOverride: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
let assignmentsToCreate = input.assignments
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
|
||||||
|
// Capacity check (unless forceOverride)
|
||||||
|
if (!input.forceOverride) {
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId: input.roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsToCreate = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ctx.prisma.assignment.createMany({
|
||||||
|
data: assignmentsToCreate.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,
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
detailsJson: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
count: created.count,
|
||||||
|
usedAI: input.usedAI,
|
||||||
|
forceOverride: input.forceOverride,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||||
|
(acc, a) => {
|
||||||
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
)
|
||||||
|
|
||||||
|
const stage = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
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: created.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
forceOverride: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
let assignmentsToCreate = input.assignments
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
|
||||||
|
// Capacity check (unless forceOverride)
|
||||||
|
if (!input.forceOverride) {
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId: input.roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsToCreate = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ctx.prisma.assignment.createMany({
|
||||||
|
data: assignmentsToCreate.map((a) => ({
|
||||||
|
userId: a.userId,
|
||||||
|
projectId: a.projectId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
method: 'ALGORITHM',
|
||||||
|
aiReasoning: a.reasoning,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'APPLY_SUGGESTIONS',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
detailsJson: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
count: created.count,
|
||||||
|
forceOverride: input.forceOverride,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||||
|
(acc, a) => {
|
||||||
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
)
|
||||||
|
|
||||||
|
const stage = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
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: created.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an AI assignment job (background processing)
|
||||||
|
*/
|
||||||
|
startAIAssignmentJob: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existingJob = await ctx.prisma.assignmentJob.findFirst({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
status: { in: ['PENDING', 'RUNNING'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingJob) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'An AI assignment job is already running for this stage',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpenAIConfigured()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'OpenAI API is not configured',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await ctx.prisma.assignmentJob.create({
|
||||||
|
data: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
|
||||||
|
|
||||||
|
return { jobId: job.id }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI assignment job status (for polling)
|
||||||
|
*/
|
||||||
|
getAIAssignmentJobStatus: adminProcedure
|
||||||
|
.input(z.object({ jobId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({
|
||||||
|
where: { id: input.jobId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
totalProjects: job.totalProjects,
|
||||||
|
totalBatches: job.totalBatches,
|
||||||
|
currentBatch: job.currentBatch,
|
||||||
|
processedCount: job.processedCount,
|
||||||
|
suggestionsCount: job.suggestionsCount,
|
||||||
|
fallbackUsed: job.fallbackUsed,
|
||||||
|
errorMessage: job.errorMessage,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest AI assignment job for a round
|
||||||
|
*/
|
||||||
|
getLatestAIAssignmentJob: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const job = await ctx.prisma.assignmentJob.findFirst({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!job) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
totalProjects: job.totalProjects,
|
||||||
|
totalBatches: job.totalBatches,
|
||||||
|
currentBatch: job.currentBatch,
|
||||||
|
processedCount: job.processedCount,
|
||||||
|
suggestionsCount: job.suggestionsCount,
|
||||||
|
fallbackUsed: job.fallbackUsed,
|
||||||
|
errorMessage: job.errorMessage,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
15
src/server/routers/assignment/index.ts
Normal file
15
src/server/routers/assignment/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { router } from '../../trpc'
|
||||||
|
import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../../services/juror-reassignment'
|
||||||
|
import { assignmentCrudRouter } from './assignment-crud'
|
||||||
|
import { assignmentSuggestionsRouter } from './assignment-suggestions'
|
||||||
|
import { assignmentNotificationsRouter } from './assignment-notifications'
|
||||||
|
import { assignmentRedistributionRouter } from './assignment-redistribution'
|
||||||
|
|
||||||
|
export { reassignAfterCOI, reassignDroppedJurorAssignments }
|
||||||
|
|
||||||
|
export const assignmentRouter = router({
|
||||||
|
...assignmentCrudRouter._def.procedures,
|
||||||
|
...assignmentSuggestionsRouter._def.procedures,
|
||||||
|
...assignmentNotificationsRouter._def.procedures,
|
||||||
|
...assignmentRedistributionRouter._def.procedures,
|
||||||
|
})
|
||||||
93
src/server/routers/assignment/shared.ts
Normal file
93
src/server/routers/assignment/shared.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
import { createBulkNotifications, NotificationTypes } from '../../services/in-app-notification'
|
||||||
|
|
||||||
|
/** Evaluation statuses that are safe to move (not yet finalized). */
|
||||||
|
export const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups a per-user assignment count map into batches by count, then sends
|
||||||
|
* BATCH_ASSIGNED notifications via createBulkNotifications.
|
||||||
|
*
|
||||||
|
* @param userAssignmentCounts - map of userId → number of newly-assigned projects
|
||||||
|
* @param stageName - display name of the round (for the notification message)
|
||||||
|
* @param deadline - formatted deadline string (optional)
|
||||||
|
*/
|
||||||
|
export async function buildBatchNotifications(
|
||||||
|
userAssignmentCounts: Record<string, number>,
|
||||||
|
stageName: string | null | undefined,
|
||||||
|
deadline: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const usersByProjectCount = new Map<number, string[]>()
|
||||||
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
||||||
|
const existing = usersByProjectCount.get(projectCount) || []
|
||||||
|
existing.push(userId)
|
||||||
|
usersByProjectCount.set(projectCount, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
||||||
|
if (userIds.length === 0) continue
|
||||||
|
await createBulkNotifications({
|
||||||
|
userIds,
|
||||||
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||||||
|
title: `${projectCount} Projects Assigned`,
|
||||||
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stageName || 'this stage'}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: {
|
||||||
|
projectCount,
|
||||||
|
roundName: stageName,
|
||||||
|
deadline,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CandidateJuror = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
maxAssignments: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the candidate juror pool for a round, scoped to the jury group if one
|
||||||
|
* is assigned, otherwise falling back to all active JURY_MEMBER users who have
|
||||||
|
* at least one assignment in the round.
|
||||||
|
*
|
||||||
|
* @param prisma - Prisma client (or transaction client)
|
||||||
|
* @param roundId - round being processed
|
||||||
|
* @param juryGroupId - optional jury group id from the round
|
||||||
|
* @param excludeUserId - userId to exclude from results (the source / dropped juror)
|
||||||
|
*/
|
||||||
|
export async function getCandidateJurors(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
roundId: string,
|
||||||
|
juryGroupId: string | null | undefined,
|
||||||
|
excludeUserId: string,
|
||||||
|
): Promise<CandidateJuror[]> {
|
||||||
|
if (juryGroupId) {
|
||||||
|
const members = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return members
|
||||||
|
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== excludeUserId)
|
||||||
|
.map((m) => m.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== excludeUserId)
|
||||||
|
|
||||||
|
if (ids.length === 0) return []
|
||||||
|
|
||||||
|
return prisma.user.findMany({
|
||||||
|
where: { id: { in: ids }, roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' },
|
||||||
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1343,7 +1343,7 @@ export const mentorRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return workspaceSendMessage(
|
return workspaceSendMessage(
|
||||||
{
|
{
|
||||||
mentorAssignmentId: input.mentorAssignmentId,
|
workspaceId: input.mentorAssignmentId,
|
||||||
senderId: ctx.user.id,
|
senderId: ctx.user.id,
|
||||||
message: input.message,
|
message: input.message,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
@@ -1389,7 +1389,7 @@ export const mentorRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return workspaceUploadFile(
|
return workspaceUploadFile(
|
||||||
{
|
{
|
||||||
mentorAssignmentId: input.mentorAssignmentId,
|
workspaceId: input.mentorAssignmentId,
|
||||||
uploadedByUserId: ctx.user.id,
|
uploadedByUserId: ctx.user.id,
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/open
|
|||||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||||
import { classifyAIError, logAIError } from './ai-errors'
|
import { classifyAIError, logAIError } from './ai-errors'
|
||||||
import { extractMultipleFileContents } from './file-content-extractor'
|
import { extractMultipleFileContents } from './file-content-extractor'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient, CompetitionCategory } from '@prisma/client'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -95,14 +95,14 @@ async function generateCategoryShortlist(
|
|||||||
rubric?: string
|
rubric?: string
|
||||||
aiParseFiles: boolean
|
aiParseFiles: boolean
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> {
|
): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> {
|
||||||
const { roundId, category, topN, rubric, aiParseFiles } = params
|
const { roundId, category, topN, rubric, aiParseFiles } = params
|
||||||
|
|
||||||
// Load projects with evaluations for this category
|
// Load projects with evaluations for this category
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
competitionCategory: category,
|
competitionCategory: category as CompetitionCategory,
|
||||||
assignments: { some: { roundId } },
|
assignments: { some: { roundId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -320,7 +320,7 @@ export async function generateShortlist(
|
|||||||
rubric?: string
|
rubric?: string
|
||||||
aiParseFiles?: boolean
|
aiParseFiles?: boolean
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<ShortlistResult> {
|
): Promise<ShortlistResult> {
|
||||||
const {
|
const {
|
||||||
roundId,
|
roundId,
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ type WorkspaceResult = { success: boolean; errors?: string[] }
|
|||||||
* Activate a mentor workspace for a given assignment.
|
* Activate a mentor workspace for a given assignment.
|
||||||
*/
|
*/
|
||||||
export async function activateWorkspace(
|
export async function activateWorkspace(
|
||||||
mentorAssignmentId: string,
|
workspaceId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WorkspaceResult> {
|
): Promise<WorkspaceResult> {
|
||||||
try {
|
try {
|
||||||
const assignment = await prisma.mentorAssignment.findUnique({
|
const assignment = await prisma.mentorAssignment.findUnique({
|
||||||
where: { id: mentorAssignmentId },
|
where: { id: workspaceId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
@@ -36,9 +36,9 @@ export async function activateWorkspace(
|
|||||||
return { success: false, errors: ['Workspace is already enabled'] }
|
return { success: false, errors: ['Workspace is already enabled'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.mentorAssignment.update({
|
await tx.mentorAssignment.update({
|
||||||
where: { id: mentorAssignmentId },
|
where: { id: workspaceId },
|
||||||
data: {
|
data: {
|
||||||
workspaceEnabled: true,
|
workspaceEnabled: true,
|
||||||
workspaceOpenAt: new Date(),
|
workspaceOpenAt: new Date(),
|
||||||
@@ -49,7 +49,7 @@ export async function activateWorkspace(
|
|||||||
data: {
|
data: {
|
||||||
eventType: 'mentor_workspace.activated',
|
eventType: 'mentor_workspace.activated',
|
||||||
entityType: 'MentorAssignment',
|
entityType: 'MentorAssignment',
|
||||||
entityId: mentorAssignmentId,
|
entityId: workspaceId,
|
||||||
actorId,
|
actorId,
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
projectId: assignment.projectId,
|
projectId: assignment.projectId,
|
||||||
@@ -64,7 +64,7 @@ export async function activateWorkspace(
|
|||||||
userId: actorId,
|
userId: actorId,
|
||||||
action: 'WORKSPACE_ACTIVATE',
|
action: 'WORKSPACE_ACTIVATE',
|
||||||
entityType: 'MentorAssignment',
|
entityType: 'MentorAssignment',
|
||||||
entityId: mentorAssignmentId,
|
entityId: workspaceId,
|
||||||
detailsJson: { projectId: assignment.projectId },
|
detailsJson: { projectId: assignment.projectId },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -86,15 +86,15 @@ export async function activateWorkspace(
|
|||||||
*/
|
*/
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
params: {
|
params: {
|
||||||
mentorAssignmentId: string
|
workspaceId: string
|
||||||
senderId: string
|
senderId: string
|
||||||
message: string
|
message: string
|
||||||
role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE'
|
role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE'
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const assignment = await prisma.mentorAssignment.findUnique({
|
const assignment = await prisma.mentorAssignment.findUnique({
|
||||||
where: { id: params.mentorAssignmentId },
|
where: { id: params.workspaceId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
@@ -107,11 +107,11 @@ export async function sendMessage(
|
|||||||
|
|
||||||
return prisma.mentorMessage.create({
|
return prisma.mentorMessage.create({
|
||||||
data: {
|
data: {
|
||||||
mentorAssignmentId: params.mentorAssignmentId,
|
workspaceId: params.workspaceId,
|
||||||
projectId: assignment.projectId,
|
projectId: assignment.projectId,
|
||||||
senderId: params.senderId,
|
senderId: params.senderId,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
role: params.role,
|
senderRole: params.role,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
sender: { select: { id: true, name: true, email: true } },
|
sender: { select: { id: true, name: true, email: true } },
|
||||||
@@ -123,11 +123,11 @@ export async function sendMessage(
|
|||||||
* Get messages for a workspace.
|
* Get messages for a workspace.
|
||||||
*/
|
*/
|
||||||
export async function getMessages(
|
export async function getMessages(
|
||||||
mentorAssignmentId: string,
|
workspaceId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.mentorMessage.findMany({
|
return prisma.mentorMessage.findMany({
|
||||||
where: { mentorAssignmentId },
|
where: { workspaceId },
|
||||||
include: {
|
include: {
|
||||||
sender: { select: { id: true, name: true, email: true, role: true } },
|
sender: { select: { id: true, name: true, email: true, role: true } },
|
||||||
},
|
},
|
||||||
@@ -140,7 +140,7 @@ export async function getMessages(
|
|||||||
*/
|
*/
|
||||||
export async function markRead(
|
export async function markRead(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await prisma.mentorMessage.update({
|
await prisma.mentorMessage.update({
|
||||||
where: { id: messageId },
|
where: { id: messageId },
|
||||||
@@ -155,7 +155,7 @@ export async function markRead(
|
|||||||
*/
|
*/
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
params: {
|
params: {
|
||||||
mentorAssignmentId: string
|
workspaceId: string
|
||||||
uploadedByUserId: string
|
uploadedByUserId: string
|
||||||
fileName: string
|
fileName: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
@@ -164,10 +164,10 @@ export async function uploadFile(
|
|||||||
objectKey: string
|
objectKey: string
|
||||||
description?: string
|
description?: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const assignment = await prisma.mentorAssignment.findUnique({
|
const assignment = await prisma.mentorAssignment.findUnique({
|
||||||
where: { id: params.mentorAssignmentId },
|
where: { id: params.workspaceId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
@@ -180,7 +180,7 @@ export async function uploadFile(
|
|||||||
|
|
||||||
return prisma.mentorFile.create({
|
return prisma.mentorFile.create({
|
||||||
data: {
|
data: {
|
||||||
mentorAssignmentId: params.mentorAssignmentId,
|
mentorAssignmentId: params.workspaceId,
|
||||||
uploadedByUserId: params.uploadedByUserId,
|
uploadedByUserId: params.uploadedByUserId,
|
||||||
fileName: params.fileName,
|
fileName: params.fileName,
|
||||||
mimeType: params.mimeType,
|
mimeType: params.mimeType,
|
||||||
@@ -205,7 +205,7 @@ export async function addFileComment(
|
|||||||
content: string
|
content: string
|
||||||
parentCommentId?: string
|
parentCommentId?: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.mentorFileComment.create({
|
return prisma.mentorFileComment.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -233,7 +233,7 @@ export async function promoteFile(
|
|||||||
slotKey: string
|
slotKey: string
|
||||||
promotedById: string
|
promotedById: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ success: boolean; errors?: string[] }> {
|
): Promise<{ success: boolean; errors?: string[] }> {
|
||||||
try {
|
try {
|
||||||
const file = await prisma.mentorFile.findUnique({
|
const file = await prisma.mentorFile.findUnique({
|
||||||
@@ -251,7 +251,7 @@ export async function promoteFile(
|
|||||||
return { success: false, errors: ['File is already promoted'] }
|
return { success: false, errors: ['File is already promoted'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Mark file as promoted
|
// Mark file as promoted
|
||||||
await tx.mentorFile.update({
|
await tx.mentorFile.update({
|
||||||
where: { id: params.mentorFileId },
|
where: { id: params.mentorFileId },
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function lockResults(
|
|||||||
lockedById: string
|
lockedById: string
|
||||||
resultSnapshot: unknown
|
resultSnapshot: unknown
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<LockResult> {
|
): Promise<LockResult> {
|
||||||
try {
|
try {
|
||||||
// Validate deliberation is finalized
|
// Validate deliberation is finalized
|
||||||
@@ -84,7 +84,7 @@ export async function lockResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lock = await prisma.$transaction(async (tx: any) => {
|
const lock = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const created = await tx.resultLock.create({
|
const created = await tx.resultLock.create({
|
||||||
data: {
|
data: {
|
||||||
competitionId: params.competitionId,
|
competitionId: params.competitionId,
|
||||||
@@ -109,7 +109,7 @@ export async function lockResults(
|
|||||||
snapshotJson: {
|
snapshotJson: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
emittedBy: 'result-lock',
|
emittedBy: 'result-lock',
|
||||||
resultSnapshot: params.resultSnapshot,
|
resultSnapshot: params.resultSnapshot as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -155,7 +155,7 @@ export async function unlockResults(
|
|||||||
unlockedById: string
|
unlockedById: string
|
||||||
reason: string
|
reason: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<UnlockResult> {
|
): Promise<UnlockResult> {
|
||||||
try {
|
try {
|
||||||
const lock = await prisma.resultLock.findUnique({
|
const lock = await prisma.resultLock.findUnique({
|
||||||
@@ -166,7 +166,7 @@ export async function unlockResults(
|
|||||||
return { success: false, errors: ['Result lock not found'] }
|
return { success: false, errors: ['Result lock not found'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await prisma.$transaction(async (tx: any) => {
|
const event = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const created = await tx.resultUnlockEvent.create({
|
const created = await tx.resultUnlockEvent.create({
|
||||||
data: {
|
data: {
|
||||||
resultLockId: params.resultLockId,
|
resultLockId: params.resultLockId,
|
||||||
@@ -226,7 +226,7 @@ export async function isLocked(
|
|||||||
competitionId: string,
|
competitionId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
category: CompetitionCategory,
|
category: CompetitionCategory,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<LockStatus> {
|
): Promise<LockStatus> {
|
||||||
const lock = await prisma.resultLock.findFirst({
|
const lock = await prisma.resultLock.findFirst({
|
||||||
where: { competitionId, roundId, category },
|
where: { competitionId, roundId, category },
|
||||||
@@ -265,7 +265,7 @@ export async function isLocked(
|
|||||||
*/
|
*/
|
||||||
export async function getLockHistory(
|
export async function getLockHistory(
|
||||||
competitionId: string,
|
competitionId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.resultLock.findMany({
|
return prisma.resultLock.findMany({
|
||||||
where: { competitionId },
|
where: { competitionId },
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
|
|||||||
export async function previewRoundAssignment(
|
export async function previewRoundAssignment(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
config?: { honorIntents?: boolean; requiredReviews?: number },
|
config?: { honorIntents?: boolean; requiredReviews?: number },
|
||||||
prisma?: PrismaClient | any,
|
prisma?: PrismaClient,
|
||||||
): Promise<AssignmentPreview> {
|
): Promise<AssignmentPreview> {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
const honorIntents = config?.honorIntents ?? true
|
const honorIntents = config?.honorIntents ?? true
|
||||||
@@ -390,7 +390,7 @@ export async function executeRoundAssignment(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
assignments: Array<{ userId: string; projectId: string }>,
|
assignments: Array<{ userId: string; projectId: string }>,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ created: number; errors: string[] }> {
|
): Promise<{ created: number; errors: string[] }> {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
@@ -398,7 +398,7 @@ export async function executeRoundAssignment(
|
|||||||
|
|
||||||
for (const assignment of assignments) {
|
for (const assignment of assignments) {
|
||||||
try {
|
try {
|
||||||
await db.$transaction(async (tx: any) => {
|
await db.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Create assignment record
|
// Create assignment record
|
||||||
await tx.assignment.create({
|
await tx.assignment.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -483,7 +483,7 @@ export async function executeRoundAssignment(
|
|||||||
export async function getRoundCoverageReport(
|
export async function getRoundCoverageReport(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
requiredReviews: number = 3,
|
requiredReviews: number = 3,
|
||||||
prisma?: PrismaClient | any,
|
prisma?: PrismaClient,
|
||||||
): Promise<CoverageReport> {
|
): Promise<CoverageReport> {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
|
|
||||||
@@ -558,7 +558,7 @@ export async function getRoundCoverageReport(
|
|||||||
export async function getUnassignedQueue(
|
export async function getUnassignedQueue(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
requiredReviews: number = 3,
|
requiredReviews: number = 3,
|
||||||
prisma?: PrismaClient | any,
|
prisma?: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
|||||||
export async function activateRound(
|
export async function activateRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult> {
|
): Promise<RoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
@@ -127,7 +127,7 @@ export async function activateRound(
|
|||||||
windowData.windowOpenAt = now
|
windowData.windowOpenAt = now
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx: any) => {
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const result = await tx.round.update({
|
const result = await tx.round.update({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
data: { status: 'ROUND_ACTIVE', ...windowData },
|
data: { status: 'ROUND_ACTIVE', ...windowData },
|
||||||
@@ -234,7 +234,7 @@ export async function activateRound(
|
|||||||
export async function closeRound(
|
export async function closeRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult> {
|
): Promise<RoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
@@ -267,7 +267,7 @@ export async function closeRound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx: any) => {
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const result = await tx.round.update({
|
const result = await tx.round.update({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
data: { status: 'ROUND_CLOSED' },
|
data: { status: 'ROUND_CLOSED' },
|
||||||
@@ -383,7 +383,7 @@ export async function closeRound(
|
|||||||
export async function archiveRound(
|
export async function archiveRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult> {
|
): Promise<RoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
||||||
@@ -399,7 +399,7 @@ export async function archiveRound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx: any) => {
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const result = await tx.round.update({
|
const result = await tx.round.update({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
data: { status: 'ROUND_ARCHIVED' },
|
data: { status: 'ROUND_ARCHIVED' },
|
||||||
@@ -456,7 +456,7 @@ export async function archiveRound(
|
|||||||
export async function reopenRound(
|
export async function reopenRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
|
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
@@ -475,7 +475,7 @@ export async function reopenRound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Pause any subsequent active rounds in the same competition
|
// Pause any subsequent active rounds in the same competition
|
||||||
const subsequentActiveRounds = await tx.round.findMany({
|
const subsequentActiveRounds = await tx.round.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -601,7 +601,7 @@ export async function transitionProject(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
newState: ProjectRoundStateValue,
|
newState: ProjectRoundStateValue,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
options?: { adminOverride?: boolean },
|
options?: { adminOverride?: boolean },
|
||||||
): Promise<ProjectRoundTransitionResult> {
|
): Promise<ProjectRoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
@@ -624,7 +624,7 @@ export async function transitionProject(
|
|||||||
return { success: false, errors: [`Project ${projectId} not found`] }
|
return { success: false, errors: [`Project ${projectId} not found`] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// Upsert ProjectRoundState
|
// Upsert ProjectRoundState
|
||||||
@@ -722,7 +722,7 @@ export async function batchTransitionProjects(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
newState: ProjectRoundStateValue,
|
newState: ProjectRoundStateValue,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
options?: { adminOverride?: boolean },
|
options?: { adminOverride?: boolean },
|
||||||
): Promise<BatchProjectTransitionResult> {
|
): Promise<BatchProjectTransitionResult> {
|
||||||
const succeeded: string[] = []
|
const succeeded: string[] = []
|
||||||
@@ -754,7 +754,7 @@ export async function batchTransitionProjects(
|
|||||||
|
|
||||||
export async function getProjectRoundStates(
|
export async function getProjectRoundStates(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const states = await prisma.projectRoundState.findMany({
|
const states = await prisma.projectRoundState.findMany({
|
||||||
where: { roundId },
|
where: { roundId },
|
||||||
@@ -803,7 +803,7 @@ export async function getProjectRoundStates(
|
|||||||
export async function getProjectRoundState(
|
export async function getProjectRoundState(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.projectRoundState.findUnique({
|
return prisma.projectRoundState.findUnique({
|
||||||
where: { projectId_roundId: { projectId, roundId } },
|
where: { projectId_roundId: { projectId, roundId } },
|
||||||
@@ -823,7 +823,7 @@ export async function checkRequirementsAndTransition(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||||
try {
|
try {
|
||||||
// Get all required FileRequirements for this round (legacy model)
|
// Get all required FileRequirements for this round (legacy model)
|
||||||
@@ -939,7 +939,7 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
projectIds: string[],
|
projectIds: string[],
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
||||||
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
||||||
|
|
||||||
@@ -1051,7 +1051,7 @@ export async function triggerInProgressOnActivity(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const prs = await prisma.projectRoundState.findUnique({
|
const prs = await prisma.projectRoundState.findUnique({
|
||||||
@@ -1078,7 +1078,7 @@ export async function checkEvaluationCompletionAndTransition(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitioned: boolean }> {
|
): Promise<{ transitioned: boolean }> {
|
||||||
try {
|
try {
|
||||||
const prs = await prisma.projectRoundState.findUnique({
|
const prs = await prisma.projectRoundState.findUnique({
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export type ConfirmFinalizationResult = {
|
|||||||
export async function processRoundClose(
|
export async function processRoundClose(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ processed: number }> {
|
): Promise<{ processed: number }> {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
@@ -305,7 +305,7 @@ export async function processRoundClose(
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
if (transitionUpdates.length > 0) {
|
if (transitionUpdates.length > 0) {
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Step through intermediate states in bulk
|
// Step through intermediate states in bulk
|
||||||
// PENDING → IN_PROGRESS for projects going to COMPLETED
|
// PENDING → IN_PROGRESS for projects going to COMPLETED
|
||||||
const pendingToCompleted = transitionUpdates.filter(
|
const pendingToCompleted = transitionUpdates.filter(
|
||||||
@@ -402,7 +402,7 @@ export async function processRoundClose(
|
|||||||
|
|
||||||
export async function getFinalizationSummary(
|
export async function getFinalizationSummary(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<FinalizationSummary> {
|
): Promise<FinalizationSummary> {
|
||||||
const round = await prisma.round.findUniqueOrThrow({
|
const round = await prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
@@ -568,7 +568,7 @@ export async function confirmFinalization(
|
|||||||
rejectionMessage?: string
|
rejectionMessage?: string
|
||||||
},
|
},
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<ConfirmFinalizationResult> {
|
): Promise<ConfirmFinalizationResult> {
|
||||||
// Validate: round is CLOSED, not already finalized, grace period expired
|
// Validate: round is CLOSED, not already finalized, grace period expired
|
||||||
const round = await prisma.round.findUniqueOrThrow({
|
const round = await prisma.round.findUniqueOrThrow({
|
||||||
@@ -612,7 +612,7 @@ export async function confirmFinalization(
|
|||||||
: 'Next Round'
|
: 'Next Round'
|
||||||
|
|
||||||
// Execute finalization in a transaction
|
// Execute finalization in a transaction
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const projectStates = await tx.projectRoundState.findMany({
|
const projectStates = await tx.projectRoundState.findMany({
|
||||||
where: { roundId, proposedOutcome: { not: null } },
|
where: { roundId, proposedOutcome: { not: null } },
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type SubmissionValidationResult = {
|
|||||||
export async function openWindow(
|
export async function openWindow(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WindowLifecycleResult> {
|
): Promise<WindowLifecycleResult> {
|
||||||
try {
|
try {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
@@ -47,7 +47,7 @@ export async function openWindow(
|
|||||||
return { success: false, errors: ['Cannot open a locked window'] }
|
return { success: false, errors: ['Cannot open a locked window'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.submissionWindow.update({
|
await tx.submissionWindow.update({
|
||||||
where: { id: windowId },
|
where: { id: windowId },
|
||||||
data: {
|
data: {
|
||||||
@@ -93,7 +93,7 @@ export async function openWindow(
|
|||||||
export async function closeWindow(
|
export async function closeWindow(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WindowLifecycleResult> {
|
): Promise<WindowLifecycleResult> {
|
||||||
try {
|
try {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
@@ -102,7 +102,7 @@ export async function closeWindow(
|
|||||||
return { success: false, errors: [`Submission window ${windowId} not found`] }
|
return { success: false, errors: [`Submission window ${windowId} not found`] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = {
|
||||||
windowCloseAt: new Date(),
|
windowCloseAt: new Date(),
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ export async function closeWindow(
|
|||||||
export async function lockWindow(
|
export async function lockWindow(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WindowLifecycleResult> {
|
): Promise<WindowLifecycleResult> {
|
||||||
try {
|
try {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
@@ -168,7 +168,7 @@ export async function lockWindow(
|
|||||||
return { success: false, errors: ['Window is already locked'] }
|
return { success: false, errors: ['Window is already locked'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.submissionWindow.update({
|
await tx.submissionWindow.update({
|
||||||
where: { id: windowId },
|
where: { id: windowId },
|
||||||
data: { isLocked: true },
|
data: { isLocked: true },
|
||||||
@@ -212,7 +212,7 @@ export async function lockWindow(
|
|||||||
*/
|
*/
|
||||||
export async function checkDeadlinePolicy(
|
export async function checkDeadlinePolicy(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<DeadlineStatus> {
|
): Promise<DeadlineStatus> {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ export async function validateSubmission(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
windowId: string,
|
windowId: string,
|
||||||
files: Array<{ mimeType: string; size: number; requirementId?: string }>,
|
files: Array<{ mimeType: string; size: number; requirementId?: string }>,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<SubmissionValidationResult> {
|
): Promise<SubmissionValidationResult> {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ export async function validateSubmission(
|
|||||||
*/
|
*/
|
||||||
export async function isWindowReadOnly(
|
export async function isWindowReadOnly(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const status = await checkDeadlinePolicy(windowId, prisma)
|
const status = await checkDeadlinePolicy(windowId, prisma)
|
||||||
return status.status === 'LOCKED' || status.status === 'CLOSED'
|
return status.status === 'LOCKED' || status.status === 'CLOSED'
|
||||||
@@ -340,7 +340,7 @@ export async function isWindowReadOnly(
|
|||||||
*/
|
*/
|
||||||
export async function getVisibleWindows(
|
export async function getVisibleWindows(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const visibility = await prisma.roundSubmissionVisibility.findMany({
|
const visibility = await prisma.roundSubmissionVisibility.findMany({
|
||||||
where: { roundId, canView: true },
|
where: { roundId, canView: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user