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

@@ -513,6 +513,13 @@ export default function AwardDetailPage({
},
onError: (err) => toast.error(err.message),
})
const notifyJurors = trpc.specialAward.notifyJurors.useMutation({
onSuccess: (data) => {
const failedNote = data.failed > 0 ? ` (${data.failed} failed)` : ''
toast.success(`Reminder sent to ${data.sent} of ${data.targeted} juror(s)${failedNote}`)
},
onError: (err) => toast.error(err.message),
})
const setWinner = trpc.specialAward.setWinner.useMutation({
onSuccess: invalidateAward,
})
@@ -1335,7 +1342,7 @@ export default function AwardDetailPage({
{/* Jurors Tab */}
<TabsContent value="jurors" className="space-y-4">
<div className="flex gap-2">
<div className="flex flex-wrap items-center gap-2">
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Select a juror..." />
@@ -1355,6 +1362,19 @@ export default function AwardDetailPage({
<UserPlus className="mr-2 h-4 w-4" />
Add Juror
</Button>
{jurors && jurors.length > 0 && (
<Button
variant="outline"
onClick={() => notifyJurors.mutate({ awardId })}
disabled={notifyJurors.isPending}
className="ml-auto"
>
<Mail className="mr-2 h-4 w-4" />
{notifyJurors.isPending
? 'Sending...'
: `Send reminder to all (${jurors.length})`}
</Button>
)}
</div>
{/* Import from Jury Group */}
@@ -1549,11 +1569,23 @@ export default function AwardDetailPage({
/>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
notifyJurors.mutate({ awardId, userIds: [j.userId] })
}
disabled={notifyJurors.isPending}
title="Send reminder email"
>
<Mail className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveJuror(j.userId)}
disabled={removeJuror.isPending}
title="Remove juror"
>
<X className="h-4 w-4" />
</Button>