feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -1678,6 +1678,222 @@ Together for a healthier ocean.
}
}
/**
* Generate "Project Advanced" notification email template
*/
export function getAdvancementNotificationTemplate(
name: string,
projectName: string,
fromRoundName: string,
toRoundName: string,
customMessage?: string,
accountUrl?: string,
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const celebrationBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #059669 0%, #0d9488 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Great News</p>
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Your project has advanced!</h2>
</td>
</tr>
</table>
`
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${infoBox(`<strong>"${projectName}"</strong>`, 'success')}
${infoBox(`Advanced from <strong>${fromRoundName}</strong> to <strong>${toRoundName}</strong>`, 'info')}
${
escapedMessage
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
: paragraph('Our team will be in touch with more details about the next phase.')
}
${accountUrl
? ctaButton(accountUrl, 'Create Your Account')
: ctaButton('/applicant', 'View Your Dashboard')}
`
return {
subject: `Your project has advanced: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project has advanced!
Project: ${projectName}
Advanced from: ${fromRoundName}
To: ${toRoundName}
${customMessage || 'Our team will be in touch with more details about the next phase.'}
${accountUrl
? `Create your account: ${getBaseUrl()}${accountUrl}`
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Project Not Advanced" (rejection) notification email template
*/
export function getRejectionNotificationTemplate(
name: string,
projectName: string,
roundName: string,
customMessage?: string
): EmailTemplate {
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
${infoBox('After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.', 'info')}
${
escapedMessage
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
: ''
}
${paragraph('We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
Thank you for being part of the Monaco Ocean Protection Challenge community.
</p>
`
return {
subject: `Update on your application: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Thank you for your participation in ${roundName} with your project "${projectName}".
After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.
${customMessage || ''}
We encourage you to continue developing your ocean protection initiative and to apply again in future editions.
Thank you for being part of the Monaco Ocean Protection Challenge community.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Selected for Special Award" notification email template
*/
export function getAwardSelectionNotificationTemplate(
name: string,
projectName: string,
awardName: string,
customMessage?: string,
accountUrl?: string,
): EmailTemplate {
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const celebrationBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Congratulations</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">Your project has been selected!</h2>
</td>
</tr>
</table>
`
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${infoBox(`<strong>"${projectName}"</strong> has been selected for the <strong>${awardName}</strong>`, 'success')}
${
escapedMessage
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
: paragraph('Our team will be in touch with more details about this award and next steps.')
}
${accountUrl
? ctaButton(accountUrl, 'Create Your Account')
: ctaButton('/applicant', 'View Your Dashboard')}
`
return {
subject: `Your project has been selected for ${awardName}: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project has been selected!
Project: ${projectName}
Award: ${awardName}
${customMessage || 'Our team will be in touch with more details about this award and next steps.'}
${accountUrl
? `Create your account: ${getBaseUrl()}${accountUrl}`
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate a preview HTML wrapper for admin email previews
*/
export function getEmailPreviewHtml(subject: string, body: string): string {
const formattedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const content = `
${sectionTitle(subject)}
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedBody}
</div>
`
return getEmailWrapper(content)
}
/**
* Template registry mapping notification types to template generators
*/
@@ -1828,6 +2044,32 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
(ctx.metadata?.awardName as string) || 'Award'
),
ADVANCEMENT_NOTIFICATION: (ctx) =>
getAdvancementNotificationTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.fromRoundName as string) || 'previous round',
(ctx.metadata?.toRoundName as string) || 'next round',
ctx.metadata?.customMessage as string | undefined,
ctx.metadata?.accountUrl as string | undefined,
),
REJECTION_NOTIFICATION: (ctx) =>
getRejectionNotificationTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.roundName as string) || 'this round',
ctx.metadata?.customMessage as string | undefined
),
AWARD_SELECTION_NOTIFICATION: (ctx) =>
getAwardSelectionNotificationTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.awardName as string) || 'Special Award',
ctx.metadata?.customMessage as string | undefined,
ctx.metadata?.accountUrl as string | undefined,
),
// Admin templates
NEW_APPLICATION: (ctx) =>
getNewApplicationTemplate(