feat: selectFinalists creates PENDING confirmations and sends emails

- New service module createPendingConfirmation: writes a PENDING
  FinalistConfirmation row with a signed token whose exp matches the
  computed deadline.
- selectFinalists admin mutation: reads windowHours from the round's
  configJson.confirmationWindowHours (default 24), validates category
  match + quota, then creates one confirmation per selected project
  and sends a notification email to the team lead. Email failures are
  logged but never roll back the row creation.
- New email helpers: getFinalistConfirmationTemplate +
  sendFinalistConfirmationEmail.
This commit is contained in:
Matt
2026-04-28 17:55:09 +02:00
parent 3ea36296b9
commit 895be93678
4 changed files with 419 additions and 0 deletions

View File

@@ -2567,3 +2567,79 @@ export async function sendMentorOnboardingEmail(email: string, name: string | nu
const template = getMentorOnboardingTemplate(name || '', baseUrl)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
function getFinalistConfirmationTemplate(
name: string,
projectTitle: string,
deadlineIso: string,
confirmUrl: string,
): EmailTemplate {
const subject = `Grand Finale: confirm your attendance for "${projectTitle}"`
const greeting = name ? `Hi ${name},` : 'Hi,'
const text = [
greeting,
'',
`Congratulations — your project "${projectTitle}" has been selected as a finalist`,
'for the Monaco Ocean Protection Challenge grand finale.',
'',
`Please confirm your team's attendance by ${deadlineIso}.`,
'On the confirmation page you will:',
' • Choose which team members will attend',
' • Indicate who needs visa support',
'',
`Confirm here: ${confirmUrl}`,
'',
'If your team cannot attend, please use the same link to decline so',
'we can offer the slot to a waitlisted team in time.',
'',
'The MOPC team',
].join('\n')
const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
<h1 style="margin:0;font-size:20px;font-weight:600;">You're a Grand Finale finalist</h1>
</div>
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
<p style="margin-top:0;">${greeting}</p>
<p>Congratulations — your project <strong>${escapeHtml(projectTitle)}</strong> has been selected as a finalist for the Monaco Ocean Protection Challenge grand finale.</p>
<p style="margin-top:20px;padding:12px 16px;background:#fef3c7;border-left:3px solid #d97706;border-radius:4px;">
<strong>Confirm by ${escapeHtml(deadlineIso)}.</strong>
</p>
<p>On the confirmation page you'll choose which team members will attend and indicate who needs visa support.</p>
<p style="margin-top:24px;">
<a href="${confirmUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Confirm Attendance</a>
</p>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
If your team cannot attend, please use the same link to decline so we can offer the slot to a waitlisted team in time.
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
</div>
</div>
</body>
</html>
`.trim()
return { subject, text, html }
}
/**
* Send a finalist confirmation email. Failures are intentionally not awaited
* inside any DB transaction — the calling tRPC mutation logs failures but
* does not roll back the confirmation row creation.
*/
export async function sendFinalistConfirmationEmail(
email: string,
name: string | null,
projectTitle: string,
deadline: Date,
confirmUrl: string,
): Promise<void> {
const template = getFinalistConfirmationTemplate(name || '', projectTitle, deadline.toISOString(), confirmUrl)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}