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

- 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:
2026-02-23 14:56:30 +01:00
parent c1b3a6ade3
commit 49e9405e01
7 changed files with 403 additions and 124 deletions

View File

@@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Loader2, ShieldAlert } from 'lucide-react'
import { AlertTriangle, Loader2, ShieldAlert } from 'lucide-react'
import { toast } from 'sonner'
interface COIDeclarationDialogProps {
@@ -39,22 +39,31 @@ export function COIDeclarationDialog({
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
const [conflictType, setConflictType] = useState<string>('')
const [description, setDescription] = useState('')
const [showConfirmation, setShowConfirmation] = useState(false)
const declareCOI = trpc.evaluation.declareCOI.useMutation({
onSuccess: (data) => {
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)
},
onError: (error) => {
toast.error(error.message || 'Failed to submit COI declaration')
setShowConfirmation(false)
},
})
const handleSubmit = () => {
if (hasConflict === null) return
// If declaring a conflict, show confirmation first
if (hasConflict && !showConfirmation) {
setShowConfirmation(true)
return
}
declareCOI.mutate({
assignmentId,
hasConflict,
@@ -71,91 +80,132 @@ export function COIDeclarationDialog({
return (
<AlertDialog open={open}>
<AlertDialogContent className="max-w-md">
<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 &ldquo;{projectTitle}&rdquo;, please declare whether
you have any conflict of interest with this project.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-3">
<Label className="text-sm font-medium">
Do you have a conflict of interest with this project?
</Label>
<div className="flex gap-3">
{showConfirmation ? (
<>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Confirm Conflict of Interest
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
Are you sure you want to declare a conflict of interest with
&ldquo;{projectTitle}&rdquo;?
</p>
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
<strong>This action cannot be undone.</strong> The project will be
removed from your assignments and reassigned to another juror.
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant={hasConflict === false ? 'default' : 'outline'}
className="flex-1"
onClick={() => setHasConflict(false)}
variant="outline"
onClick={() => setShowConfirmation(false)}
disabled={declareCOI.isPending}
>
No Conflict
Go Back
</Button>
<Button
type="button"
variant={hasConflict === true ? 'destructive' : 'outline'}
className="flex-1"
onClick={() => setHasConflict(true)}
variant="destructive"
onClick={handleSubmit}
disabled={declareCOI.isPending}
>
Yes, I Have a Conflict
{declareCOI.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Yes, Confirm COI
</Button>
</div>
</div>
</AlertDialogFooter>
</>
) : (
<>
<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 &ldquo;{projectTitle}&rdquo;, please declare whether
you have any conflict of interest with this project.
</AlertDialogDescription>
</AlertDialogHeader>
{hasConflict && (
<>
<div className="space-y-2">
<Label htmlFor="conflict-type">Type of Conflict</Label>
<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>
<div className="space-y-4 py-2">
<div className="space-y-3">
<Label className="text-sm font-medium">
Do you have a conflict of interest with this project?
</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 className="flex gap-3">
<Button
type="button"
variant={hasConflict === false ? 'default' : 'outline'}
className="flex-1"
onClick={() => setHasConflict(false)}
>
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>
<AlertDialogFooter>
<Button
onClick={handleSubmit}
disabled={!canSubmit}
>
{declareCOI.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{hasConflict === null
? 'Select an option'
: hasConflict
? 'Submit Declaration'
: 'Confirm No Conflict'}
</Button>
</AlertDialogFooter>
{hasConflict && (
<>
<div className="space-y-2">
<Label htmlFor="conflict-type">Type of Conflict</Label>
<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>
<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>
</AlertDialog>
)