feat(awards): notify jurors on assignment + admin reminder button
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m41s
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:
@@ -513,6 +513,13 @@ export default function AwardDetailPage({
|
|||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
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({
|
const setWinner = trpc.specialAward.setWinner.useMutation({
|
||||||
onSuccess: invalidateAward,
|
onSuccess: invalidateAward,
|
||||||
})
|
})
|
||||||
@@ -1335,7 +1342,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
{/* Jurors Tab */}
|
{/* Jurors Tab */}
|
||||||
<TabsContent value="jurors" className="space-y-4">
|
<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}>
|
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
|
||||||
<SelectTrigger className="w-64">
|
<SelectTrigger className="w-64">
|
||||||
<SelectValue placeholder="Select a juror..." />
|
<SelectValue placeholder="Select a juror..." />
|
||||||
@@ -1355,6 +1362,19 @@ export default function AwardDetailPage({
|
|||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Add Juror
|
Add Juror
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Import from Jury Group */}
|
{/* Import from Jury Group */}
|
||||||
@@ -1549,11 +1569,23 @@ export default function AwardDetailPage({
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveJuror(j.userId)}
|
onClick={() => handleRemoveJuror(j.userId)}
|
||||||
disabled={removeJuror.isPending}
|
disabled={removeJuror.isPending}
|
||||||
|
title="Remove juror"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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
|
* 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 })
|
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)
|
* Send jury invitation email (round-specific)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { getUserAvatarUrl } from '../utils/avatar-url'
|
|||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||||
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
||||||
import { getAwardSelectionNotificationTemplate, sendJuryInvitationEmail } from '@/lib/email'
|
import {
|
||||||
|
getAwardSelectionNotificationTemplate,
|
||||||
|
sendJuryInvitationEmail,
|
||||||
|
sendAwardJurorNotificationEmail,
|
||||||
|
} from '@/lib/email'
|
||||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||||
import { attachProjectLogoUrls } from '../utils/project-logo-url'
|
import { attachProjectLogoUrls } from '../utils/project-logo-url'
|
||||||
import { sendBatchNotifications } from '../services/notification-sender'
|
import { sendBatchNotifications } from '../services/notification-sender'
|
||||||
@@ -31,6 +35,58 @@ async function ensureUserExists(db: PrismaClient, userId: string): Promise<strin
|
|||||||
return user.id
|
return user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the "you've been assigned to vote on this award" email to a set of
|
||||||
|
* jurors. Used by addJuror / bulkInviteJurors (auto-send on assignment) and
|
||||||
|
* by the explicit notifyJurors admin reminder. Errors per recipient are
|
||||||
|
* caught so a single SMTP failure doesn't break the bulk operation.
|
||||||
|
*/
|
||||||
|
async function sendAwardJurorEmails(
|
||||||
|
db: PrismaClient,
|
||||||
|
awardId: string,
|
||||||
|
userIds: string[],
|
||||||
|
options: { customMessage?: string; isReminder?: boolean } = {},
|
||||||
|
): Promise<{ sent: number; failed: number }> {
|
||||||
|
if (userIds.length === 0) return { sent: 0, failed: 0 }
|
||||||
|
|
||||||
|
const award = await db.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: awardId },
|
||||||
|
select: { id: true, name: true, votingEndAt: true },
|
||||||
|
})
|
||||||
|
const eligibleCount = await db.awardEligibility.count({
|
||||||
|
where: { awardId, eligible: true },
|
||||||
|
})
|
||||||
|
const users = await db.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, email: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || ''
|
||||||
|
const url = `${baseUrl}/jury/awards/${awardId}`
|
||||||
|
|
||||||
|
let sent = 0
|
||||||
|
let failed = 0
|
||||||
|
for (const u of users) {
|
||||||
|
try {
|
||||||
|
await sendAwardJurorNotificationEmail({
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
awardName: award.name,
|
||||||
|
url,
|
||||||
|
eligibleCount,
|
||||||
|
votingEndAt: award.votingEndAt,
|
||||||
|
customMessage: options.customMessage,
|
||||||
|
isReminder: options.isReminder,
|
||||||
|
})
|
||||||
|
sent++
|
||||||
|
} catch {
|
||||||
|
// Email failure shouldn't break the calling operation.
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sent, failed }
|
||||||
|
}
|
||||||
|
|
||||||
export const specialAwardRouter = router({
|
export const specialAwardRouter = router({
|
||||||
// ─── Admin Queries ──────────────────────────────────────────────────────
|
// ─── Admin Queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -521,15 +577,22 @@ export const specialAwardRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
awardId: z.string(),
|
awardId: z.string(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
|
sendEmail: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return ctx.prisma.awardJuror.create({
|
const created = await ctx.prisma.awardJuror.create({
|
||||||
data: {
|
data: {
|
||||||
awardId: input.awardId,
|
awardId: input.awardId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (input.sendEmail) {
|
||||||
|
await sendAwardJurorEmails(ctx.prisma, input.awardId, [input.userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
return created
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -561,20 +624,35 @@ export const specialAwardRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
awardId: z.string(),
|
awardId: z.string(),
|
||||||
userIds: z.array(z.string()),
|
userIds: z.array(z.string()),
|
||||||
|
sendEmail: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const data = input.userIds.map((userId) => ({
|
const existingRows = await ctx.prisma.awardJuror.findMany({
|
||||||
|
where: { awardId: input.awardId, userId: { in: input.userIds } },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
const existing = new Set(existingRows.map((r) => r.userId))
|
||||||
|
const newlyAddedIds = input.userIds.filter((id) => !existing.has(id))
|
||||||
|
|
||||||
|
const data = newlyAddedIds.map((userId) => ({
|
||||||
awardId: input.awardId,
|
awardId: input.awardId,
|
||||||
userId,
|
userId,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await ctx.prisma.awardJuror.createMany({
|
if (data.length > 0) {
|
||||||
data,
|
await ctx.prisma.awardJuror.createMany({
|
||||||
skipDuplicates: true,
|
data,
|
||||||
})
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { added: input.userIds.length }
|
let emailStats = { sent: 0, failed: 0 }
|
||||||
|
if (input.sendEmail && newlyAddedIds.length > 0) {
|
||||||
|
emailStats = await sendAwardJurorEmails(ctx.prisma, input.awardId, newlyAddedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added: newlyAddedIds.length, ...emailStats }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -641,6 +719,13 @@ export const specialAwardRouter = router({
|
|||||||
results.push({ email: invitee.email, status: 'existing' })
|
results.push({ email: invitee.email, status: 'existing' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const priorAttachment = await ctx.prisma.awardJuror.findUnique({
|
||||||
|
where: {
|
||||||
|
awardId_userId: { awardId: input.awardId, userId: user.id },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
await ctx.prisma.awardJuror.upsert({
|
await ctx.prisma.awardJuror.upsert({
|
||||||
where: {
|
where: {
|
||||||
awardId_userId: { awardId: input.awardId, userId: user.id },
|
awardId_userId: { awardId: input.awardId, userId: user.id },
|
||||||
@@ -648,6 +733,16 @@ export const specialAwardRouter = router({
|
|||||||
update: {},
|
update: {},
|
||||||
create: { awardId: input.awardId, userId: user.id },
|
create: { awardId: input.awardId, userId: user.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// For existing-user invitees the new-account invite email above
|
||||||
|
// never fired (no `created` branch). Send the juror-assignment
|
||||||
|
// notification so they know they were added — but only if this
|
||||||
|
// call actually attached them (skip duplicate "Bulk Invite" clicks
|
||||||
|
// to avoid spam).
|
||||||
|
const lastResult = results[results.length - 1]
|
||||||
|
if (lastResult?.status === 'existing' && !priorAttachment) {
|
||||||
|
await sendAwardJurorEmails(ctx.prisma, input.awardId, [user.id])
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.push({
|
results.push({
|
||||||
email: invitee.email,
|
email: invitee.email,
|
||||||
@@ -679,6 +774,51 @@ export const specialAwardRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a reminder email to currently-assigned jurors. Pass `userIds` to
|
||||||
|
* target a subset, omit to email every juror on the award. The email links
|
||||||
|
* the juror straight to the voting page.
|
||||||
|
*/
|
||||||
|
notifyJurors: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
userIds: z.array(z.string()).optional(),
|
||||||
|
customMessage: z.string().max(1000).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const jurors = await ctx.prisma.awardJuror.findMany({
|
||||||
|
where: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
...(input.userIds && input.userIds.length > 0
|
||||||
|
? { userId: { in: input.userIds } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetIds = jurors.map((j) => j.userId)
|
||||||
|
const stats = await sendAwardJurorEmails(ctx.prisma, input.awardId, targetIds, {
|
||||||
|
customMessage: input.customMessage,
|
||||||
|
isReminder: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'NOTIFY',
|
||||||
|
entityType: 'AwardJuror',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'JUROR_REMINDER',
|
||||||
|
targetUserIds: targetIds,
|
||||||
|
...stats,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { targeted: targetIds.length, ...stats }
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Jury Queries ───────────────────────────────────────────────────────
|
// ─── Jury Queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user