Add juror quick actions to Members section, redistribute button, dropout emails, and transfer duplicate detection
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Add mail/transfer/reshuffle/redistribute icons to each juror row in Members card
- New redistributeJurorAssignments procedure: reassign all pending projects without dropping juror from group
- New DROPOUT_REASSIGNED email template with project names, deadline, and dropped juror context
- Update reassignDroppedJuror to send per-juror DROPOUT_REASSIGNED emails instead of generic BATCH_ASSIGNED
- Transfer dialog now shows all candidates with "Already assigned" / "At cap" labels instead of hiding them
- SQL script for prod DB insertion of new notification setting without seeding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 16:08:46 +01:00
parent 49e9405e01
commit 95d51e7de3
7 changed files with 570 additions and 15 deletions

View File

@@ -0,0 +1,16 @@
-- Insert DROPOUT_REASSIGNED notification email setting into production DB
-- Run manually: psql -d mopc -f prisma/migrations/insert-dropout-reassigned-setting.sql
-- Safe to run multiple times (uses ON CONFLICT to skip if already exists)
INSERT INTO "NotificationEmailSetting" (
"id", "notificationType", "category", "label", "description", "sendEmail", "createdAt", "updatedAt"
) VALUES (
gen_random_uuid()::text,
'DROPOUT_REASSIGNED',
'jury',
'Juror Dropout Reassignment',
'When projects are reassigned to you because a juror dropped out or became unavailable',
true,
NOW(),
NOW()
) ON CONFLICT ("notificationType") DO NOTHING;

View File

@@ -104,6 +104,13 @@ const NOTIFICATION_EMAIL_SETTINGS = [
description: 'When an admin manually reassigns a project to you', description: 'When an admin manually reassigns a project to you',
sendEmail: true, sendEmail: true,
}, },
{
notificationType: 'DROPOUT_REASSIGNED',
category: 'jury',
label: 'Juror Dropout Reassignment',
description: 'When projects are reassigned to you because a juror dropped out or became unavailable',
sendEmail: true,
},
{ {
notificationType: 'ROUND_NOW_OPEN', notificationType: 'ROUND_NOW_OPEN',
category: 'jury', category: 'jury',

View File

@@ -50,6 +50,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { import {
ArrowLeft, ArrowLeft,
ArrowRightLeft,
Save, Save,
Loader2, Loader2,
ChevronDown, ChevronDown,
@@ -64,6 +65,8 @@ import {
Settings, Settings,
Zap, Zap,
Shield, Shield,
Mail,
Shuffle,
UserPlus, UserPlus,
CheckCircle2, CheckCircle2,
AlertTriangle, AlertTriangle,
@@ -99,6 +102,7 @@ import {
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap' import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue' import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue'
import { JuryProgressTable } from '@/components/admin/assignment/jury-progress-table' import { JuryProgressTable } from '@/components/admin/assignment/jury-progress-table'
import { TransferAssignmentsDialog } from '@/components/admin/assignment/transfer-assignments-dialog'
import { ReassignmentHistory } from '@/components/admin/assignment/reassignment-history' import { ReassignmentHistory } from '@/components/admin/assignment/reassignment-history'
import { ScoreDistribution } from '@/components/admin/round/score-distribution' import { ScoreDistribution } from '@/components/admin/round/score-distribution'
import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button' import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button'
@@ -355,6 +359,46 @@ export default function RoundDetailPage() {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// Jury member quick actions (same as in JuryProgressTable)
const [memberTransferJuror, setMemberTransferJuror] = useState<{ id: string; name: string } | null>(null)
const notifyMemberMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
onSuccess: (data) => {
toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
},
onError: (err) => toast.error(err.message),
})
const redistributeMemberMutation = trpc.assignment.redistributeJurorAssignments.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
if (data.failedCount > 0) {
toast.warning(`Reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned.`)
} else {
toast.success(`Reassigned ${data.movedCount} project(s) to other jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
const reshuffleMemberMutation = trpc.assignment.reassignDroppedJuror.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
if (data.failedCount > 0) {
toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned.`)
} else {
toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
const advanceMutation = trpc.round.advanceProjects.useMutation({ const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId }) utils.round.getById.invalidate({ id: roundId })
@@ -1655,6 +1699,93 @@ export default function RoundDetailPage() {
maxAssignmentsOverride: val, maxAssignmentsOverride: val,
})} })}
/> />
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
disabled={notifyMemberMutation.isPending}
onClick={() => notifyMemberMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMemberMutation.isPending && notifyMemberMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Mail className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
disabled={redistributeMemberMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Reassign all pending projects from ${member.user.name || member.user.email} to other available jurors? The juror will remain in the group but lose unsubmitted assignments.`
)
if (!ok) return
redistributeMemberMutation.mutate({ roundId, jurorId: member.userId })
}}
>
{redistributeMemberMutation.isPending && redistributeMemberMutation.variables?.jurorId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Shuffle className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Reassign all pending projects to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setMemberTransferJuror({ id: member.userId, name: member.user.name || member.user.email })}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer specific assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={reshuffleMemberMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Remove ${member.user.name || member.user.email} from this jury pool and reassign all their unsubmitted projects to other jurors? Submitted evaluations will be preserved. This cannot be undone.`
)
if (!ok) return
reshuffleMemberMutation.mutate({ roundId, jurorId: member.userId })
}}
>
{reshuffleMemberMutation.isPending && reshuffleMemberMutation.variables?.jurorId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Drop juror & reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
@@ -1911,6 +2042,67 @@ export default function RoundDetailPage() {
maxAssignmentsOverride: val, maxAssignmentsOverride: val,
})} })}
/> />
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
disabled={notifyMemberMutation.isPending}
onClick={() => notifyMemberMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMemberMutation.isPending && notifyMemberMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Mail className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setMemberTransferJuror({ id: member.userId, name: member.user.name || member.user.email })}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={reshuffleMemberMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Remove ${member.user.name || member.user.email} from this jury pool and reassign all their unsubmitted projects to other jurors? Submitted evaluations will be preserved. This cannot be undone.`
)
if (!ok) return
reshuffleMemberMutation.mutate({ roundId, jurorId: member.userId })
}}
>
{reshuffleMemberMutation.isPending && reshuffleMemberMutation.variables?.jurorId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Drop juror & reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
@@ -2421,6 +2613,15 @@ export default function RoundDetailPage() {
</div> </div>
</div> </div>
)} )}
{memberTransferJuror && (
<TransferAssignmentsDialog
roundId={roundId}
sourceJuror={memberTransferJuror}
open={!!memberTransferJuror}
onClose={() => setMemberTransferJuror(null)}
/>
)}
</div> </div>
) )
} }

View File

@@ -273,19 +273,28 @@ export function TransferAssignmentsDialog({
value={currentDest} value={currentDest}
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))} onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
> >
<SelectTrigger className="w-[200px] h-8 text-xs"> <SelectTrigger className="w-[220px] h-8 text-xs">
<SelectValue placeholder="Select juror" /> <SelectValue placeholder="Select juror" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{candidateData.candidates {candidateData.candidates.map((c) => {
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId)) const isEligible = c.eligibleProjectIds.includes(assignment.projectId)
.map((c) => ( const alreadyHas = c.alreadyAssignedProjectIds?.includes(assignment.projectId)
<SelectItem key={c.userId} value={c.userId}> return (
<SelectItem
key={c.userId}
value={c.userId}
disabled={!isEligible}
className={cn(!isEligible && 'opacity-50')}
>
<span>{c.name}</span> <span>{c.name}</span>
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span> <span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>} {c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
{alreadyHas && <span className="text-amber-600 ml-1">Already assigned</span>}
{!isEligible && !alreadyHas && c.currentLoad >= c.cap && <span className="text-red-500 ml-1">At cap</span>}
</SelectItem> </SelectItem>
))} )
})}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -1077,6 +1077,76 @@ Together for a healthier ocean.
} }
} }
/**
* Generate "Dropout Reassignment" email template (for jury)
* Sent when a juror drops out and their projects are redistributed.
*/
function getDropoutReassignedTemplate(
name: string,
projectNames: string[],
roundName: string,
droppedJurorName: string,
deadline?: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const count = projectNames.length
const isSingle = count === 1
const projectList = projectNames.map((p) => `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
<tr>
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
</td>
</tr>
</table>
`).join('')
const deadlineBox = deadline ? `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
</td>
</tr>
</table>
` : ''
const content = `
${sectionTitle(greeting)}
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${projectList}
${deadlineBox}
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
`
const projectListText = projectNames.map((p) => ` - ${p}`).join('\n')
return {
subject: `Project${isSingle ? '' : 's'} Reassigned to You (Juror Unavailable) - ${roundName}`,
html: getEmailWrapper(content),
text: `
${greeting}
Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been reassigned to you for evaluation in ${roundName}.
${isSingle ? `Project: ${projectNames[0]}` : `Projects:\n${projectListText}`}
${deadline ? `Deadline: ${deadline}` : ''}
${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/** /**
* Generate "Batch Assigned" email template (for jury) * Generate "Batch Assigned" email template (for jury)
*/ */
@@ -1677,6 +1747,15 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
ctx.metadata?.deadline as string | undefined, ctx.metadata?.deadline as string | undefined,
ctx.linkUrl ctx.linkUrl
), ),
DROPOUT_REASSIGNED: (ctx) =>
getDropoutReassignedTemplate(
ctx.name || '',
(ctx.metadata?.projectNames as string[]) || [(ctx.metadata?.projectName as string) || 'Project'],
(ctx.metadata?.roundName as string) || 'this round',
(ctx.metadata?.droppedJurorName as string) || 'a fellow juror',
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
BATCH_ASSIGNED: (ctx) => BATCH_ASSIGNED: (ctx) =>
getBatchAssignedTemplate( getBatchAssignedTemplate(
ctx.name || '', ctx.name || '',

View File

@@ -476,17 +476,39 @@ async function reassignDroppedJurorAssignments(params: {
} }
if (actualMoves.length > 0) { if (actualMoves.length > 0) {
await createBulkNotifications({ // Build per-juror project name lists for proper emails
userIds: Object.keys(reassignedTo), const destProjectNames: Record<string, string[]> = {}
type: NotificationTypes.BATCH_ASSIGNED, for (const move of actualMoves) {
title: 'Additional Projects Assigned', if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
message: `You have received additional project assignments due to a jury reassignment in ${round.name}.`, destProjectNames[move.newJurorId].push(move.projectTitle)
linkUrl: `/jury/competitions`, }
linkLabel: 'View Assignments',
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
})
const droppedName = droppedJuror.name || droppedJuror.email const droppedName = droppedJuror.name || droppedJuror.email
// Fetch round deadline for email
const roundFull = await prisma.round.findUnique({
where: { id: params.roundId },
select: { windowCloseAt: true },
})
const deadline = roundFull?.windowCloseAt
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
: undefined
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
const count = projectNames.length
await createNotification({
userId: jurorId,
type: NotificationTypes.DROPOUT_REASSIGNED,
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
message: count === 1
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
})
}
const topReceivers = Object.entries(reassignedTo) const topReceivers = Object.entries(reassignedTo)
.map(([jurorId, count]) => { .map(([jurorId, count]) => {
const juror = candidateMeta.get(jurorId) const juror = candidateMeta.get(jurorId)
@@ -2116,6 +2138,218 @@ export const assignmentRouter = router({
}) })
}), }),
/**
* Redistribute all movable assignments from a juror to other jurors (without dropping them from the group).
* Uses the same greedy algorithm as reassignDroppedJuror but keeps the juror in the jury group.
* Prefers jurors who haven't finished all evaluations; as last resort uses completed jurors.
*/
redistributeJurorAssignments: adminProcedure
.input(z.object({ roundId: z.string(), jurorId: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true, windowCloseAt: true },
})
const sourceJuror = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.jurorId },
select: { id: true, name: true, email: true },
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
const assignmentsToMove = await ctx.prisma.assignment.findMany({
where: {
roundId: input.roundId,
userId: input.jurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
select: {
id: true, projectId: true, juryGroupId: true, isRequired: true,
project: { select: { title: true } },
},
orderBy: { createdAt: 'asc' },
})
if (assignmentsToMove.length === 0) {
return { movedCount: 0, failedCount: 0, failedProjects: [] as string[] }
}
// Build candidate pool
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } },
})
candidateJurors = members.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId).map((m) => m.user)
} else {
const roundJurorIds = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
})
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
candidateJurors = ids.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: ids }, role: 'JURY_MEMBER', status: 'ACTIVE' },
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
if (candidateJurors.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
}
const candidateIds = candidateJurors.map((j) => j.id)
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: { roundId: input.roundId, hasConflict: true, userId: { in: candidateIds } },
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
// Completed eval counts for "prefer not-finished" logic
const completedEvals = await ctx.prisma.evaluation.findMany({
where: { assignment: { roundId: input.roundId, userId: { in: candidateIds } }, status: 'SUBMITTED' },
select: { assignment: { select: { userId: true } } },
})
const completedCounts = new Map<string, number>()
for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
const caps = new Map<string, number>()
for (const j of candidateJurors) caps.set(j.id, j.maxAssignments ?? fallbackCap)
const plannedMoves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string; juryGroupId: string | null; isRequired: boolean }[] = []
const failedProjects: string[] = []
for (const assignment of assignmentsToMove) {
// First pass: prefer jurors who haven't completed all evals
let eligible = candidateIds
.filter((jid) => !alreadyAssigned.has(`${jid}:${assignment.projectId}`))
.filter((jid) => !coiPairs.has(`${jid}:${assignment.projectId}`))
.filter((jid) => (currentLoads.get(jid) ?? 0) < (caps.get(jid) ?? fallbackCap))
// Sort: prefer not-all-completed, then lowest load
eligible.sort((a, b) => {
const loadA = currentLoads.get(a) ?? 0
const loadB = currentLoads.get(b) ?? 0
const compA = completedCounts.get(a) ?? 0
const compB = completedCounts.get(b) ?? 0
const doneA = loadA > 0 && compA === loadA ? 1 : 0
const doneB = loadB > 0 && compB === loadB ? 1 : 0
if (doneA !== doneB) return doneA - doneB
return loadA - loadB
})
if (eligible.length === 0) {
failedProjects.push(assignment.project.title)
continue
}
const selectedId = eligible[0]
plannedMoves.push({
assignmentId: assignment.id, projectId: assignment.projectId,
projectTitle: assignment.project.title, newJurorId: selectedId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired,
})
alreadyAssigned.add(`${selectedId}:${assignment.projectId}`)
currentLoads.set(selectedId, (currentLoads.get(selectedId) ?? 0) + 1)
}
// Execute in transaction
const actualMoves: typeof plannedMoves = []
if (plannedMoves.length > 0) {
await ctx.prisma.$transaction(async (tx) => {
for (const move of plannedMoves) {
const deleted = await tx.assignment.deleteMany({
where: {
id: move.assignmentId, userId: input.jurorId,
OR: [{ evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }],
},
})
if (deleted.count === 0) { failedProjects.push(move.projectTitle); continue }
await tx.assignment.create({
data: {
roundId: input.roundId, projectId: move.projectId, userId: move.newJurorId,
juryGroupId: move.juryGroupId ?? undefined, isRequired: move.isRequired,
method: 'MANUAL', createdBy: ctx.user.id,
},
})
actualMoves.push(move)
}
})
}
// Send MANUAL_REASSIGNED emails per destination juror
if (actualMoves.length > 0) {
const destProjectNames: Record<string, string[]> = {}
for (const move of actualMoves) {
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
destProjectNames[move.newJurorId].push(move.projectTitle)
}
const deadline = round.windowCloseAt
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(round.windowCloseAt)
: undefined
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
const count = projectNames.length
await createNotification({
userId: jurorId,
type: NotificationTypes.MANUAL_REASSIGNED,
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
message: count === 1
? `The project "${projectNames[0]}" has been reassigned to you for evaluation in ${round.name}.`
: `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, roundName: round.name, projectNames, deadline, reason: 'admin_redistribute' },
})
}
const sourceName = sourceJuror.name || sourceJuror.email
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
const topReceivers = Object.entries(destProjectNames)
.map(([jid, ps]) => { const j = candidateMeta.get(jid); return `${j?.name || j?.email || jid} (${ps.length})` })
.join(', ')
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'Assignment Redistribution',
message: `Redistributed ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} could not be reassigned.` : ''}`,
linkUrl: `/admin/rounds/${round.id}`,
linkLabel: 'View Round',
metadata: { roundId: round.id, sourceJurorId: input.jurorId, movedCount: actualMoves.length, failedCount: failedProjects.length },
})
await logAudit({
prisma: ctx.prisma, userId: ctx.user.id, action: 'ASSIGNMENT_REDISTRIBUTE',
entityType: 'Round', entityId: round.id,
detailsJson: { sourceJurorId: input.jurorId, sourceName, movedCount: actualMoves.length, failedCount: failedProjects.length },
ipAddress: ctx.ip, userAgent: ctx.userAgent,
})
}
return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects }
}),
/** /**
* Get transfer candidates: which of the source juror's assignments can be moved, * Get transfer candidates: which of the source juror's assignments can be moved,
* and which other jurors are eligible to receive them. * and which other jurors are eligible to receive them.
@@ -2253,6 +2487,11 @@ export const assignmentRouter = router({
load < cap load < cap
) )
// Track which movable projects this candidate already has assigned
const alreadyAssignedProjectIds = movableProjectIds.filter((pid) =>
alreadyAssigned.has(`${j.id}:${pid}`)
)
return { return {
userId: j.id, userId: j.id,
name: j.name || j.email, name: j.name || j.email,
@@ -2261,6 +2500,7 @@ export const assignmentRouter = router({
cap, cap,
allCompleted, allCompleted,
eligibleProjectIds, eligibleProjectIds,
alreadyAssignedProjectIds,
} }
}) })

View File

@@ -33,6 +33,7 @@ export const NotificationTypes = {
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT', ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
COI_REASSIGNED: 'COI_REASSIGNED', COI_REASSIGNED: 'COI_REASSIGNED',
MANUAL_REASSIGNED: 'MANUAL_REASSIGNED', MANUAL_REASSIGNED: 'MANUAL_REASSIGNED',
DROPOUT_REASSIGNED: 'DROPOUT_REASSIGNED',
BATCH_ASSIGNED: 'BATCH_ASSIGNED', BATCH_ASSIGNED: 'BATCH_ASSIGNED',
PROJECT_UPDATED: 'PROJECT_UPDATED', PROJECT_UPDATED: 'PROJECT_UPDATED',
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN', ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
@@ -104,6 +105,7 @@ export const NotificationIcons: Record<string, string> = {
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList', [NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
[NotificationTypes.COI_REASSIGNED]: 'RefreshCw', [NotificationTypes.COI_REASSIGNED]: 'RefreshCw',
[NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft', [NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft',
[NotificationTypes.DROPOUT_REASSIGNED]: 'UserMinus',
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle', [NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
[NotificationTypes.REMINDER_24H]: 'Clock', [NotificationTypes.REMINDER_24H]: 'Clock',
[NotificationTypes.REMINDER_1H]: 'AlertCircle', [NotificationTypes.REMINDER_1H]: 'AlertCircle',
@@ -131,6 +133,7 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high', [NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
[NotificationTypes.COI_REASSIGNED]: 'high', [NotificationTypes.COI_REASSIGNED]: 'high',
[NotificationTypes.MANUAL_REASSIGNED]: 'high', [NotificationTypes.MANUAL_REASSIGNED]: 'high',
[NotificationTypes.DROPOUT_REASSIGNED]: 'high',
[NotificationTypes.ROUND_NOW_OPEN]: 'high', [NotificationTypes.ROUND_NOW_OPEN]: 'high',
[NotificationTypes.DEADLINE_24H]: 'high', [NotificationTypes.DEADLINE_24H]: 'high',
[NotificationTypes.REMINDER_24H]: 'high', [NotificationTypes.REMINDER_24H]: 'high',