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

@@ -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++
}
}
}