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:
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user