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
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:
16
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal file
16
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal 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;
|
||||
@@ -104,6 +104,13 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
||||
description: 'When an admin manually reassigns a project to you',
|
||||
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',
|
||||
category: 'jury',
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRightLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
@@ -64,6 +65,8 @@ import {
|
||||
Settings,
|
||||
Zap,
|
||||
Shield,
|
||||
Mail,
|
||||
Shuffle,
|
||||
UserPlus,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
@@ -99,6 +102,7 @@ import {
|
||||
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
|
||||
import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue'
|
||||
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 { ScoreDistribution } from '@/components/admin/round/score-distribution'
|
||||
import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button'
|
||||
@@ -355,6 +359,46 @@ export default function RoundDetailPage() {
|
||||
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({
|
||||
onSuccess: (data) => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
@@ -1655,6 +1699,93 @@ export default function RoundDetailPage() {
|
||||
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>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
@@ -1911,6 +2042,67 @@ export default function RoundDetailPage() {
|
||||
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>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
@@ -2421,6 +2613,15 @@ export default function RoundDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memberTransferJuror && (
|
||||
<TransferAssignmentsDialog
|
||||
roundId={roundId}
|
||||
sourceJuror={memberTransferJuror}
|
||||
open={!!memberTransferJuror}
|
||||
onClose={() => setMemberTransferJuror(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -273,19 +273,28 @@ export function TransferAssignmentsDialog({
|
||||
value={currentDest}
|
||||
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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidateData.candidates
|
||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||
.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId}>
|
||||
{candidateData.candidates.map((c) => {
|
||||
const isEligible = c.eligibleProjectIds.includes(assignment.projectId)
|
||||
const alreadyHas = c.alreadyAssignedProjectIds?.includes(assignment.projectId)
|
||||
return (
|
||||
<SelectItem
|
||||
key={c.userId}
|
||||
value={c.userId}
|
||||
disabled={!isEligible}
|
||||
className={cn(!isEligible && 'opacity-50')}
|
||||
>
|
||||
<span>{c.name}</span>
|
||||
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
@@ -1677,6 +1747,15 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
||||
ctx.metadata?.deadline as string | undefined,
|
||||
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) =>
|
||||
getBatchAssignedTemplate(
|
||||
ctx.name || '',
|
||||
|
||||
@@ -476,17 +476,39 @@ async function reassignDroppedJurorAssignments(params: {
|
||||
}
|
||||
|
||||
if (actualMoves.length > 0) {
|
||||
await createBulkNotifications({
|
||||
userIds: Object.keys(reassignedTo),
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: 'Additional Projects Assigned',
|
||||
message: `You have received additional project assignments due to a jury reassignment in ${round.name}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
|
||||
})
|
||||
// Build per-juror project name lists for proper emails
|
||||
const destProjectNames: Record<string, string[]> = {}
|
||||
for (const move of actualMoves) {
|
||||
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||||
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||||
}
|
||||
|
||||
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)
|
||||
.map(([jurorId, count]) => {
|
||||
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,
|
||||
* and which other jurors are eligible to receive them.
|
||||
@@ -2253,6 +2487,11 @@ export const assignmentRouter = router({
|
||||
load < cap
|
||||
)
|
||||
|
||||
// Track which movable projects this candidate already has assigned
|
||||
const alreadyAssignedProjectIds = movableProjectIds.filter((pid) =>
|
||||
alreadyAssigned.has(`${j.id}:${pid}`)
|
||||
)
|
||||
|
||||
return {
|
||||
userId: j.id,
|
||||
name: j.name || j.email,
|
||||
@@ -2261,6 +2500,7 @@ export const assignmentRouter = router({
|
||||
cap,
|
||||
allCompleted,
|
||||
eligibleProjectIds,
|
||||
alreadyAssignedProjectIds,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const NotificationTypes = {
|
||||
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
||||
COI_REASSIGNED: 'COI_REASSIGNED',
|
||||
MANUAL_REASSIGNED: 'MANUAL_REASSIGNED',
|
||||
DROPOUT_REASSIGNED: 'DROPOUT_REASSIGNED',
|
||||
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
|
||||
PROJECT_UPDATED: 'PROJECT_UPDATED',
|
||||
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
|
||||
@@ -104,6 +105,7 @@ export const NotificationIcons: Record<string, string> = {
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
||||
[NotificationTypes.COI_REASSIGNED]: 'RefreshCw',
|
||||
[NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft',
|
||||
[NotificationTypes.DROPOUT_REASSIGNED]: 'UserMinus',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
|
||||
[NotificationTypes.REMINDER_24H]: 'Clock',
|
||||
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
|
||||
@@ -131,6 +133,7 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
||||
[NotificationTypes.COI_REASSIGNED]: 'high',
|
||||
[NotificationTypes.MANUAL_REASSIGNED]: 'high',
|
||||
[NotificationTypes.DROPOUT_REASSIGNED]: 'high',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
|
||||
[NotificationTypes.DEADLINE_24H]: 'high',
|
||||
[NotificationTypes.REMINDER_24H]: 'high',
|
||||
|
||||
Reference in New Issue
Block a user