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

@@ -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)
*/
@@ -1527,6 +1661,22 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
ctx.metadata?.deadline as string | undefined,
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) =>
getBatchAssignedTemplate(
ctx.name || '',