Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup
Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1f32597a0
commit
8d0979e649
File diff suppressed because it is too large
Load Diff
|
|
@ -56,7 +56,6 @@
|
|||
"@trpc/client": "^11.0.0-rc.678",
|
||||
"@trpc/react-query": "^11.0.0-rc.678",
|
||||
"@trpc/server": "^11.0.0-rc.678",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -82,13 +81,13 @@
|
|||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"twilio": "^5.4.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
|
|
|
|||
|
|
@ -259,7 +259,6 @@ model User {
|
|||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@index([email])
|
||||
@@index([role])
|
||||
@@index([status])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -519,7 +519,7 @@ export default function ProjectsPage() {
|
|||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{project.files?.length ?? 0}</TableCell>
|
||||
<TableCell>{project._count?.files ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -186,8 +186,8 @@ function ProgramRounds({ program }: { program: any }) {
|
|||
<CardContent>
|
||||
{rounds.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{/* Desktop: Table header */}
|
||||
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
<div>Order</div>
|
||||
<div>Round</div>
|
||||
<div>Status</div>
|
||||
|
|
@ -207,7 +207,7 @@ function ProgramRounds({ program }: { program: any }) {
|
|||
items={rounds.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2 lg:space-y-1">
|
||||
{rounds.map((round, index) => (
|
||||
<SortableRoundRow
|
||||
key={round.id}
|
||||
|
|
@ -378,157 +378,229 @@ function SortableRoundRow({
|
|||
)
|
||||
}
|
||||
|
||||
const actionsMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Round
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Judge Assignments
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{round.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Activate Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
|
||||
}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Close Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'CLOSED' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Round
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
const deleteDialog = (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{round.name}"? This will
|
||||
remove {round._count?.projects || 0} project assignments,{' '}
|
||||
{round._count?.assignments || 0} reviewer assignments, and all evaluations
|
||||
in this round. The projects themselves will remain in the program. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteRound.mutate({ id: round.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteRound.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5 rounded-lg border bg-card transition-all',
|
||||
'rounded-lg border bg-card transition-all',
|
||||
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
|
||||
isReordering && !isDragging && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Order number with drag handle */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
|
||||
disabled={isReordering}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||
{index}
|
||||
</span>
|
||||
{/* Desktop: Table row layout */}
|
||||
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5">
|
||||
{/* Order number with drag handle */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
|
||||
disabled={isReordering}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||
{index}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Round name */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>{getStatusBadge()}</div>
|
||||
|
||||
{/* Voting window */}
|
||||
<div>{getVotingWindow()}</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.projects || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Assignments */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div>
|
||||
{actionsMenu}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round name */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>{getStatusBadge()}</div>
|
||||
|
||||
{/* Voting window */}
|
||||
<div>{getVotingWindow()}</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.projects || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Assignments */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Round
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Judge Assignments
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{round.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Activate Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
|
||||
}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Close Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'CLOSED' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
{/* Mobile/Tablet: Card layout */}
|
||||
<div className="lg:hidden p-4">
|
||||
{/* Top row: drag handle, order, name, status badge, actions */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center gap-1 pt-0.5">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
|
||||
disabled={isReordering}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Round
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||
{index}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline line-clamp-1"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{getStatusBadge()}
|
||||
{actionsMenu}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{round.name}"? This will
|
||||
remove {round._count?.projects || 0} project assignments,{' '}
|
||||
{round._count?.assignments || 0} reviewer assignments, and all evaluations
|
||||
in this round. The projects themselves will remain in the program. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteRound.mutate({ id: round.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteRound.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{/* Details row */}
|
||||
<div className="mt-3 ml-11 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Voting Window</p>
|
||||
<div className="mt-0.5">{getVotingWindow()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.projects || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Reviewers</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteDialog}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -548,7 +620,8 @@ function RoundsListSkeleton() {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block space-y-3">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className="flex justify-between items-center py-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
|
|
@ -560,6 +633,26 @@ function RoundsListSkeleton() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile/Tablet skeleton */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
<div className="ml-10 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { Inter } from 'next/font/google'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
|
|
@ -8,14 +6,12 @@ export default function PublicLayout({
|
|||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={`min-h-screen bg-background ${inter.className}`}>
|
||||
<div className="min-h-screen bg-background font-sans">
|
||||
{/* Simple header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-white">M</span>
|
||||
</div>
|
||||
<Logo variant="small" />
|
||||
<span className="font-semibold">Monaco Ocean Protection Challenge</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
|
@ -73,15 +73,17 @@ export default function ProfileSettingsPage() {
|
|||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
|
||||
// Populate form when user data loads
|
||||
if (user && !profileLoaded) {
|
||||
setName(user.name || '')
|
||||
const meta = (user.metadataJson as Record<string, unknown>) || {}
|
||||
setBio((meta.bio as string) || '')
|
||||
setPhoneNumber(user.phoneNumber || '')
|
||||
setNotificationPreference(user.notificationPreference || 'EMAIL')
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
setProfileLoaded(true)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (user && !profileLoaded) {
|
||||
setName(user.name || '')
|
||||
const meta = (user.metadataJson as Record<string, unknown>) || {}
|
||||
setBio((meta.bio as string) || '')
|
||||
setPhoneNumber(user.phoneNumber || '')
|
||||
setNotificationPreference(user.notificationPreference || 'EMAIL')
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
setProfileLoaded(true)
|
||||
}
|
||||
}, [user, profileLoaded])
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
|
|
@ -23,6 +24,15 @@ function validateNewPassword(password: string): string | null {
|
|||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000)
|
||||
|
||||
|
|
@ -50,6 +60,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only change their own email password
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only change your own email password.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000)
|
||||
|
||||
|
|
@ -30,6 +40,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only check their own email credentials
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only verify your own email credentials.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export async function GET() {
|
|||
services: {
|
||||
database: 'disconnected',
|
||||
},
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
|
|
@ -12,25 +13,32 @@ export default function Error({
|
|||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error)
|
||||
console.error('Application error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
|
||||
<AlertTriangle className="h-16 w-16 text-destructive/50" />
|
||||
<h1 className="text-2xl font-semibold">Something went wrong</h1>
|
||||
<p className="max-w-md text-muted-foreground">
|
||||
An unexpected error occurred. Please try again or contact support if the
|
||||
problem persists.
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<AlertTriangle className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-display font-bold text-brand-blue">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-body text-muted-foreground">
|
||||
An unexpected error occurred. Please try again or return to the
|
||||
dashboard.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground">Error ID: {error.digest}</p>
|
||||
<p className="mt-2 text-tiny text-muted-foreground/60">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Refresh Page
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button size="lg" onClick={() => reset()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@
|
|||
--secondary-foreground: 198 85% 18%;
|
||||
|
||||
--muted: 30 6% 96%;
|
||||
--muted-foreground: 30 8% 45%;
|
||||
--muted-foreground: 30 8% 38%;
|
||||
|
||||
/* Accent - MOPC Teal */
|
||||
--accent: 194 25% 44%;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default function RootLayout({
|
|||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="light">
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster
|
||||
|
|
|
|||
|
|
@ -1,18 +1,53 @@
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileQuestion } from 'lucide-react'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
|
||||
<FileQuestion className="h-16 w-16 text-muted-foreground/50" />
|
||||
<h1 className="text-2xl font-semibold">Page Not Found</h1>
|
||||
<p className="text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-white">
|
||||
<div className="container-app flex h-16 items-center">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/images/MOPC-blue-long.png"
|
||||
alt="MOPC - Monaco Ocean Protection Challenge"
|
||||
width={140}
|
||||
height={45}
|
||||
className="h-10 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<p className="text-[8rem] font-bold leading-none tracking-tight text-brand-blue/10 sm:text-[12rem]">
|
||||
404
|
||||
</p>
|
||||
<h1 className="-mt-4 text-display font-bold text-brand-blue sm:-mt-8">
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-body text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border bg-brand-blue py-6 text-white">
|
||||
<div className="container-app text-center">
|
||||
<p className="text-small">
|
||||
© {new Date().getFullYear()} Monaco Ocean Protection Challenge.
|
||||
All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,26 +244,26 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||
<div className="border-t p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{/* Avatar */}
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
|
||||
{/* Online indicator */}
|
||||
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" />
|
||||
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-background bg-emerald-500" />
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{user.name || 'User'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{roleLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-slate-600" />
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -207,6 +208,18 @@ function NotificationItem({
|
|||
export function NotificationBell() {
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all')
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
// Derive the role-based path prefix from the current route
|
||||
const pathPrefix = pathname.startsWith('/admin')
|
||||
? '/admin'
|
||||
: pathname.startsWith('/jury')
|
||||
? '/jury'
|
||||
: pathname.startsWith('/mentor')
|
||||
? '/mentor'
|
||||
: pathname.startsWith('/observer')
|
||||
? '/observer'
|
||||
: ''
|
||||
|
||||
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
|
||||
undefined,
|
||||
|
|
@ -277,7 +290,7 @@ export function NotificationBell() {
|
|||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={'/admin/settings' as Route}>
|
||||
<Link href={`${pathPrefix}/settings` as Route}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="sr-only">Notification settings</span>
|
||||
</Link>
|
||||
|
|
@ -342,7 +355,7 @@ export function NotificationBell() {
|
|||
{notifications.length > 0 && (
|
||||
<div className="p-2 border-t bg-muted/30">
|
||||
<Button variant="ghost" className="w-full" asChild>
|
||||
<Link href={'/admin/notifications' as Route}>View all notifications</Link>
|
||||
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,18 @@ export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_
|
|||
function createMinioClient(): Minio.Client {
|
||||
const url = new URL(MINIO_ENDPOINT)
|
||||
|
||||
const accessKey = process.env.MINIO_ACCESS_KEY
|
||||
const secretKey = process.env.MINIO_SECRET_KEY
|
||||
if (process.env.NODE_ENV === 'production' && (!accessKey || !secretKey)) {
|
||||
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables are required in production')
|
||||
}
|
||||
|
||||
return new Minio.Client({
|
||||
endPoint: url.hostname,
|
||||
port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
|
||||
useSSL: url.protocol === 'https:',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
|
||||
accessKey: accessKey || 'minioadmin',
|
||||
secretKey: secretKey || 'minioadmin',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -109,12 +115,3 @@ export function generateObjectKey(
|
|||
return `projects/${projectId}/${timestamp}-${sanitizedName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata from MinIO
|
||||
*/
|
||||
export async function getObjectInfo(
|
||||
bucket: string,
|
||||
objectKey: string
|
||||
): Promise<Minio.BucketItemStat> {
|
||||
return minio.statObject(bucket, objectKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,10 +129,3 @@ export function isValidImageType(contentType: string): boolean {
|
|||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
return validTypes.includes(contentType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image file size (default 5MB max)
|
||||
*/
|
||||
export function isValidImageSize(sizeBytes: number, maxMB: number = 5): boolean {
|
||||
return sizeBytes <= maxMB * 1024 * 1024
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { createHmac } from 'crypto'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { StorageProvider } from './types'
|
||||
|
||||
const SECRET_KEY = process.env.NEXTAUTH_SECRET || 'local-storage-secret'
|
||||
function getSecretKey(): string {
|
||||
const key = process.env.NEXTAUTH_SECRET
|
||||
if (!key) {
|
||||
throw new Error('NEXTAUTH_SECRET environment variable is required for local storage signing')
|
||||
}
|
||||
return key
|
||||
}
|
||||
const DEFAULT_BASE_PATH = './uploads'
|
||||
|
||||
/**
|
||||
|
|
@ -31,7 +37,7 @@ export class LocalStorageProvider implements StorageProvider {
|
|||
): string {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds
|
||||
const payload = `${action}:${key}:${expiresAt}`
|
||||
const signature = createHmac('sha256', SECRET_KEY)
|
||||
const signature = createHmac('sha256', getSecretKey())
|
||||
.update(payload)
|
||||
.digest('hex')
|
||||
|
||||
|
|
@ -55,17 +61,29 @@ export class LocalStorageProvider implements StorageProvider {
|
|||
signature: string
|
||||
): boolean {
|
||||
const payload = `${action}:${key}:${expiresAt}`
|
||||
const expectedSignature = createHmac('sha256', SECRET_KEY)
|
||||
const expectedSignature = createHmac('sha256', getSecretKey())
|
||||
.update(payload)
|
||||
.digest('hex')
|
||||
|
||||
return signature === expectedSignature && expiresAt > Date.now() / 1000
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
const sigBuffer = Buffer.from(signature, 'hex')
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'hex')
|
||||
if (sigBuffer.length !== expectedBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(sigBuffer, expectedBuffer) && expiresAt > Date.now() / 1000
|
||||
}
|
||||
|
||||
private getFilePath(key: string): string {
|
||||
// Sanitize key to prevent path traversal
|
||||
const sanitizedKey = key.replace(/\.\./g, '').replace(/^\//, '')
|
||||
return path.join(this.basePath, sanitizedKey)
|
||||
const resolved = path.resolve(this.basePath, sanitizedKey)
|
||||
const resolvedBase = path.resolve(this.basePath)
|
||||
if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
|
||||
throw new Error('Invalid file path: path traversal detected')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private async ensureDirectory(filePath: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -150,9 +150,13 @@ export const analyticsRouter = router({
|
|||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
status: true,
|
||||
assignments: {
|
||||
include: {
|
||||
select: {
|
||||
evaluation: {
|
||||
select: { criterionScoresJson: true, status: true },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
// Zod schemas for the application form
|
||||
const teamMemberSchema = z.object({
|
||||
|
|
@ -153,6 +154,16 @@ export const applicationRouter = router({
|
|||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Stricter rate limit for application submissions: 5 per hour per IP
|
||||
const ip = ctx.ip || 'unknown'
|
||||
const submitRateLimit = checkRateLimit(`app-submit:${ip}`, 5, 60 * 60 * 1000)
|
||||
if (!submitRateLimit.success) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: 'Too many application submissions. Please try again later.',
|
||||
})
|
||||
}
|
||||
|
||||
const { roundId, data } = input
|
||||
|
||||
// Verify round exists and is open
|
||||
|
|
@ -351,6 +362,16 @@ export const applicationRouter = router({
|
|||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Rate limit to prevent email enumeration
|
||||
const ip = ctx.ip || 'unknown'
|
||||
const emailCheckLimit = checkRateLimit(`email-check:${ip}`, 20, 15 * 60 * 1000)
|
||||
if (!emailCheckLimit.success) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: 'Too many requests. Please try again later.',
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
|
|
|
|||
|
|
@ -472,10 +472,19 @@ export const assignmentRouter = router({
|
|||
})
|
||||
: undefined
|
||||
|
||||
// Send batch notification to each user
|
||||
// Group users by project count so we can send bulk notifications per group
|
||||
const usersByProjectCount = new Map<number, string[]>()
|
||||
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
||||
await createNotification({
|
||||
userId,
|
||||
const existing = usersByProjectCount.get(projectCount) || []
|
||||
existing.push(userId)
|
||||
usersByProjectCount.set(projectCount, existing)
|
||||
}
|
||||
|
||||
// Send bulk notifications for each project count group
|
||||
for (const [projectCount, userIds] of usersByProjectCount) {
|
||||
if (userIds.length === 0) continue
|
||||
await createBulkNotifications({
|
||||
userIds,
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: `${projectCount} Projects Assigned`,
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
|
||||
|
|
@ -884,9 +893,19 @@ export const assignmentRouter = router({
|
|||
})
|
||||
: undefined
|
||||
|
||||
// Group users by project count so we can send bulk notifications per group
|
||||
const usersByProjectCount = new Map<number, string[]>()
|
||||
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
||||
await createNotification({
|
||||
userId,
|
||||
const existing = usersByProjectCount.get(projectCount) || []
|
||||
existing.push(userId)
|
||||
usersByProjectCount.set(projectCount, existing)
|
||||
}
|
||||
|
||||
// Send bulk notifications for each project count group
|
||||
for (const [projectCount, userIds] of usersByProjectCount) {
|
||||
if (userIds.length === 0) continue
|
||||
await createBulkNotifications({
|
||||
userIds,
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: `${projectCount} Projects Assigned`,
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
|
||||
|
|
@ -972,9 +991,19 @@ export const assignmentRouter = router({
|
|||
})
|
||||
: undefined
|
||||
|
||||
// Group users by project count so we can send bulk notifications per group
|
||||
const usersByProjectCount = new Map<number, string[]>()
|
||||
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
||||
await createNotification({
|
||||
userId,
|
||||
const existing = usersByProjectCount.get(projectCount) || []
|
||||
existing.push(userId)
|
||||
usersByProjectCount.set(projectCount, existing)
|
||||
}
|
||||
|
||||
// Send bulk notifications for each project count group
|
||||
for (const [projectCount, userIds] of usersByProjectCount) {
|
||||
if (userIds.length === 0) continue
|
||||
await createBulkNotifications({
|
||||
userIds,
|
||||
type: NotificationTypes.BATCH_ASSIGNED,
|
||||
title: `${projectCount} Projects Assigned`,
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
|
||||
|
|
@ -1038,7 +1067,7 @@ export const assignmentRouter = router({
|
|||
/**
|
||||
* Get AI assignment job status (for polling)
|
||||
*/
|
||||
getAIAssignmentJobStatus: protectedProcedure
|
||||
getAIAssignmentJobStatus: adminProcedure
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({
|
||||
|
|
|
|||
|
|
@ -196,21 +196,21 @@ export const evaluationRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// Submit
|
||||
const updated = await ctx.prisma.evaluation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
// Mark assignment as completed
|
||||
await ctx.prisma.assignment.update({
|
||||
where: { id: evaluation.assignmentId },
|
||||
data: { isCompleted: true },
|
||||
})
|
||||
// Submit evaluation and mark assignment as completed atomically
|
||||
const [updated] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.evaluation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: now,
|
||||
},
|
||||
}),
|
||||
ctx.prisma.assignment.update({
|
||||
where: { id: evaluation.assignmentId },
|
||||
data: { isCompleted: true },
|
||||
}),
|
||||
])
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio'
|
||||
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
|
||||
|
||||
export const fileRouter = router({
|
||||
/**
|
||||
|
|
@ -83,6 +83,16 @@ export const fileRouter = router({
|
|||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Block dangerous file extensions
|
||||
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
|
||||
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
|
||||
if (dangerousExtensions.includes(ext)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `File type "${ext}" is not allowed`,
|
||||
})
|
||||
}
|
||||
|
||||
const bucket = BUCKET_NAME
|
||||
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
||||
|
||||
|
|
@ -147,8 +157,14 @@ export const fileRouter = router({
|
|||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Note: Actual MinIO deletion could be done here or via background job
|
||||
// For now, we just delete the database record
|
||||
// Delete actual storage object (best-effort, don't fail the operation)
|
||||
try {
|
||||
if (file.bucket && file.objectKey) {
|
||||
await deleteObject(file.bucket, file.objectKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[File] Failed to delete storage object ${file.objectKey}:`, error)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
|
|
|
|||
|
|
@ -499,36 +499,40 @@ export const filteringRouter = router({
|
|||
// Execute rules
|
||||
const results = await executeFilteringRules(rules, projects)
|
||||
|
||||
// Upsert results
|
||||
await ctx.prisma.$transaction(
|
||||
results.map((r) =>
|
||||
ctx.prisma.filteringResult.upsert({
|
||||
where: {
|
||||
roundId_projectId: {
|
||||
// Upsert results in batches to avoid long-held locks
|
||||
const BATCH_SIZE = 25
|
||||
for (let i = 0; i < results.length; i += BATCH_SIZE) {
|
||||
const batch = results.slice(i, i + BATCH_SIZE)
|
||||
await ctx.prisma.$transaction(
|
||||
batch.map((r) =>
|
||||
ctx.prisma.filteringResult.upsert({
|
||||
where: {
|
||||
roundId_projectId: {
|
||||
roundId: input.roundId,
|
||||
projectId: r.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
roundId: input.roundId,
|
||||
projectId: r.projectId,
|
||||
outcome: r.outcome,
|
||||
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
roundId: input.roundId,
|
||||
projectId: r.projectId,
|
||||
outcome: r.outcome,
|
||||
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
},
|
||||
update: {
|
||||
outcome: r.outcome,
|
||||
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
// Clear any previous override
|
||||
overriddenBy: null,
|
||||
overriddenAt: null,
|
||||
overrideReason: null,
|
||||
finalOutcome: null,
|
||||
},
|
||||
})
|
||||
update: {
|
||||
outcome: r.outcome,
|
||||
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
|
||||
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
// Clear any previous override
|
||||
overriddenBy: null,
|
||||
overriddenAt: null,
|
||||
overrideReason: null,
|
||||
finalOutcome: null,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
|
|
|
|||
|
|
@ -46,36 +46,37 @@ export const mentorRouter = router({
|
|||
input.limit
|
||||
)
|
||||
|
||||
// Enrich with mentor details
|
||||
const enrichedSuggestions = await Promise.all(
|
||||
suggestions.map(async (suggestion) => {
|
||||
const mentor = await ctx.prisma.user.findUnique({
|
||||
where: { id: suggestion.mentorId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
expertiseTags: true,
|
||||
mentorAssignments: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
// Enrich with mentor details (batch query to avoid N+1)
|
||||
const mentorIds = suggestions.map((s) => s.mentorId)
|
||||
const mentors = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: mentorIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
expertiseTags: true,
|
||||
mentorAssignments: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
const mentorMap = new Map(mentors.map((m) => [m.id, m]))
|
||||
|
||||
return {
|
||||
...suggestion,
|
||||
mentor: mentor
|
||||
? {
|
||||
id: mentor.id,
|
||||
name: mentor.name,
|
||||
email: mentor.email,
|
||||
expertiseTags: mentor.expertiseTags,
|
||||
assignmentCount: mentor.mentorAssignments.length,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
)
|
||||
const enrichedSuggestions = suggestions.map((suggestion) => {
|
||||
const mentor = mentorMap.get(suggestion.mentorId)
|
||||
return {
|
||||
...suggestion,
|
||||
mentor: mentor
|
||||
? {
|
||||
id: mentor.id,
|
||||
name: mentor.name,
|
||||
email: mentor.email,
|
||||
expertiseTags: mentor.expertiseTags,
|
||||
assignmentCount: mentor.mentorAssignments.length,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
currentMentor: null,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export const projectRouter = router({
|
|||
hasFiles: z.boolean().optional(),
|
||||
hasAssignments: z.boolean().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(5000).default(20),
|
||||
perPage: z.number().int().min(1).max(200).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
|
@ -131,7 +131,6 @@ export const projectRouter = router({
|
|||
take: perPage,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
files: true,
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
|
|
@ -139,7 +138,7 @@ export const projectRouter = router({
|
|||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { assignments: true } },
|
||||
_count: { select: { assignments: true, files: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.count({ where }),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { router, adminProcedure, superAdminProcedure, protectedProcedure } from
|
|||
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
|
||||
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
|
||||
import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage'
|
||||
import { clearStorageProviderCache } from '@/lib/storage'
|
||||
|
||||
/**
|
||||
* Categorize an OpenAI model for display
|
||||
|
|
@ -117,6 +118,11 @@ export const settingsRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Clear storage provider cache when storage_provider setting changes
|
||||
if (input.key === 'storage_provider') {
|
||||
clearStorageProviderCache()
|
||||
}
|
||||
|
||||
// Audit log (don't log actual value for secrets)
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
|
|
@ -181,6 +187,11 @@ export const settingsRouter = router({
|
|||
)
|
||||
)
|
||||
|
||||
// Clear storage provider cache if storage_provider was updated
|
||||
if (input.settings.some((s) => s.key === 'storage_provider')) {
|
||||
clearStorageProviderCache()
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -760,59 +760,6 @@ export const tagRouter = router({
|
|||
}
|
||||
}),
|
||||
|
||||
// Legacy endpoints kept for backward compatibility (redirect to job-based)
|
||||
/**
|
||||
* @deprecated Use startTaggingJob instead
|
||||
*/
|
||||
batchTagProjects: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Start job and return immediately with placeholder
|
||||
const job = await ctx.prisma.taggingJob.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
runTaggingJob(job.id, ctx.user.id).catch(console.error)
|
||||
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
jobId: job.id,
|
||||
message: 'Tagging job started in background. Check job status for progress.',
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* @deprecated Use startTaggingJob instead
|
||||
*/
|
||||
batchTagProgramProjects: adminProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Start job and return immediately with placeholder
|
||||
const job = await ctx.prisma.taggingJob.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
runTaggingJob(job.id, ctx.user.id).catch(console.error)
|
||||
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
jobId: job.id,
|
||||
message: 'Tagging job started in background. Check job status for progress.',
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Manually add a tag to a project
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -243,22 +243,15 @@ export const userRouter = router({
|
|||
get: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
console.log('[user.get] Fetching user:', input.id)
|
||||
try {
|
||||
const user = await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, mentorAssignments: true },
|
||||
},
|
||||
const user = await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, mentorAssignments: true },
|
||||
},
|
||||
})
|
||||
console.log('[user.get] Found user:', user.email)
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error('[user.get] Error fetching user:', input.id, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
return user
|
||||
}),
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
|||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
toProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
|
|
@ -131,32 +131,6 @@ function getFieldValue(
|
|||
|
||||
// ─── AI Criteria Interpretation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert project to enhanced format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: ProjectForEligibility): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: project.submissionSource ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? 0,
|
||||
},
|
||||
files: project.files?.map(f => ({ fileType: f.fileType as any })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch for AI eligibility evaluation
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
|||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
toProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
|
|
@ -275,32 +275,6 @@ interface AIScreeningResult {
|
|||
spamRisk: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert project to enhanced format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: ProjectForFiltering): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: project.submissionSource ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project.files?.length ?? 0,
|
||||
},
|
||||
files: project.files?.map(f => ({ fileType: f.fileType ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute AI screening on a batch of projects
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -22,9 +22,8 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
|||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
toProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -43,14 +42,6 @@ export interface TaggingResult {
|
|||
tokensUsed: number
|
||||
}
|
||||
|
||||
export interface BatchTaggingResult {
|
||||
processed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
results: TaggingResult[]
|
||||
}
|
||||
|
||||
interface AvailableTag {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -60,8 +51,6 @@ interface AvailableTag {
|
|||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 10
|
||||
const MAX_BATCH_SIZE = 25
|
||||
const CONFIDENCE_THRESHOLD = 0.5
|
||||
const DEFAULT_MAX_TAGS = 5
|
||||
|
||||
|
|
@ -138,48 +127,6 @@ export async function getAvailableTags(): Promise<AvailableTag[]> {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert project to format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
oceanIssue?: string | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
institution?: string | null
|
||||
tags: string[]
|
||||
foundedAt?: Date | null
|
||||
wantsMentorship?: boolean
|
||||
submissionSource?: string
|
||||
submittedAt?: Date | null
|
||||
_count?: { teamMembers?: number; files?: number }
|
||||
files?: Array<{ fileType: string | null }>
|
||||
}): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: (project.submissionSource as any) ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? 0,
|
||||
},
|
||||
files: project.files?.map((f) => ({ fileType: (f.fileType as any) ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Tagging Core ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -406,254 +353,6 @@ export async function tagProject(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common validation and setup for batch tagging
|
||||
*/
|
||||
async function validateBatchTagging(): Promise<{
|
||||
valid: boolean
|
||||
error?: string
|
||||
availableTags?: AvailableTag[]
|
||||
}> {
|
||||
const settings = await getTaggingSettings()
|
||||
console.log('[AI Tagging] Settings:', settings)
|
||||
|
||||
if (!settings.enabled) {
|
||||
console.log('[AI Tagging] AI tagging is disabled in settings')
|
||||
return {
|
||||
valid: false,
|
||||
error: 'AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if OpenAI is configured
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
console.log('[AI Tagging] OpenAI is not configured')
|
||||
return {
|
||||
valid: false,
|
||||
error: 'OpenAI API is not configured. Add your API key in Settings > AI.',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are any available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
console.log(`[AI Tagging] Found ${availableTags.length} available expertise tags`)
|
||||
if (availableTags.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No expertise tags defined. Create tags in Settings > Tags first.',
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, availableTags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch tag all untagged projects in a round
|
||||
*
|
||||
* Only processes projects with zero tags.
|
||||
*/
|
||||
export async function batchTagProjects(
|
||||
roundId: string,
|
||||
userId?: string,
|
||||
onProgress?: (processed: number, total: number) => void
|
||||
): Promise<BatchTaggingResult> {
|
||||
const validation = await validateBatchTagging()
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [validation.error!],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get ALL projects in round to check their tag status
|
||||
const allProjects = await prisma.project.findMany({
|
||||
where: { roundId },
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[AI Tagging] Found ${allProjects.length} total projects in round`)
|
||||
|
||||
// Filter to only projects that truly have no tags (empty tags array)
|
||||
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
|
||||
|
||||
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
|
||||
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
|
||||
|
||||
if (untaggedProjects.length === 0) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors: alreadyTaggedCount > 0
|
||||
? []
|
||||
: ['No projects found in this round'],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results: TaggingResult[] = []
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
console.log(`[AI Tagging] Starting batch processing of ${untaggedProjects.length} projects in round...`)
|
||||
const startTime = Date.now()
|
||||
|
||||
for (let i = 0; i < untaggedProjects.length; i++) {
|
||||
const project = untaggedProjects[i]
|
||||
const projectStartTime = Date.now()
|
||||
console.log(`[AI Tagging] Processing project ${i + 1}/${untaggedProjects.length}: "${project.title.substring(0, 50)}..."`)
|
||||
|
||||
try {
|
||||
const result = await tagProject(project.id, userId)
|
||||
results.push(result)
|
||||
processed++
|
||||
const elapsed = ((Date.now() - projectStartTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] ✓ Tagged "${project.title.substring(0, 30)}..." with ${result.applied.length} tags (${elapsed}s)`)
|
||||
} catch (error) {
|
||||
failed++
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
|
||||
errors.push(`${project.title}: ${errorMsg}`)
|
||||
console.error(`[AI Tagging] ✗ Failed "${project.title.substring(0, 30)}...": ${errorMsg}`)
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, untaggedProjects.length)
|
||||
}
|
||||
|
||||
// Log progress every 10 projects
|
||||
if ((i + 1) % 10 === 0) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0)
|
||||
const avgTime = (Date.now() - startTime) / (i + 1) / 1000
|
||||
const remaining = avgTime * (untaggedProjects.length - i - 1)
|
||||
console.log(`[AI Tagging] Progress: ${i + 1}/${untaggedProjects.length} (${elapsed}s elapsed, ~${remaining.toFixed(0)}s remaining)`)
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] Batch complete: ${processed} tagged, ${failed} failed, ${alreadyTaggedCount} skipped in ${totalTime}s`)
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch tag all untagged projects in an entire program (edition)
|
||||
*
|
||||
* Processes all projects across all rounds in the program.
|
||||
*/
|
||||
export async function batchTagProgramProjects(
|
||||
programId: string,
|
||||
userId?: string,
|
||||
onProgress?: (processed: number, total: number) => void
|
||||
): Promise<BatchTaggingResult> {
|
||||
const validation = await validateBatchTagging()
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [validation.error!],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get ALL projects in the program (across all rounds)
|
||||
const allProjects = await prisma.project.findMany({
|
||||
where: {
|
||||
round: { programId },
|
||||
},
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[AI Tagging] Found ${allProjects.length} total projects in program`)
|
||||
|
||||
// Filter to only projects that truly have no tags (empty tags array)
|
||||
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
|
||||
|
||||
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
|
||||
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
|
||||
|
||||
if (untaggedProjects.length === 0) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors: alreadyTaggedCount > 0
|
||||
? []
|
||||
: ['No projects found in this program'],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results: TaggingResult[] = []
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
console.log(`[AI Tagging] Starting batch processing of ${untaggedProjects.length} projects...`)
|
||||
const startTime = Date.now()
|
||||
|
||||
for (let i = 0; i < untaggedProjects.length; i++) {
|
||||
const project = untaggedProjects[i]
|
||||
const projectStartTime = Date.now()
|
||||
console.log(`[AI Tagging] Processing project ${i + 1}/${untaggedProjects.length}: "${project.title.substring(0, 50)}..."`)
|
||||
|
||||
try {
|
||||
const result = await tagProject(project.id, userId)
|
||||
results.push(result)
|
||||
processed++
|
||||
const elapsed = ((Date.now() - projectStartTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] ✓ Tagged "${project.title.substring(0, 30)}..." with ${result.applied.length} tags (${elapsed}s)`)
|
||||
} catch (error) {
|
||||
failed++
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
|
||||
errors.push(`${project.title}: ${errorMsg}`)
|
||||
console.error(`[AI Tagging] ✗ Failed "${project.title.substring(0, 30)}...": ${errorMsg}`)
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, untaggedProjects.length)
|
||||
}
|
||||
|
||||
// Log progress every 10 projects
|
||||
if ((i + 1) % 10 === 0) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0)
|
||||
const avgTime = (Date.now() - startTime) / (i + 1) / 1000
|
||||
const remaining = avgTime * (untaggedProjects.length - i - 1)
|
||||
console.log(`[AI Tagging] Progress: ${i + 1}/${untaggedProjects.length} (${elapsed}s elapsed, ~${remaining.toFixed(0)}s remaining)`)
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] Batch complete: ${processed} tagged, ${failed} failed, ${alreadyTaggedCount} skipped in ${totalTime}s`)
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag suggestions for a project without applying them
|
||||
* Useful for preview/review before applying
|
||||
|
|
|
|||
|
|
@ -132,6 +132,51 @@ export interface ProjectAIMapping {
|
|||
realId: string
|
||||
}
|
||||
|
||||
// ─── Project Conversion Helper ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a loosely-typed Prisma project result to ProjectWithRelations.
|
||||
* Used by ai-tagging, ai-filtering, and ai-award-eligibility services.
|
||||
*/
|
||||
export function toProjectWithRelations(project: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
oceanIssue?: string | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
institution?: string | null
|
||||
tags: string[]
|
||||
foundedAt?: Date | null
|
||||
wantsMentorship?: boolean | null
|
||||
submissionSource?: string
|
||||
submittedAt?: Date | null
|
||||
_count?: { teamMembers?: number; files?: number }
|
||||
files?: Array<{ fileType?: string | null; [key: string]: unknown }>
|
||||
}): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as ProjectWithRelations['competitionCategory'],
|
||||
oceanIssue: project.oceanIssue as ProjectWithRelations['oceanIssue'],
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: (project.submissionSource as ProjectWithRelations['submissionSource']) ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? project.files?.length ?? 0,
|
||||
},
|
||||
files: project.files?.map((f) => ({ fileType: (f.fileType as FileType) ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Basic Anonymization (Assignment Service) ────────────────────────────────
|
||||
|
||||
interface JurorInput {
|
||||
|
|
|
|||
Loading…
Reference in New Issue