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

@@ -0,0 +1,82 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { UserPlus, Loader2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
interface BulkInviteButtonProps {
roundId: string
}
export function BulkInviteButton({ roundId }: BulkInviteButtonProps) {
const preview = trpc.round.getBulkInvitePreview.useQuery({ roundId })
const inviteMutation = trpc.round.bulkInviteTeamMembers.useMutation({
onSuccess: (data) => {
toast.success(
`Invited ${data.invited} team member${data.invited !== 1 ? 's' : ''}${data.skipped ? ` (${data.skipped} already active/invited)` : ''}`
)
void preview.refetch()
},
onError: (err) => toast.error(err.message),
})
const uninvited = preview.data?.uninvitedCount ?? 0
if (uninvited === 0 && !preview.isLoading) return null
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-blue-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<UserPlus className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Invite Team Members</p>
<p className="text-xs text-muted-foreground mt-0.5">
{preview.isLoading
? 'Checking...'
: `${uninvited} team member${uninvited !== 1 ? 's' : ''} need invitations`}
</p>
</div>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Invite team members?</AlertDialogTitle>
<AlertDialogDescription>
This will send invitation emails to {uninvited} team member
{uninvited !== 1 ? 's' : ''} who haven&apos;t been invited yet.
{preview.data?.alreadyInvitedCount
? ` (${preview.data.alreadyInvitedCount} already invited)`
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => inviteMutation.mutate({ roundId })}
disabled={inviteMutation.isPending}
>
{inviteMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
`Send ${uninvited} Invitation${uninvited !== 1 ? 's' : ''}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}