diff --git a/src/lib/email.ts b/src/lib/email.ts
index f5f9511..052ca3c 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -107,6 +107,19 @@ const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal /g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
/**
* 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 `
${text}
`
+ return `${escapeHtml(text)}
`
}
/**
@@ -305,8 +318,8 @@ function statCard(label: string, value: string | number): string {
|
- ${label}
- ${value}
+ ${escapeHtml(label)}
+ ${escapeHtml(String(value))}
|
@@ -462,7 +475,7 @@ function getEvaluationReminderTemplate(
|
Deadline
- ${deadline}
+ ${escapeHtml(deadline)}
|
@@ -470,7 +483,7 @@ function getEvaluationReminderTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`This is a friendly reminder about your pending evaluations for ${roundName}.`)}
+ ${paragraph(`This is a friendly reminder about your pending evaluations for ${escapeHtml(roundName)}.`)}
${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, '&')
- .replace(//g, '>')
- .replace(/\n/g, '
')
+ const formattedMessage = escapeHtml(message).replace(/\n/g, '
')
// Title card with success styling
const titleCard = `
- ${title}
+ ${escapeHtml(title)}
|
@@ -567,7 +576,7 @@ function getJuryInvitationTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`You've been invited to serve as a jury member for ${roundName}.`)}
+ ${paragraph(`You've been invited to serve as a jury member for ${escapeHtml(roundName)}.`)}
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
${ctaButton(url, 'Accept Invitation')}
@@ -608,13 +617,13 @@ function getApplicationConfirmationTemplate(
const greeting = name ? `Hello ${name},` : 'Hello,'
const customMessageHtml = customMessage
- ? `
${customMessage.replace(/\n/g, '
')}
`
+ ? `${escapeHtml(customMessage).replace(/\n/g, '
')}
`
: ''
const content = `
${sectionTitle(greeting)}
- ${paragraph(`Thank you for submitting your application to ${programName}!`)}
- ${infoBox(`Your project "${projectName}" has been successfully received.`, 'success')}
+ ${paragraph(`Thank you for submitting your application to ${escapeHtml(programName)}!`)}
+ ${infoBox(`Your project "${escapeHtml(projectName)}" 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.')}
@@ -656,7 +665,7 @@ function getTeamMemberInviteTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`${teamLeadName} has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform.`)}
+ ${paragraph(`${escapeHtml(teamLeadName)} has invited you to join their team for the project "${escapeHtml(projectName)}" 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 "${projectName}" has been selected to advance to the semi-finals of ${programName}.`)}
+ ${paragraph(`Your project "${escapeHtml(projectName)}" 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(`Next Steps: ${nextSteps}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
+ ${nextSteps ? paragraph(`Next Steps: ${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 "${projectName}" has been selected as a Finalist in ${programName}.`)}
+ ${paragraph(`Your project "${escapeHtml(projectName)}" has been selected as a Finalist in ${escapeHtml(programName)}.`)}
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
- ${nextSteps ? paragraph(`What Happens Next: ${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(`What Happens Next: ${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(
|
Your Mentor
- ${mentorName}
- ${mentorBio ? `${mentorBio} ` : ''}
+ ${escapeHtml(mentorName)}
+ ${mentorBio ? `${escapeHtml(mentorBio)} ` : ''}
|
@@ -827,7 +836,7 @@ function getMentorAssignedTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`Great news! A mentor has been assigned to support your project "${projectName}".`)}
+ ${paragraph(`Great news! A mentor has been assigned to support your project "${escapeHtml(projectName)}".`)}
${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 "${projectName}".`)}
+ ${paragraph(`Thank you for participating in ${escapeHtml(roundName)} with your project "${escapeHtml(projectName)}".`)}
${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.')}
Thank you for being part of the Monaco Ocean Protection Challenge community.
@@ -919,7 +928,7 @@ function getWinnerAnnouncementTemplate(
🏆
Winner
- ${awardName}
+ ${escapeHtml(awardName)}
|
@@ -928,9 +937,9 @@ function getWinnerAnnouncementTemplate(
const content = `
${sectionTitle(greeting)}
${trophyBanner}
- ${paragraph(`We are thrilled to announce that your project "${projectName}" has been selected as the winner of the ${awardName}!`)}
+ ${paragraph(`We are thrilled to announce that your project "${escapeHtml(projectName)}" has been selected as the winner of the ${escapeHtml(awardName)}!`)}
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
- ${prizeDetails ? paragraph(`Your Prize: ${prizeDetails}`) : ''}
+ ${prizeDetails ? paragraph(`Your Prize: ${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(
|
New Assignment
- ${projectName}
+ ${escapeHtml(projectName)}
|
@@ -983,7 +992,7 @@ function getAssignedToProjectTemplate(
|
Deadline
- ${deadline}
+ ${escapeHtml(deadline)}
|
@@ -991,7 +1000,7 @@ function getAssignedToProjectTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`You have been assigned a new project to evaluate for ${roundName}.`)}
+ ${paragraph(`You have been assigned a new project to evaluate for ${escapeHtml(roundName)}.`)}
${projectCard}
${deadlineBox}
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
@@ -1037,7 +1046,7 @@ function getCOIReassignedTemplate(
|
Reassigned Project
- ${projectName}
+ ${escapeHtml(projectName)}
|
@@ -1048,7 +1057,7 @@ function getCOIReassignedTemplate(
|
Deadline
- ${deadline}
+ ${escapeHtml(deadline)}
|
@@ -1056,7 +1065,7 @@ function getCOIReassignedTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`A project has been reassigned to you for evaluation in ${roundName}, because the previously assigned juror declared a conflict of interest.`)}
+ ${paragraph(`A project has been reassigned to you for evaluation in ${escapeHtml(roundName)}, 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(
|
- ${p}
+ ${escapeHtml(p)}
|
@@ -1115,7 +1124,7 @@ function getManualReassignedTemplate(
|
Deadline
- ${deadline}
+ ${escapeHtml(deadline)}
|
@@ -1123,7 +1132,7 @@ function getManualReassignedTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`An administrator has reassigned ${isSingle ? 'a project' : `${count} projects`} to you for evaluation in ${roundName}.`)}
+ ${paragraph(`An administrator has reassigned ${isSingle ? 'a project' : `${count} projects`} to you for evaluation in ${escapeHtml(roundName)}.`)}
${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(
|
- ${p}
+ ${escapeHtml(p)}
|
@@ -1185,7 +1194,7 @@ function getDropoutReassignedTemplate(
|
Deadline
- ${deadline}
+ ${escapeHtml(deadline)}
|
@@ -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 reassigned to you for evaluation in ${roundName}.`)}
+ ${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been reassigned to you for evaluation in ${escapeHtml(roundName)}.`)}
${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(
|
Deadline
- ${deadline}
+ ${escapeHtml(deadline)}
|
@@ -1249,7 +1258,7 @@ function getBatchAssignedTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`You have been assigned projects to evaluate for ${roundName}.`)}
+ ${paragraph(`You have been assigned projects to evaluate for ${escapeHtml(roundName)}.`)}
${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(
|
Evaluation Round
- ${roundName} is Now Open
+ ${escapeHtml(roundName)} is Now Open
|
@@ -1305,7 +1314,7 @@ function getRoundNowOpenTemplate(
|
Deadline
- ${deadline}
+ ${escapeHtml(deadline)}
|
@@ -1367,9 +1376,9 @@ function getReminder24HTemplate(
const content = `
${sectionTitle(greeting)}
${urgentBox}
- ${paragraph(`This is a reminder that ${roundName} closes in 24 hours.`)}
+ ${paragraph(`This is a reminder that ${escapeHtml(roundName)} closes in 24 hours.`)}
${statCard('Pending Evaluations', pendingCount)}
- ${infoBox(`Deadline: ${deadline}`, 'warning')}
+ ${infoBox(`Deadline: ${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 ${roundName} closes in 3 days.`)}
+ ${paragraph(`This is a reminder that ${escapeHtml(roundName)} closes in 3 days.`)}
${statCard('Pending Evaluations', pendingCount)}
- ${infoBox(`Deadline: ${deadline}`, 'warning')}
+ ${infoBox(`Deadline: ${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(`${roundName} closes in 1 hour.`)}
+ ${paragraph(`${escapeHtml(roundName)} closes in 1 hour.`)}
${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(
🏆
Special Award
- ${awardName}
+ ${escapeHtml(awardName)}
|
@@ -1530,9 +1539,9 @@ function getAwardVotingOpenTemplate(
const content = `
${sectionTitle(greeting)}
${awardBanner}
- ${paragraph(`Voting is now open for the ${awardName}.`)}
+ ${paragraph(`Voting is now open for the ${escapeHtml(awardName)}.`)}
${statCard('Finalists', finalistCount)}
- ${deadline ? infoBox(`Voting closes: ${deadline}`, 'warning') : ''}
+ ${deadline ? infoBox(`Voting closes: ${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(
|
Your New Mentee
- ${projectName}
+ ${escapeHtml(projectName)}
- Team Lead: ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
+ Team Lead: ${escapeHtml(teamLeadName)}${teamLeadEmail ? ` (${escapeHtml(teamLeadEmail)})` : ''}
|
@@ -1630,9 +1639,9 @@ function getMenteeAdvancedTemplate(
const content = `
${sectionTitle(greeting)}
${infoBox('Great news about your mentee!', 'success')}
- ${paragraph(`Your mentee project "${projectName}" has advanced to the next stage!`)}
+ ${paragraph(`Your mentee project "${escapeHtml(projectName)}" has advanced to the next stage!`)}
${statCard('Advanced From', roundName)}
- ${nextRoundName ? paragraph(`They will now compete in ${nextRoundName}.`) : ''}
+ ${nextRoundName ? paragraph(`They will now compete in ${escapeHtml(nextRoundName)}.`) : ''}
${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 "${projectName}" has won the ${awardName}!`)}
+ ${paragraph(`Your mentee project "${escapeHtml(projectName)}" has won the ${escapeHtml(awardName)}!`)}
${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(
|
New Application
- ${projectName}
+ ${escapeHtml(projectName)}
- Applicant: ${applicantName} (${applicantEmail})
+ Applicant: ${escapeHtml(applicantName)} (${escapeHtml(applicantEmail)})
|
@@ -1730,7 +1739,7 @@ function getNewApplicationTemplate(
const content = `
${sectionTitle('New Application Received')}
- ${paragraph(`A new application has been submitted to ${programName}.`)}
+ ${paragraph(`A new application has been submitted to ${escapeHtml(programName)}.`)}
${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, '&')
- .replace(//g, '>')
- .replace(/\n/g, '
')
+ ? escapeHtml(customMessage).replace(/\n/g, '
')
: 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(`"${projectName}"`, 'success')}
- ${infoBox(`Advanced from ${fromRoundName} to ${toRoundName}`, 'info')}
+ ${infoBox(`"${escapeHtml(projectName)}"`, 'success')}
+ ${infoBox(`Advanced from ${escapeHtml(fromRoundName)} to ${escapeHtml(toRoundName)}`, 'info')}
${
escapedMessage
? `${escapedMessage}
`
@@ -1857,11 +1862,7 @@ export function getRejectionNotificationTemplate(
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const escapedMessage = customMessage
- ? customMessage
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/\n/g, '
')
+ ? escapeHtml(customMessage).replace(/\n/g, '
')
: 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 ${roundName} with your project "${projectName}".`)}
+ ${paragraph(`Thank you for your participation in ${escapeHtml(roundName)} with your project "${escapeHtml(projectName)}".`)}
${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, '&')
- .replace(//g, '>')
- .replace(/\n/g, '
')
+ ? escapeHtml(customMessage).replace(/\n/g, '
')
: null
const content = `
${sectionTitle(greeting)}
${announcementBanner}
- ${infoBox(`"${projectName}" has been shortlisted for consideration for the ${awardName}.`, 'info')}
+ ${infoBox(`"${escapeHtml(projectName)}" has been shortlisted for consideration for the ${escapeHtml(awardName)}.`, '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, '&')
- .replace(//g, '>')
- .replace(/\n/g, '
')
+ const formattedBody = escapeHtml(body).replace(/\n/g, '
')
const content = `
${sectionTitle(subject)}
@@ -2021,7 +2014,7 @@ export function getAccountReminderTemplate(
const content = `
${sectionTitle(greeting)}
- ${paragraph(`Your project "${projectName}" has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
+ ${paragraph(`Your project "${escapeHtml(projectName)}" 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, '&')
- .replace(//g, '>')
- .replace(/\n/g, '
')
+ const formattedBody = escapeHtml(body).replace(/\n/g, '
')
const content = `
${sectionTitle(greeting)}
diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts
index c24643b..de7f7a1 100644
--- a/src/server/routers/round.ts
+++ b/src/server/routers/round.ts
@@ -1264,33 +1264,53 @@ 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 },
- data: {
- status: 'INVITED',
- inviteToken: token,
- inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
- },
+ if (toInvite.length > 0) {
+ await ctx.prisma.$transaction(
+ toInvite.map((u) =>
+ ctx.prisma.user.update({
+ where: { id: u.id },
+ data: {
+ status: 'INVITED',
+ inviteToken: u.token,
+ inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
+ },
+ })
+ )
+ )
+ }
+
+ // 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)
})
-
- const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
- await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
- invited++
- } catch (err) {
- console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
- failed++
+ )
+ for (const result of results) {
+ if (result.status === 'fulfilled') {
+ invited++
+ } else {
+ console.error('[bulkInviteTeamMembers] Email send failed:', result.reason)
+ failed++
+ }
}
}
diff --git a/src/server/services/award-eligibility-job.ts b/src/server/services/award-eligibility-job.ts
index 06456c1..b778d0c 100644
--- a/src/server/services/award-eligibility-job.ts
+++ b/src/server/services/award-eligibility-job.ts
@@ -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)
}
}
}