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

@@ -13,17 +13,27 @@ import {
Loader2,
Trash2,
RefreshCw,
Eye,
Download,
FileText,
Languages,
Play,
X,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { toast } from 'sonner'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
function getMimeLabel(mime: string): string {
if (mime === 'application/pdf') return 'PDF'
if (mime.startsWith('image/')) return 'Images'
if (mime === 'video/mp4') return 'MP4'
if (mime === 'video/quicktime') return 'MOV'
if (mime === 'video/webm') return 'WebM'
if (mime.startsWith('video/')) return 'Video'
if (mime.includes('wordprocessingml')) return 'Word'
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'Word'
if (mime.includes('spreadsheetml')) return 'Excel'
if (mime.includes('presentationml')) return 'PowerPoint'
if (mime.includes('presentationml') || mime === 'application/vnd.ms-powerpoint') return 'PowerPoint'
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
@@ -44,6 +54,11 @@ interface UploadedFile {
size: number
createdAt: string | Date
requirementId?: string | null
bucket?: string
objectKey?: string
pageCount?: number | null
detectedLang?: string | null
analyzedAt?: string | Date | null
}
interface RequirementUploadSlotProps {
@@ -55,6 +70,36 @@ interface RequirementUploadSlotProps {
disabled?: boolean
}
function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) {
const { data } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: false },
{ staleTime: 10 * 60 * 1000 }
)
const href = typeof data === 'string' ? data : data?.url
return (
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
<a href={href || '#'} target="_blank" rel="noopener noreferrer">
<Eye className="h-3 w-3" /> View
</a>
</Button>
)
}
function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
const { data } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName },
{ staleTime: 10 * 60 * 1000 }
)
const href = typeof data === 'string' ? data : data?.url
return (
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
<a href={href || '#'} download={fileName}>
<Download className="h-3 w-3" /> Download
</a>
</Button>
)
}
export function RequirementUploadSlot({
requirement,
existingFile,
@@ -66,6 +111,7 @@ export function RequirementUploadSlot({
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [deleting, setDeleting] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
@@ -181,6 +227,20 @@ export function RequirementUploadSlot({
}
}, [existingFile, deleteFile, onFileChange])
// Fetch preview URL only when preview is toggled on
const { data: previewUrlData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
{ bucket: existingFile?.bucket || '', objectKey: existingFile?.objectKey || '', forDownload: false },
{ enabled: showPreview && !!existingFile?.bucket && !!existingFile?.objectKey, staleTime: 10 * 60 * 1000 }
)
const previewUrl = typeof previewUrlData === 'string' ? previewUrlData : previewUrlData?.url
const canPreview = existingFile
? existingFile.mimeType.startsWith('video/') ||
existingFile.mimeType === 'application/pdf' ||
existingFile.mimeType.startsWith('image/') ||
isOfficeFile(existingFile.mimeType, existingFile.fileName)
: false
const isFulfilled = !!existingFile
const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
@@ -222,9 +282,9 @@ export function RequirementUploadSlot({
)}
<div className="flex flex-wrap gap-1 ml-6 mb-2">
{requirement.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
{[...new Set(requirement.acceptedMimeTypes.map(getMimeLabel))].map((label) => (
<Badge key={label} variant="outline" className="text-xs">
{label}
</Badge>
))}
{requirement.maxSizeMB && (
@@ -235,10 +295,65 @@ export function RequirementUploadSlot({
</div>
{existingFile && (
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
<div className="ml-6 space-y-1.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
{existingFile.pageCount != null && (
<span className="flex items-center gap-0.5">
<FileText className="h-3 w-3" />
{existingFile.pageCount} page{existingFile.pageCount !== 1 ? 's' : ''}
</span>
)}
{existingFile.detectedLang && existingFile.detectedLang !== 'und' && (
<span className="flex items-center gap-0.5">
<Languages className="h-3 w-3" />
{existingFile.detectedLang.toUpperCase()}
</span>
)}
</div>
{existingFile.bucket && existingFile.objectKey && (
<div className="flex items-center gap-1.5">
{canPreview && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<><X className="h-3 w-3" /> Close Preview</>
) : (
<><Play className="h-3 w-3" /> Preview</>
)}
</Button>
)}
<ViewFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} />
<DownloadFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} fileName={existingFile.fileName} />
</div>
)}
</div>
)}
{/* Inline preview panel */}
{showPreview && existingFile && (
<div className="ml-6 mt-2 rounded-lg border bg-muted/50 overflow-hidden">
{isLoadingPreview ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : previewUrl ? (
<FilePreview
file={{ mimeType: existingFile.mimeType, fileName: existingFile.fileName }}
url={previewUrl}
/>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
Failed to load preview
</div>
)}
</div>
)}
@@ -349,6 +464,11 @@ export function RequirementUploadList({ projectId, roundId, disabled }: Requirem
size: existing.size,
createdAt: existing.createdAt,
requirementId: (existing as { requirementId?: string | null }).requirementId,
bucket: (existing as { bucket?: string }).bucket,
objectKey: (existing as { objectKey?: string }).objectKey,
pageCount: (existing as { pageCount?: number | null }).pageCount,
detectedLang: (existing as { detectedLang?: string | null }).detectedLang,
analyzedAt: (existing as { analyzedAt?: string | null }).analyzedAt,
}
: null
}