feat(finalization): winner email + UI for terminal rounds
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
When finalizing a round with no further round to advance to, passing teams
are winners — not advancers. Detected for both special-award terminal rounds
(label = award name) and the main competition's terminal round (label =
competition name). Wording uses "a winner" so it works for both single-winner
awards and top-N main-track outcomes.
Adds AWARD_WINNER_NOTIFICATION email type + template ("Your project has won!"
with "our team will reach out about next steps" copy). Routes through the
notification dispatch table the same way ADVANCEMENT_NOTIFICATION does.
The FinalizationSummary gains a `winnerContext` field; the admin finalization
tab uses it to swap "X projects will advance to Y" → "X winners will be
notified for [label]" and renames "Advancement Message" → "Winner Message"
in the custom-message field. The email-preview button shows the winner
template when applicable.
In-app notification (bell icon) gets matching winner copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -666,7 +666,9 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="text-sm font-medium">Advancement Message</label>
|
<label className="text-sm font-medium">
|
||||||
|
{summary.winnerContext ? 'Winner Message' : 'Advancement Message'}
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -681,7 +683,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
placeholder={
|
||||||
|
summary.winnerContext
|
||||||
|
? 'Custom message for winners (added to the standard winner email template)...'
|
||||||
|
: 'Custom message for projects that are advancing (added to the standard email template)...'
|
||||||
|
}
|
||||||
value={advancementMessage}
|
value={advancementMessage}
|
||||||
onChange={(e) => setAdvancementMessage(e.target.value)}
|
onChange={(e) => setAdvancementMessage(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -715,7 +721,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="flex items-center justify-between border-t pt-4">
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{summary.nextRound ? (
|
{summary.winnerContext ? (
|
||||||
|
<span>
|
||||||
|
<strong>{passedCount}</strong>{' '}
|
||||||
|
{passedCount !== 1 ? 'winners' : 'winner'} will be notified for{' '}
|
||||||
|
<strong>{summary.winnerContext.label}</strong>
|
||||||
|
</span>
|
||||||
|
) : summary.nextRound ? (
|
||||||
<span>
|
<span>
|
||||||
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
|
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
|
||||||
<strong>{summary.nextRound.name}</strong>
|
<strong>{summary.nextRound.name}</strong>
|
||||||
@@ -751,9 +763,11 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
<ul className="list-disc pl-5 space-y-1">
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
|
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
|
||||||
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
|
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
|
||||||
{summary.nextRound && (
|
{summary.winnerContext ? (
|
||||||
|
<li>Notify <strong>{passedCount}</strong> {passedCount !== 1 ? 'winners' : 'winner'} for <strong>{summary.winnerContext.label}</strong> (no further round)</li>
|
||||||
|
) : summary.nextRound ? (
|
||||||
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
|
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
|
||||||
)}
|
) : null}
|
||||||
<li>Send email notifications to all affected teams</li>
|
<li>Send email notifications to all affected teams</li>
|
||||||
</ul>
|
</ul>
|
||||||
{undecidedCount > 0 && (
|
{undecidedCount > 0 && (
|
||||||
|
|||||||
@@ -1992,6 +1992,84 @@ Together for a healthier ocean.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate "Award Winner" notification email template — used when finalizing
|
||||||
|
* the terminal round of a special award (no further rounds to advance to).
|
||||||
|
*/
|
||||||
|
export function getAwardWinnerNotificationTemplate(
|
||||||
|
name: string,
|
||||||
|
projectName: string,
|
||||||
|
winnerLabel: string,
|
||||||
|
customMessage?: string,
|
||||||
|
accountUrl?: string,
|
||||||
|
fullCustomBody?: boolean,
|
||||||
|
): EmailTemplate {
|
||||||
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||||||
|
|
||||||
|
const escapedMessage = customMessage
|
||||||
|
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (fullCustomBody && escapedMessage) {
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||||||
|
${accountUrl ? ctaButton(accountUrl, 'Create Your Account') : ctaButton('/applicant', 'View Your Dashboard')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: `Your project has won: "${projectName}"`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `${greeting}\n\n${customMessage}\n\n${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerBanner = `
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, #b45309 0%, #f59e0b 100%); border-radius: 12px; padding: 28px; text-align: center;">
|
||||||
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner Announcement</p>
|
||||||
|
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Your project has won!</h2>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
${winnerBanner}
|
||||||
|
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong> has been selected as a winner of <strong>${escapeHtml(winnerLabel)}</strong>.`, 'success')}
|
||||||
|
${
|
||||||
|
escapedMessage
|
||||||
|
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
||||||
|
: paragraph('Our team will reach out shortly with details on the next steps, including award presentation, communications, and any administrative requirements.')
|
||||||
|
}
|
||||||
|
${accountUrl ? ctaButton(accountUrl, 'Create Your Account') : ctaButton('/applicant', 'View Your Dashboard')}
|
||||||
|
`
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Your project has won: "${projectName}"`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `
|
||||||
|
${greeting}
|
||||||
|
|
||||||
|
Your project has won!
|
||||||
|
|
||||||
|
Project: ${projectName}
|
||||||
|
${winnerLabel}
|
||||||
|
|
||||||
|
"${projectName}" has been selected as a winner of ${winnerLabel}.
|
||||||
|
|
||||||
|
${customMessage || 'Our team will reach out shortly with details on the next steps, including award presentation, communications, and any administrative requirements.'}
|
||||||
|
|
||||||
|
${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}
|
||||||
|
|
||||||
|
---
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
Together for a healthier ocean.
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate "Under Consideration for Special Award" notification email template
|
* Generate "Under Consideration for Special Award" notification email template
|
||||||
*/
|
*/
|
||||||
@@ -2273,6 +2351,16 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
ctx.metadata?.fullCustomBody as boolean | undefined,
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
AWARD_WINNER_NOTIFICATION: (ctx) =>
|
||||||
|
getAwardWinnerNotificationTemplate(
|
||||||
|
ctx.name || '',
|
||||||
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
||||||
|
(ctx.metadata?.awardName as string) || 'Special Award',
|
||||||
|
ctx.metadata?.customMessage as string | undefined,
|
||||||
|
ctx.metadata?.accountUrl as string | undefined,
|
||||||
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||||
|
),
|
||||||
|
|
||||||
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
||||||
getAwardSelectionNotificationTemplate(
|
getAwardSelectionNotificationTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../services/round-finalization'
|
} from '../services/round-finalization'
|
||||||
import {
|
import {
|
||||||
getAdvancementNotificationTemplate,
|
getAdvancementNotificationTemplate,
|
||||||
|
getAwardWinnerNotificationTemplate,
|
||||||
getRejectionNotificationTemplate,
|
getRejectionNotificationTemplate,
|
||||||
} from '@/lib/email'
|
} from '@/lib/email'
|
||||||
|
|
||||||
@@ -384,28 +385,43 @@ export const roundEngineRouter = router({
|
|||||||
name: true,
|
name: true,
|
||||||
competition: {
|
competition: {
|
||||||
select: {
|
select: {
|
||||||
|
name: true,
|
||||||
rounds: {
|
rounds: {
|
||||||
select: { id: true, name: true, sortOrder: true, roundType: true },
|
select: { id: true, name: true, sortOrder: true, roundType: true },
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
specialAward: { select: { name: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const rounds = round.competition.rounds
|
const rounds = round.competition.rounds
|
||||||
// Skip MENTORING rounds for display — those are opt-in and not the
|
// Skip MENTORING rounds for display — those are opt-in and not the
|
||||||
// shared destination for all advancing teams.
|
// shared destination for all advancing teams.
|
||||||
const toRoundName = findDisplayNextRound(rounds, input.roundId)?.name ?? 'Next Round'
|
const displayNext = findDisplayNextRound(rounds, input.roundId)
|
||||||
|
|
||||||
const passedCount = await ctx.prisma.projectRoundState.count({
|
const passedCount = await ctx.prisma.projectRoundState.count({
|
||||||
where: { roundId: input.roundId, proposedOutcome: 'PASSED' },
|
where: { roundId: input.roundId, proposedOutcome: 'PASSED' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const template = getAdvancementNotificationTemplate(
|
// No further round → passing teams are winners (of the special award if
|
||||||
|
// attached, otherwise of the competition itself).
|
||||||
|
const winnerLabel = !displayNext
|
||||||
|
? round.specialAward?.name ?? round.competition.name
|
||||||
|
: null
|
||||||
|
|
||||||
|
const template = winnerLabel
|
||||||
|
? getAwardWinnerNotificationTemplate(
|
||||||
|
'Team Member',
|
||||||
|
'Your Project',
|
||||||
|
winnerLabel,
|
||||||
|
input.customMessage || undefined,
|
||||||
|
)
|
||||||
|
: getAdvancementNotificationTemplate(
|
||||||
'Team Member',
|
'Team Member',
|
||||||
'Your Project',
|
'Your Project',
|
||||||
round.name,
|
round.name,
|
||||||
toRoundName,
|
displayNext?.name ?? 'Next Round',
|
||||||
input.customMessage || undefined,
|
input.customMessage || undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export type FinalizationSummary = {
|
|||||||
conceptProposed: number
|
conceptProposed: number
|
||||||
}
|
}
|
||||||
nextRound: { id: string; name: string } | null
|
nextRound: { id: string; name: string } | null
|
||||||
|
/**
|
||||||
|
* Set when this round has no further round to advance to — passing teams
|
||||||
|
* are winners, not advancers. Covers both the main-track terminal round
|
||||||
|
* (winners of the competition, possibly multiple e.g. top 3) and the
|
||||||
|
* terminal round of a special award. `label` is what we tell the team they
|
||||||
|
* have won (competition name or award name); `awardId` is set only when
|
||||||
|
* the round is attached to a SpecialAward.
|
||||||
|
*/
|
||||||
|
winnerContext: { label: string; awardId: string | null } | null
|
||||||
accountStats: {
|
accountStats: {
|
||||||
needsInvite: number
|
needsInvite: number
|
||||||
hasAccount: number
|
hasAccount: number
|
||||||
@@ -435,12 +444,14 @@ export async function getFinalizationSummary(
|
|||||||
include: {
|
include: {
|
||||||
competition: {
|
competition: {
|
||||||
select: {
|
select: {
|
||||||
|
name: true,
|
||||||
rounds: {
|
rounds: {
|
||||||
select: { id: true, name: true, sortOrder: true, roundType: true },
|
select: { id: true, name: true, sortOrder: true, roundType: true },
|
||||||
orderBy: { sortOrder: 'asc' as const },
|
orderBy: { sortOrder: 'asc' as const },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
specialAward: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -456,6 +467,15 @@ export async function getFinalizationSummary(
|
|||||||
const rounds = round.competition.rounds
|
const rounds = round.competition.rounds
|
||||||
const nextRound = findDisplayNextRound(rounds, roundId)
|
const nextRound = findDisplayNextRound(rounds, roundId)
|
||||||
|
|
||||||
|
// Terminal round: no further round to advance to. Passing teams are
|
||||||
|
// winners (possibly multiple — e.g. top 3 in the main bracket, or the
|
||||||
|
// recipient of a special award). The label is what we tell them they've won.
|
||||||
|
const winnerContext = !nextRound
|
||||||
|
? round.specialAward
|
||||||
|
? { label: round.specialAward.name, awardId: round.specialAward.id }
|
||||||
|
: { label: round.competition.name, awardId: null }
|
||||||
|
: null
|
||||||
|
|
||||||
// Get all project states with project details
|
// Get all project states with project details
|
||||||
const projectStates = await prisma.projectRoundState.findMany({
|
const projectStates = await prisma.projectRoundState.findMany({
|
||||||
where: { roundId },
|
where: { roundId },
|
||||||
@@ -611,6 +631,7 @@ export async function getFinalizationSummary(
|
|||||||
conceptProposed,
|
conceptProposed,
|
||||||
},
|
},
|
||||||
nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null,
|
nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null,
|
||||||
|
winnerContext,
|
||||||
accountStats: { needsInvite, hasAccount },
|
accountStats: { needsInvite, hasAccount },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,12 +655,14 @@ export async function confirmFinalization(
|
|||||||
competition: {
|
competition: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
name: true,
|
||||||
rounds: {
|
rounds: {
|
||||||
select: { id: true, name: true, sortOrder: true, roundType: true },
|
select: { id: true, name: true, sortOrder: true, roundType: true },
|
||||||
orderBy: { sortOrder: 'asc' as const },
|
orderBy: { sortOrder: 'asc' as const },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
specialAward: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -668,7 +691,15 @@ export async function confirmFinalization(
|
|||||||
// Display name for emails / notifications: skip MENTORING rounds so the
|
// Display name for emails / notifications: skip MENTORING rounds so the
|
||||||
// copy reflects the shared destination (e.g. "Grand Finale") rather than
|
// copy reflects the shared destination (e.g. "Grand Finale") rather than
|
||||||
// the opt-in mentoring step.
|
// the opt-in mentoring step.
|
||||||
const targetRoundName = findDisplayNextRound(rounds, roundId)?.name ?? 'Next Round'
|
const displayNextRound = findDisplayNextRound(rounds, roundId)
|
||||||
|
const targetRoundName = displayNextRound?.name ?? 'Next Round'
|
||||||
|
|
||||||
|
// Terminal round: no further round to advance to. Passing teams are
|
||||||
|
// winners — of the special award if one is attached, otherwise of the
|
||||||
|
// competition itself (where there may be multiple winners, e.g. top 3).
|
||||||
|
const winnerLabel = !displayNextRound
|
||||||
|
? round.specialAward?.name ?? round.competition.name
|
||||||
|
: null
|
||||||
|
|
||||||
// Execute finalization in a transaction
|
// Execute finalization in a transaction
|
||||||
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
@@ -914,6 +945,27 @@ export async function confirmFinalization(
|
|||||||
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
||||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||||
|
|
||||||
|
if (winnerLabel) {
|
||||||
|
notificationItems.push({
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name || '',
|
||||||
|
type: 'AWARD_WINNER_NOTIFICATION',
|
||||||
|
context: {
|
||||||
|
title: 'Your project has won!',
|
||||||
|
message: '',
|
||||||
|
linkUrl: accountUrl || '/applicant',
|
||||||
|
metadata: {
|
||||||
|
projectName: prs.project.title,
|
||||||
|
awardName: winnerLabel,
|
||||||
|
customMessage: options.advancementMessage || undefined,
|
||||||
|
accountUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
projectId: prs.projectId,
|
||||||
|
userId: recipient.userId || undefined,
|
||||||
|
roundId: round.id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
notificationItems.push({
|
notificationItems.push({
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
name: recipient.name || '',
|
name: recipient.name || '',
|
||||||
@@ -934,6 +986,7 @@ export async function confirmFinalization(
|
|||||||
userId: recipient.userId || undefined,
|
userId: recipient.userId || undefined,
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
notificationItems.push({
|
notificationItems.push({
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
@@ -964,9 +1017,11 @@ export async function confirmFinalization(
|
|||||||
if (advancedUserIds.size > 0) {
|
if (advancedUserIds.size > 0) {
|
||||||
createBulkNotifications({
|
createBulkNotifications({
|
||||||
userIds: [...advancedUserIds],
|
userIds: [...advancedUserIds],
|
||||||
type: 'project_advanced',
|
type: winnerLabel ? 'project_won' : 'project_advanced',
|
||||||
title: 'Your project has advanced!',
|
title: winnerLabel ? 'Your project has won!' : 'Your project has advanced!',
|
||||||
message: `Your project has advanced from "${round.name}" to "${targetRoundName}".`,
|
message: winnerLabel
|
||||||
|
? `Your project has been selected as a winner of "${winnerLabel}". Our team will reach out about next steps.`
|
||||||
|
: `Your project has advanced from "${round.name}" to "${targetRoundName}".`,
|
||||||
linkUrl: '/applicant',
|
linkUrl: '/applicant',
|
||||||
linkLabel: 'View Dashboard',
|
linkLabel: 'View Dashboard',
|
||||||
icon: 'Trophy',
|
icon: 'Trophy',
|
||||||
|
|||||||
Reference in New Issue
Block a user