Add COI/manual reassignment emails, confirmation dialog, and smart juror selection
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m14s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m14s
- Add COI_REASSIGNED and MANUAL_REASSIGNED notification types with distinct email templates, icons, and priorities - COI declaration dialog now shows a confirmation step warning that the project will be reassigned before submitting - reassignAfterCOI now checks historical assignments (all rounds, audit logs) to never assign the same project to a juror twice, and prefers jurors with incomplete evaluations over those who have finished all their work - Admin transfer (transferAssignments) sends per-juror MANUAL_REASSIGNED notifications with actual project names instead of generic batch emails - docker-entrypoint syncs notification settings on every deploy via upsert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,5 +33,9 @@ else
|
|||||||
echo "==> Database already seeded ($USER_COUNT users found), skipping seed."
|
echo "==> Database already seeded ($USER_COUNT users found), skipping seed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Always sync notification email settings (upsert — safe for existing data)
|
||||||
|
echo "==> Syncing notification email settings..."
|
||||||
|
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
|
||||||
|
|
||||||
echo "==> Starting application..."
|
echo "==> Starting application..."
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
|||||||
description: 'When multiple projects are assigned at once',
|
description: 'When multiple projects are assigned at once',
|
||||||
sendEmail: true,
|
sendEmail: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'COI_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'COI Reassignment',
|
||||||
|
description: 'When a project is reassigned to you due to another juror\'s conflict of interest',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'MANUAL_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'Manual Reassignment',
|
||||||
|
description: 'When an admin manually reassigns a project to you',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
notificationType: 'ROUND_NOW_OPEN',
|
notificationType: 'ROUND_NOW_OPEN',
|
||||||
category: 'jury',
|
category: 'jury',
|
||||||
|
|||||||
@@ -920,6 +920,8 @@ async function main() {
|
|||||||
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
|
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
|
||||||
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
|
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
|
||||||
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
|
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
|
||||||
|
{ notificationType: 'COI_REASSIGNED', category: 'jury', label: 'COI Reassignment', description: 'When a project is reassigned to you due to another juror\'s conflict of interest', sendEmail: true },
|
||||||
|
{ notificationType: 'MANUAL_REASSIGNED', category: 'jury', label: 'Manual Reassignment', description: 'When an admin manually reassigns a project to you', sendEmail: true },
|
||||||
// Mentor notifications
|
// Mentor notifications
|
||||||
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
|
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
|
||||||
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
|
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Loader2, ShieldAlert } from 'lucide-react'
|
import { AlertTriangle, Loader2, ShieldAlert } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface COIDeclarationDialogProps {
|
interface COIDeclarationDialogProps {
|
||||||
@@ -39,22 +39,31 @@ export function COIDeclarationDialog({
|
|||||||
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
|
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
|
||||||
const [conflictType, setConflictType] = useState<string>('')
|
const [conflictType, setConflictType] = useState<string>('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||||
|
|
||||||
const declareCOI = trpc.evaluation.declareCOI.useMutation({
|
const declareCOI = trpc.evaluation.declareCOI.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.hasConflict) {
|
if (data.hasConflict) {
|
||||||
toast.info('Conflict of interest recorded. An admin will review your declaration.')
|
toast.info('Conflict of interest recorded. This project will be reassigned to another juror.')
|
||||||
}
|
}
|
||||||
|
setShowConfirmation(false)
|
||||||
onComplete(data.hasConflict)
|
onComplete(data.hasConflict)
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to submit COI declaration')
|
toast.error(error.message || 'Failed to submit COI declaration')
|
||||||
|
setShowConfirmation(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (hasConflict === null) return
|
if (hasConflict === null) return
|
||||||
|
|
||||||
|
// If declaring a conflict, show confirmation first
|
||||||
|
if (hasConflict && !showConfirmation) {
|
||||||
|
setShowConfirmation(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
declareCOI.mutate({
|
declareCOI.mutate({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
hasConflict,
|
hasConflict,
|
||||||
@@ -71,91 +80,132 @@ export function COIDeclarationDialog({
|
|||||||
return (
|
return (
|
||||||
<AlertDialog open={open}>
|
<AlertDialog open={open}>
|
||||||
<AlertDialogContent className="max-w-md">
|
<AlertDialogContent className="max-w-md">
|
||||||
<AlertDialogHeader>
|
{showConfirmation ? (
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
<>
|
||||||
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
<AlertDialogHeader>
|
||||||
Conflict of Interest Declaration
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
</AlertDialogTitle>
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||||
<AlertDialogDescription>
|
Confirm Conflict of Interest
|
||||||
Before evaluating “{projectTitle}”, please declare whether
|
</AlertDialogTitle>
|
||||||
you have any conflict of interest with this project.
|
<AlertDialogDescription asChild>
|
||||||
</AlertDialogDescription>
|
<div className="space-y-3">
|
||||||
</AlertDialogHeader>
|
<p>
|
||||||
|
Are you sure you want to declare a conflict of interest with
|
||||||
<div className="space-y-4 py-2">
|
“{projectTitle}”?
|
||||||
<div className="space-y-3">
|
</p>
|
||||||
<Label className="text-sm font-medium">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||||
Do you have a conflict of interest with this project?
|
<strong>This action cannot be undone.</strong> The project will be
|
||||||
</Label>
|
removed from your assignments and reassigned to another juror.
|
||||||
<div className="flex gap-3">
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
variant={hasConflict === false ? 'default' : 'outline'}
|
onClick={() => setShowConfirmation(false)}
|
||||||
className="flex-1"
|
disabled={declareCOI.isPending}
|
||||||
onClick={() => setHasConflict(false)}
|
|
||||||
>
|
>
|
||||||
No Conflict
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="destructive"
|
||||||
variant={hasConflict === true ? 'destructive' : 'outline'}
|
onClick={handleSubmit}
|
||||||
className="flex-1"
|
disabled={declareCOI.isPending}
|
||||||
onClick={() => setHasConflict(true)}
|
|
||||||
>
|
>
|
||||||
Yes, I Have a Conflict
|
{declareCOI.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Yes, Confirm COI
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||||
|
Conflict of Interest Declaration
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Before evaluating “{projectTitle}”, please declare whether
|
||||||
|
you have any conflict of interest with this project.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
{hasConflict && (
|
<div className="space-y-4 py-2">
|
||||||
<>
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<Label className="text-sm font-medium">
|
||||||
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
Do you have a conflict of interest with this project?
|
||||||
<Select value={conflictType} onValueChange={setConflictType}>
|
|
||||||
<SelectTrigger id="conflict-type">
|
|
||||||
<SelectValue placeholder="Select conflict type..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="financial">Financial Interest</SelectItem>
|
|
||||||
<SelectItem value="personal">Personal Relationship</SelectItem>
|
|
||||||
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
|
||||||
<SelectItem value="other">Other</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="conflict-description">
|
|
||||||
Description <span className="text-muted-foreground">(optional)</span>
|
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<div className="flex gap-3">
|
||||||
id="conflict-description"
|
<Button
|
||||||
placeholder="Briefly describe the nature of your conflict..."
|
type="button"
|
||||||
value={description}
|
variant={hasConflict === false ? 'default' : 'outline'}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
className="flex-1"
|
||||||
rows={3}
|
onClick={() => setHasConflict(false)}
|
||||||
maxLength={1000}
|
>
|
||||||
/>
|
No Conflict
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={hasConflict === true ? 'destructive' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setHasConflict(true)}
|
||||||
|
>
|
||||||
|
Yes, I Have a Conflict
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialogFooter>
|
{hasConflict && (
|
||||||
<Button
|
<>
|
||||||
onClick={handleSubmit}
|
<div className="space-y-2">
|
||||||
disabled={!canSubmit}
|
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
||||||
>
|
<Select value={conflictType} onValueChange={setConflictType}>
|
||||||
{declareCOI.isPending && (
|
<SelectTrigger id="conflict-type">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<SelectValue placeholder="Select conflict type..." />
|
||||||
)}
|
</SelectTrigger>
|
||||||
{hasConflict === null
|
<SelectContent>
|
||||||
? 'Select an option'
|
<SelectItem value="financial">Financial Interest</SelectItem>
|
||||||
: hasConflict
|
<SelectItem value="personal">Personal Relationship</SelectItem>
|
||||||
? 'Submit Declaration'
|
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
||||||
: 'Confirm No Conflict'}
|
<SelectItem value="other">Other</SelectItem>
|
||||||
</Button>
|
</SelectContent>
|
||||||
</AlertDialogFooter>
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="conflict-description">
|
||||||
|
Description <span className="text-muted-foreground">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="conflict-description"
|
||||||
|
placeholder="Briefly describe the nature of your conflict..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{hasConflict === null
|
||||||
|
? 'Select an option'
|
||||||
|
: hasConflict
|
||||||
|
? 'Submit Declaration'
|
||||||
|
: 'Confirm No Conflict'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)
|
)
|
||||||
|
|||||||
150
src/lib/email.ts
150
src/lib/email.ts
@@ -943,6 +943,140 @@ Together for a healthier ocean.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate "COI Reassignment" email template (for jury receiving a reassigned project)
|
||||||
|
*/
|
||||||
|
function getCOIReassignedTemplate(
|
||||||
|
name: string,
|
||||||
|
projectName: string,
|
||||||
|
roundName: string,
|
||||||
|
deadline?: string,
|
||||||
|
assignmentsUrl?: string
|
||||||
|
): EmailTemplate {
|
||||||
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
|
const projectCard = `
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Reassigned Project</p>
|
||||||
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
|
||||||
|
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(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>, because the previously assigned juror declared a conflict of interest.`)}
|
||||||
|
${projectCard}
|
||||||
|
${deadlineBox}
|
||||||
|
${paragraph('Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.')}
|
||||||
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignment') : ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Project Reassigned to You: "${projectName}" - ${roundName}`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `
|
||||||
|
${greeting}
|
||||||
|
|
||||||
|
A project has been reassigned to you for evaluation in ${roundName}, because the previously assigned juror declared a conflict of interest.
|
||||||
|
|
||||||
|
Project: ${projectName}
|
||||||
|
${deadline ? `Deadline: ${deadline}` : ''}
|
||||||
|
|
||||||
|
Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.
|
||||||
|
|
||||||
|
${assignmentsUrl ? `View assignment: ${assignmentsUrl}` : ''}
|
||||||
|
|
||||||
|
---
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
Together for a healthier ocean.
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate "Manual Reassignment" email template (for jury)
|
||||||
|
* Sent when an admin manually transfers a project assignment to a juror.
|
||||||
|
*/
|
||||||
|
function getManualReassignedTemplate(
|
||||||
|
name: string,
|
||||||
|
projectNames: string[],
|
||||||
|
roundName: 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: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; 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(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
||||||
|
${projectList}
|
||||||
|
${deadlineBox}
|
||||||
|
${paragraph(`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 - ${roundName}`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `
|
||||||
|
${greeting}
|
||||||
|
|
||||||
|
An administrator has reassigned ${isSingle ? 'a project' : `${count} projects`} to you for evaluation in ${roundName}.
|
||||||
|
|
||||||
|
${isSingle ? `Project: ${projectNames[0]}` : `Projects:\n${projectListText}`}
|
||||||
|
${deadline ? `Deadline: ${deadline}` : ''}
|
||||||
|
|
||||||
|
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)
|
||||||
*/
|
*/
|
||||||
@@ -1527,6 +1661,22 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
ctx.metadata?.deadline as string | undefined,
|
ctx.metadata?.deadline as string | undefined,
|
||||||
ctx.linkUrl
|
ctx.linkUrl
|
||||||
),
|
),
|
||||||
|
COI_REASSIGNED: (ctx) =>
|
||||||
|
getCOIReassignedTemplate(
|
||||||
|
ctx.name || '',
|
||||||
|
(ctx.metadata?.projectName as string) || 'Project',
|
||||||
|
(ctx.metadata?.roundName as string) || 'this round',
|
||||||
|
ctx.metadata?.deadline as string | undefined,
|
||||||
|
ctx.linkUrl
|
||||||
|
),
|
||||||
|
MANUAL_REASSIGNED: (ctx) =>
|
||||||
|
getManualReassignedTemplate(
|
||||||
|
ctx.name || '',
|
||||||
|
(ctx.metadata?.projectNames as string[]) || [(ctx.metadata?.projectName as string) || 'Project'],
|
||||||
|
(ctx.metadata?.roundName as string) || 'this round',
|
||||||
|
ctx.metadata?.deadline as string | undefined,
|
||||||
|
ctx.linkUrl
|
||||||
|
),
|
||||||
BATCH_ASSIGNED: (ctx) =>
|
BATCH_ASSIGNED: (ctx) =>
|
||||||
getBatchAssignedTemplate(
|
getBatchAssignedTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
|
|||||||
@@ -46,21 +46,51 @@ export async function reassignAfterCOI(params: {
|
|||||||
(config.maxAssignmentsPerJuror as number) ??
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
20
|
20
|
||||||
|
|
||||||
// Get all jurors already assigned to this project in this round
|
// ── Build exclusion set: jurors who must NEVER get this project ──────────
|
||||||
const existingAssignments = await prisma.assignment.findMany({
|
|
||||||
where: { roundId, projectId },
|
// 1. Currently assigned to this project in ANY round (not just current)
|
||||||
|
const allProjectAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { projectId },
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
})
|
})
|
||||||
const alreadyAssignedIds = new Set(existingAssignments.map((a) => a.userId))
|
const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId))
|
||||||
|
|
||||||
// Get all COI records for this project (any juror who declared conflict)
|
// 2. COI records for this project (any juror who declared conflict, ever)
|
||||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
where: { projectId, hasConflict: true },
|
where: { projectId, hasConflict: true },
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
})
|
})
|
||||||
const coiUserIds = new Set(coiRecords.map((c) => c.userId))
|
for (const c of coiRecords) excludedUserIds.add(c.userId)
|
||||||
|
|
||||||
|
// 3. Historical: jurors who previously had this project but were removed
|
||||||
|
// (via COI reassignment or admin transfer — tracked in audit logs)
|
||||||
|
const historicalAuditLogs = await prisma.decisionAuditLog.findMany({
|
||||||
|
where: {
|
||||||
|
eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] },
|
||||||
|
detailsJson: { path: ['projectId'], equals: projectId },
|
||||||
|
},
|
||||||
|
select: { detailsJson: true },
|
||||||
|
})
|
||||||
|
for (const log of historicalAuditLogs) {
|
||||||
|
const details = log.detailsJson as Record<string, unknown> | null
|
||||||
|
if (!details) continue
|
||||||
|
// COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it
|
||||||
|
if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string)
|
||||||
|
// ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project
|
||||||
|
if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string)
|
||||||
|
// Transfer logs may have a moves array with per-project details
|
||||||
|
if (Array.isArray(details.moves)) {
|
||||||
|
for (const move of details.moves as Array<Record<string, unknown>>) {
|
||||||
|
if (move.projectId === projectId && move.newJurorId) {
|
||||||
|
// The juror who received via past transfer also had it
|
||||||
|
excludedUserIds.add(move.newJurorId as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find candidate jurors ───────────────────────────────────────────────
|
||||||
|
|
||||||
// Find eligible jurors: in the jury group (or all JURY_MEMBERs), not already assigned, no COI
|
|
||||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||||
|
|
||||||
if (assignment.round.juryGroupId) {
|
if (assignment.round.juryGroupId) {
|
||||||
@@ -92,34 +122,51 @@ export async function reassignAfterCOI(params: {
|
|||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out already assigned and COI jurors
|
// Filter out all excluded jurors (current assignments, COI, historical)
|
||||||
const eligible = candidateJurors.filter(
|
const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id))
|
||||||
(j) => !alreadyAssignedIds.has(j.id) && !coiUserIds.has(j.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (eligible.length === 0) return null
|
if (eligible.length === 0) return null
|
||||||
|
|
||||||
// Get current assignment counts for eligible jurors in this round
|
// ── Score eligible jurors: prefer those with incomplete evaluations ──────
|
||||||
const counts = await prisma.assignment.groupBy({
|
|
||||||
by: ['userId'],
|
const eligibleIds = eligible.map((j) => j.id)
|
||||||
where: { roundId, userId: { in: eligible.map((j) => j.id) } },
|
|
||||||
_count: true,
|
// Get assignment counts and evaluation completion for eligible jurors in this round
|
||||||
|
const roundAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { roundId, userId: { in: eligibleIds } },
|
||||||
|
select: { userId: true, evaluation: { select: { status: true } } },
|
||||||
})
|
})
|
||||||
const countMap = new Map(counts.map((c) => [c.userId, c._count]))
|
|
||||||
|
|
||||||
// Find jurors under their limit, sorted by fewest assignments (load balancing)
|
// Build per-juror stats: total assignments, completed evaluations
|
||||||
const underLimit = eligible
|
const jurorStats = new Map<string, { total: number; completed: number }>()
|
||||||
.map((j) => ({
|
for (const a of roundAssignments) {
|
||||||
...j,
|
const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 }
|
||||||
currentCount: countMap.get(j.id) || 0,
|
stats.total++
|
||||||
effectiveMax: j.maxAssignments ?? maxAssignmentsPerJuror,
|
if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') {
|
||||||
}))
|
stats.completed++
|
||||||
|
}
|
||||||
|
jurorStats.set(a.userId, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank jurors: under cap, then prefer those still working (completed < total)
|
||||||
|
const ranked = eligible
|
||||||
|
.map((j) => {
|
||||||
|
const stats = jurorStats.get(j.id) || { total: 0, completed: 0 }
|
||||||
|
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
const hasIncomplete = stats.completed < stats.total
|
||||||
|
return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete }
|
||||||
|
})
|
||||||
.filter((j) => j.currentCount < j.effectiveMax)
|
.filter((j) => j.currentCount < j.effectiveMax)
|
||||||
.sort((a, b) => a.currentCount - b.currentCount)
|
.sort((a, b) => {
|
||||||
|
// 1. Prefer jurors with incomplete evaluations (still active)
|
||||||
|
if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1
|
||||||
|
// 2. Then fewest current assignments (load balancing)
|
||||||
|
return a.currentCount - b.currentCount
|
||||||
|
})
|
||||||
|
|
||||||
if (underLimit.length === 0) return null
|
if (ranked.length === 0) return null
|
||||||
|
|
||||||
const replacement = underLimit[0]
|
const replacement = ranked[0]
|
||||||
|
|
||||||
// Delete old assignment and create replacement atomically.
|
// Delete old assignment and create replacement atomically.
|
||||||
// Cascade deletes COI record and any draft evaluation.
|
// Cascade deletes COI record and any draft evaluation.
|
||||||
@@ -137,15 +184,15 @@ export async function reassignAfterCOI(params: {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify the replacement juror
|
// Notify the replacement juror (COI-specific notification)
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: replacement.id,
|
userId: replacement.id,
|
||||||
type: NotificationTypes.ASSIGNED_TO_PROJECT,
|
type: NotificationTypes.COI_REASSIGNED,
|
||||||
title: 'New Project Assigned',
|
title: 'Project Reassigned to You (COI)',
|
||||||
message: `You have been assigned to evaluate "${assignment.project.title}" for ${assignment.round.name}.`,
|
message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`,
|
||||||
linkUrl: `/jury/competitions`,
|
linkUrl: `/jury/competitions`,
|
||||||
linkLabel: 'View Assignment',
|
linkLabel: 'View Assignment',
|
||||||
metadata: { projectId, roundName: assignment.round.name },
|
metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify admins of the reassignment
|
// Notify admins of the reassignment
|
||||||
@@ -2397,22 +2444,28 @@ export const assignmentRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify destination jurors
|
// Notify destination jurors with per-juror project names
|
||||||
if (actualMoves.length > 0) {
|
if (actualMoves.length > 0) {
|
||||||
const destCounts: Record<string, number> = {}
|
const destMoves: Record<string, string[]> = {}
|
||||||
for (const move of actualMoves) {
|
for (const move of actualMoves) {
|
||||||
destCounts[move.destinationJurorId] = (destCounts[move.destinationJurorId] ?? 0) + 1
|
if (!destMoves[move.destinationJurorId]) destMoves[move.destinationJurorId] = []
|
||||||
|
destMoves[move.destinationJurorId].push(move.projectTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
await createBulkNotifications({
|
for (const [jurorId, projectNames] of Object.entries(destMoves)) {
|
||||||
userIds: Object.keys(destCounts),
|
const count = projectNames.length
|
||||||
type: NotificationTypes.BATCH_ASSIGNED,
|
await createNotification({
|
||||||
title: 'Additional Projects Assigned',
|
userId: jurorId,
|
||||||
message: `You have received additional project assignments via transfer in ${round.name}.`,
|
type: NotificationTypes.MANUAL_REASSIGNED,
|
||||||
linkUrl: `/jury/competitions`,
|
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||||||
linkLabel: 'View Assignments',
|
message: count === 1
|
||||||
metadata: { roundId: round.id, reason: 'assignment_transfer' },
|
? `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, reason: 'admin_transfer' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Notify admins
|
// Notify admins
|
||||||
const sourceJuror = await ctx.prisma.user.findUnique({
|
const sourceJuror = await ctx.prisma.user.findUnique({
|
||||||
@@ -2421,10 +2474,10 @@ export const assignmentRouter = router({
|
|||||||
})
|
})
|
||||||
const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'
|
const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'
|
||||||
|
|
||||||
const topReceivers = Object.entries(destCounts)
|
const topReceivers = Object.entries(destMoves)
|
||||||
.map(([jurorId, count]) => {
|
.map(([jurorId, projects]) => {
|
||||||
const u = destUserMap.get(jurorId)
|
const u = destUserMap.get(jurorId)
|
||||||
return `${u?.name || u?.email || jurorId} (${count})`
|
return `${u?.name || u?.email || jurorId} (${projects.length})`
|
||||||
})
|
})
|
||||||
.join(', ')
|
.join(', ')
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export const NotificationTypes = {
|
|||||||
|
|
||||||
// Jury notifications
|
// Jury notifications
|
||||||
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
||||||
|
COI_REASSIGNED: 'COI_REASSIGNED',
|
||||||
|
MANUAL_REASSIGNED: 'MANUAL_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',
|
||||||
@@ -100,6 +102,8 @@ export const NotificationIcons: Record<string, string> = {
|
|||||||
[NotificationTypes.BULK_APPLICATIONS]: 'Files',
|
[NotificationTypes.BULK_APPLICATIONS]: 'Files',
|
||||||
[NotificationTypes.DOCUMENTS_UPLOADED]: 'Upload',
|
[NotificationTypes.DOCUMENTS_UPLOADED]: 'Upload',
|
||||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
||||||
|
[NotificationTypes.COI_REASSIGNED]: 'RefreshCw',
|
||||||
|
[NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft',
|
||||||
[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',
|
||||||
@@ -125,6 +129,8 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
|
|||||||
[NotificationTypes.REMINDER_1H]: 'urgent',
|
[NotificationTypes.REMINDER_1H]: 'urgent',
|
||||||
[NotificationTypes.SYSTEM_ERROR]: 'urgent',
|
[NotificationTypes.SYSTEM_ERROR]: 'urgent',
|
||||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
||||||
|
[NotificationTypes.COI_REASSIGNED]: 'high',
|
||||||
|
[NotificationTypes.MANUAL_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',
|
||||||
|
|||||||
Reference in New Issue
Block a user