feat(finalization): winner email + UI for terminal rounds
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:
Matt
2026-05-05 20:30:35 +02:00
parent e8d0bb050f
commit 55e6abc161
4 changed files with 209 additions and 36 deletions

View File

@@ -19,6 +19,7 @@ import {
} from '../services/round-finalization'
import {
getAdvancementNotificationTemplate,
getAwardWinnerNotificationTemplate,
getRejectionNotificationTemplate,
} from '@/lib/email'
@@ -384,30 +385,45 @@ export const roundEngineRouter = router({
name: true,
competition: {
select: {
name: true,
rounds: {
select: { id: true, name: true, sortOrder: true, roundType: true },
orderBy: { sortOrder: 'asc' },
},
},
},
specialAward: { select: { name: true } },
},
})
const rounds = round.competition.rounds
// Skip MENTORING rounds for display — those are opt-in and not the
// 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({
where: { roundId: input.roundId, proposedOutcome: 'PASSED' },
})
const template = getAdvancementNotificationTemplate(
'Team Member',
'Your Project',
round.name,
toRoundName,
input.customMessage || undefined,
)
// 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',
'Your Project',
round.name,
displayNext?.name ?? 'Next Round',
input.customMessage || undefined,
)
return { html: template.html, subject: template.subject, recipientCount: passedCount }
}),