Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup
Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1f32597a0
commit
8d0979e649
File diff suppressed because it is too large
Load Diff
|
|
@ -56,7 +56,6 @@
|
||||||
"@trpc/client": "^11.0.0-rc.678",
|
"@trpc/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",
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,6 @@ model User {
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|
||||||
@@index([email])
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.` },
|
||||||
|
|
|
||||||
|
|
@ -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.` },
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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're looking for doesn't exist or has been moved.
|
The page you're looking for doesn'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">
|
||||||
|
© {new Date().getFullYear()} Monaco Ocean Protection Challenge.
|
||||||
|
All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -244,26 +244,26 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||||
<div className="border-t p-3">
|
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue