feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
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:
242
src/lib/email.ts
242
src/lib/email.ts
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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(
|
||||
|
||||
Reference in New Issue
Block a user