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/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678", "@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678", "@trpc/server": "^11.0.0-rc.678",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -82,13 +81,13 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"twilio": "^5.4.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15", "@types/papaparse": "^5.3.15",

View File

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

View File

@ -519,7 +519,7 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
</TableCell> </TableCell>
<TableCell>{project.files?.length ?? 0}</TableCell> <TableCell>{project._count?.files ?? 0}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />

View File

@ -186,8 +186,8 @@ function ProgramRounds({ program }: { program: any }) {
<CardContent> <CardContent>
{rounds.length > 0 ? ( {rounds.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{/* Header */} {/* Desktop: Table 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"> <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>Order</div>
<div>Round</div> <div>Round</div>
<div>Status</div> <div>Status</div>
@ -207,7 +207,7 @@ function ProgramRounds({ program }: { program: any }) {
items={rounds.map((r) => r.id)} items={rounds.map((r) => r.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-1"> <div className="space-y-2 lg:space-y-1">
{rounds.map((round, index) => ( {rounds.map((round, index) => (
<SortableRoundRow <SortableRoundRow
key={round.id} key={round.id}
@ -378,64 +378,7 @@ function SortableRoundRow({
) )
} }
return ( const actionsMenu = (
<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>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions"> <Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
@ -502,7 +445,9 @@ function SortableRoundRow({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)
const deleteDialog = (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@ -528,7 +473,134 @@ function SortableRoundRow({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> </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> </div>
) )
} }
@ -548,7 +620,8 @@ function RoundsListSkeleton() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> {/* Desktop skeleton */}
<div className="hidden lg:block space-y-3">
{[1, 2, 3].map((j) => ( {[1, 2, 3].map((j) => (
<div key={j} className="flex justify-between items-center py-2"> <div key={j} className="flex justify-between items-center py-2">
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
@ -560,6 +633,26 @@ function RoundsListSkeleton() {
</div> </div>
))} ))}
</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> </CardContent>
</Card> </Card>
))} ))}

View File

@ -1,6 +1,4 @@
import { Inter } from 'next/font/google' import { Logo } from '@/components/shared/logo'
const inter = Inter({ subsets: ['latin'] })
export default function PublicLayout({ export default function PublicLayout({
children, children,
@ -8,14 +6,12 @@ export default function PublicLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<div className={`min-h-screen bg-background ${inter.className}`}> <div className="min-h-screen bg-background font-sans">
{/* Simple header */} {/* Simple header */}
<header className="border-b bg-card"> <header className="border-b bg-card">
<div className="container mx-auto px-4 py-4"> <div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center"> <Logo variant="small" />
<span className="text-sm font-bold text-white">M</span>
</div>
<span className="font-semibold">Monaco Ocean Protection Challenge</span> <span className="font-semibold">Monaco Ocean Protection Challenge</span>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@ -73,6 +73,7 @@ export default function ProfileSettingsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
// Populate form when user data loads // Populate form when user data loads
useEffect(() => {
if (user && !profileLoaded) { if (user && !profileLoaded) {
setName(user.name || '') setName(user.name || '')
const meta = (user.metadataJson as Record<string, unknown>) || {} const meta = (user.metadataJson as Record<string, unknown>) || {}
@ -82,6 +83,7 @@ export default function ProfileSettingsPage() {
setExpertiseTags(user.expertiseTags || []) setExpertiseTags(user.expertiseTags || [])
setProfileLoaded(true) setProfileLoaded(true)
} }
}, [user, profileLoaded])
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
try { try {

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit' import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com' const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
const SMTP_HOST = process.env.SMTP_HOST || 'localhost' 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> { 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 ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000) 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() 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}`)) { if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
return NextResponse.json( return NextResponse.json(
{ error: `Email must be an @${MAIL_DOMAIN} address.` }, { error: `Email must be an @${MAIL_DOMAIN} address.` },

View File

@ -1,12 +1,22 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit' import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com' const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
const SMTP_HOST = process.env.SMTP_HOST || 'localhost' const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587') const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
export async function POST(request: NextRequest): Promise<NextResponse> { 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 ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000) 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() 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}`)) { if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
return NextResponse.json( return NextResponse.json(
{ error: `Email must be an @${MAIL_DOMAIN} address.` }, { error: `Email must be an @${MAIL_DOMAIN} address.` },

View File

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

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
@ -12,25 +13,32 @@ export default function Error({
reset: () => void reset: () => void
}) { }) {
useEffect(() => { useEffect(() => {
// Log the error to an error reporting service console.error('Application error:', error)
console.error(error)
}, [error]) }, [error])
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center"> <div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center">
<AlertTriangle className="h-16 w-16 text-destructive/50" /> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<h1 className="text-2xl font-semibold">Something went wrong</h1> <AlertTriangle className="h-8 w-8 text-primary" />
<p className="max-w-md text-muted-foreground"> </div>
An unexpected error occurred. Please try again or contact support if the <h1 className="mt-6 text-display font-bold text-brand-blue">
problem persists. 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> </p>
{error.digest && ( {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"> <div className="mt-8 flex gap-4">
<Button onClick={() => reset()}>Try Again</Button> <Button size="lg" onClick={() => reset()}>
<Button variant="outline" onClick={() => window.location.reload()}> Try Again
Refresh Page </Button>
<Button variant="outline" size="lg" asChild>
<Link href="/">Return to Dashboard</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -159,7 +159,7 @@
--secondary-foreground: 198 85% 18%; --secondary-foreground: 198 85% 18%;
--muted: 30 6% 96%; --muted: 30 6% 96%;
--muted-foreground: 30 8% 45%; --muted-foreground: 30 8% 38%;
/* Accent - MOPC Teal */ /* Accent - MOPC Teal */
--accent: 194 25% 44%; --accent: 194 25% 44%;

View File

@ -20,7 +20,7 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className="light">
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers> <Providers>{children}</Providers>
<Toaster <Toaster

View File

@ -1,18 +1,53 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { FileQuestion } from 'lucide-react'
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center"> <div className="flex min-h-screen flex-col bg-background">
<FileQuestion className="h-16 w-16 text-muted-foreground/50" /> {/* Header */}
<h1 className="text-2xl font-semibold">Page Not Found</h1> <header className="border-b border-border bg-white">
<p className="text-muted-foreground"> <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. The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p> </p>
<Button asChild> <div className="mt-8">
<Link href="/">Go Home</Link> <Button asChild size="lg">
<Link href="/">Return to Dashboard</Link>
</Button> </Button>
</div> </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"> <div className="border-t p-3">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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 */} {/* Avatar */}
<div className="relative shrink-0"> <div className="relative shrink-0">
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" /> <UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
{/* Online indicator */} {/* 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> </div>
{/* User info */} {/* User info */}
<div className="min-w-0 flex-1"> <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'} {user.name || 'User'}
</p> </p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400"> <p className="truncate text-xs text-muted-foreground">
{roleLabel} {roleLabel}
</p> </p>
</div> </div>
{/* Chevron */} {/* 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> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@ -3,6 +3,7 @@
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils' import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -207,6 +208,18 @@ function NotificationItem({
export function NotificationBell() { export function NotificationBell() {
const [filter, setFilter] = useState<'all' | 'unread'>('all') const [filter, setFilter] = useState<'all' | 'unread'>('all')
const [open, setOpen] = useState(false) 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( const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined, undefined,
@ -277,7 +290,7 @@ export function NotificationBell() {
</Button> </Button>
)} )}
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" asChild>
<Link href={'/admin/settings' as Route}> <Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span> <span className="sr-only">Notification settings</span>
</Link> </Link>
@ -342,7 +355,7 @@ export function NotificationBell() {
{notifications.length > 0 && ( {notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30"> <div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild> <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> </Button>
</div> </div>
)} )}

View File

@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'text-2xl font-semibold leading-none tracking-tight', 'text-lg font-semibold leading-none tracking-tight',
className className
)} )}
{...props} {...props}

View File

@ -15,12 +15,18 @@ export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_
function createMinioClient(): Minio.Client { function createMinioClient(): Minio.Client {
const url = new URL(MINIO_ENDPOINT) 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({ return new Minio.Client({
endPoint: url.hostname, endPoint: url.hostname,
port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80), port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
useSSL: url.protocol === 'https:', useSSL: url.protocol === 'https:',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', accessKey: accessKey || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin', secretKey: secretKey || 'minioadmin',
}) })
} }
@ -109,12 +115,3 @@ export function generateObjectKey(
return `projects/${projectId}/${timestamp}-${sanitizedName}` 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'] const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
return validTypes.includes(contentType) 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 fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import type { StorageProvider } from './types' 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' const DEFAULT_BASE_PATH = './uploads'
/** /**
@ -31,7 +37,7 @@ export class LocalStorageProvider implements StorageProvider {
): string { ): string {
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds
const payload = `${action}:${key}:${expiresAt}` const payload = `${action}:${key}:${expiresAt}`
const signature = createHmac('sha256', SECRET_KEY) const signature = createHmac('sha256', getSecretKey())
.update(payload) .update(payload)
.digest('hex') .digest('hex')
@ -55,17 +61,29 @@ export class LocalStorageProvider implements StorageProvider {
signature: string signature: string
): boolean { ): boolean {
const payload = `${action}:${key}:${expiresAt}` const payload = `${action}:${key}:${expiresAt}`
const expectedSignature = createHmac('sha256', SECRET_KEY) const expectedSignature = createHmac('sha256', getSecretKey())
.update(payload) .update(payload)
.digest('hex') .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 { private getFilePath(key: string): string {
// Sanitize key to prevent path traversal // Sanitize key to prevent path traversal
const sanitizedKey = key.replace(/\.\./g, '').replace(/^\//, '') 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> { private async ensureDirectory(filePath: string): Promise<void> {

View File

@ -150,9 +150,13 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: { select: {
id: true,
title: true,
teamName: true,
status: true,
assignments: { assignments: {
include: { select: {
evaluation: { evaluation: {
select: { criterionScoresJson: true, status: true }, select: { criterionScoresJson: true, status: true },
}, },

View File

@ -7,6 +7,7 @@ import {
notifyAdmins, notifyAdmins,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { checkRateLimit } from '@/lib/rate-limit'
// Zod schemas for the application form // Zod schemas for the application form
const teamMemberSchema = z.object({ const teamMemberSchema = z.object({
@ -153,6 +154,16 @@ export const applicationRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .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 const { roundId, data } = input
// Verify round exists and is open // Verify round exists and is open
@ -351,6 +362,16 @@ export const applicationRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .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({ const existing = await ctx.prisma.project.findFirst({
where: { where: {
roundId: input.roundId, roundId: input.roundId,

View File

@ -472,10 +472,19 @@ export const assignmentRouter = router({
}) })
: undefined : 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)) { for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({ const existing = usersByProjectCount.get(projectCount) || []
userId, 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, type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`, title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`, 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 : 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)) { for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({ const existing = usersByProjectCount.get(projectCount) || []
userId, 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, type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`, title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`, 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 : 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)) { for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({ const existing = usersByProjectCount.get(projectCount) || []
userId, 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, type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`, title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`, 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) * Get AI assignment job status (for polling)
*/ */
getAIAssignmentJobStatus: protectedProcedure getAIAssignmentJobStatus: adminProcedure
.input(z.object({ jobId: z.string() })) .input(z.object({ jobId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({ const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({

View File

@ -196,21 +196,21 @@ export const evaluationRouter = router({
}) })
} }
// Submit // Submit evaluation and mark assignment as completed atomically
const updated = await ctx.prisma.evaluation.update({ const [updated] = await ctx.prisma.$transaction([
ctx.prisma.evaluation.update({
where: { id }, where: { id },
data: { data: {
...data, ...data,
status: 'SUBMITTED', status: 'SUBMITTED',
submittedAt: now, submittedAt: now,
}, },
}) }),
ctx.prisma.assignment.update({
// Mark assignment as completed
await ctx.prisma.assignment.update({
where: { id: evaluation.assignmentId }, where: { id: evaluation.assignmentId },
data: { isCompleted: true }, data: { isCompleted: true },
}) }),
])
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({

View File

@ -1,7 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc' 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({ export const fileRouter = router({
/** /**
@ -83,6 +83,16 @@ export const fileRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .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 bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName) const objectKey = generateObjectKey(input.projectId, input.fileName)
@ -147,8 +157,14 @@ export const fileRouter = router({
where: { id: input.id }, where: { id: input.id },
}) })
// Note: Actual MinIO deletion could be done here or via background job // Delete actual storage object (best-effort, don't fail the operation)
// For now, we just delete the database record 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 // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({

View File

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

View File

@ -46,11 +46,10 @@ export const mentorRouter = router({
input.limit input.limit
) )
// Enrich with mentor details // Enrich with mentor details (batch query to avoid N+1)
const enrichedSuggestions = await Promise.all( const mentorIds = suggestions.map((s) => s.mentorId)
suggestions.map(async (suggestion) => { const mentors = await ctx.prisma.user.findMany({
const mentor = await ctx.prisma.user.findUnique({ where: { id: { in: mentorIds } },
where: { id: suggestion.mentorId },
select: { select: {
id: true, id: true,
name: 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 { return {
...suggestion, ...suggestion,
mentor: mentor mentor: mentor
@ -75,7 +77,6 @@ export const mentorRouter = router({
: null, : null,
} }
}) })
)
return { return {
currentMentor: null, currentMentor: null,

View File

@ -55,7 +55,7 @@ export const projectRouter = router({
hasFiles: z.boolean().optional(), hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(), hasAssignments: z.boolean().optional(),
page: z.number().int().min(1).default(1), 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 }) => { .query(async ({ ctx, input }) => {
@ -131,7 +131,6 @@ export const projectRouter = router({
take: perPage, take: perPage,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
files: true,
round: { round: {
select: { select: {
id: true, id: true,
@ -139,7 +138,7 @@ export const projectRouter = router({
program: { select: { id: true, name: true, year: true } }, program: { select: { id: true, name: true, year: true } },
}, },
}, },
_count: { select: { assignments: true } }, _count: { select: { assignments: true, files: true } },
}, },
}), }),
ctx.prisma.project.count({ where }), ctx.prisma.project.count({ where }),

View File

@ -3,6 +3,7 @@ import { router, adminProcedure, superAdminProcedure, protectedProcedure } from
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp' import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai' import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage' import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage'
import { clearStorageProviderCache } from '@/lib/storage'
/** /**
* Categorize an OpenAI model for display * 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) // Audit log (don't log actual value for secrets)
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
data: { 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 // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
data: { 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 * Manually add a tag to a project
*/ */

View File

@ -243,8 +243,6 @@ export const userRouter = router({
get: adminProcedure get: adminProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
console.log('[user.get] Fetching user:', input.id)
try {
const user = await ctx.prisma.user.findUniqueOrThrow({ const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.id }, where: { id: input.id },
include: { include: {
@ -253,12 +251,7 @@ export const userRouter = router({
}, },
}, },
}) })
console.log('[user.get] Found user:', user.email)
return user 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 { import {
anonymizeProjectsForAI, anonymizeProjectsForAI,
validateAnonymizedProjects, validateAnonymizedProjects,
type ProjectWithRelations, toProjectWithRelations,
type AnonymizedProjectForAI, type AnonymizedProjectForAI,
type ProjectAIMapping, type ProjectAIMapping,
} from './anonymization' } from './anonymization'
@ -131,32 +131,6 @@ function getFieldValue(
// ─── AI Criteria Interpretation ───────────────────────────────────────────── // ─── 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 * Process a batch for AI eligibility evaluation
*/ */

View File

@ -18,7 +18,7 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { import {
anonymizeProjectsForAI, anonymizeProjectsForAI,
validateAnonymizedProjects, validateAnonymizedProjects,
type ProjectWithRelations, toProjectWithRelations,
type AnonymizedProjectForAI, type AnonymizedProjectForAI,
type ProjectAIMapping, type ProjectAIMapping,
} from './anonymization' } from './anonymization'
@ -275,32 +275,6 @@ interface AIScreeningResult {
spamRisk: boolean 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 * Execute AI screening on a batch of projects
*/ */

View File

@ -22,9 +22,8 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { import {
anonymizeProjectsForAI, anonymizeProjectsForAI,
validateAnonymizedProjects, validateAnonymizedProjects,
type ProjectWithRelations, toProjectWithRelations,
type AnonymizedProjectForAI, type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization' } from './anonymization'
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@ -43,14 +42,6 @@ export interface TaggingResult {
tokensUsed: number tokensUsed: number
} }
export interface BatchTaggingResult {
processed: number
failed: number
skipped: number
errors: string[]
results: TaggingResult[]
}
interface AvailableTag { interface AvailableTag {
id: string id: string
name: string name: string
@ -60,8 +51,6 @@ interface AvailableTag {
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const DEFAULT_BATCH_SIZE = 10
const MAX_BATCH_SIZE = 25
const CONFIDENCE_THRESHOLD = 0.5 const CONFIDENCE_THRESHOLD = 0.5
const DEFAULT_MAX_TAGS = 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 ───────────────────────────────────────────────────────── // ─── 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 * Get tag suggestions for a project without applying them
* Useful for preview/review before applying * Useful for preview/review before applying

View File

@ -132,6 +132,51 @@ export interface ProjectAIMapping {
realId: string 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) ──────────────────────────────── // ─── Basic Anonymization (Assignment Service) ────────────────────────────────
interface JurorInput { interface JurorInput {