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:
@@ -54,6 +54,15 @@ export type FinalizationSummary = {
|
||||
conceptProposed: number
|
||||
}
|
||||
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: {
|
||||
needsInvite: number
|
||||
hasAccount: number
|
||||
@@ -435,12 +444,14 @@ export async function getFinalizationSummary(
|
||||
include: {
|
||||
competition: {
|
||||
select: {
|
||||
name: true,
|
||||
rounds: {
|
||||
select: { id: true, name: true, sortOrder: true, roundType: true },
|
||||
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 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
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: { roundId },
|
||||
@@ -611,6 +631,7 @@ export async function getFinalizationSummary(
|
||||
conceptProposed,
|
||||
},
|
||||
nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null,
|
||||
winnerContext,
|
||||
accountStats: { needsInvite, hasAccount },
|
||||
}
|
||||
}
|
||||
@@ -634,12 +655,14 @@ export async function confirmFinalization(
|
||||
competition: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
rounds: {
|
||||
select: { id: true, name: true, sortOrder: true, roundType: true },
|
||||
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
|
||||
// copy reflects the shared destination (e.g. "Grand Finale") rather than
|
||||
// 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
|
||||
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
@@ -914,26 +945,48 @@ export async function confirmFinalization(
|
||||
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||
|
||||
notificationItems.push({
|
||||
email: recipient.email,
|
||||
name: recipient.name || '',
|
||||
type: 'ADVANCEMENT_NOTIFICATION',
|
||||
context: {
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: accountUrl || '/applicant',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
fromRoundName: round.name,
|
||||
toRoundName: targetRoundName,
|
||||
customMessage: options.advancementMessage || undefined,
|
||||
accountUrl,
|
||||
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,
|
||||
})
|
||||
projectId: prs.projectId,
|
||||
userId: recipient.userId || undefined,
|
||||
roundId: round.id,
|
||||
})
|
||||
} else {
|
||||
notificationItems.push({
|
||||
email: recipient.email,
|
||||
name: recipient.name || '',
|
||||
type: 'ADVANCEMENT_NOTIFICATION',
|
||||
context: {
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: accountUrl || '/applicant',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
fromRoundName: round.name,
|
||||
toRoundName: targetRoundName,
|
||||
customMessage: options.advancementMessage || undefined,
|
||||
accountUrl,
|
||||
},
|
||||
},
|
||||
projectId: prs.projectId,
|
||||
userId: recipient.userId || undefined,
|
||||
roundId: round.id,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
notificationItems.push({
|
||||
email: recipient.email,
|
||||
@@ -964,9 +1017,11 @@ export async function confirmFinalization(
|
||||
if (advancedUserIds.size > 0) {
|
||||
createBulkNotifications({
|
||||
userIds: [...advancedUserIds],
|
||||
type: 'project_advanced',
|
||||
title: 'Your project has advanced!',
|
||||
message: `Your project has advanced from "${round.name}" to "${targetRoundName}".`,
|
||||
type: winnerLabel ? 'project_won' : 'project_advanced',
|
||||
title: winnerLabel ? 'Your project has won!' : 'Your project has advanced!',
|
||||
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',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Trophy',
|
||||
|
||||
Reference in New Issue
Block a user