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:
Matt 2026-02-05 20:31:08 +01:00
parent a1f32597a0
commit 8d0979e649
35 changed files with 2463 additions and 730 deletions

1828
docs/platform-review.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -259,7 +259,6 @@ model User {
accounts Account[]
sessions Session[]
@@index([email])
@@index([role])
@@index([status])
}

View File

@ -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" />

View File

@ -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,64 +378,7 @@ function SortableRoundRow({
)
}
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',
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>
</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>
const actionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
@ -502,7 +445,9 @@ function SortableRoundRow({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const deleteDialog = (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
@ -528,7 +473,134 @@ function SortableRoundRow({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border bg-card transition-all',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}
>
{/* 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>
{/* 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}
>
<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>
{/* 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>
))}

View File

@ -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>

View File

@ -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,6 +73,7 @@ export default function ProfileSettingsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
// Populate form when user data loads
useEffect(() => {
if (user && !profileLoaded) {
setName(user.name || '')
const meta = (user.metadataJson as Record<string, unknown>) || {}
@ -82,6 +83,7 @@ export default function ProfileSettingsPage() {
setExpertiseTags(user.expertiseTags || [])
setProfileLoaded(true)
}
}, [user, profileLoaded])
const handleSaveProfile = async () => {
try {

View File

@ -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.` },

View File

@ -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.` },

View File

@ -26,7 +26,6 @@ export async function GET() {
services: {
database: 'disconnected',
},
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 503 }
)

View File

@ -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>

View File

@ -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%;

View File

@ -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

View File

@ -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">
<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&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Button asChild>
<Link href="/">Go Home</Link>
<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">
&copy; {new Date().getFullYear()} Monaco Ocean Protection Challenge.
All rights reserved.
</p>
</div>
</footer>
</div>
)
}

View File

@ -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>

View File

@ -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>
)}

View File

@ -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}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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> {

View File

@ -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 },
},

View File

@ -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,

View File

@ -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({

View File

@ -196,21 +196,21 @@ export const evaluationRouter = router({
})
}
// Submit
const updated = await ctx.prisma.evaluation.update({
// 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,
},
})
// Mark assignment as completed
await ctx.prisma.assignment.update({
}),
ctx.prisma.assignment.update({
where: { id: evaluation.assignmentId },
data: { isCompleted: true },
})
}),
])
// Audit log
await ctx.prisma.auditLog.create({

View File

@ -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({

View File

@ -499,9 +499,12 @@ export const filteringRouter = router({
// Execute rules
const results = await executeFilteringRules(rules, projects)
// Upsert results
// 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(
results.map((r) =>
batch.map((r) =>
ctx.prisma.filteringResult.upsert({
where: {
roundId_projectId: {
@ -529,6 +532,7 @@ export const filteringRouter = router({
})
)
)
}
await logAudit({
userId: ctx.user.id,

View File

@ -46,11 +46,10 @@ 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 },
// 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,
@ -61,7 +60,10 @@ export const mentorRouter = router({
},
},
})
const mentorMap = new Map(mentors.map((m) => [m.id, m]))
const enrichedSuggestions = suggestions.map((suggestion) => {
const mentor = mentorMap.get(suggestion.mentorId)
return {
...suggestion,
mentor: mentor
@ -75,7 +77,6 @@ export const mentorRouter = router({
: null,
}
})
)
return {
currentMentor: null,

View File

@ -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 }),

View File

@ -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: {

View File

@ -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
*/

View File

@ -243,8 +243,6 @@ 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: {
@ -253,12 +251,7 @@ export const userRouter = router({
},
},
})
console.log('[user.get] Found user:', user.email)
return user
} catch (error) {
console.error('[user.get] Error fetching user:', input.id, error)
throw error
}
}),
/**

View File

@ -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
*/

View File

@ -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
*/

View File

@ -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

View File

@ -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 {