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

@@ -40,7 +40,7 @@ const OFFICE_MIME_TYPES = [
const OFFICE_EXTENSIONS = ['.pptx', '.ppt', '.docx', '.doc']
function isOfficeFile(mimeType: string, fileName: string): boolean {
export function isOfficeFile(mimeType: string, fileName: string): boolean {
if (OFFICE_MIME_TYPES.includes(mimeType)) return true
const ext = fileName.toLowerCase().slice(fileName.lastIndexOf('.'))
return OFFICE_EXTENSIONS.includes(ext)
@@ -633,7 +633,7 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla
)
}
function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
export function FilePreview({ file, url }: { file: { mimeType: string; fileName: string }; url: string }) {
if (file.mimeType.startsWith('video/')) {
return (
<video

View File

@@ -252,6 +252,14 @@ export function NotificationBell() {
}
)
// Mark all notifications as read when popover opens
useEffect(() => {
if (open && isAuthenticated && unreadCount > 0) {
markAllAsReadMutation.mutate()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})

View File

@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Upload, Loader2, ZoomIn, ImageIcon } from 'lucide-react'
import { Upload, Loader2, Trash2, ZoomIn, ImageIcon } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
@@ -24,6 +24,7 @@ type ProjectLogoUploadProps = {
projectId: string
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
@@ -72,6 +73,7 @@ export function ProjectLogoUpload({
projectId,
currentLogoUrl,
onUploadComplete,
children,
}: ProjectLogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
@@ -79,10 +81,12 @@ export function ProjectLogoUpload({
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getProjectLogoUploadUrl.useMutation()
const confirmUpload = trpc.applicant.confirmProjectLogo.useMutation()
const deleteLogo = trpc.applicant.deleteProjectLogo.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
@@ -148,6 +152,21 @@ export function ProjectLogoUpload({
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
@@ -164,43 +183,48 @@ export function ProjectLogoUpload({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
{children || (
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom.'
: 'Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP.'}
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
showGrid={false}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
@@ -213,6 +237,7 @@ export function ProjectLogoUpload({
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
@@ -227,6 +252,7 @@ export function ProjectLogoUpload({
</>
) : (
<>
{/* Current logo preview */}
{currentLogoUrl && (
<div className="flex justify-center">
<img
@@ -237,6 +263,7 @@ export function ProjectLogoUpload({
</div>
)}
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
@@ -253,6 +280,22 @@ export function ProjectLogoUpload({
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel

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
}

View File

@@ -11,7 +11,7 @@ type UserAvatarProps = {
email?: string | null
profileImageKey?: string | null
}
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
className?: string
showEditOverlay?: boolean
avatarUrl?: string | null
@@ -23,6 +23,7 @@ const sizeClasses = {
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
'2xl': 'h-24 w-24',
}
const textSizeClasses = {
@@ -31,6 +32,7 @@ const textSizeClasses = {
md: 'text-sm',
lg: 'text-base',
xl: 'text-lg',
'2xl': 'text-2xl',
}
const iconSizeClasses = {
@@ -39,6 +41,7 @@ const iconSizeClasses = {
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-6 w-6',
'2xl': 'h-8 w-8',
}
export function UserAvatar({