feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -64,12 +64,13 @@ import {
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'ROUND_APPLICANTS' | 'PROGRAM_TEAM' | 'USER'
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
{ value: 'ALL', label: 'All Users' },
{ value: 'ROLE', label: 'By Role' },
{ value: 'ROUND_JURY', label: 'Round Jury' },
{ value: 'ROUND_APPLICANTS', label: 'Round Applicants' },
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
{ value: 'USER', label: 'Specific User' },
]
@@ -110,6 +111,16 @@ export default function MessagesPage() {
{ refetchInterval: 30_000 }
)
const emailPreview = trpc.message.previewEmail.useQuery(
{ subject, body },
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
)
const sendTestMutation = trpc.message.sendTest.useMutation({
onSuccess: (data) => toast.success(`Test email sent to ${data.to}`),
onError: (e) => toast.error(e.message),
})
const sendMutation = trpc.message.send.useMutation({
onSuccess: (data) => {
const count = (data as Record<string, unknown>)?.recipientCount || ''
@@ -183,6 +194,13 @@ export default function MessagesPage() {
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
: 'Stage Jury'
}
case 'ROUND_APPLICANTS': {
if (!roundId) return 'Round Applicants (none selected)'
const appRound = rounds?.find((r) => r.id === roundId)
return appRound
? `Applicants in ${appRound.program ? `${appRound.program.name} - ` : ''}${appRound.name}`
: 'Round Applicants'
}
case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)'
const program = (programs as Array<{ id: string; name: string }> | undefined)?.find(
@@ -218,7 +236,7 @@ export default function MessagesPage() {
toast.error('Please select a role')
return
}
if (recipientType === 'ROUND_JURY' && !roundId) {
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
toast.error('Please select a round')
return
}
@@ -333,7 +351,7 @@ export default function MessagesPage() {
</div>
)}
{recipientType === 'ROUND_JURY' && (
{(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={roundId} onValueChange={setRoundId}>
@@ -670,9 +688,20 @@ export default function MessagesPage() {
<p className="text-sm font-medium mt-1">{subject}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Message</p>
<div className="mt-1 rounded-lg border bg-muted/30 p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email Preview</p>
<div className="mt-1 rounded-lg border overflow-hidden bg-gray-50">
{emailPreview.data?.html ? (
<iframe
srcDoc={emailPreview.data.html}
sandbox="allow-same-origin"
className="w-full h-[500px] border-0"
title="Email Preview"
/>
) : (
<div className="p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
</div>
)}
</div>
</div>
<div>
@@ -699,7 +728,21 @@ export default function MessagesPage() {
</div>
)}
</div>
<DialogFooter>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => sendTestMutation.mutate({ subject, body })}
disabled={sendTestMutation.isPending}
className="sm:mr-auto"
>
{sendTestMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Send Test to Me
</Button>
<Button variant="outline" onClick={() => setShowPreview(false)}>
Edit
</Button>