AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table - Jury invite flow: create user + add to group + send invitation from dialog - Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount - Moved notify-on-advance from competition-level to per-round setting - AI filtering: round-tagged files with newest-first sorting, optional file content extraction - File content extractor service (pdf-parse for PDF, utf-8 for text files) - AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT) - generateAIRecommendations tRPC endpoint with per-round config integration - AI recommendations UI: trigger button, confirmation dialog, per-category results display - Category-aware advance dialog: select/deselect projects by category with target caps - STAGE_ACTIVE bug fix in assignment router Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93f4ad4b31
commit
80c9e35971
|
|
@ -62,6 +62,7 @@
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -83,6 +84,7 @@
|
||||||
"@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",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
|
|
@ -1629,6 +1631,190 @@
|
||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
|
|
@ -4589,6 +4775,16 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pdf-parse": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/raf": {
|
"node_modules/@types/raf": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
|
|
@ -10826,6 +11022,38 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-parse": {
|
||||||
|
"version": "2.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||||
|
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "0.1.80",
|
||||||
|
"pdfjs-dist": "5.4.296"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pdf-parse": "bin/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.4.296",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||||
|
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.16.0 || >=22.3.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.80"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -96,6 +97,7 @@
|
||||||
"@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",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ export default function CompetitionDetailPage() {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={round.id}
|
key={round.id}
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
>
|
>
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||||
<CardContent className="pt-4 pb-3 space-y-3">
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
|
|
@ -510,9 +510,6 @@ export default function CompetitionDetailPage() {
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
{competition.notifyOnRoundAdvance && (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
|
|
||||||
)}
|
|
||||||
{competition.notifyOnDeadlineApproach && (
|
{competition.notifyOnDeadlineApproach && (
|
||||||
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -473,7 +474,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Rounds</p>
|
<p className="text-sm font-medium">Rounds</p>
|
||||||
<p className="text-xs text-muted-foreground">Manage competition rounds</p>
|
<p className="text-xs text-muted-foreground">Manage rounds</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
||||||
|
|
@ -513,7 +514,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
Rounds
|
Rounds
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Competition rounds in {edition.name}
|
Active rounds in {edition.name}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -541,8 +542,9 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
|
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
|
||||||
<div
|
<Link
|
||||||
key={round.id}
|
key={round.id}
|
||||||
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
|
@ -569,7 +571,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
|
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ type UploadState = {
|
||||||
type UploadMap = Record<string, UploadState>
|
type UploadMap = Record<string, UploadState>
|
||||||
|
|
||||||
export default function BulkUploadPage() {
|
export default function BulkUploadPage() {
|
||||||
const [windowId, setWindowId] = useState('')
|
const [roundId, setRoundId] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
|
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
|
||||||
|
|
@ -96,20 +96,20 @@ export default function BulkUploadPage() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
|
const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery()
|
||||||
|
|
||||||
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
|
const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery(
|
||||||
{
|
{
|
||||||
submissionWindowId: windowId,
|
roundId,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
page,
|
page,
|
||||||
pageSize: perPage,
|
pageSize: perPage,
|
||||||
},
|
},
|
||||||
{ enabled: !!windowId }
|
{ enabled: !!roundId }
|
||||||
)
|
)
|
||||||
|
|
||||||
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
|
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
|
||||||
|
|
||||||
// Upload a single file for a project requirement
|
// Upload a single file for a project requirement
|
||||||
const uploadFileForRequirement = useCallback(
|
const uploadFileForRequirement = useCallback(
|
||||||
|
|
@ -117,7 +117,7 @@ export default function BulkUploadPage() {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
requirementId: string,
|
requirementId: string,
|
||||||
file: File,
|
file: File,
|
||||||
submissionWindowId: string
|
targetRoundId: string
|
||||||
) => {
|
) => {
|
||||||
const key = `${projectId}:${requirementId}`
|
const key = `${projectId}:${requirementId}`
|
||||||
setUploads((prev) => ({
|
setUploads((prev) => ({
|
||||||
|
|
@ -131,8 +131,8 @@ export default function BulkUploadPage() {
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType: file.type || 'application/octet-stream',
|
mimeType: file.type || 'application/octet-stream',
|
||||||
size: file.size,
|
size: file.size,
|
||||||
submissionWindowId,
|
roundId: targetRoundId,
|
||||||
submissionFileRequirementId: requirementId,
|
requirementId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// XHR upload with progress
|
// XHR upload with progress
|
||||||
|
|
@ -186,18 +186,18 @@ export default function BulkUploadPage() {
|
||||||
}
|
}
|
||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (file && windowId) {
|
if (file && roundId) {
|
||||||
uploadFileForRequirement(projectId, requirementId, file, windowId)
|
uploadFileForRequirement(projectId, requirementId, file, roundId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
},
|
},
|
||||||
[windowId, uploadFileForRequirement]
|
[roundId, uploadFileForRequirement]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle bulk row upload
|
// Handle bulk row upload
|
||||||
const handleBulkUploadAll = useCallback(async () => {
|
const handleBulkUploadAll = useCallback(async () => {
|
||||||
if (!bulkProject || !windowId) return
|
if (!bulkProject || !roundId) return
|
||||||
|
|
||||||
const entries = Object.entries(bulkFiles).filter(
|
const entries = Object.entries(bulkFiles).filter(
|
||||||
([, file]) => file !== null
|
([, file]) => file !== null
|
||||||
|
|
@ -211,14 +211,14 @@ export default function BulkUploadPage() {
|
||||||
// Upload all in parallel
|
// Upload all in parallel
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
entries.map(([reqId, file]) =>
|
entries.map(([reqId, file]) =>
|
||||||
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
|
uploadFileForRequirement(bulkProject.id, reqId, file, roundId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
setBulkProject(null)
|
setBulkProject(null)
|
||||||
setBulkFiles({})
|
setBulkFiles({})
|
||||||
toast.success('Bulk upload complete')
|
toast.success('Bulk upload complete')
|
||||||
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
|
}, [bulkProject, bulkFiles, roundId, uploadFileForRequirement])
|
||||||
|
|
||||||
const progressPercent =
|
const progressPercent =
|
||||||
data && data.totalProjects > 0
|
data && data.totalProjects > 0
|
||||||
|
|
@ -242,32 +242,37 @@ export default function BulkUploadPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Window Selector */}
|
{/* Round Selector */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Submission Window</CardTitle>
|
<CardTitle className="text-base">Round</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{windowsLoading ? (
|
{roundsLoading ? (
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
|
) : !rounds || rounds.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>No rounds have file requirements configured. Add file requirements to a round first.</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={windowId}
|
value={roundId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setWindowId(v)
|
setRoundId(v)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setUploads({})
|
setUploads({})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a submission window..." />
|
<SelectValue placeholder="Select a round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{windows?.map((w) => (
|
{rounds.map((r) => (
|
||||||
<SelectItem key={w.id} value={w.id}>
|
<SelectItem key={r.id} value={r.id}>
|
||||||
{w.competition.program.name} {w.competition.program.year} — {w.name}{' '}
|
{r.competition.program.name} {r.competition.program.year} — {r.name}{' '}
|
||||||
({w.fileRequirements.length} requirement
|
({r.fileRequirements.length} requirement
|
||||||
{w.fileRequirements.length !== 1 ? 's' : ''})
|
{r.fileRequirements.length !== 1 ? 's' : ''})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -276,8 +281,8 @@ export default function BulkUploadPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Content (only if window selected) */}
|
{/* Content (only if round selected) */}
|
||||||
{windowId && data && (
|
{roundId && data && (
|
||||||
<>
|
<>
|
||||||
{/* Progress Summary */}
|
{/* Progress Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ function ImportPageContent() {
|
||||||
Create a competition with rounds before importing projects
|
Create a competition with rounds before importing projects
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="mt-4">
|
<Button asChild className="mt-4">
|
||||||
<Link href="/admin/competitions">View Competitions</Link>
|
<Link href="/admin/rounds">View Rounds</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
@ -147,6 +148,11 @@ export default function RoundDetailPage() {
|
||||||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
||||||
const [exportOpen, setExportOpen] = useState(false)
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
|
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
|
||||||
|
const [aiRecommendations, setAiRecommendations] = useState<{
|
||||||
|
STARTUP: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
|
||||||
|
BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
|
||||||
|
} | null>(null)
|
||||||
|
const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
|
@ -243,6 +249,25 @@ export default function RoundDetailPage() {
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shortlistMutation = trpc.round.generateAIRecommendations.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.success) {
|
||||||
|
setAiRecommendations(data.recommendations)
|
||||||
|
toast.success(
|
||||||
|
`AI recommendations generated: ${data.recommendations.STARTUP.length} startups, ${data.recommendations.BUSINESS_CONCEPT.length} concepts` +
|
||||||
|
(data.tokensUsed ? ` (${data.tokensUsed} tokens)` : ''),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.error(data.errors?.join('; ') || 'AI shortlist failed')
|
||||||
|
}
|
||||||
|
setShortlistDialogOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message)
|
||||||
|
setShortlistDialogOpen(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
|
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
|
||||||
|
|
||||||
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
|
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
|
||||||
|
|
@ -828,6 +853,25 @@ export default function RoundDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* AI Shortlist Recommendations */}
|
||||||
|
{(isEvaluation || isFiltering) && projectCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShortlistDialogOpen(true)}
|
||||||
|
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-purple-200 bg-purple-50/50"
|
||||||
|
disabled={shortlistMutation.isPending}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{shortlistMutation.isPending ? 'Generating...' : 'AI Recommendations'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Generate ranked shortlist per category using AI analysis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Advance projects (shown when PASSED > 0) */}
|
{/* Advance projects (shown when PASSED > 0) */}
|
||||||
{passedCount > 0 && (
|
{passedCount > 0 && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -847,29 +891,62 @@ export default function RoundDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Advance Projects Confirmation Dialog */}
|
{/* Advance Projects Dialog */}
|
||||||
<AlertDialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
|
<AdvanceProjectsDialog
|
||||||
|
open={advanceDialogOpen}
|
||||||
|
onOpenChange={setAdvanceDialogOpen}
|
||||||
|
roundId={roundId}
|
||||||
|
projectStates={projectStates}
|
||||||
|
config={config}
|
||||||
|
advanceMutation={advanceMutation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* AI Shortlist Confirmation Dialog */}
|
||||||
|
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Advance {passedCount} project(s)?</AlertDialogTitle>
|
<AlertDialogTitle>Generate AI Recommendations?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
All projects with PASSED status in this round will be moved to the next round.
|
The AI will analyze all project evaluations and generate a ranked shortlist
|
||||||
This action creates new entries in the next round and marks current entries as completed.
|
for each category independently.
|
||||||
|
{config.startupAdvanceCount ? (
|
||||||
|
<span className="block mt-1">
|
||||||
|
Startup target: top {String(config.startupAdvanceCount)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{config.conceptAdvanceCount ? (
|
||||||
|
<span className="block">
|
||||||
|
Business Concept target: top {String(config.conceptAdvanceCount)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{config.aiParseFiles ? (
|
||||||
|
<span className="block mt-1 text-amber-600">
|
||||||
|
Document parsing is enabled — the AI will read uploaded file contents.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => advanceMutation.mutate({ roundId })}
|
onClick={() => shortlistMutation.mutate({ roundId })}
|
||||||
disabled={advanceMutation.isPending}
|
disabled={shortlistMutation.isPending}
|
||||||
>
|
>
|
||||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
{shortlistMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
Advance Projects
|
Generate
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* AI Recommendations Display */}
|
||||||
|
{aiRecommendations && (
|
||||||
|
<AIRecommendationsDisplay
|
||||||
|
recommendations={aiRecommendations}
|
||||||
|
onClear={() => setAiRecommendations(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Round Info + Project Breakdown */}
|
{/* Round Info + Project Breakdown */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -1054,7 +1131,7 @@ export default function RoundDetailPage() {
|
||||||
<CardTitle className="text-base">General Settings</CardTitle>
|
<CardTitle className="text-base">General Settings</CardTitle>
|
||||||
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
|
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
|
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
|
||||||
|
|
@ -1072,6 +1149,85 @@ export default function RoundDetailPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
|
||||||
|
Notify on advance
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Send an email to project applicants when their project advances from this round to the next
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="notify-on-advance"
|
||||||
|
checked={!!config.notifyOnAdvance}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleConfigChange({ ...config, notifyOnAdvance: checked })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="ai-parse-files" className="text-sm font-medium">
|
||||||
|
AI document parsing
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Allow AI to read the contents of uploaded files (PDF/text) for deeper analysis during filtering and evaluation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="ai-parse-files"
|
||||||
|
checked={!!config.aiParseFiles}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleConfigChange({ ...config, aiParseFiles: checked })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<Label className="text-sm font-medium">Advancement Targets</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
Target number of projects per category to advance from this round
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
|
||||||
|
Startup Projects
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="startup-advance-count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={(config.startupAdvanceCount as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
handleConfigChange({ ...config, startupAdvanceCount: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
|
||||||
|
Concept Projects
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="concept-advance-count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={(config.conceptAdvanceCount as number) ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
handleConfigChange({ ...config, conceptAdvanceCount: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -1585,6 +1741,304 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||||
|
|
||||||
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Advance Projects Dialog ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AdvanceProjectsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
roundId,
|
||||||
|
projectStates,
|
||||||
|
config,
|
||||||
|
advanceMutation,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
roundId: string
|
||||||
|
projectStates: any[] | undefined
|
||||||
|
config: Record<string, unknown>
|
||||||
|
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
|
||||||
|
}) {
|
||||||
|
const passedProjects = useMemo(() =>
|
||||||
|
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
||||||
|
[projectStates])
|
||||||
|
|
||||||
|
const startups = useMemo(() =>
|
||||||
|
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
||||||
|
[passedProjects])
|
||||||
|
|
||||||
|
const concepts = useMemo(() =>
|
||||||
|
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
|
||||||
|
[passedProjects])
|
||||||
|
|
||||||
|
const other = useMemo(() =>
|
||||||
|
passedProjects.filter((ps: any) =>
|
||||||
|
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
|
||||||
|
),
|
||||||
|
[passedProjects])
|
||||||
|
|
||||||
|
const startupCap = (config.startupAdvanceCount as number) || 0
|
||||||
|
const conceptCap = (config.conceptAdvanceCount as number) || 0
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Reset selection when dialog opens
|
||||||
|
if (open && selected.size === 0 && passedProjects.length > 0) {
|
||||||
|
const initial = new Set<string>()
|
||||||
|
// Auto-select all (or up to cap if configured)
|
||||||
|
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
|
||||||
|
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
|
||||||
|
for (const ps of startupSlice) initial.add(ps.project?.id)
|
||||||
|
for (const ps of conceptSlice) initial.add(ps.project?.id)
|
||||||
|
for (const ps of other) initial.add(ps.project?.id)
|
||||||
|
setSelected(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleProject = (projectId: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(projectId)) next.delete(projectId)
|
||||||
|
else next.add(projectId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAll = (projects: any[], on: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const ps of projects) {
|
||||||
|
if (on) next.add(ps.project?.id)
|
||||||
|
else next.delete(ps.project?.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdvance = () => {
|
||||||
|
const ids = Array.from(selected)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
advanceMutation.mutate({ roundId, projectIds: ids })
|
||||||
|
onOpenChange(false)
|
||||||
|
setSelected(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false)
|
||||||
|
setSelected(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCategorySection = (
|
||||||
|
label: string,
|
||||||
|
projects: any[],
|
||||||
|
cap: number,
|
||||||
|
badgeColor: string,
|
||||||
|
) => {
|
||||||
|
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
|
||||||
|
const overCap = cap > 0 && selectedInCategory > cap
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
|
||||||
|
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
|
||||||
|
{selectedInCategory}/{projects.length}
|
||||||
|
</Badge>
|
||||||
|
{cap > 0 && (
|
||||||
|
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
|
||||||
|
(target: {cap})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 pl-7">
|
||||||
|
{projects.map((ps: any) => (
|
||||||
|
<label
|
||||||
|
key={ps.project?.id}
|
||||||
|
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(ps.project?.id)}
|
||||||
|
onCheckedChange={() => toggleProject(ps.project?.id)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
|
||||||
|
{ps.project?.teamName && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Advance Projects</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select which passed projects to advance to the next round.
|
||||||
|
{selected.size} of {passedProjects.length} selected.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
||||||
|
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
||||||
|
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
||||||
|
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAdvance}
|
||||||
|
disabled={selected.size === 0 || advanceMutation.isPending}
|
||||||
|
>
|
||||||
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI Recommendations Display ──────────────────────────────────────────
|
||||||
|
|
||||||
|
type RecommendationItem = {
|
||||||
|
projectId: string
|
||||||
|
rank: number
|
||||||
|
score: number
|
||||||
|
category: string
|
||||||
|
strengths: string[]
|
||||||
|
concerns: string[]
|
||||||
|
recommendation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function AIRecommendationsDisplay({
|
||||||
|
recommendations,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
|
||||||
|
onClear: () => void
|
||||||
|
}) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
|
||||||
|
if (items.length === 0) return (
|
||||||
|
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||||
|
No {label.toLowerCase()} projects evaluated
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isExpanded = expandedId === `${item.category}-${item.projectId}`
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.projectId}
|
||||||
|
className="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0',
|
||||||
|
colorClass,
|
||||||
|
)}>
|
||||||
|
{item.rank}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{item.projectId}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-xs font-mono">
|
||||||
|
{item.score}/100
|
||||||
|
</Badge>
|
||||||
|
<ChevronDown className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
|
||||||
|
isExpanded && 'rotate-180',
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
|
||||||
|
<div className="pt-2">
|
||||||
|
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||||
|
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{item.concerns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||||
|
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ranked independently per category — {recommendations.STARTUP.length} startups, {recommendations.BUSINESS_CONCEPT.length} concepts
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClear}>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
Startup ({recommendations.STARTUP.length})
|
||||||
|
</h4>
|
||||||
|
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-purple-500" />
|
||||||
|
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
|
||||||
|
</h4>
|
||||||
|
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evaluation Criteria Editor ───────────────────────────────────────────
|
||||||
|
|
||||||
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [criteria, setCriteria] = useState<Array<{
|
const [criteria, setCriteria] = useState<Array<{
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,6 @@ export default function RoundsPage() {
|
||||||
categoryMode: comp.categoryMode,
|
categoryMode: comp.categoryMode,
|
||||||
startupFinalistCount: comp.startupFinalistCount,
|
startupFinalistCount: comp.startupFinalistCount,
|
||||||
conceptFinalistCount: comp.conceptFinalistCount,
|
conceptFinalistCount: comp.conceptFinalistCount,
|
||||||
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
|
|
||||||
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
|
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -492,13 +491,6 @@ function CompetitionGroup({
|
||||||
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 pt-6">
|
|
||||||
<Switch
|
|
||||||
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
|
|
||||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
|
|
||||||
/>
|
|
||||||
<Label className="text-xs font-medium">Notify on Advance</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 pt-6">
|
<div className="flex items-center gap-3 pt-6">
|
||||||
<Switch
|
<Switch
|
||||||
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,9 @@ type RoundSummary = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompetitionTimeline({
|
export function CompetitionTimeline({
|
||||||
competitionId,
|
|
||||||
rounds,
|
rounds,
|
||||||
}: {
|
}: {
|
||||||
competitionId: string
|
competitionId?: string
|
||||||
rounds: RoundSummary[]
|
rounds: RoundSummary[]
|
||||||
}) {
|
}) {
|
||||||
if (rounds.length === 0) {
|
if (rounds.length === 0) {
|
||||||
|
|
@ -70,7 +69,7 @@ export function CompetitionTimeline({
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="flex items-start">
|
<div key={round.id} className="flex items-start">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
className="group flex flex-col items-center text-center w-32 shrink-0"
|
className="group flex flex-col items-center text-center w-32 shrink-0"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -116,7 +115,7 @@ export function CompetitionTimeline({
|
||||||
return (
|
return (
|
||||||
<div key={round.id}>
|
<div key={round.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
|
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center shrink-0">
|
<div className="flex flex-col items-center shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Search } from 'lucide-react'
|
import { Search, UserPlus, Mail } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
interface AddMemberDialogProps {
|
interface AddMemberDialogProps {
|
||||||
juryGroupId: string
|
juryGroupId: string
|
||||||
|
|
@ -30,10 +31,22 @@ interface AddMemberDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
|
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
|
||||||
|
const [tab, setTab] = useState<'search' | 'invite'>('search')
|
||||||
|
|
||||||
|
// Search existing user state
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
||||||
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
|
const [capMode, setCapMode] = useState<string>('')
|
||||||
|
|
||||||
|
// Invite new user state
|
||||||
|
const [inviteName, setInviteName] = useState('')
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
|
const [inviteRole, setInviteRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
||||||
|
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
|
||||||
|
const [inviteCapMode, setInviteCapMode] = useState<string>('')
|
||||||
|
const [inviteExpertise, setInviteExpertise] = useState('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
|
@ -44,7 +57,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||||
|
|
||||||
const users = userResponse?.users || []
|
const users = userResponse?.users || []
|
||||||
|
|
||||||
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
|
const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
||||||
toast.success('Member added successfully')
|
toast.success('Member added successfully')
|
||||||
|
|
@ -56,14 +69,49 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { mutate: createUser, isPending: isCreating } = trpc.user.create.useMutation({
|
||||||
|
onSuccess: (newUser) => {
|
||||||
|
// Immediately add the newly created user to the jury group
|
||||||
|
addMember({
|
||||||
|
juryGroupId,
|
||||||
|
userId: newUser.id,
|
||||||
|
role: inviteRole,
|
||||||
|
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
|
||||||
|
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||||
|
})
|
||||||
|
// Send invitation email
|
||||||
|
sendInvitation({ userId: newUser.id, juryGroupId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Invitation sent to ${result.email}`)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
// Don't block — user was created and added, just invitation failed
|
||||||
|
toast.error(`Member added but invitation email failed: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
setRole('MEMBER')
|
setRole('MEMBER')
|
||||||
setMaxAssignments('')
|
setMaxAssignments('')
|
||||||
|
setCapMode('')
|
||||||
|
setInviteName('')
|
||||||
|
setInviteEmail('')
|
||||||
|
setInviteRole('MEMBER')
|
||||||
|
setInviteMaxAssignments('')
|
||||||
|
setInviteCapMode('')
|
||||||
|
setInviteExpertise('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!selectedUserId) {
|
if (!selectedUserId) {
|
||||||
|
|
@ -76,20 +124,59 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||||
userId: selectedUserId,
|
userId: selectedUserId,
|
||||||
role,
|
role,
|
||||||
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
|
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
|
||||||
|
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleInviteSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!inviteEmail.trim()) {
|
||||||
|
toast.error('Please enter an email address')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expertiseTags = inviteExpertise
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
createUser({
|
||||||
|
email: inviteEmail.trim(),
|
||||||
|
name: inviteName.trim() || undefined,
|
||||||
|
role: 'JURY_MEMBER',
|
||||||
|
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
|
||||||
|
maxAssignments: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = isAdding || isCreating
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Member to Jury Group</DialogTitle>
|
<DialogTitle>Add Member to Jury Group</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Search for a user and assign them to this jury group
|
Search for an existing user or invite a new juror to the platform
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<Tabs value={tab} onValueChange={(v) => setTab(v as 'search' | 'invite')}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="search" className="flex items-center gap-2">
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
Search Existing
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="invite" className="flex items-center gap-2">
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
Invite New
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Search existing user tab */}
|
||||||
|
<TabsContent value="search">
|
||||||
|
<form onSubmit={handleSearchSubmit} className="space-y-4 pt-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="search">Search User</Label>
|
<Label htmlFor="search">Search User</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -127,9 +214,10 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role">Role</Label>
|
<Label htmlFor="role">Group Role</Label>
|
||||||
<Select value={role} onValueChange={(val) => setRole(val as any)}>
|
<Select value={role} onValueChange={(val) => setRole(val as typeof role)}>
|
||||||
<SelectTrigger id="role">
|
<SelectTrigger id="role">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -141,6 +229,22 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="capMode">Cap Mode</Label>
|
||||||
|
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
|
||||||
|
<SelectTrigger id="capMode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="DEFAULT">Group Default</SelectItem>
|
||||||
|
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||||
|
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||||
|
<SelectItem value="NONE">No Cap</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
|
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -158,10 +262,115 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isPending || !selectedUserId}>
|
<Button type="submit" disabled={isPending || !selectedUserId}>
|
||||||
{isPending ? 'Adding...' : 'Add Member'}
|
{isAdding ? 'Adding...' : 'Add Member'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Invite new user tab */}
|
||||||
|
<TabsContent value="invite">
|
||||||
|
<form onSubmit={handleInviteSubmit} className="space-y-4 pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteName">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteName"
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
value={inviteName}
|
||||||
|
onChange={(e) => setInviteName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteEmail">
|
||||||
|
Email <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
required
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteGroupRole">Group Role</Label>
|
||||||
|
<Select value={inviteRole} onValueChange={(val) => setInviteRole(val as typeof inviteRole)}>
|
||||||
|
<SelectTrigger id="inviteGroupRole">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="MEMBER">Member</SelectItem>
|
||||||
|
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||||
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteCapMode">Cap Mode</Label>
|
||||||
|
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
|
||||||
|
<SelectTrigger id="inviteCapMode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="DEFAULT">Group Default</SelectItem>
|
||||||
|
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||||
|
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||||
|
<SelectItem value="NONE">No Cap</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteMaxAssignments">Max Assignments Override (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteMaxAssignments"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Leave empty to use group default"
|
||||||
|
value={inviteMaxAssignments}
|
||||||
|
onChange={(e) => setInviteMaxAssignments(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteExpertise"
|
||||||
|
placeholder="marine biology, policy, finance"
|
||||||
|
value={inviteExpertise}
|
||||||
|
onChange={(e) => setInviteExpertise(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Comma-separated tags for smart assignment matching
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
<UserPlus className="mr-1 inline h-3 w-3" />
|
||||||
|
This will create a new user account and send an invitation email to join the platform as a jury member.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending || !inviteEmail.trim()}>
|
||||||
|
{isCreating || isAdding ? 'Creating & Inviting...' : 'Create & Invite'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface JuryMember {
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
maxAssignmentsOverride: number | null
|
maxAssignmentsOverride: number | null
|
||||||
|
capModeOverride: string | null
|
||||||
preferredStartupRatio: number | null
|
preferredStartupRatio: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,13 +83,14 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
<TableHead className="hidden md:table-cell">Role</TableHead>
|
||||||
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
|
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
No members yet. Add members to get started.
|
No members yet. Add members to get started.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -109,6 +111,15 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
||||||
<TableCell className="hidden sm:table-cell">
|
<TableCell className="hidden sm:table-cell">
|
||||||
{member.maxAssignmentsOverride ?? '—'}
|
{member.maxAssignmentsOverride ?? '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
{member.capModeOverride ? (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{member.capModeOverride}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">Group default</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||||
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
||||||
title: 'AI Assignment Suggestions Ready',
|
title: 'AI Assignment Suggestions Ready',
|
||||||
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||||
linkUrl: `/admin/competitions/${round.competitionId}/assignments`,
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
linkLabel: 'View Suggestions',
|
linkLabel: 'View Suggestions',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|
|
||||||
|
|
@ -1206,4 +1206,285 @@ export const fileRouter = router({
|
||||||
orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }],
|
orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List rounds with their file requirement counts (for bulk upload round selector)
|
||||||
|
*/
|
||||||
|
listRoundsForBulkUpload: adminProcedure
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
return ctx.prisma.round.findMany({
|
||||||
|
where: {
|
||||||
|
fileRequirements: { some: {} },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
roundType: true,
|
||||||
|
sortOrder: true,
|
||||||
|
competition: {
|
||||||
|
select: { id: true, name: true, program: { select: { name: true, year: true } } },
|
||||||
|
},
|
||||||
|
fileRequirements: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ competition: { program: { year: 'desc' } } },
|
||||||
|
{ sortOrder: 'asc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List projects with upload status against a round's FileRequirements (for bulk upload)
|
||||||
|
*/
|
||||||
|
listProjectsByRoundRequirements: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
status: z.enum(['all', 'missing', 'complete']).default('all'),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
pageSize: z.number().int().min(1).max(100).default(50),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
include: {
|
||||||
|
competition: { select: { id: true, programId: true, name: true } },
|
||||||
|
fileRequirements: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Normalize requirements to a common shape
|
||||||
|
const requirements = round.fileRequirements.map((req) => ({
|
||||||
|
id: req.id,
|
||||||
|
label: req.name,
|
||||||
|
mimeTypes: req.acceptedMimeTypes,
|
||||||
|
required: req.isRequired,
|
||||||
|
maxSizeMb: req.maxSizeMB,
|
||||||
|
description: req.description,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Build project filter
|
||||||
|
const projectWhere: Record<string, unknown> = {
|
||||||
|
programId: round.competition.programId,
|
||||||
|
}
|
||||||
|
if (input.search) {
|
||||||
|
projectWhere.OR = [
|
||||||
|
{ title: { contains: input.search, mode: 'insensitive' } },
|
||||||
|
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const allProjects = await ctx.prisma.project.findMany({
|
||||||
|
where: projectWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
teamName: true,
|
||||||
|
submittedByUserId: true,
|
||||||
|
submittedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
files: {
|
||||||
|
where: { roundId: input.roundId, requirementId: { not: null } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
mimeType: true,
|
||||||
|
size: true,
|
||||||
|
createdAt: true,
|
||||||
|
requirementId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { title: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map projects with their requirement status
|
||||||
|
const mapped = allProjects.map((project) => {
|
||||||
|
const reqStatus = requirements.map((req) => {
|
||||||
|
const file = project.files.find(
|
||||||
|
(f) => f.requirementId === req.id
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
requirementId: req.id,
|
||||||
|
label: req.label,
|
||||||
|
mimeTypes: req.mimeTypes,
|
||||||
|
required: req.required,
|
||||||
|
file: file ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalRequired = reqStatus.filter((r) => r.required).length
|
||||||
|
const filledRequired = reqStatus.filter(
|
||||||
|
(r) => r.required && r.file
|
||||||
|
).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
teamName: project.teamName,
|
||||||
|
submittedBy: project.submittedBy,
|
||||||
|
},
|
||||||
|
requirements: reqStatus,
|
||||||
|
isComplete: totalRequired > 0 ? filledRequired >= totalRequired : reqStatus.every((r) => r.file),
|
||||||
|
filledCount: reqStatus.filter((r) => r.file).length,
|
||||||
|
totalCount: reqStatus.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
const filtered =
|
||||||
|
input.status === 'missing'
|
||||||
|
? mapped.filter((p) => !p.isComplete)
|
||||||
|
: input.status === 'complete'
|
||||||
|
? mapped.filter((p) => p.isComplete)
|
||||||
|
: mapped
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
const total = filtered.length
|
||||||
|
const totalPages = Math.ceil(total / input.pageSize)
|
||||||
|
const page = Math.min(input.page, Math.max(totalPages, 1))
|
||||||
|
const projects = filtered.slice(
|
||||||
|
(page - 1) * input.pageSize,
|
||||||
|
page * input.pageSize
|
||||||
|
)
|
||||||
|
|
||||||
|
const completeCount = mapped.filter((p) => p.isComplete).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
requirements,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
completeCount,
|
||||||
|
totalProjects: mapped.length,
|
||||||
|
competition: round.competition,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file for a round's FileRequirement (admin bulk upload)
|
||||||
|
*/
|
||||||
|
adminUploadForRoundRequirement: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
fileName: z.string(),
|
||||||
|
mimeType: z.string(),
|
||||||
|
size: z.number().int().positive(),
|
||||||
|
roundId: z.string(),
|
||||||
|
requirementId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.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`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate requirement exists and belongs to the round
|
||||||
|
const requirement = await ctx.prisma.fileRequirement.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.requirementId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!requirement) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Requirement not found for this round',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate MIME type if requirement specifies allowed types
|
||||||
|
if (requirement.acceptedMimeTypes.length > 0) {
|
||||||
|
const isAllowed = requirement.acceptedMimeTypes.some((allowed) => {
|
||||||
|
if (allowed.endsWith('/*')) {
|
||||||
|
return input.mimeType.startsWith(allowed.replace('/*', '/'))
|
||||||
|
}
|
||||||
|
return input.mimeType === allowed
|
||||||
|
})
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `File type "${input.mimeType}" is not allowed for this requirement. Accepted: ${requirement.acceptedMimeTypes.join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer fileType from mimeType
|
||||||
|
let fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' = 'OTHER'
|
||||||
|
if (input.mimeType.startsWith('video/')) fileType = 'VIDEO'
|
||||||
|
else if (input.mimeType === 'application/pdf') fileType = 'EXEC_SUMMARY'
|
||||||
|
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
|
||||||
|
fileType = 'PRESENTATION'
|
||||||
|
|
||||||
|
// Fetch project title and round name for storage path
|
||||||
|
const [project, round] = await Promise.all([
|
||||||
|
ctx.prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: { title: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const bucket = BUCKET_NAME
|
||||||
|
const objectKey = generateObjectKey(project.title, input.fileName, round.name)
|
||||||
|
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
|
||||||
|
|
||||||
|
// Remove any existing file for this project+requirement combo (replace)
|
||||||
|
await ctx.prisma.projectFile.deleteMany({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
requirementId: input.requirementId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create file record
|
||||||
|
const file = await ctx.prisma.projectFile.create({
|
||||||
|
data: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
fileType,
|
||||||
|
fileName: input.fileName,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
size: input.size,
|
||||||
|
bucket,
|
||||||
|
objectKey,
|
||||||
|
roundId: input.roundId,
|
||||||
|
requirementId: input.requirementId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPLOAD_FILE',
|
||||||
|
entityType: 'ProjectFile',
|
||||||
|
entityId: file.id,
|
||||||
|
detailsJson: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
fileName: input.fileName,
|
||||||
|
roundId: input.roundId,
|
||||||
|
requirementId: input.requirementId,
|
||||||
|
bulkUpload: true,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { uploadUrl, file }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||||
orderBy: { priority: 'asc' },
|
orderBy: { priority: 'asc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get current round with config
|
||||||
|
const currentRound = await prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: { id: true, name: true, configJson: true },
|
||||||
|
})
|
||||||
|
const roundConfig = (currentRound.configJson as Record<string, unknown>) || {}
|
||||||
|
const aiParseFiles = !!roundConfig.aiParseFiles
|
||||||
|
|
||||||
// Get projects in this round via ProjectRoundState
|
// Get projects in this round via ProjectRoundState
|
||||||
const projectStates = await prisma.projectRoundState.findMany({
|
const projectStates = await prisma.projectRoundState.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -54,13 +62,67 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||||
project: {
|
project: {
|
||||||
include: {
|
include: {
|
||||||
files: {
|
files: {
|
||||||
select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileType: true,
|
||||||
|
mimeType: true,
|
||||||
|
size: true,
|
||||||
|
pageCount: true,
|
||||||
|
objectKey: true,
|
||||||
|
roundId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const projects = projectStates.map((pss: any) => pss.project).filter(Boolean)
|
|
||||||
|
// Get round names for file tagging
|
||||||
|
const roundIds = new Set<string>()
|
||||||
|
for (const pss of projectStates) {
|
||||||
|
for (const f of (pss as any).project?.files || []) {
|
||||||
|
if (f.roundId) roundIds.add(f.roundId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const roundNames = new Map<string, string>()
|
||||||
|
if (roundIds.size > 0) {
|
||||||
|
const rounds = await prisma.round.findMany({
|
||||||
|
where: { id: { in: [...roundIds] } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
for (const r of rounds) roundNames.set(r.id, r.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally extract file contents
|
||||||
|
let fileContents: Map<string, string> | undefined
|
||||||
|
if (aiParseFiles) {
|
||||||
|
const { extractMultipleFileContents } = await import('@/server/services/file-content-extractor')
|
||||||
|
const allFiles = projectStates.flatMap((pss: any) =>
|
||||||
|
((pss.project?.files || []) as Array<{ id: string; fileName: string; mimeType: string; objectKey: string }>)
|
||||||
|
)
|
||||||
|
const extractions = await extractMultipleFileContents(allFiles)
|
||||||
|
fileContents = new Map()
|
||||||
|
for (const e of extractions) {
|
||||||
|
if (e.content) fileContents.set(e.fileId, e.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich projects with round-tagged file data
|
||||||
|
const projects = projectStates.map((pss: any) => {
|
||||||
|
const project = pss.project
|
||||||
|
if (project?.files) {
|
||||||
|
project.files = project.files.map((f: any) => ({
|
||||||
|
...f,
|
||||||
|
roundName: f.roundId ? (roundNames.get(f.roundId) || 'Unknown Round') : null,
|
||||||
|
isCurrentRound: f.roundId === roundId,
|
||||||
|
textContent: fileContents?.get(f.id) || undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return project
|
||||||
|
}).filter(Boolean)
|
||||||
|
|
||||||
// Calculate batch info
|
// Calculate batch info
|
||||||
const BATCH_SIZE = 20
|
const BATCH_SIZE = 20
|
||||||
|
|
@ -149,10 +211,10 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get round name and competitionId for notification
|
// Get round name for notification
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
select: { name: true, competitionId: true },
|
select: { name: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify admins that filtering is complete
|
// Notify admins that filtering is complete
|
||||||
|
|
@ -160,7 +222,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||||
type: NotificationTypes.FILTERING_COMPLETE,
|
type: NotificationTypes.FILTERING_COMPLETE,
|
||||||
title: 'AI Filtering Complete',
|
title: 'AI Filtering Complete',
|
||||||
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
|
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
|
||||||
linkUrl: `/admin/competitions/${round?.competitionId}/rounds/${roundId}`,
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
linkLabel: 'View Results',
|
linkLabel: 'View Results',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|
@ -183,16 +245,11 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify admins of failure - need to fetch round info for competitionId
|
|
||||||
const round = await prisma.round.findUnique({
|
|
||||||
where: { id: roundId },
|
|
||||||
select: { competitionId: true },
|
|
||||||
})
|
|
||||||
await notifyAdmins({
|
await notifyAdmins({
|
||||||
type: NotificationTypes.FILTERING_FAILED,
|
type: NotificationTypes.FILTERING_FAILED,
|
||||||
title: 'AI Filtering Failed',
|
title: 'AI Filtering Failed',
|
||||||
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
linkUrl: round?.competitionId ? `/admin/competitions/${round.competitionId}/rounds/${roundId}` : `/admin/competitions`,
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
linkLabel: 'View Details',
|
linkLabel: 'View Details',
|
||||||
priority: 'urgent',
|
priority: 'urgent',
|
||||||
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
|
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client'
|
||||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||||
|
import { generateShortlist } from '../services/ai-shortlist'
|
||||||
import {
|
import {
|
||||||
openWindow,
|
openWindow,
|
||||||
closeWindow,
|
closeWindow,
|
||||||
|
|
@ -358,6 +359,74 @@ export const roundRouter = router({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AI Shortlist Recommendations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI-powered shortlist recommendations for a round.
|
||||||
|
* Runs independently for STARTUP and BUSINESS_CONCEPT categories.
|
||||||
|
* Uses per-round config for advancement targets and file parsing.
|
||||||
|
*/
|
||||||
|
generateAIRecommendations: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
rubric: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
competitionId: true,
|
||||||
|
configJson: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||||
|
const startupTopN = (config.startupAdvanceCount as number) || 10
|
||||||
|
const conceptTopN = (config.conceptAdvanceCount as number) || 10
|
||||||
|
const aiParseFiles = !!config.aiParseFiles
|
||||||
|
|
||||||
|
const result = await generateShortlist(
|
||||||
|
{
|
||||||
|
roundId: input.roundId,
|
||||||
|
competitionId: round.competitionId,
|
||||||
|
startupTopN,
|
||||||
|
conceptTopN,
|
||||||
|
rubric: input.rubric,
|
||||||
|
aiParseFiles,
|
||||||
|
},
|
||||||
|
ctx.prisma,
|
||||||
|
)
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'AI_SHORTLIST',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
roundName: round.name,
|
||||||
|
startupTopN,
|
||||||
|
conceptTopN,
|
||||||
|
aiParseFiles,
|
||||||
|
success: result.success,
|
||||||
|
startupCount: result.recommendations.STARTUP.length,
|
||||||
|
conceptCount: result.recommendations.BUSINESS_CONCEPT.length,
|
||||||
|
tokensUsed: result.tokensUsed,
|
||||||
|
errors: result.errors,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Submission Window Management
|
// Submission Window Management
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,16 @@ interface ProjectForFiltering {
|
||||||
institution?: string | null
|
institution?: string | null
|
||||||
submissionSource?: SubmissionSource
|
submissionSource?: SubmissionSource
|
||||||
submittedAt?: Date | null
|
submittedAt?: Date | null
|
||||||
files: Array<{ id: string; fileName: string; fileType?: FileType | null; size?: number; pageCount?: number | null }>
|
files: Array<{
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
fileType?: FileType | null
|
||||||
|
size?: number
|
||||||
|
pageCount?: number | null
|
||||||
|
roundName?: string | null
|
||||||
|
isCurrentRound?: boolean
|
||||||
|
textContent?: string
|
||||||
|
}>
|
||||||
_count?: {
|
_count?: {
|
||||||
teamMembers?: number
|
teamMembers?: number
|
||||||
files?: number
|
files?: number
|
||||||
|
|
@ -170,9 +179,10 @@ Return a JSON object with this exact structure:
|
||||||
- founded_year: when the company/initiative was founded (use for age checks)
|
- founded_year: when the company/initiative was founded (use for age checks)
|
||||||
- ocean_issue: the ocean conservation area
|
- ocean_issue: the ocean conservation area
|
||||||
- file_count, file_types: uploaded documents summary
|
- file_count, file_types: uploaded documents summary
|
||||||
- files[]: per-file details with file_type, page_count (if known), and size_kb
|
- files[]: per-file details with file_type, page_count (if known), size_kb, round_name (which round the file was submitted for), and is_current_round flag
|
||||||
- description: project summary text
|
- description: project summary text
|
||||||
- tags: topic tags
|
- tags: topic tags
|
||||||
|
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
- Evaluate ONLY against the provided criteria, not your own standards
|
- Evaluate ONLY against the provided criteria, not your own standards
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
* AI Shortlist Service
|
* AI Shortlist Service
|
||||||
*
|
*
|
||||||
* Generates ranked recommendations at end of evaluation rounds.
|
* Generates ranked recommendations at end of evaluation rounds.
|
||||||
* Follows patterns from ai-filtering.ts and ai-evaluation-summary.ts.
|
* Runs SEPARATELY for each category (STARTUP / BUSINESS_CONCEPT)
|
||||||
|
* to produce independent rankings per the competition's advancement rules.
|
||||||
*
|
*
|
||||||
* GDPR Compliance:
|
* GDPR Compliance:
|
||||||
* - All project data is anonymized before AI processing
|
* - All project data is anonymized before AI processing
|
||||||
|
|
@ -12,73 +13,156 @@
|
||||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||||
import { classifyAIError, logAIError } from './ai-errors'
|
import { classifyAIError, logAIError } from './ai-errors'
|
||||||
|
import { extractMultipleFileContents } from './file-content-extractor'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ShortlistResult = {
|
export type ShortlistResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
recommendations: ShortlistRecommendation[]
|
recommendations: CategoryRecommendations
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
tokensUsed?: number
|
tokensUsed?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CategoryRecommendations = {
|
||||||
|
STARTUP: ShortlistRecommendation[]
|
||||||
|
BUSINESS_CONCEPT: ShortlistRecommendation[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ShortlistRecommendation = {
|
export type ShortlistRecommendation = {
|
||||||
projectId: string
|
projectId: string
|
||||||
rank: number
|
rank: number
|
||||||
score: number
|
score: number
|
||||||
|
category: string
|
||||||
strengths: string[]
|
strengths: string[]
|
||||||
concerns: string[]
|
concerns: string[]
|
||||||
recommendation: string
|
recommendation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Function ──────────────────────────────────────────────────────────
|
// ─── Prompt Building ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
function buildShortlistPrompt(category: string, topN: number, rubric?: string): string {
|
||||||
* Generate an AI shortlist for projects in a round.
|
const categoryLabel = category === 'STARTUP' ? 'Startup' : 'Business Concept'
|
||||||
* Only runs if EvaluationConfig.generateAiShortlist is true.
|
|
||||||
*/
|
return `You are a senior jury advisor for the Monaco Ocean Protection Challenge.
|
||||||
export async function generateShortlist(
|
|
||||||
|
## Your Role
|
||||||
|
Analyze aggregated evaluation data to produce a ranked shortlist of the top ${topN} ${categoryLabel} projects.
|
||||||
|
You are evaluating ONLY ${categoryLabel} projects in this batch — rank them against each other within this category.
|
||||||
|
|
||||||
|
## Ranking Criteria (Weighted)
|
||||||
|
- Evaluation Scores (40%): Average scores across all jury evaluations
|
||||||
|
- Innovation & Impact (25%): Novelty of approach and potential environmental impact
|
||||||
|
- Feasibility (20%): Likelihood of successful implementation
|
||||||
|
- Alignment (15%): Fit with ocean protection mission and competition goals
|
||||||
|
|
||||||
|
## Document Analysis
|
||||||
|
If document content is provided (text_content field in files), use it for deeper qualitative analysis.
|
||||||
|
Pay SPECIAL ATTENTION to files marked with is_current_round=true — these are the most recent submissions.
|
||||||
|
Older documents provide context, but recent ones should carry more weight in your assessment.
|
||||||
|
|
||||||
|
${rubric ? `## Custom Evaluation Rubric\n${rubric}\n` : ''}
|
||||||
|
## Output Format
|
||||||
|
Return a JSON array:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"anonymousId": "PROJECT_001",
|
||||||
|
"rank": 1,
|
||||||
|
"score": 0-100,
|
||||||
|
"strengths": ["strength 1", "strength 2"],
|
||||||
|
"concerns": ["concern 1"],
|
||||||
|
"recommendation": "1-2 sentence recommendation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
- Only include the top ${topN} projects in your ranking
|
||||||
|
- Score should reflect weighted combination of all criteria
|
||||||
|
- Be specific in strengths and concerns — avoid generic statements
|
||||||
|
- Consider feedback themes and evaluator consensus
|
||||||
|
- Higher evaluator consensus should boost confidence in ranking
|
||||||
|
- Do not include any personal identifiers`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Single Category Processing ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function generateCategoryShortlist(
|
||||||
params: {
|
params: {
|
||||||
roundId: string
|
roundId: string
|
||||||
competitionId: string
|
category: string
|
||||||
category?: string
|
topN: number
|
||||||
topN?: number
|
|
||||||
rubric?: string
|
rubric?: string
|
||||||
|
aiParseFiles: boolean
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient | any,
|
||||||
): Promise<ShortlistResult> {
|
): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> {
|
||||||
const { roundId, competitionId, category, topN = 10, rubric } = params
|
const { roundId, category, topN, rubric, aiParseFiles } = params
|
||||||
|
|
||||||
try {
|
|
||||||
// Load projects with evaluations
|
|
||||||
const where: Record<string, unknown> = {
|
|
||||||
assignments: { some: { roundId } },
|
|
||||||
}
|
|
||||||
if (category) {
|
|
||||||
where.competitionCategory = category
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Load projects with evaluations for this category
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where,
|
where: {
|
||||||
|
competitionCategory: category,
|
||||||
|
assignments: { some: { roundId } },
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
assignments: {
|
assignments: {
|
||||||
where: { roundId },
|
where: { roundId },
|
||||||
include: {
|
include: { evaluation: true },
|
||||||
evaluation: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
projectTags: { include: { tag: true } },
|
projectTags: { include: { tag: true } },
|
||||||
files: { select: { id: true, type: true } },
|
files: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileType: true,
|
||||||
|
mimeType: true,
|
||||||
|
size: true,
|
||||||
|
pageCount: true,
|
||||||
|
objectKey: true,
|
||||||
|
roundId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' as const },
|
||||||
|
},
|
||||||
teamMembers: { select: { user: { select: { name: true } } } },
|
teamMembers: { select: { user: { select: { name: true } } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
|
||||||
recommendations: [],
|
recommendations: [],
|
||||||
errors: ['No projects found for this round'],
|
tokensUsed: 0,
|
||||||
|
errors: [`No ${category} projects found for this round`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get round names for file tagging
|
||||||
|
const roundIds = new Set<string>()
|
||||||
|
for (const p of projects) {
|
||||||
|
for (const f of (p as any).files || []) {
|
||||||
|
if (f.roundId) roundIds.add(f.roundId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const roundNames = new Map<string, string>()
|
||||||
|
if (roundIds.size > 0) {
|
||||||
|
const rounds = await prisma.round.findMany({
|
||||||
|
where: { id: { in: [...roundIds] } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
for (const r of rounds) roundNames.set(r.id, r.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally extract file contents
|
||||||
|
let fileContents: Map<string, string> | undefined
|
||||||
|
if (aiParseFiles) {
|
||||||
|
const allFiles = projects.flatMap((p: any) =>
|
||||||
|
((p.files || []) as Array<{ id: string; fileName: string; mimeType: string; objectKey: string }>)
|
||||||
|
)
|
||||||
|
const extractions = await extractMultipleFileContents(allFiles)
|
||||||
|
fileContents = new Map()
|
||||||
|
for (const e of extractions) {
|
||||||
|
if (e.content) fileContents.set(e.fileId, e.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,18 +179,25 @@ export async function generateShortlist(
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const feedbacks = evaluations
|
const feedbacks = evaluations
|
||||||
.map((e: any) => e.feedbackGeneral)
|
.map((e: any) => e.feedbackGeneral || e.feedbackText)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
title: project.title,
|
|
||||||
description: project.description,
|
description: project.description,
|
||||||
category: project.competitionCategory,
|
category: project.competitionCategory,
|
||||||
tags: project.projectTags.map((pt: any) => pt.tag.name),
|
tags: project.projectTags.map((pt: any) => pt.tag.name),
|
||||||
avgScore,
|
avgScore,
|
||||||
evaluationCount: evaluations.length,
|
evaluationCount: evaluations.length,
|
||||||
feedbackSamples: feedbacks.slice(0, 3), // Max 3 feedback samples
|
feedbackSamples: feedbacks.slice(0, 3),
|
||||||
|
files: (project.files || []).map((f: any) => ({
|
||||||
|
file_type: f.fileType ?? 'OTHER',
|
||||||
|
page_count: f.pageCount ?? null,
|
||||||
|
size_kb: Math.round((f.size ?? 0) / 1024),
|
||||||
|
round_name: f.roundId ? (roundNames.get(f.roundId) || null) : null,
|
||||||
|
is_current_round: f.roundId === roundId,
|
||||||
|
...(fileContents?.get(f.id) ? { text_content: fileContents.get(f.id) } : {}),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -114,8 +205,6 @@ export async function generateShortlist(
|
||||||
const anonymized = projectSummaries.map((p: any, index: number) => ({
|
const anonymized = projectSummaries.map((p: any, index: number) => ({
|
||||||
anonymousId: `PROJECT_${String(index + 1).padStart(3, '0')}`,
|
anonymousId: `PROJECT_${String(index + 1).padStart(3, '0')}`,
|
||||||
...p,
|
...p,
|
||||||
// Strip identifying info
|
|
||||||
title: undefined,
|
|
||||||
id: undefined,
|
id: undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -125,62 +214,22 @@ export async function generateShortlist(
|
||||||
idMap.set(`PROJECT_${String(index + 1).padStart(3, '0')}`, p.id)
|
idMap.set(`PROJECT_${String(index + 1).padStart(3, '0')}`, p.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build prompt
|
// Call AI
|
||||||
const systemPrompt = `You are a senior jury advisor for the Monaco Ocean Protection Challenge.
|
|
||||||
|
|
||||||
## Your Role
|
|
||||||
Analyze aggregated evaluation data to produce a ranked shortlist of top projects.
|
|
||||||
|
|
||||||
## Ranking Criteria (Weighted)
|
|
||||||
- Evaluation Scores (40%): Average scores across all jury evaluations
|
|
||||||
- Innovation & Impact (25%): Novelty of approach and potential environmental impact
|
|
||||||
- Feasibility (20%): Likelihood of successful implementation
|
|
||||||
- Alignment (15%): Fit with ocean protection mission and competition goals
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
Return a JSON array:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"anonymousId": "PROJECT_001",
|
|
||||||
"rank": 1,
|
|
||||||
"score": 0-100,
|
|
||||||
"strengths": ["strength 1", "strength 2"],
|
|
||||||
"concerns": ["concern 1"],
|
|
||||||
"recommendation": "1-2 sentence recommendation",
|
|
||||||
"criterionBreakdown": {
|
|
||||||
"evaluationScores": 38,
|
|
||||||
"innovationImpact": 22,
|
|
||||||
"feasibility": 18,
|
|
||||||
"alignment": 14
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
- Only include the requested number of top projects
|
|
||||||
- Score should reflect weighted combination of all criteria
|
|
||||||
- Be specific in strengths and concerns — avoid generic statements
|
|
||||||
- Consider feedback themes and evaluator consensus
|
|
||||||
- Higher evaluator consensus should boost confidence in ranking`
|
|
||||||
|
|
||||||
const userPrompt = `Analyze these anonymized project evaluations and produce a ranked shortlist of the top ${topN} projects.
|
|
||||||
|
|
||||||
${rubric ? `Evaluation rubric:\n${rubric}\n\n` : ''}Projects:
|
|
||||||
${JSON.stringify(anonymized, null, 2)}
|
|
||||||
|
|
||||||
Return a JSON array following the format specified in your instructions. Only include the top ${topN} projects. Rank by overall quality considering scores and feedback.`
|
|
||||||
|
|
||||||
const openai = await getOpenAI()
|
const openai = await getOpenAI()
|
||||||
const model = await getConfiguredModel()
|
const model = await getConfiguredModel()
|
||||||
|
|
||||||
if (!openai) {
|
if (!openai) {
|
||||||
return {
|
return { recommendations: [], tokensUsed: 0, errors: ['OpenAI client not configured'] }
|
||||||
success: false,
|
|
||||||
recommendations: [],
|
|
||||||
errors: ['OpenAI client not configured'],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemPrompt = buildShortlistPrompt(category, topN, rubric)
|
||||||
|
const userPrompt = `Analyze these anonymized ${category} project evaluations and produce a ranked shortlist of the top ${topN}.
|
||||||
|
|
||||||
|
Projects (${anonymized.length} total):
|
||||||
|
${JSON.stringify(anonymized, null, 2)}
|
||||||
|
|
||||||
|
Return a JSON array following the format specified. Only include the top ${topN} projects. Rank by overall quality within this category.`
|
||||||
|
|
||||||
const MAX_PARSE_RETRIES = 2
|
const MAX_PARSE_RETRIES = 2
|
||||||
let parseAttempts = 0
|
let parseAttempts = 0
|
||||||
let response = await openai.chat.completions.create(
|
let response = await openai.chat.completions.create(
|
||||||
|
|
@ -197,7 +246,7 @@ Return a JSON array following the format specified in your instructions. Only in
|
||||||
let tokenUsage = extractTokenUsage(response)
|
let tokenUsage = extractTokenUsage(response)
|
||||||
|
|
||||||
await logAIUsage({
|
await logAIUsage({
|
||||||
action: 'FILTERING',
|
action: 'SHORTLIST',
|
||||||
model,
|
model,
|
||||||
promptTokens: tokenUsage.promptTokens,
|
promptTokens: tokenUsage.promptTokens,
|
||||||
completionTokens: tokenUsage.completionTokens,
|
completionTokens: tokenUsage.completionTokens,
|
||||||
|
|
@ -205,29 +254,20 @@ Return a JSON array following the format specified in your instructions. Only in
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Parse response with retry logic
|
// Parse response
|
||||||
let parsed: any[]
|
let parsed: any[]
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const content = response.choices[0]?.message?.content
|
const content = response.choices[0]?.message?.content
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return {
|
return { recommendations: [], tokensUsed: tokenUsage.totalTokens, errors: ['Empty AI response'] }
|
||||||
success: false,
|
|
||||||
recommendations: [],
|
|
||||||
errors: ['Empty AI response'],
|
|
||||||
tokensUsed: tokenUsage.totalTokens,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const json = JSON.parse(content)
|
const json = JSON.parse(content)
|
||||||
parsed = Array.isArray(json) ? json : json.rankings ?? json.projects ?? json.shortlist ?? []
|
parsed = Array.isArray(json) ? json : json.rankings ?? json.projects ?? json.shortlist ?? []
|
||||||
break
|
break
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
|
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
|
||||||
parseAttempts++
|
parseAttempts++
|
||||||
console.warn(`[AI Shortlist] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
|
|
||||||
|
|
||||||
// Retry the API call with hint
|
|
||||||
response = await openai.chat.completions.create(
|
response = await openai.chat.completions.create(
|
||||||
buildCompletionParams(model, {
|
buildCompletionParams(model, {
|
||||||
messages: [
|
messages: [
|
||||||
|
|
@ -242,33 +282,95 @@ Return a JSON array following the format specified in your instructions. Only in
|
||||||
tokenUsage.totalTokens += retryUsage.totalTokens
|
tokenUsage.totalTokens += retryUsage.totalTokens
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
return { recommendations: [], tokensUsed: tokenUsage.totalTokens, errors: ['Failed to parse AI response'] }
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
recommendations: [],
|
|
||||||
errors: ['Failed to parse AI response as JSON'],
|
|
||||||
tokensUsed: tokenUsage.totalTokens,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// De-anonymize and build recommendations
|
// De-anonymize
|
||||||
const recommendations: ShortlistRecommendation[] = parsed
|
const recommendations: ShortlistRecommendation[] = parsed
|
||||||
.filter((item: any) => item.anonymousId && idMap.has(item.anonymousId))
|
.filter((item: any) => item.anonymousId && idMap.has(item.anonymousId))
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
projectId: idMap.get(item.anonymousId)!,
|
projectId: idMap.get(item.anonymousId)!,
|
||||||
rank: item.rank ?? 0,
|
rank: item.rank ?? 0,
|
||||||
score: item.score ?? 0,
|
score: item.score ?? 0,
|
||||||
|
category,
|
||||||
strengths: item.strengths ?? [],
|
strengths: item.strengths ?? [],
|
||||||
concerns: item.concerns ?? [],
|
concerns: item.concerns ?? [],
|
||||||
recommendation: item.recommendation ?? '',
|
recommendation: item.recommendation ?? '',
|
||||||
}))
|
}))
|
||||||
.sort((a: ShortlistRecommendation, b: ShortlistRecommendation) => a.rank - b.rank)
|
.sort((a: ShortlistRecommendation, b: ShortlistRecommendation) => a.rank - b.rank)
|
||||||
|
|
||||||
|
return { recommendations, tokensUsed: tokenUsage.totalTokens, errors: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Function ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an AI shortlist for projects in a round, split by category.
|
||||||
|
* Runs independently for STARTUP and BUSINESS_CONCEPT.
|
||||||
|
*/
|
||||||
|
export async function generateShortlist(
|
||||||
|
params: {
|
||||||
|
roundId: string
|
||||||
|
competitionId: string
|
||||||
|
category?: string // If provided, only run for this category
|
||||||
|
topN?: number // Global fallback
|
||||||
|
startupTopN?: number // Per-category override
|
||||||
|
conceptTopN?: number // Per-category override
|
||||||
|
rubric?: string
|
||||||
|
aiParseFiles?: boolean
|
||||||
|
},
|
||||||
|
prisma: PrismaClient | any,
|
||||||
|
): Promise<ShortlistResult> {
|
||||||
|
const {
|
||||||
|
roundId,
|
||||||
|
category,
|
||||||
|
topN = 10,
|
||||||
|
startupTopN,
|
||||||
|
conceptTopN,
|
||||||
|
rubric,
|
||||||
|
aiParseFiles = false,
|
||||||
|
} = params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categories = category
|
||||||
|
? [category]
|
||||||
|
: ['STARTUP', 'BUSINESS_CONCEPT']
|
||||||
|
|
||||||
|
const allRecommendations: CategoryRecommendations = {
|
||||||
|
STARTUP: [],
|
||||||
|
BUSINESS_CONCEPT: [],
|
||||||
|
}
|
||||||
|
let totalTokens = 0
|
||||||
|
const allErrors: string[] = []
|
||||||
|
|
||||||
|
// Run each category independently
|
||||||
|
for (const cat of categories) {
|
||||||
|
const catTopN = cat === 'STARTUP'
|
||||||
|
? (startupTopN ?? topN)
|
||||||
|
: (conceptTopN ?? topN)
|
||||||
|
|
||||||
|
console.log(`[AI Shortlist] Generating top-${catTopN} for ${cat}`)
|
||||||
|
|
||||||
|
const result = await generateCategoryShortlist(
|
||||||
|
{ roundId, category: cat, topN: catTopN, rubric, aiParseFiles },
|
||||||
|
prisma,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cat === 'STARTUP') {
|
||||||
|
allRecommendations.STARTUP = result.recommendations
|
||||||
|
} else {
|
||||||
|
allRecommendations.BUSINESS_CONCEPT = result.recommendations
|
||||||
|
}
|
||||||
|
totalTokens += result.tokensUsed
|
||||||
|
allErrors.push(...result.errors)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
recommendations,
|
recommendations: allRecommendations,
|
||||||
tokensUsed: tokenUsage.totalTokens,
|
tokensUsed: totalTokens,
|
||||||
|
errors: allErrors.length > 0 ? allErrors : undefined,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const classification = classifyAIError(error)
|
const classification = classifyAIError(error)
|
||||||
|
|
@ -277,7 +379,7 @@ Return a JSON array following the format specified in your instructions. Only in
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
recommendations: [],
|
recommendations: { STARTUP: [], BUSINESS_CONCEPT: [] },
|
||||||
errors: [error instanceof Error ? error.message : 'AI shortlist generation failed'],
|
errors: [error instanceof Error ? error.message : 'AI shortlist generation failed'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ export interface AnonymizedFileInfo {
|
||||||
file_type: string // FileType enum value
|
file_type: string // FileType enum value
|
||||||
page_count: number | null // Number of pages if known
|
page_count: number | null // Number of pages if known
|
||||||
size_kb: number // File size in KB
|
size_kb: number // File size in KB
|
||||||
|
round_name?: string | null // Which round the file was submitted for
|
||||||
|
is_current_round?: boolean // Whether this file belongs to the current filtering/evaluation round
|
||||||
|
text_content?: string // Extracted text content (when aiParseFiles is enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnonymizedProjectForAI {
|
export interface AnonymizedProjectForAI {
|
||||||
|
|
@ -299,10 +302,13 @@ export function anonymizeProjectForAI(
|
||||||
file_types: project.files
|
file_types: project.files
|
||||||
?.map((f) => f.fileType)
|
?.map((f) => f.fileType)
|
||||||
.filter((ft): ft is FileType => ft !== null) ?? [],
|
.filter((ft): ft is FileType => ft !== null) ?? [],
|
||||||
files: project.files?.map((f) => ({
|
files: project.files?.map((f: any) => ({
|
||||||
file_type: f.fileType ?? 'OTHER',
|
file_type: f.fileType ?? 'OTHER',
|
||||||
page_count: f.pageCount ?? null,
|
page_count: f.pageCount ?? null,
|
||||||
size_kb: Math.round((f.size ?? 0) / 1024),
|
size_kb: Math.round((f.size ?? 0) / 1024),
|
||||||
|
...(f.roundName ? { round_name: f.roundName } : {}),
|
||||||
|
...(f.isCurrentRound !== undefined ? { is_current_round: f.isCurrentRound } : {}),
|
||||||
|
...(f.textContent ? { text_content: f.textContent } : {}),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
wants_mentorship: project.wantsMentorship ?? false,
|
wants_mentorship: project.wantsMentorship ?? false,
|
||||||
submission_source: project.submissionSource,
|
submission_source: project.submissionSource,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* File Content Extractor
|
||||||
|
*
|
||||||
|
* Downloads files from storage and extracts text content for AI analysis.
|
||||||
|
* Supports PDF and plain text files. Used when round config has aiParseFiles=true.
|
||||||
|
*
|
||||||
|
* Limits:
|
||||||
|
* - Max 50KB of extracted text per file (to stay within AI token limits)
|
||||||
|
* - Only PDF and text-based files are parsed
|
||||||
|
* - Extraction failures are non-fatal (file is skipped)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getStorageProvider } from '@/lib/storage'
|
||||||
|
|
||||||
|
const MAX_TEXT_PER_FILE = 50_000 // ~50KB of text per file
|
||||||
|
const PARSEABLE_MIME_TYPES = [
|
||||||
|
'application/pdf',
|
||||||
|
'text/plain',
|
||||||
|
'text/csv',
|
||||||
|
'text/markdown',
|
||||||
|
'text/html',
|
||||||
|
'application/rtf',
|
||||||
|
]
|
||||||
|
|
||||||
|
export type ExtractedFileContent = {
|
||||||
|
fileId: string
|
||||||
|
fileName: string
|
||||||
|
content: string | null
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file's mime type supports content extraction
|
||||||
|
*/
|
||||||
|
export function isParseableMimeType(mimeType: string): boolean {
|
||||||
|
return PARSEABLE_MIME_TYPES.some((t) => mimeType.startsWith(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from a single file stored in MinIO/S3.
|
||||||
|
* Returns null content if file type is unsupported or extraction fails.
|
||||||
|
*/
|
||||||
|
export async function extractFileContent(
|
||||||
|
objectKey: string,
|
||||||
|
mimeType: string,
|
||||||
|
fileName: string,
|
||||||
|
fileId: string,
|
||||||
|
): Promise<ExtractedFileContent> {
|
||||||
|
if (!isParseableMimeType(mimeType)) {
|
||||||
|
return { fileId, fileName, content: null, error: 'Unsupported mime type' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = await getStorageProvider()
|
||||||
|
const buffer = await storage.getObject(objectKey)
|
||||||
|
|
||||||
|
let text: string
|
||||||
|
|
||||||
|
if (mimeType === 'application/pdf') {
|
||||||
|
// Dynamic import to avoid loading pdf-parse when not needed
|
||||||
|
const pdfParseModule = await import('pdf-parse')
|
||||||
|
const pdfParse = typeof pdfParseModule === 'function' ? pdfParseModule : (pdfParseModule as any).default ?? pdfParseModule
|
||||||
|
const pdf = await pdfParse(buffer)
|
||||||
|
text = pdf.text
|
||||||
|
} else {
|
||||||
|
// Text-based files
|
||||||
|
text = buffer.toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to limit
|
||||||
|
if (text.length > MAX_TEXT_PER_FILE) {
|
||||||
|
text = text.slice(0, MAX_TEXT_PER_FILE) + '\n[... content truncated ...]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fileId, fileName, content: text }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[FileExtractor] Failed to extract content from ${fileName}:`, error)
|
||||||
|
return {
|
||||||
|
fileId,
|
||||||
|
fileName,
|
||||||
|
content: null,
|
||||||
|
error: error instanceof Error ? error.message : 'Extraction failed',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content from multiple files in parallel.
|
||||||
|
* Non-fatal: files that fail extraction are returned with null content.
|
||||||
|
*/
|
||||||
|
export async function extractMultipleFileContents(
|
||||||
|
files: Array<{
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
objectKey: string
|
||||||
|
}>,
|
||||||
|
): Promise<ExtractedFileContent[]> {
|
||||||
|
const parseableFiles = files.filter((f) => isParseableMimeType(f.mimeType))
|
||||||
|
|
||||||
|
if (parseableFiles.length === 0) return []
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
parseableFiles.map((f) => extractFileContent(f.objectKey, f.mimeType, f.fileName, f.id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return results.map((r, i) =>
|
||||||
|
r.status === 'fulfilled'
|
||||||
|
? r.value
|
||||||
|
: { fileId: parseableFiles[i].id, fileName: parseableFiles[i].fileName, content: null, error: 'Promise rejected' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ export type AIAction =
|
||||||
| 'PROJECT_TAGGING'
|
| 'PROJECT_TAGGING'
|
||||||
| 'EVALUATION_SUMMARY'
|
| 'EVALUATION_SUMMARY'
|
||||||
| 'ROUTING'
|
| 'ROUTING'
|
||||||
|
| 'SHORTLIST'
|
||||||
|
|
||||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue