From 94cbfec70a15d380c09d4e1f2a061fb35bafa454 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 7 Mar 2026 16:59:56 +0100 Subject: [PATCH] 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 --- src/lib/email.ts | 171 +++++++++---------- src/server/routers/round.ts | 54 ++++-- src/server/services/award-eligibility-job.ts | 4 +- 3 files changed, 119 insertions(+), 110 deletions(-) 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) } } }