Files
MOPC-Portal/src/lib/email.ts

1910 lines
63 KiB
TypeScript
Raw Normal View History

import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import { prisma } from '@/lib/prisma'
// Cached transporter and config hash to detect changes
let cachedTransporter: Transporter | null = null
let cachedConfigHash = ''
let cachedFrom = ''
/**
* Get SMTP transporter using database settings with env var fallback.
* Caches the transporter and rebuilds it when settings change.
*/
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
// Read DB settings
const dbSettings = await prisma.systemSettings.findMany({
where: {
key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from_name', 'email_from'] },
},
select: { key: true, value: true },
})
const db: Record<string, string> = {}
for (const s of dbSettings) {
db[s.key] = s.value
}
// DB settings take priority, env vars as fallback
const host = db.smtp_host || process.env.SMTP_HOST || 'localhost'
const port = db.smtp_port || process.env.SMTP_PORT || '587'
const user = db.smtp_user || process.env.SMTP_USER || ''
const pass = db.smtp_password || process.env.SMTP_PASS || ''
// Combine sender name and email into "Name <email>" format
const fromName = db.email_from_name || 'MOPC Portal'
const rawFromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com'
// Strip "Name <...>" wrapper if present — extract just the email address
const fromEmail = rawFromEmail.includes('<')
? rawFromEmail.match(/<([^>]+)>/)?.[1] || rawFromEmail
: rawFromEmail
const from = `${fromName} <${fromEmail}>`
// Check if config changed since last call
const configHash = `${host}:${port}:${user}:${pass}:${from}`
if (cachedTransporter && configHash === cachedConfigHash) {
return { transporter: cachedTransporter, from: cachedFrom }
}
// Create new transporter
cachedTransporter = nodemailer.createTransport({
host,
port: parseInt(port),
secure: port === '465',
auth: { user, pass },
})
cachedConfigHash = configHash
cachedFrom = from
return { transporter: cachedTransporter, from: cachedFrom }
}
// Legacy references for backward compat — default sender from env
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
// =============================================================================
// Brand Colors & Logo URLs
// =============================================================================
const BRAND = {
red: '#de0f1e',
redHover: '#b91c1c',
darkBlue: '#053d57',
teal: '#557f8c',
white: '#fefefe',
lightGray: '#f5f5f5',
textDark: '#1f2937',
textMuted: '#6b7280',
}
const getSmallLogoUrl = () => 'https://s3.monaco-opc.com/public/MOPC-blue-small.png'
const getBigLogoUrl = () => 'https://s3.monaco-opc.com/public/MOPC-blue-long.png'
// =============================================================================
// Email Template Wrapper & Helpers
// =============================================================================
/**
* Wrap email content with consistent branding:
* - Small logo at top of white content box
* - Big logo in dark blue footer
*/
function getEmailWrapper(content: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>MOPC</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: ${BRAND.darkBlue}; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-image: url('https://s3.monaco-opc.com/public/ocean.png'); background-size: cover; background-position: center top; background-repeat: no-repeat; background-color: ${BRAND.darkBlue};">
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Main Container -->
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
<!-- Content Box -->
<tr>
<td style="background-color: ${BRAND.white}; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<!-- Small Logo Header -->
<tr>
<td align="center" style="padding: 32px 40px 24px 40px;">
<img src="${getSmallLogoUrl()}" alt="MOPC" width="100" height="auto" style="display: block; border: 0; max-width: 100px;">
</td>
</tr>
<!-- Email Content -->
<tr>
<td style="padding: 0 40px 32px 40px;">
${content}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 0 40px 32px 40px; border-top: 1px solid #e5e7eb;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" style="padding-top: 24px;">
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="180" height="auto" style="display: block; border: 0; max-width: 180px; margin-bottom: 12px;">
</td>
</tr>
<tr>
<td align="center">
<p style="color: ${BRAND.textMuted}; font-size: 13px; margin: 0; line-height: 1.5;">
Together for a healthier ocean
</p>
<p style="color: ${BRAND.textMuted}; font-size: 12px; margin: 8px 0 0 0;">
&copy; ${new Date().getFullYear()} Monaco Ocean Protection Challenge
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`
}
/**
* Generate a styled CTA button
*/
function ctaButton(url: string, text: string): string {
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td align="center">
<a href="${url}" target="_blank" style="display: inline-block; background-color: ${BRAND.red}; color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-weight: 600; font-size: 16px; mso-padding-alt: 0;">
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%; mso-text-raise: 30pt;">&nbsp;</i>
<![endif]-->
<span style="mso-text-raise: 15pt;">${text}</span>
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%;">&nbsp;</i>
<![endif]-->
</a>
</td>
</tr>
</table>
`
}
/**
* Generate styled section title
*/
function sectionTitle(text: string): string {
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${text}</h2>`
}
/**
* Generate styled paragraph
*/
function paragraph(text: string): string {
return `<p style="color: ${BRAND.textDark}; margin: 0 0 16px 0; font-size: 15px; line-height: 1.6;">${text}</p>`
}
/**
* Generate styled info box
*/
function infoBox(content: string, variant: 'warning' | 'info' | 'success' = 'info'): string {
const colors = {
warning: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
info: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' },
success: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
}
const c = colors[variant]
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${c.bg}; border-left: 4px solid ${c.border}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: ${c.text}; margin: 0; font-size: 14px; font-weight: 500;">${content}</p>
</td>
</tr>
</table>
`
}
/**
* Generate styled stat card
*/
function statCard(label: string, value: string | number): string {
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${label}</p>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${value}</p>
</td>
</tr>
</table>
`
}
// =============================================================================
// Email Templates
// =============================================================================
interface EmailTemplate {
subject: string
html: string
text: string
}
/**
* Generate magic link email template
*/
function getMagicLinkTemplate(url: string, expiryMinutes: number = 15): EmailTemplate {
const content = `
${sectionTitle('Sign in to your account')}
${paragraph('Click the button below to securely sign in to the MOPC Portal.')}
${infoBox(`<strong>This link expires in ${expiryMinutes} minutes</strong>`, 'warning')}
${ctaButton(url, 'Sign In to MOPC')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
If you didn't request this email, you can safely ignore it.
</p>
`
return {
subject: 'Sign in to MOPC Portal',
html: getEmailWrapper(content),
text: `
Sign in to MOPC Portal
=========================
Click the link below to sign in to your account:
${url}
This link will expire in ${expiryMinutes} minutes.
If you didn't request this email, you can safely ignore it.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate generic invitation email template (not round-specific)
*/
function formatExpiryLabel(hours: number): string {
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''}`
const days = Math.round(hours / 24)
return `${days} day${days !== 1 ? 's' : ''}`
}
function getGenericInvitationTemplate(
name: string,
url: string,
role: string,
expiryHours: number = 72
): EmailTemplate {
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
const article = /^[aeiou]/i.test(roleLabel) ? 'an' : 'a'
const greeting = name ? `Hello ${name},` : 'Hello,'
const expiryLabel = formatExpiryLabel(expiryHours)
const content = `
${sectionTitle(greeting)}
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} <strong>${roleLabel}</strong>.`)}
${paragraph('Click the button below to set up your account and get started.')}
${ctaButton(url, 'Accept Invitation')}
${infoBox(`This link will expire in ${expiryLabel}.`, 'info')}
`
return {
subject: "You're invited to join the MOPC Portal",
html: getEmailWrapper(content),
text: `
${greeting}
You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} ${roleLabel}.
Click the link below to set up your account and get started:
${url}
This link will expire in ${expiryLabel}.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate evaluation reminder email template
*/
function getEvaluationReminderTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
// Deadline alert box (styled differently from info box)
const deadlineBox = `
<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(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${statCard('Pending Evaluations', pendingCount)}
${deadlineBox}
${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')}
${ctaButton(assignmentsUrl, 'View My Assignments')}
`
return {
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} awaiting your review`,
html: getEmailWrapper(content),
text: `
${greeting}
This is a friendly reminder that you have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${roundName}.
Deadline: ${deadline}
Please complete your evaluations before the deadline to ensure your feedback is included in the selection process.
View your assignments: ${assignmentsUrl}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate announcement email template
*/
function getAnnouncementTemplate(
name: string,
title: string,
message: string,
ctaText?: string,
ctaUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : ''
// Escape HTML in message but preserve line breaks
const formattedMessage = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
// Title card with success styling
const titleCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;">
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${title}</h3>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${titleCard}
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedMessage}
</div>
${ctaText && ctaUrl ? ctaButton(ctaUrl, ctaText) : ''}
`
return {
subject: title,
html: getEmailWrapper(content),
text: `
${greeting}
${title}
${message}
${ctaTextPlain}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate jury invitation email template
*/
function getJuryInvitationTemplate(
name: string,
url: string,
roundName: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const content = `
${sectionTitle(greeting)}
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
${ctaButton(url, 'Accept Invitation')}
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
This link will allow you to access the platform and view your assigned projects.
</p>
`
return {
subject: `You're invited to evaluate projects for ${roundName}`,
html: getEmailWrapper(content),
text: `
${greeting}
You've been invited to serve as a jury member for ${roundName}.
As a jury member, you'll evaluate innovative ocean protection projects and help select the most promising initiatives.
Click the link below to accept your invitation:
${url}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate application confirmation email template
*/
function getApplicationConfirmationTemplate(
name: string,
projectName: string,
programName: string,
customMessage?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const customMessageHtml = customMessage
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${customMessage.replace(/\n/g, '<br>')}</div>`
: ''
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>!`)}
${infoBox(`Your project "<strong>${projectName}</strong>" has been successfully received.`, 'success')}
${customMessageHtml}
${paragraph('Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
You will receive email updates about your application status.
</p>
`
return {
subject: `Application Received - ${projectName}`,
html: getEmailWrapper(content),
text: `
${greeting}
Thank you for submitting your application to ${programName}!
Your project "${projectName}" has been successfully received.
${customMessage || ''}
Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don't hesitate to reach out.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate team member invite email template
*/
function getTeamMemberInviteTemplate(
name: string,
projectName: string,
teamLeadName: string,
inviteUrl: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const content = `
${sectionTitle(greeting)}
${paragraph(`<strong>${teamLeadName}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${projectName}</strong>" on the Monaco Ocean Protection Challenge platform.`)}
${paragraph('Click the button below to accept the invitation and set up your account.')}
${ctaButton(inviteUrl, 'Accept Invitation')}
${infoBox('This invitation link will expire in 30 days.', 'info')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
If you weren't expecting this invitation, you can safely ignore this email.
</p>
`
return {
subject: `You've been invited to join "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
${teamLeadName} has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform.
Click the link below to accept the invitation and set up your account:
${inviteUrl}
This invitation link will expire in 30 days.
If you weren't expecting this invitation, you can safely ignore this email.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
// =============================================================================
// Notification Email Templates
// =============================================================================
/**
* Context passed to notification email templates
*/
export interface NotificationEmailContext {
name?: string
title: string
message: string
linkUrl?: string
linkLabel?: string
metadata?: Record<string, unknown>
}
/**
* Generate "Advanced to Semi-Finals" email template
*/
function getAdvancedSemifinalTemplate(
name: string,
projectName: string,
programName: string,
nextSteps?: 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;">Exciting News</p>
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Semi-Finalist!</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected to advance to the semi-finals of ${programName}.`)}
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${nextSteps}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
`
return {
subject: `Congratulations! "${projectName}" advances to Semi-Finals`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project "${projectName}" has been selected to advance to the semi-finals of ${programName}.
Your innovative approach to ocean protection stood out among hundreds of submissions.
${nextSteps || 'Our team will be in touch shortly with details about the next phase of the competition.'}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Selected as Finalist" email template
*/
function getAdvancedFinalTemplate(
name: string,
projectName: string,
programName: string,
nextSteps?: string
): EmailTemplate {
const greeting = name ? `Incredible news, ${name}!` : 'Incredible news!'
const celebrationBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, #0891b2 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;">Outstanding Achievement</p>
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Finalist!</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as a <strong>Finalist</strong> in ${programName}.`)}
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${nextSteps}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
`
return {
subject: `You're a Finalist! "${projectName}" selected for finals`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project "${projectName}" has been selected as a Finalist in ${programName}.
You are now among the top projects competing for the grand prize!
${nextSteps || 'Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.'}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentor Assigned" email template (for team)
*/
function getMentorAssignedTemplate(
name: string,
projectName: string,
mentorName: string,
mentorBio?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const mentorCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${mentorName}</p>
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${mentorBio}</p>` : ''}
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong>.`)}
${mentorCard}
${paragraph('Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.')}
${infoBox('Mentorship is a valuable opportunity - make the most of their expertise!', 'info')}
`
return {
subject: `A mentor has been assigned to "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Great news! A mentor has been assigned to support your project "${projectName}".
Your Mentor: ${mentorName}
${mentorBio || ''}
Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Not Selected" email template
*/
function getNotSelectedTemplate(
name: string,
projectName: string,
roundName: string,
feedbackUrl?: string,
encouragement?: string
): EmailTemplate {
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for participating in ${roundName} with your project <strong>"${projectName}"</strong>.`)}
${paragraph('After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.')}
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
${paragraph(encouragement || '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 participating in ${roundName} with your project "${projectName}".
After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.
This decision was incredibly difficult given the high quality of submissions we received this year.
${feedbackUrl ? `View jury feedback: ${feedbackUrl}` : ''}
${encouragement || '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 "Winner Announcement" email template
*/
function getWinnerAnnouncementTemplate(
name: string,
projectName: string,
awardName: string,
prizeDetails?: string
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const trophyBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">&#127942;</p>
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${awardName}</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${trophyBanner}
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as the winner of the <strong>${awardName}</strong>!`)}
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${prizeDetails}`) : ''}
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
`
return {
subject: `You Won! "${projectName}" wins ${awardName}`,
html: getEmailWrapper(content),
text: `
${greeting}
We are thrilled to announce that your project "${projectName}" has been selected as the winner of the ${awardName}!
Your outstanding work in ocean protection has made a lasting impression on our jury.
${prizeDetails ? `Your Prize: ${prizeDetails}` : ''}
Our team will be in touch shortly with details about the award ceremony and next steps.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Assigned to Project" email template (for jury)
*/
function getAssignedToProjectTemplate(
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: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; 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;">New Assignment</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(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${projectCard}
${deadlineBox}
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignment') : ''}
`
return {
subject: `New Assignment: "${projectName}" - ${roundName}`,
html: getEmailWrapper(content),
text: `
${greeting}
You have been assigned a new project to evaluate for ${roundName}.
Project: ${projectName}
${deadline ? `Deadline: ${deadline}` : ''}
Please review the project materials and submit your evaluation before the deadline.
${assignmentsUrl ? `View assignment: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Batch Assigned" email template (for jury)
*/
function getBatchAssignedTemplate(
name: string,
projectCount: number,
roundName: string,
deadline?: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
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(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${statCard('Projects Assigned', projectCount)}
${deadlineBox}
${paragraph('Please review each project and submit your evaluations before the deadline. Your expert assessment is crucial to identifying the most promising ocean protection initiatives.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View All Assignments') : ''}
`
return {
subject: `${projectCount} Projects Assigned - ${roundName}`,
html: getEmailWrapper(content),
text: `
${greeting}
You have been assigned ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate for ${roundName}.
${deadline ? `Deadline: ${deadline}` : ''}
Please review each project and submit your evaluations before the deadline.
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Round Now Open" email template (for jury)
*/
function getRoundNowOpenTemplate(
name: string,
roundName: string,
projectCount: number,
deadline?: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const openBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, ${BRAND.teal} 0%, #0891b2 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;">Evaluation Round</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${roundName} is Now Open</h2>
</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)}
${openBanner}
${statCard('Projects to Evaluate', projectCount)}
${deadlineBox}
${paragraph('The evaluation round is now open. Please log in to the MOPC Portal to begin reviewing your assigned projects.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Start Evaluating') : ''}
`
return {
subject: `${roundName} is Now Open - ${projectCount} Projects Await`,
html: getEmailWrapper(content),
text: `
${greeting}
${roundName} is now open for evaluation.
You have ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate.
${deadline ? `Deadline: ${deadline}` : ''}
Please log in to the MOPC Portal to begin reviewing your assigned projects.
${assignmentsUrl ? `Start evaluating: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "24 Hour Reminder" email template (for jury)
*/
function getReminder24HTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const urgentBox = `
<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 8px 8px 0; padding: 16px 20px;">
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">&#9888; 24 Hours Remaining</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${urgentBox}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 24 hours.`)}
${statCard('Pending Evaluations', pendingCount)}
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
`
return {
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 24 hours`,
html: getEmailWrapper(content),
text: `
${greeting}
This is a reminder that ${roundName} closes in 24 hours.
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
Deadline: ${deadline}
Please complete your remaining evaluations before the deadline.
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
/**
* Generate "3 Days Remaining" email template (for jury)
*/
function getReminder3DaysTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const urgentBox = `
<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 8px 8px 0; padding: 16px 20px;">
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">&#9888; 3 Days Remaining</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${urgentBox}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
${statCard('Pending Evaluations', pendingCount)}
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
`
return {
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 3 days`,
html: getEmailWrapper(content),
text: `
${greeting}
This is a reminder that ${roundName} closes in 3 days.
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
Deadline: ${deadline}
Please plan to complete your remaining evaluations before the deadline.
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "1 Hour Reminder" email template (for jury)
*/
function getReminder1HTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `${name},` : 'Attention,'
const urgentBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, ${BRAND.red} 0%, #dc2626 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;">Urgent</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">1 Hour Remaining</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${urgentBanner}
${paragraph(`<strong style="color: ${BRAND.red};">${roundName}</strong> closes in <strong>1 hour</strong>.`)}
${statCard('Evaluations Still Pending', pendingCount)}
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
`
return {
subject: `URGENT: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 1 hour`,
html: getEmailWrapper(content),
text: `
URGENT
${roundName} closes in 1 hour!
You have ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} still pending.
Please submit your remaining evaluations immediately.
${assignmentsUrl ? `Submit now: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Award Voting Open" email template (for jury)
*/
function getAwardVotingOpenTemplate(
name: string,
awardName: string,
finalistCount: number,
deadline?: string,
votingUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const awardBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">&#127942;</p>
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${awardName}</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${awardBanner}
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${awardName}</strong>.`)}
${statCard('Finalists', finalistCount)}
${deadline ? infoBox(`<strong>Voting closes:</strong> ${deadline}`, 'warning') : ''}
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
`
return {
subject: `Vote Now: ${awardName}`,
html: getEmailWrapper(content),
text: `
${greeting}
Voting is now open for the ${awardName}.
${finalistCount} finalists are competing for this award.
${deadline ? `Voting closes: ${deadline}` : ''}
Please review the finalist projects and cast your vote.
${votingUrl ? `Cast your vote: ${votingUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentee Assigned" email template (for mentor)
*/
function getMenteeAssignedTemplate(
name: string,
projectName: string,
teamLeadName: string,
teamLeadEmail?: string,
projectUrl?: 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: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${projectName}</p>
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
<strong>Team Lead:</strong> ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${paragraph('You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.')}
${projectCard}
${paragraph('As a mentor, you play a crucial role in guiding this team toward success. Please reach out to introduce yourself and schedule your first meeting.')}
${infoBox('Your expertise and guidance can make a significant impact on their ocean protection initiative.', 'info')}
${projectUrl ? ctaButton(projectUrl, 'View Project Details') : ''}
`
return {
subject: `New Mentee Assignment: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.
Project: ${projectName}
Team Lead: ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
Please reach out to introduce yourself and schedule your first meeting.
${projectUrl ? `View project: ${projectUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentee Advanced" email template (for mentor)
*/
function getMenteeAdvancedTemplate(
name: string,
projectName: string,
roundName: string,
nextRoundName?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const content = `
${sectionTitle(greeting)}
${infoBox('Great news about your mentee!', 'success')}
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has advanced to the next stage!`)}
${statCard('Advanced From', roundName)}
${nextRoundName ? paragraph(`They will now compete in <strong>${nextRoundName}</strong>.`) : ''}
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
`
return {
subject: `Your Mentee Advanced: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Great news! Your mentee project "${projectName}" has advanced to the next stage.
Advanced from: ${roundName}
${nextRoundName ? `Now competing in: ${nextRoundName}` : ''}
Your guidance is making a difference. Continue supporting the team as they progress.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentee Won" email template (for mentor)
*/
function getMenteeWonTemplate(
name: string,
projectName: string,
awardName: string
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const trophyBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">&#127942;</p>
<p style="color: #ffffff; margin: 0; font-size: 16px; font-weight: 600;">Your Mentee Won!</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${trophyBanner}
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has won the <strong>${awardName}</strong>!`)}
${infoBox('Your mentorship played a vital role in their success.', 'success')}
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
`
return {
subject: `Your Mentee Won: "${projectName}" - ${awardName}`,
html: getEmailWrapper(content),
text: `
${greeting}
Your mentee project "${projectName}" has won the ${awardName}!
Your mentorship played a vital role in their success.
Thank you for your dedication and support.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "New Application" email template (for admins)
*/
function getNewApplicationTemplate(
projectName: string,
applicantName: string,
applicantEmail: string,
programName: string,
reviewUrl?: string
): EmailTemplate {
const applicationCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; 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;">New Application</p>
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${projectName}</p>
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
<strong>Applicant:</strong> ${applicantName} (${applicantEmail})
</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle('New Application Received')}
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>.`)}
${applicationCard}
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
`
return {
subject: `New Application: "${projectName}"`,
html: getEmailWrapper(content),
text: `
New Application Received
A new application has been submitted to ${programName}.
Project: ${projectName}
Applicant: ${applicantName} (${applicantEmail})
${reviewUrl ? `Review application: ${reviewUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Template registry mapping notification types to template generators
*/
type TemplateGenerator = (context: NotificationEmailContext) => EmailTemplate
export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
// Team/Applicant templates
ADVANCED_SEMIFINAL: (ctx) =>
getAdvancedSemifinalTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.programName as string) || 'MOPC',
ctx.metadata?.nextSteps as string | undefined
),
ADVANCED_FINAL: (ctx) =>
getAdvancedFinalTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.programName as string) || 'MOPC',
ctx.metadata?.nextSteps as string | undefined
),
MENTOR_ASSIGNED: (ctx) =>
getMentorAssignedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.mentorName as string) || 'Your Mentor',
ctx.metadata?.mentorBio as string | undefined
),
NOT_SELECTED: (ctx) =>
getNotSelectedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.roundName as string) || 'this round',
ctx.linkUrl,
ctx.metadata?.encouragement as string | undefined
),
WINNER_ANNOUNCEMENT: (ctx) =>
getWinnerAnnouncementTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.awardName as string) || 'the Award',
ctx.metadata?.prizeDetails as string | undefined
),
// Jury templates
ASSIGNED_TO_PROJECT: (ctx) =>
getAssignedToProjectTemplate(
ctx.name || '',
(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 || '',
(ctx.metadata?.projectCount as number) || 1,
(ctx.metadata?.roundName as string) || 'this round',
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
ROUND_NOW_OPEN: (ctx) =>
getRoundNowOpenTemplate(
ctx.name || '',
(ctx.metadata?.roundName as string) || 'Evaluation Round',
(ctx.metadata?.projectCount as number) || 0,
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
REMINDER_3_DAYS: (ctx) =>
getReminder3DaysTemplate(
ctx.name || '',
(ctx.metadata?.pendingCount as number) || 0,
(ctx.metadata?.roundName as string) || 'this round',
(ctx.metadata?.deadline as string) || 'Soon',
ctx.linkUrl
),
REMINDER_24H: (ctx) =>
getReminder24HTemplate(
ctx.name || '',
(ctx.metadata?.pendingCount as number) || 0,
(ctx.metadata?.roundName as string) || 'this round',
(ctx.metadata?.deadline as string) || 'Soon',
ctx.linkUrl
),
REMINDER_1H: (ctx) =>
getReminder1HTemplate(
ctx.name || '',
(ctx.metadata?.pendingCount as number) || 0,
(ctx.metadata?.roundName as string) || 'this round',
(ctx.metadata?.deadline as string) || 'Very Soon',
ctx.linkUrl
),
AWARD_VOTING_OPEN: (ctx) =>
getAwardVotingOpenTemplate(
ctx.name || '',
(ctx.metadata?.awardName as string) || 'Special Award',
(ctx.metadata?.finalistCount as number) || 0,
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
// Mentor templates
MENTEE_ASSIGNED: (ctx) =>
getMenteeAssignedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Project',
(ctx.metadata?.teamLeadName as string) || 'Team Lead',
ctx.metadata?.teamLeadEmail as string | undefined,
ctx.linkUrl
),
MENTEE_ADVANCED: (ctx) =>
getMenteeAdvancedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Project',
(ctx.metadata?.roundName as string) || 'this round',
ctx.metadata?.nextRoundName as string | undefined
),
MENTEE_WON: (ctx) =>
getMenteeWonTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Project',
(ctx.metadata?.awardName as string) || 'Award'
),
// Admin templates
NEW_APPLICATION: (ctx) =>
getNewApplicationTemplate(
(ctx.metadata?.projectName as string) || 'New Project',
(ctx.metadata?.applicantName as string) || 'Applicant',
(ctx.metadata?.applicantEmail as string) || '',
(ctx.metadata?.programName as string) || 'MOPC',
ctx.linkUrl
),
}
/**
* Send styled notification email using the appropriate template
*/
export async function sendStyledNotificationEmail(
email: string,
name: string,
type: string,
context: NotificationEmailContext,
subjectOverride?: string
): Promise<void> {
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type]
let template: EmailTemplate
if (templateGenerator) {
// Use styled template
template = templateGenerator({ ...context, name })
// Apply subject override if provided
if (subjectOverride) {
template.subject = subjectOverride
}
} else {
// Fall back to generic template
template = getNotificationEmailTemplate(
name,
subjectOverride || context.title,
context.message,
context.linkUrl
)
}
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
// =============================================================================
// Email Sending Functions
// =============================================================================
/**
* Send magic link email for authentication
*/
export async function sendMagicLinkEmail(
email: string,
url: string
): Promise<void> {
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
const template = getMagicLinkTemplate(url, expiryMinutes)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send generic invitation email (not round-specific)
*/
export async function sendInvitationEmail(
email: string,
name: string | null,
url: string,
role: string,
expiryHours?: number
): Promise<void> {
const template = getGenericInvitationTemplate(name || '', url, role, expiryHours)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send jury invitation email (round-specific)
*/
export async function sendJuryInvitationEmail(
email: string,
name: string | null,
url: string,
roundName: string
): Promise<void> {
const template = getJuryInvitationTemplate(name || '', url, roundName)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send evaluation reminder email
*/
export async function sendEvaluationReminderEmail(
email: string,
name: string | null,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl: string
): Promise<void> {
const template = getEvaluationReminderTemplate(
name || '',
pendingCount,
roundName,
deadline,
assignmentsUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send announcement email
*/
export async function sendAnnouncementEmail(
email: string,
name: string | null,
title: string,
message: string,
ctaText?: string,
ctaUrl?: string
): Promise<void> {
const template = getAnnouncementTemplate(
name || '',
title,
message,
ctaText,
ctaUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send a test email to verify SMTP configuration
*/
export async function sendTestEmail(toEmail: string): Promise<boolean> {
try {
const content = `
${sectionTitle('Test Email')}
${paragraph('This is a test email from the MOPC Portal.')}
${infoBox('If you received this, your email configuration is working correctly!', 'success')}
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
Sent at ${new Date().toISOString()}
</p>
`
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: toEmail,
subject: 'MOPC Portal - Test Email',
text: 'This is a test email from the MOPC Portal. If you received this, your email configuration is working correctly.',
html: getEmailWrapper(content),
})
return true
} catch {
return false
}
}
/**
* Verify SMTP connection
*/
export async function verifyEmailConnection(): Promise<boolean> {
try {
const { transporter } = await getTransporter()
await transporter.verify()
return true
} catch {
return false
}
}
/**
* Send application confirmation email to applicant
*/
export async function sendApplicationConfirmationEmail(
email: string,
applicantName: string,
projectName: string,
programName: string,
customMessage?: string
): Promise<void> {
const template = getApplicationConfirmationTemplate(
applicantName,
projectName,
programName,
customMessage
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send team member invite email
*/
export async function sendTeamMemberInviteEmail(
email: string,
memberName: string,
projectName: string,
teamLeadName: string,
inviteUrl: string
): Promise<void> {
const template = getTeamMemberInviteTemplate(
memberName,
projectName,
teamLeadName,
inviteUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Generate notification email template
*/
function getNotificationEmailTemplate(
name: string,
subject: string,
body: string,
linkUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
// Format body text preserving line breaks
const formattedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const content = `
${sectionTitle(greeting)}
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedBody}
</div>
${linkUrl ? ctaButton(linkUrl, 'View Details') : ''}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
You received this email because of your notification preferences on the MOPC Portal.
</p>
`
return {
subject,
html: getEmailWrapper(content),
text: `
${greeting}
${body}
${linkUrl ? `View details: ${linkUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Send notification email (triggered by in-app notification system)
*/
export async function sendNotificationEmail(
email: string,
name: string,
subject: string,
body: string,
linkUrl?: string
): Promise<void> {
const template = getNotificationEmailTemplate(name, subject, body, linkUrl)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}