fix: email XSS sanitization, bulk invite concurrency, error handling (code review batch 2)

- Add escapeHtml() helper and apply to all user-supplied variables in 20+ HTML email templates
- Auto-escape in sectionTitle() and statCard() helpers for defense-in-depth
- Replace 5 instances of incomplete manual escaping with escapeHtml()
- Refactor bulkInviteTeamMembers: batch all DB writes in $transaction, then send emails via Promise.allSettled with concurrency pool of 10
- Fix inner catch block in award-eligibility-job.ts to capture its own error variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 16:59:56 +01:00
parent b85a9b9a7b
commit 94cbfec70a
3 changed files with 119 additions and 110 deletions

View File

@@ -107,6 +107,19 @@ const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.c
// Helpers
// =============================================================================
/**
* Escape user-supplied strings for safe injection into HTML email templates.
* Prevents XSS if email content is rendered in a webmail client.
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Get the base URL for links in emails.
* Uses NEXTAUTH_URL with a safe production fallback.
@@ -266,7 +279,7 @@ function ctaButton(url: string, text: string): string {
* 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>`
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${escapeHtml(text)}</h2>`
}
/**
@@ -305,8 +318,8 @@ function statCard(label: string, value: string | number): string {
<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>
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${escapeHtml(label)}</p>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${escapeHtml(String(value))}</p>
</td>
</tr>
</table>
@@ -462,7 +475,7 @@ function getEvaluationReminderTemplate(
<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>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
</td>
</tr>
</table>
@@ -470,7 +483,7 @@ function getEvaluationReminderTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(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.')}
@@ -512,18 +525,14 @@ function getAnnouncementTemplate(
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>')
const formattedMessage = escapeHtml(message).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>
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(title)}</h3>
</td>
</tr>
</table>
@@ -567,7 +576,7 @@ function getJuryInvitationTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(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;">
@@ -608,13 +617,13 @@ function getApplicationConfirmationTemplate(
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>`
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${escapeHtml(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')}
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>!`)}
${infoBox(`Your project "<strong>${escapeHtml(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;">
@@ -656,7 +665,7 @@ function getTeamMemberInviteTemplate(
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(`<strong>${escapeHtml(teamLeadName)}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${escapeHtml(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')}
@@ -729,9 +738,9 @@ function getAdvancedSemifinalTemplate(
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}.`)}
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected to advance to the semi-finals of ${escapeHtml(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.')}
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
`
return {
@@ -778,9 +787,9 @@ function getAdvancedFinalTemplate(
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}.`)}
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as a <strong>Finalist</strong> in ${escapeHtml(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.')}
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
`
return {
@@ -818,8 +827,8 @@ function getMentorAssignedTemplate(
<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>` : ''}
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(mentorName)}</p>
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(mentorBio)}</p>` : ''}
</td>
</tr>
</table>
@@ -827,7 +836,7 @@ function getMentorAssignedTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong>.`)}
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(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')}
@@ -867,11 +876,11 @@ function getNotSelectedTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for participating in ${roundName} with your project <strong>"${projectName}"</strong>.`)}
${paragraph(`Thank you for participating in ${escapeHtml(roundName)} with your project <strong>"${escapeHtml(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.')}
${paragraph(encouragement ? escapeHtml(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>
@@ -919,7 +928,7 @@ function getWinnerAnnouncementTemplate(
<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>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(awardName)}</h2>
</td>
</tr>
</table>
@@ -928,9 +937,9 @@ function getWinnerAnnouncementTemplate(
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>!`)}
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as the winner of the <strong>${escapeHtml(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}`) : ''}
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${escapeHtml(prizeDetails)}`) : ''}
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
`
@@ -972,7 +981,7 @@ function getAssignedToProjectTemplate(
<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>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
</td>
</tr>
</table>
@@ -983,7 +992,7 @@ function getAssignedToProjectTemplate(
<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>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
</td>
</tr>
</table>
@@ -991,7 +1000,7 @@ function getAssignedToProjectTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
${projectCard}
${deadlineBox}
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
@@ -1037,7 +1046,7 @@ function getCOIReassignedTemplate(
<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>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
</td>
</tr>
</table>
@@ -1048,7 +1057,7 @@ function getCOIReassignedTemplate(
<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>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
</td>
</tr>
</table>
@@ -1056,7 +1065,7 @@ function getCOIReassignedTemplate(
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.`)}
${paragraph(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(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.')}
@@ -1104,7 +1113,7 @@ function getManualReassignedTemplate(
<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>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
</td>
</tr>
</table>
@@ -1115,7 +1124,7 @@ function getManualReassignedTemplate(
<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>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
</td>
</tr>
</table>
@@ -1123,7 +1132,7 @@ function getManualReassignedTemplate(
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>.`)}
${paragraph(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
${projectList}
${deadlineBox}
${paragraph(`Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
@@ -1174,7 +1183,7 @@ function getDropoutReassignedTemplate(
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
<tr>
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
</td>
</tr>
</table>
@@ -1185,7 +1194,7 @@ function getDropoutReassignedTemplate(
<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>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
</td>
</tr>
</table>
@@ -1193,10 +1202,10 @@ function getDropoutReassignedTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
${projectList}
${deadlineBox}
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${escapeHtml(droppedJurorName)}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
`
@@ -1241,7 +1250,7 @@ function getBatchAssignedTemplate(
<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>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
</td>
</tr>
</table>
@@ -1249,7 +1258,7 @@ function getBatchAssignedTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(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.')}
@@ -1294,7 +1303,7 @@ function getRoundNowOpenTemplate(
<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>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(roundName)} is Now Open</h2>
</td>
</tr>
</table>
@@ -1305,7 +1314,7 @@ function getRoundNowOpenTemplate(
<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>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
</td>
</tr>
</table>
@@ -1367,9 +1376,9 @@ function getReminder24HTemplate(
const content = `
${sectionTitle(greeting)}
${urgentBox}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 24 hours.`)}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 24 hours.`)}
${statCard('Pending Evaluations', pendingCount)}
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(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') : ''}
`
@@ -1421,9 +1430,9 @@ function getReminder3DaysTemplate(
const content = `
${sectionTitle(greeting)}
${urgentBox}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 3 days.`)}
${statCard('Pending Evaluations', pendingCount)}
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(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') : ''}
`
@@ -1476,7 +1485,7 @@ function getReminder1HTemplate(
const content = `
${sectionTitle(greeting)}
${urgentBanner}
${paragraph(`<strong style="color: ${BRAND.red};">${roundName}</strong> closes in <strong>1 hour</strong>.`)}
${paragraph(`<strong style="color: ${BRAND.red};">${escapeHtml(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') : ''}
@@ -1521,7 +1530,7 @@ function getAwardVotingOpenTemplate(
<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>
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${escapeHtml(awardName)}</h2>
</td>
</tr>
</table>
@@ -1530,9 +1539,9 @@ function getAwardVotingOpenTemplate(
const content = `
${sectionTitle(greeting)}
${awardBanner}
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${awardName}</strong>.`)}
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`)}
${statCard('Finalists', finalistCount)}
${deadline ? infoBox(`<strong>Voting closes:</strong> ${deadline}`, 'warning') : ''}
${deadline ? infoBox(`<strong>Voting closes:</strong> ${escapeHtml(deadline)}`, 'warning') : ''}
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
`
@@ -1576,9 +1585,9 @@ function getMenteeAssignedTemplate(
<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.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(projectName)}</p>
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
<strong>Team Lead:</strong> ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
<strong>Team Lead:</strong> ${escapeHtml(teamLeadName)}${teamLeadEmail ? ` (${escapeHtml(teamLeadEmail)})` : ''}
</p>
</td>
</tr>
@@ -1630,9 +1639,9 @@ function getMenteeAdvancedTemplate(
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!`)}
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has advanced to the next stage!`)}
${statCard('Advanced From', roundName)}
${nextRoundName ? paragraph(`They will now compete in <strong>${nextRoundName}</strong>.`) : ''}
${nextRoundName ? paragraph(`They will now compete in <strong>${escapeHtml(nextRoundName)}</strong>.`) : ''}
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
`
@@ -1680,7 +1689,7 @@ function getMenteeWonTemplate(
const content = `
${sectionTitle(greeting)}
${trophyBanner}
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has won the <strong>${awardName}</strong>!`)}
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has won the <strong>${escapeHtml(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.')}
`
@@ -1719,9 +1728,9 @@ function getNewApplicationTemplate(
<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.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
<strong>Applicant:</strong> ${applicantName} (${applicantEmail})
<strong>Applicant:</strong> ${escapeHtml(applicantName)} (${escapeHtml(applicantEmail)})
</p>
</td>
</tr>
@@ -1730,7 +1739,7 @@ function getNewApplicationTemplate(
const content = `
${sectionTitle('New Application Received')}
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>.`)}
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>.`)}
${applicationCard}
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
`
@@ -1770,11 +1779,7 @@ export function getAdvancementNotificationTemplate(
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
? escapeHtml(customMessage).replace(/\n/g, '<br>')
: null
// Full custom body mode: only the custom message inside the branded wrapper
@@ -1807,8 +1812,8 @@ export function getAdvancementNotificationTemplate(
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${infoBox(`<strong>"${projectName}"</strong>`, 'success')}
${infoBox(`Advanced from <strong>${fromRoundName}</strong> to <strong>${toRoundName}</strong>`, 'info')}
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong>`, 'success')}
${infoBox(`Advanced from <strong>${escapeHtml(fromRoundName)}</strong> to <strong>${escapeHtml(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>`
@@ -1857,11 +1862,7 @@ export function getRejectionNotificationTemplate(
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
? escapeHtml(customMessage).replace(/\n/g, '<br>')
: null
// Full custom body mode: only the custom message inside the branded wrapper
@@ -1882,7 +1883,7 @@ export function getRejectionNotificationTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
${paragraph(`Thank you for your participation in <strong>${escapeHtml(roundName)}</strong> with your project <strong>"${escapeHtml(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
@@ -1942,17 +1943,13 @@ export function getAwardSelectionNotificationTemplate(
`
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
? escapeHtml(customMessage).replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${announcementBanner}
${infoBox(`<strong>"${projectName}"</strong> has been shortlisted for consideration for the <strong>${awardName}</strong>.`, 'info')}
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong> has been shortlisted for consideration for the <strong>${escapeHtml(awardName)}</strong>.`, 'info')}
${paragraph('This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.')}
${
escapedMessage
@@ -1994,11 +1991,7 @@ 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 formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
const content = `
${sectionTitle(subject)}
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
@@ -2021,7 +2014,7 @@ export function getAccountReminderTemplate(
const content = `
${sectionTitle(greeting)}
${paragraph(`Your project <strong>"${projectName}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
${paragraph(`Your project <strong>"${escapeHtml(projectName)}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')}
${ctaButton(accountUrl, 'Set Up Your Account')}
${paragraph('If you have any questions, please contact the MOPC team.')}
@@ -2454,11 +2447,7 @@ function getNotificationEmailTemplate(
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 formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
const content = `
${sectionTitle(greeting)}

View File

@@ -1264,35 +1264,55 @@ export const roundRouter = router({
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
const expiryMs = expiryHours * 60 * 60 * 1000
let invited = 0
let skipped = 0
let failed = 0
// Phase 1: Batch all DB writes — generate tokens and update users
const toInvite: Array<{ id: string; email: string; name: string | null; role: string; token: string }> = []
for (const [, user] of users) {
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
skipped++
continue
}
toInvite.push({ ...user, token: generateInviteToken() })
}
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
if (toInvite.length > 0) {
await ctx.prisma.$transaction(
toInvite.map((u) =>
ctx.prisma.user.update({
where: { id: u.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteToken: u.token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
)
)
}
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Phase 2: Send emails with concurrency pool of 10
const CONCURRENCY = 10
let invited = 0
for (let i = 0; i < toInvite.length; i += CONCURRENCY) {
const batch = toInvite.slice(i, i + CONCURRENCY)
const results = await Promise.allSettled(
batch.map((u) => {
const inviteUrl = `${baseUrl}/accept-invite?token=${u.token}`
return sendInvitationEmail(u.email, u.name, inviteUrl, u.role, expiryHours)
})
)
for (const result of results) {
if (result.status === 'fulfilled') {
invited++
} catch (err) {
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
} else {
console.error('[bulkInviteTeamMembers] Email send failed:', result.reason)
failed++
}
}
}
await logAudit({
prisma: ctx.prisma,

View File

@@ -284,9 +284,9 @@ export async function processEligibilityJob(
eligibilityJobError: errorMessage,
},
})
} catch {
} catch (updateErr) {
// If we can't even update the status, log and give up
console.error('Failed to update eligibility job status:', error)
console.error('Failed to update eligibility job status:', updateErr, 'Original error:', error)
}
}
}