feat(awards): notify jurors on assignment + admin reminder button
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m41s

The previous addJuror / bulkAddJurors / bulkInviteJurors flows silently
created AwardJuror rows with no notification when the user already had
an account. The result: assigned jurors had no idea they were assigned
unless they happened to log in and check /jury/awards manually.

Three changes:

1. New email template + sender (sendAwardJurorNotificationEmail). Tells
   the juror what the award is, how many projects are eligible, when
   voting closes, and links straight to /jury/awards/<id>. Reused for
   both the initial assignment notification and admin reminders.

2. Auto-send on assignment. addJuror / bulkAddJurors / bulkInviteJurors
   now send the email to newly-attached jurors. bulkInviteJurors checks
   for a prior AwardJuror row before sending so duplicate "Bulk Invite"
   clicks don't spam jurors who were already assigned. addJuror /
   bulkAddJurors accept a `sendEmail` flag so admin tooling can opt out.

3. New admin procedure specialAward.notifyJurors(awardId, userIds?,
   customMessage?). Surfaced in the Jurors tab as a "Send reminder to
   all" button at the top and a per-row mail icon for individual
   reminders. Audit-logged with action: 'JUROR_REMINDER'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 13:17:29 +02:00
parent 7d72ee271f
commit 6e36704bb1
3 changed files with 277 additions and 9 deletions

View File

@@ -564,6 +564,79 @@ Together for a healthier ocean.
}
}
/**
* Generate award juror notification template — used when an admin assigns a
* juror to a special award and when sending follow-up reminders. Tells the
* juror what the award is, how many projects are eligible, and links them
* straight to the voting page.
*/
function getAwardJurorNotificationTemplate(
name: string,
awardName: string,
url: string,
options?: {
eligibleCount?: number
votingEndAt?: Date | null
customMessage?: string
isReminder?: boolean
},
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const eligibleCount = options?.eligibleCount
const votingEndAt = options?.votingEndAt
const customMessage = options?.customMessage?.trim()
const isReminder = options?.isReminder ?? false
const lead = isReminder
? `This is a reminder that you've been assigned as a juror for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`
: `You've been assigned as a juror for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`
const projectsLine = typeof eligibleCount === 'number' && eligibleCount > 0
? paragraph(`There ${eligibleCount === 1 ? 'is' : 'are'} <strong>${eligibleCount}</strong> eligible project${eligibleCount === 1 ? '' : 's'} for you to review.`)
: ''
const deadlineLine = votingEndAt
? paragraph(`<strong>Voting closes:</strong> ${escapeHtml(votingEndAt.toLocaleString('en-GB', { dateStyle: 'long', timeStyle: 'short' }))}`)
: ''
const customMessageHtml = customMessage
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${escapeHtml(customMessage).replace(/\n/g, '<br>')}</div>`
: ''
const content = `
${sectionTitle(greeting)}
${paragraph(lead)}
${projectsLine}
${deadlineLine}
${customMessageHtml}
${ctaButton(url, 'Review & Vote')}
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
Sign in with your existing MOPC credentials to access the voting page.
</p>
`
return {
subject: isReminder
? `Reminder: vote for the ${awardName}`
: `You've been assigned as a juror for ${awardName}`,
html: getEmailWrapper(content),
text: `
${greeting}
${isReminder ? 'This is a reminder that you' : 'You'}'ve been assigned as a juror for ${awardName}.
${typeof eligibleCount === 'number' && eligibleCount > 0 ? `\nThere ${eligibleCount === 1 ? 'is' : 'are'} ${eligibleCount} eligible project${eligibleCount === 1 ? '' : 's'} for you to review.` : ''}${votingEndAt ? `\nVoting closes: ${votingEndAt.toLocaleString('en-GB', { dateStyle: 'long', timeStyle: 'short' })}` : ''}
${customMessage ? `\n${customMessage}\n` : ''}
Review & vote: ${url}
Sign in with your existing MOPC credentials to access the voting page.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate jury invitation email template
*/
@@ -2308,6 +2381,29 @@ export async function sendInvitationEmail(
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
* Send award juror notification — used both for the initial assignment
* notification and for admin-triggered reminders.
*/
export async function sendAwardJurorNotificationEmail(opts: {
email: string
name: string | null
awardName: string
url: string
eligibleCount?: number
votingEndAt?: Date | null
customMessage?: string
isReminder?: boolean
}): Promise<void> {
const template = getAwardJurorNotificationTemplate(opts.name || '', opts.awardName, opts.url, {
eligibleCount: opts.eligibleCount,
votingEndAt: opts.votingEndAt,
customMessage: opts.customMessage,
isReminder: opts.isReminder,
})
await sendEmail({ to: opts.email, subject: template.subject, text: template.text, html: template.html })
}
/**
* Send jury invitation email (round-specific)
*/