Compare commits

..

1 Commits

Author SHA1 Message Date
3e70de3a5a Add Anthropic API, test environment, remove locale settings
Feature 1: Anthropic API Integration
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option

Feature 2: Remove Locale Settings UI
- Strip Localization tab from admin settings
- Remove i18n settings from router inferCategory and getFeatureFlags
- Keep franc document language detection intact

Feature 3: Test Environment with Role Impersonation
- Add isTest field to User, Program, Project, Competition models
- Test environment service: create/teardown with realistic dummy data
- JWT-based impersonation for test users (@test.local emails)
- Impersonation banner with quick-switch between test roles
- Test environment panel in admin settings (SUPER_ADMIN only)
- Email redirect: @test.local emails routed to admin with [TEST] prefix
- Complete data isolation: 45+ isTest:false filters across platform
  - All global queries on User/Project/Program/Competition
  - AI services blocked from processing test data
  - Cron jobs skip test rounds/users
  - Analytics/exports exclude test data
  - Admin layout/pickers hide test programs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:28:07 +01:00
82 changed files with 2894 additions and 4758 deletions

View File

@@ -40,12 +40,12 @@ const nextConfig: NextConfig = {
},
{
source: '/applicant/pipeline',
destination: '/applicant/competition',
destination: '/applicant/competitions',
permanent: true,
},
{
source: '/applicant/pipeline/:path*',
destination: '/applicant/competition',
destination: '/applicant/competitions',
permanent: true,
},
]

334
package-lock.json generated
View File

@@ -11,12 +11,14 @@
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@blocknote/shadcn": "^0.46.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13",
"@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
@@ -244,6 +246,24 @@
}
}
},
"node_modules/@blocknote/mantine": {
"version": "0.46.2",
"resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.46.2.tgz",
"integrity": "sha512-2/A82VIby8NNuQbJrXZURnGsksVMWiGWtUOfhvaawCTiB2thYDOV1XONFF1G4xZ2UreodOKLUTwhLm3u25lGrw==",
"license": "MPL-2.0",
"dependencies": {
"@blocknote/core": "0.46.2",
"@blocknote/react": "0.46.2",
"react-icons": "^5.5.0"
},
"peerDependencies": {
"@mantine/core": "^8.3.11",
"@mantine/hooks": "^8.3.11",
"@mantine/utils": "^6.0.22",
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
}
},
"node_modules/@blocknote/react": {
"version": "0.46.2",
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz",
@@ -276,55 +296,6 @@
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
"license": "MIT"
},
"node_modules/@blocknote/shadcn": {
"version": "0.46.2",
"resolved": "https://registry.npmjs.org/@blocknote/shadcn/-/shadcn-0.46.2.tgz",
"integrity": "sha512-rCmML5M814D/dcLrm5/6REQeEJKPNyygls4n751F0jBLpk8IuWsQYvGqnU6iPW2Cz+9IchA2ivg4Vltna83J1Q==",
"license": "MPL-2.0",
"dependencies": {
"@blocknote/core": "0.46.2",
"@blocknote/react": "0.46.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"react-hook-form": "^7.65.0",
"tailwind-merge": "^2.6.0"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc",
"tailwindcss": "^4.1.12"
}
},
"node_modules/@blocknote/shadcn/node_modules/lucide-react": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@blocknote/shadcn/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@@ -1681,6 +1652,34 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mantine/core": {
"version": "8.3.13",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.13.tgz",
"integrity": "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
"react-number-format": "^5.4.4",
"react-remove-scroll": "^2.7.1",
"react-textarea-autosize": "8.5.9",
"type-fest": "^4.41.0"
},
"peerDependencies": {
"@mantine/hooks": "8.3.13",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/hooks": {
"version": "8.3.13",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.13.tgz",
"integrity": "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
@@ -6011,42 +6010,6 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.24",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001766",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -6128,18 +6091,6 @@
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -6194,39 +6145,6 @@
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
@@ -7102,12 +7020,6 @@
"fast-check": "^3.23.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"license": "ISC"
},
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
@@ -7378,15 +7290,6 @@
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -8112,19 +8015,6 @@
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
@@ -11210,12 +11100,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
@@ -11744,12 +11628,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
@@ -12372,6 +12250,16 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-number-format": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
"integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==",
"license": "MIT",
"peerDependencies": {
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-phone-number-input": {
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
@@ -12473,6 +12361,23 @@
}
}
},
"node_modules/react-textarea-autosize": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"use-composed-ref": "^1.3.0",
"use-latest": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -13824,6 +13729,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -14103,36 +14020,6 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -14164,6 +14051,20 @@
}
}
},
"node_modules/use-composed-ref": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-debounce": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
@@ -14176,6 +14077,37 @@
"react": "*"
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-latest": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
"license": "MIT",
"dependencies": {
"use-isomorphic-layout-effect": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",

View File

@@ -24,12 +24,14 @@
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@blocknote/shadcn": "^0.46.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13",
"@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4",

View File

@@ -0,0 +1,8 @@
-- Add isTest field to User, Program, Project, Competition for test environment isolation
ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
-- Index for efficient test data filtering
CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest");

View File

@@ -1,16 +0,0 @@
-- Learning Hub Overhaul: Remove ResourceType/CohortLevel enums, add accessJson + coverImageKey
-- Drop columns that reference the enums
ALTER TABLE "LearningResource" DROP COLUMN "resourceType";
ALTER TABLE "LearningResource" DROP COLUMN "cohortLevel";
-- Drop the cohortLevel index
DROP INDEX IF EXISTS "LearningResource_cohortLevel_idx";
-- Add new columns
ALTER TABLE "LearningResource" ADD COLUMN "accessJson" JSONB;
ALTER TABLE "LearningResource" ADD COLUMN "coverImageKey" TEXT;
-- Drop the enum types
DROP TYPE IF EXISTS "ResourceType";
DROP TYPE IF EXISTS "CohortLevel";

View File

@@ -115,6 +115,19 @@ enum NotificationChannel {
NONE
}
enum ResourceType {
PDF
VIDEO
DOCUMENT
LINK
OTHER
}
enum CohortLevel {
ALL
SEMIFINALIST
FINALIST
}
enum PartnerVisibility {
ADMIN_ONLY
@@ -337,6 +350,9 @@ model User {
preferredWorkload Int?
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@ -481,6 +497,9 @@ model Program {
description String?
settingsJson Json? @db.JsonB
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -605,6 +624,9 @@ model Project {
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1002,7 +1024,8 @@ model LearningResource {
title String
description String? @db.Text
contentJson Json? @db.JsonB // BlockNote document structure
accessJson Json? @db.JsonB // Fine-grained access rules
resourceType ResourceType
cohortLevel CohortLevel @default(ALL)
// File storage (for uploaded resources)
fileName String?
@@ -1011,9 +1034,6 @@ model LearningResource {
bucket String?
objectKey String?
// Cover image (stored in MinIO)
coverImageKey String?
// External link
externalUrl String?
@@ -1030,6 +1050,7 @@ model LearningResource {
accessLogs ResourceAccess[]
@@index([programId])
@@index([cohortLevel])
@@index([isPublished])
@@index([sortOrder])
}
@@ -2078,6 +2099,9 @@ model Competition {
notifyOnDeadlineApproach Boolean @default(true)
deadlineReminderDays Int[] @default([7, 3, 1])
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2092,6 +2116,7 @@ model Competition {
@@index([programId])
@@index([status])
@@index([isTest])
}
model Round {

View File

@@ -69,7 +69,18 @@ export default function AssignmentsDashboardPage() {
if (!competition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<p>Competition not found</p>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="font-medium">Competition not found</p>
<p className="text-sm text-muted-foreground mt-1">
The requested competition does not exist or you don&apos;t have access.
</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -13,16 +13,34 @@ import type { Route } from 'next';
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
const params = use(paramsPromise);
const router = useRouter();
const { data: competition } = trpc.competition.getById.useQuery({
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
id: params.competitionId
});
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
programId: competition?.programId
}, {
enabled: !!competition?.programId
});
if (isCompError || isAwardsError) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Awards</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or awards data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-6">

View File

@@ -43,13 +43,13 @@ export default function DeliberationListPage({
participantUserIds: [] as string[]
});
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
// Get rounds for this competition
const { data: competition } = trpc.competition.getById.useQuery(
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
{ id: params.competitionId },
{ enabled: !!params.competitionId }
);
@@ -121,6 +121,24 @@ export default function DeliberationListPage({
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
};
if (isCompError || isSessionsError) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or deliberation data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-6 p-4 sm:p-6">

View File

@@ -48,6 +48,7 @@ import {
Loader2,
Plus,
CalendarDays,
Radio,
} from 'lucide-react'
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
@@ -435,6 +436,19 @@ export default function CompetitionDetailPage() {
<span className="truncate">{round.juryGroup.name}</span>
</div>
)}
{/* Live Control link for LIVE_FINAL rounds */}
{round.roundType === 'LIVE_FINAL' && (
<Link
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
onClick={(e) => e.stopPropagation()}
>
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
<Radio className="h-3.5 w-3.5" />
Live Control
</Button>
</Link>
)}
</CardContent>
</Card>
</Link>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
@@ -8,11 +8,15 @@ import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
@@ -20,14 +24,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import {
AlertDialog,
AlertDialogAction,
@@ -39,62 +35,46 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
Settings,
Eye,
FileText,
Video,
Link as LinkIcon,
File,
Trash2,
Eye,
AlertCircle,
} from 'lucide-react'
// Dynamically import editors to avoid SSR issues
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ROLE_OPTIONS = [
{ value: 'JURY_MEMBER', label: 'Jury Members' },
{ value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
type AccessRule =
| { type: 'everyone' }
| { type: 'roles'; roles: string[] }
| { type: 'jury_group'; juryGroupIds: string[] }
| { type: 'round'; roundIds: string[] }
function parseAccessJson(accessJson: unknown): { mode: 'everyone' | 'roles'; roles: string[] } {
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
return { mode: 'everyone', roles: [] }
}
const firstRule = accessJson[0] as AccessRule
if (firstRule.type === 'roles' && 'roles' in firstRule) {
return { mode: 'roles', roles: firstRule.roles }
}
return { mode: 'everyone', roles: [] }
}
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function EditLearningResourcePage() {
const params = useParams()
@@ -109,14 +89,11 @@ export default function EditLearningResourcePage() {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
// Access rules state
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
@@ -138,13 +115,11 @@ export default function EditLearningResourcePage() {
setTitle(resource.title)
setDescription(resource.description || '')
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
setResourceType(resource.resourceType)
setCohortLevel(resource.cohortLevel)
setExternalUrl(resource.externalUrl || '')
setIsPublished(resource.isPublished)
setProgramId(resource.programId)
const { mode, roles } = parseAccessJson(resource.accessJson)
setAccessMode(mode)
setSelectedRoles(roles)
}
}, [resource])
@@ -159,89 +134,75 @@ export default function EditLearningResourcePage() {
await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
headers: {
'Content-Type': file.type,
},
})
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch {
} catch (error) {
toast.error('Failed to upload file')
throw new Error('Upload failed')
throw error
}
}
const buildAccessJson = (): AccessRule[] | null => {
if (accessMode === 'everyone') return null
if (accessMode === 'roles' && selectedRoles.length > 0) {
return [{ type: 'roles', roles: selectedRoles }]
}
return null
}
const handleSubmit = useCallback(async () => {
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await updateResource.mutateAsync({
id: resourceId,
programId,
title,
description: description || null,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
accessJson: buildAccessJson(),
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || null,
isPublished,
})
toast.success('Resource updated')
toast.success('Resource updated successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
}
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
}
const handleDelete = async () => {
try {
await deleteResource.mutateAsync({ id: resourceId })
toast.success('Resource deleted')
toast.success('Resource deleted successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
}
}
// Ctrl+S save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSubmit])
if (isLoading) {
return (
<div className="flex min-h-screen flex-col">
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
<Skeleton className="h-8 w-20" />
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-16" />
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
</div>
<div className="flex-1 px-4 py-8">
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
</div>
)
@@ -249,7 +210,7 @@ export default function EditLearningResourcePage() {
if (error || !resource) {
return (
<div className="space-y-6 p-6">
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
@@ -268,172 +229,37 @@ export default function EditLearningResourcePage() {
}
return (
<div className="flex min-h-screen flex-col">
{/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
Back to Learning Hub
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant={previewing ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewing(!previewing)}
>
<Eye className="mr-2 h-4 w-4" />
{previewing ? 'Edit' : 'Preview'}
</Button>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</SheetTrigger>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Resource Settings</SheetTitle>
<SheetDescription>
Configure publishing, access, and metadata
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-6">
{/* Publish toggle */}
<div className="flex items-center justify-between">
<div className="flex items-start justify-between">
<div>
<Label>Published</Label>
<p className="text-sm text-muted-foreground">
Make visible to users
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
<p className="text-muted-foreground">
Update this learning resource
</p>
</div>
<Switch
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<Separator />
{/* Program */}
<div className="space-y-2">
<Label>Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* Access Rules */}
<div className="space-y-3">
<Label>Access Rules</Label>
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="everyone">Everyone</SelectItem>
<SelectItem value="roles">By Role</SelectItem>
</SelectContent>
</Select>
{accessMode === 'roles' && (
<div className="space-y-2 rounded-lg border p-3">
{ROLE_OPTIONS.map((role) => (
<label key={role.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selectedRoles.includes(role.value)}
onCheckedChange={(checked) => {
setSelectedRoles(
checked
? [...selectedRoles, role.value]
: selectedRoles.filter((r) => r !== role.value)
)
}}
/>
{role.label}
</label>
))}
</div>
)}
</div>
<Separator />
{/* External URL */}
<div className="space-y-2">
<Label>External URL</Label>
<Input
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
<p className="text-xs text-muted-foreground">
Optional link to an external resource
</p>
</div>
<Separator />
{/* Statistics */}
{stats && (
<div className="space-y-2">
<Label>Statistics</Label>
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-xs text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-xs text-muted-foreground">Unique users</p>
</div>
</div>
</div>
)}
<Separator />
{/* Danger Zone */}
<div className="space-y-2">
<Label className="text-destructive">Danger Zone</Label>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="mr-2 h-4 w-4" />
Delete Resource
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{resource.title}&quot;?
This action cannot be undone.
Are you sure you want to delete &quot;{resource.title}&quot;? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -451,66 +277,204 @@ export default function EditLearningResourcePage() {
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</SheetContent>
</Sheet>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Statistics */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Statistics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-sm text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-sm text-muted-foreground">Unique users</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
size="sm"
onClick={handleSubmit}
disabled={updateResource.isPending || !title.trim()}
className="w-full"
>
{updateResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
Save Changes
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Content area */}
<div className="flex-1 px-4 py-8">
{previewing ? (
<ResourceRenderer
title={title || 'Untitled'}
description={description || null}
contentJson={contentJson ? JSON.parse(contentJson) : null}
/>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{/* Inline title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Untitled"
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
/>
{/* Inline description */}
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
/>
{/* Divider */}
<hr className="border-border" />
{/* Block editor */}
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[400px]"
/>
</div>
)}
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
@@ -8,9 +8,15 @@ import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
@@ -18,57 +24,33 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
Settings,
Eye,
} from 'lucide-react'
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
// Dynamically import editors to avoid SSR issues
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ROLE_OPTIONS = [
{ value: 'JURY_MEMBER', label: 'Jury Members' },
{ value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
type AccessRule =
| { type: 'everyone' }
| { type: 'roles'; roles: string[] }
| { type: 'jury_group'; juryGroupIds: string[] }
| { type: 'round'; roundIds: string[] }
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function NewLearningResourcePage() {
const router = useRouter()
@@ -77,17 +59,14 @@ export default function NewLearningResourcePage() {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
// Access rules state
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const [programId, setProgramId] = useState<string | null>(null)
const utils = trpc.useUtils()
const createResource = trpc.learningResource.create.useMutation({
@@ -103,41 +82,43 @@ export default function NewLearningResourcePage() {
mimeType: file.type,
})
// Upload to MinIO
await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
headers: {
'Content-Type': file.type,
},
})
// Return the MinIO URL
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch {
} catch (error) {
toast.error('Failed to upload file')
throw new Error('Upload failed')
throw error
}
}
const buildAccessJson = (): AccessRule[] | null => {
if (accessMode === 'everyone') return null
if (accessMode === 'roles' && selectedRoles.length > 0) {
return [{ type: 'roles', roles: selectedRoles }]
}
return null
}
const handleSubmit = useCallback(async () => {
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await createResource.mutateAsync({
programId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
accessJson: buildAccessJson(),
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || undefined,
isPublished,
})
@@ -147,81 +128,161 @@ export default function NewLearningResourcePage() {
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
}
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
// Ctrl+S save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSubmit])
return (
<div className="flex min-h-screen flex-col">
{/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
Back to Learning Hub
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
<p className="text-muted-foreground">
Create a new learning resource for jury members
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<Button
variant={previewing ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewing(!previewing)}
>
<Eye className="mr-2 h-4 w-4" />
{previewing ? 'Edit' : 'Preview'}
</Button>
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</SheetTrigger>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Resource Settings</SheetTitle>
<SheetDescription>
Configure publishing, access, and metadata
</SheetDescription>
</SheetHeader>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-6 space-y-6">
{/* Publish toggle */}
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Published</Label>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make visible to users
Make this resource visible to jury members
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<Separator />
{/* Program */}
<div className="space-y-2">
<Label>Program</Label>
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
@@ -234,117 +295,32 @@ export default function NewLearningResourcePage() {
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Separator />
{/* Access Rules */}
<div className="space-y-3">
<Label>Access Rules</Label>
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="everyone">Everyone</SelectItem>
<SelectItem value="roles">By Role</SelectItem>
</SelectContent>
</Select>
{accessMode === 'roles' && (
<div className="space-y-2 rounded-lg border p-3">
{ROLE_OPTIONS.map((role) => (
<label key={role.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selectedRoles.includes(role.value)}
onCheckedChange={(checked) => {
setSelectedRoles(
checked
? [...selectedRoles, role.value]
: selectedRoles.filter((r) => r !== role.value)
)
}}
/>
{role.label}
</label>
))}
</div>
)}
</div>
<Separator />
{/* External URL */}
<div className="space-y-2">
<Label>External URL</Label>
<Input
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
<p className="text-xs text-muted-foreground">
Optional link to an external resource
</p>
</div>
</div>
</SheetContent>
</Sheet>
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
size="sm"
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
className="w-full"
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
Create Resource
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Content area */}
<div className="flex-1 px-4 py-8">
{previewing ? (
<ResourceRenderer
title={title || 'Untitled'}
description={description || null}
contentJson={contentJson ? JSON.parse(contentJson) : null}
/>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{/* Inline title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Untitled"
autoFocus
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
/>
{/* Inline description */}
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
/>
{/* Divider */}
<hr className="border-border" />
{/* Block editor */}
<BlockEditor
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[400px]"
/>
</div>
)}
</div>
</div>
)

View File

@@ -12,7 +12,6 @@ import {
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
@@ -23,212 +22,48 @@ import {
import {
Plus,
FileText,
Video,
Link as LinkIcon,
File,
Pencil,
ExternalLink,
Search,
GripVertical,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
type Resource = {
id: string
title: string
description: string | null
isPublished: boolean
sortOrder: number
externalUrl: string | null
objectKey: string | null
contentJson: unknown
accessJson: unknown
_count: { accessLogs: number }
program: { id: string; name: string; year: number } | null
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
function getAccessSummary(accessJson: unknown): string {
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
return 'Everyone'
}
const rule = accessJson[0] as { type: string; roles?: string[] }
if (rule.type === 'everyone') return 'Everyone'
if (rule.type === 'roles' && rule.roles) {
if (rule.roles.length === 1) return rule.roles[0].replace('_', ' ').toLowerCase()
return `${rule.roles.length} roles`
}
if (rule.type === 'jury_group') return 'Jury groups'
if (rule.type === 'round') return 'By round'
return 'Custom'
}
function SortableResourceCard({
resource,
onTogglePublished,
}: {
resource: Resource
onTogglePublished: (id: string, published: boolean) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: resource.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<Card
ref={setNodeRef}
style={style}
className={isDragging ? 'opacity-50 shadow-lg' : ''}
>
<CardContent className="flex items-center gap-3 py-3">
{/* Drag handle */}
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Icon */}
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-4 w-4" />
</div>
{/* Title & meta */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary" className="text-xs">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span className="capitalize">{getAccessSummary(resource.accessJson)}</span>
<span>&middot;</span>
<span>{resource._count.accessLogs} views</span>
{resource.program && (
<>
<span>&middot;</span>
<span>{resource.program.year}</span>
</>
)}
</div>
</div>
{/* Quick publish toggle */}
<Switch
checked={resource.isPublished}
onCheckedChange={(checked) => onTogglePublished(resource.id, checked)}
aria-label={resource.isPublished ? 'Unpublish' : 'Publish'}
/>
{/* Actions */}
<div className="flex items-center gap-1">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
const cohortColors: Record<string, string> = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function LearningHubPage() {
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
const resources = (data?.data || []) as Resource[]
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
const resources = data?.data
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const [publishedFilter, setPublishedFilter] = useState('all')
const utils = trpc.useUtils()
const reorderMutation = trpc.learningResource.reorder.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const updateMutation = trpc.learningResource.update.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const [typeFilter, setTypeFilter] = useState('all')
const [cohortFilter, setCohortFilter] = useState('all')
const filteredResources = useMemo(() => {
if (!resources) return []
return resources.filter((resource) => {
const matchesSearch =
!debouncedSearch ||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
const matchesPublished =
publishedFilter === 'all' ||
(publishedFilter === 'published' && resource.isPublished) ||
(publishedFilter === 'draft' && !resource.isPublished)
return matchesSearch && matchesPublished
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
return matchesSearch && matchesType && matchesCohort
})
}, [resources, debouncedSearch, publishedFilter])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = filteredResources.findIndex((r) => r.id === active.id)
const newIndex = filteredResources.findIndex((r) => r.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
const reordered = arrayMove(filteredResources, oldIndex, newIndex)
const items = reordered.map((r, i) => ({ id: r.id, sortOrder: i }))
reorderMutation.mutate({ items }, {
onError: () => toast.error('Failed to reorder'),
})
}
const handleTogglePublished = (id: string, published: boolean) => {
updateMutation.mutate({ id, isPublished: published }, {
onSuccess: () => toast.success(published ? 'Published' : 'Unpublished'),
onError: () => toast.error('Failed to update'),
})
}
}, [resources, debouncedSearch, typeFilter, cohortFilter])
if (isLoading) {
return (
@@ -240,20 +75,25 @@ export default function LearningHubPage() {
</div>
<Skeleton className="h-9 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Skeleton className="h-10 flex-1" />
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-[160px]" />
<Skeleton className="h-10 w-[160px]" />
</div>
<div className="grid gap-3">
</div>
{/* Resource list skeleton */}
<div className="grid gap-4">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-9 w-9 rounded-lg" />
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-8 w-8 rounded" />
</CardContent>
</Card>
))}
@@ -269,7 +109,7 @@ export default function LearningHubPage() {
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Manage educational resources for program participants
Manage educational resources for jury members
</p>
</div>
<Link href="/admin/learning/new">
@@ -291,49 +131,92 @@ export default function LearningHubPage() {
className="pl-9"
/>
</div>
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
<div className="flex items-center gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All" />
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="draft">Drafts</SelectItem>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="PDF">PDF</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="DOCUMENT">Document</SelectItem>
<SelectItem value="LINK">Link</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
<Select value={cohortFilter} onValueChange={setCohortFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All cohorts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All cohorts</SelectItem>
<SelectItem value="ALL">All (cohort)</SelectItem>
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Results count */}
{resources.length > 0 && (
{resources && (
<p className="text-sm text-muted-foreground">
{filteredResources.length} of {resources.length} resources
{reorderMutation.isPending && ' · Saving order...'}
</p>
)}
{/* Resource List with DnD */}
{/* Resource List */}
{filteredResources.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredResources.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="grid gap-3">
{filteredResources.map((resource) => (
<SortableResourceCard
key={resource.id}
resource={resource}
onTogglePublished={handleTogglePublished}
/>
))}
<div className="grid gap-4">
{filteredResources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5" />
</div>
</SortableContext>
</DndContext>
) : resources.length > 0 ? (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
{resource.cohortLevel}
</Badge>
<span>{resource.resourceType}</span>
<span>-</span>
<span>{resource._count.accessLogs} views</span>
</div>
</div>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
) : resources && resources.length > 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Search className="h-8 w-8 text-muted-foreground/40" />

View File

@@ -1,10 +0,0 @@
import { redirect } from 'next/navigation'
export default async function MentorDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/admin/members/${id}`)
}

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function MentorsPage() {
redirect('/admin/members')
}

View File

@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) {
const defaultEdition = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
where: { status: 'ACTIVE', isTest: false },
orderBy: { year: 'desc' },
select: { id: true },
})
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) {
const anyEdition = await prisma.program.findFirst({
where: { isTest: false },
orderBy: { year: 'desc' },
select: { id: true },
})

View File

@@ -19,7 +19,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
interface ProgramDetailPageProps {
@@ -65,6 +65,13 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/mentorship` as Route}>
<GraduationCap className="mr-2 h-4 w-4" />
Mentorship
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
@@ -72,6 +79,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</Link>
</Button>
</div>
</div>
{program.description && (
<Card>

View File

@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
async function ProgramsContent() {
const programs = await prisma.program.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
where: { isTest: false },
include: {
competitions: {
include: {

View File

@@ -88,8 +88,6 @@ import {
Mail,
History,
ChevronRight,
ArrowRightLeft,
Sparkles,
} from 'lucide-react'
import {
Command,
@@ -1669,8 +1667,6 @@ export default function RoundDetailPage() {
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride as number | null}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => updateJuryMemberMutation.mutate({
id: member.id,
maxAssignmentsOverride: val,
@@ -2246,43 +2242,20 @@ function InlineMemberCap({
memberId,
currentValue,
onSave,
roundId,
jurorUserId,
}: {
memberId: string
currentValue: number | null
onSave: (val: number | null) => void
roundId?: string
jurorUserId?: string
}) {
const utils = trpc.useUtils()
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(currentValue?.toString() ?? '')
const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
const [showBanner, setShowBanner] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate()
utils.analytics.getJurorWorkload.invalidate()
utils.roundAssignment.unassignedQueue.invalidate()
setShowBanner(false)
setOverCapInfo(null)
if (data.failed > 0) {
toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
} else {
toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
const save = async () => {
const save = () => {
const trimmed = value.trim()
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
@@ -2293,76 +2266,10 @@ function InlineMemberCap({
setEditing(false)
return
}
// Check over-cap impact before saving
if (newVal !== null && roundId && jurorUserId) {
try {
const preview = await utils.client.assignment.getOverCapPreview.query({
roundId,
jurorId: jurorUserId,
newCap: newVal,
})
if (preview.overCapCount > 0) {
setOverCapInfo(preview)
setShowBanner(true)
setEditing(false)
return
}
} catch {
// If preview fails, just save the cap normally
}
}
onSave(newVal)
setEditing(false)
}
const handleRedistribute = () => {
const newVal = parseInt(value.trim(), 10)
onSave(newVal)
if (roundId && jurorUserId) {
redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
}
}
const handleJustSave = () => {
const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
onSave(newVal)
setShowBanner(false)
setOverCapInfo(null)
}
if (showBanner && overCapInfo) {
return (
<div className="space-y-1.5">
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
<p>New cap of <strong>{value}</strong> is below current load (<strong>{overCapInfo.total}</strong> assignments). <strong>{overCapInfo.movableOverCap}</strong> can be redistributed.</p>
{overCapInfo.immovableOverCap > 0 && (
<p className="text-amber-600 mt-0.5">{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.</p>
)}
<div className="flex gap-1.5 mt-1.5">
<Button
size="sm"
variant="default"
className="h-6 text-xs px-2"
disabled={redistributeMutation.isPending || overCapInfo.movableOverCap === 0}
onClick={handleRedistribute}
>
{redistributeMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Redistribute
</Button>
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={handleJustSave}>
Just save cap
</Button>
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => { setShowBanner(false); setOverCapInfo(null) }}>
Cancel
</Button>
</div>
</div>
</div>
)
}
if (editing) {
return (
<Input
@@ -2457,8 +2364,6 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
function JuryProgressTable({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
@@ -2488,7 +2393,6 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
})
return (
<>
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Progress</CardTitle>
@@ -2544,22 +2448,6 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => setTransferJuror({ id: juror.id, name: juror.name })}
>
<ArrowRightLeft className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
@@ -2601,318 +2489,6 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
)}
</CardContent>
</Card>
{transferJuror && (
<TransferAssignmentsDialog
roundId={roundId}
sourceJuror={transferJuror}
open={!!transferJuror}
onClose={() => setTransferJuror(null)}
/>
)}
</>
)
}
// ── Transfer Assignments Dialog ──────────────────────────────────────────
function TransferAssignmentsDialog({
roundId,
sourceJuror,
open,
onClose,
}: {
roundId: string
sourceJuror: { id: string; name: string }
open: boolean
onClose: () => void
}) {
const utils = trpc.useUtils()
const [step, setStep] = useState<1 | 2>(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Fetch source juror's assignments
const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
{ roundId },
{ enabled: open },
)
const jurorAssignments = useMemo(() =>
(sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
[sourceAssignments, sourceJuror.id],
)
// Fetch transfer candidates when in step 2
const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
{ roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
{ enabled: step === 2 && selectedIds.size > 0 },
)
// Per-assignment destination overrides
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
const [forceOverCap, setForceOverCap] = useState(false)
// Auto-assign: distribute assignments across eligible candidates balanced by load
const handleAutoAssign = () => {
if (!candidateData) return
const movable = candidateData.assignments.filter((a) => a.movable)
if (movable.length === 0) return
// Simulate load starting from each candidate's current load
const simLoad = new Map<string, number>()
for (const c of candidateData.candidates) {
simLoad.set(c.userId, c.currentLoad)
}
const overrides: Record<string, string> = {}
for (const assignment of movable) {
const eligible = candidateData.candidates
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
if (eligible.length === 0) continue
// Sort: prefer not-all-completed, then under cap, then lowest simulated load
const sorted = [...eligible].sort((a, b) => {
// Prefer jurors who haven't completed all evaluations
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
const loadA = simLoad.get(a.userId) ?? 0
const loadB = simLoad.get(b.userId) ?? 0
// Prefer jurors under their cap
const overCapA = loadA >= a.cap ? 1 : 0
const overCapB = loadB >= b.cap ? 1 : 0
if (overCapA !== overCapB) return overCapA - overCapB
// Then pick the least loaded
return loadA - loadB
})
const best = sorted[0]
overrides[assignment.id] = best.userId
simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
}
setDestOverrides(overrides)
}
const transferMutation = trpc.assignment.transferAssignments.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
utils.assignment.getReassignmentHistory.invalidate({ roundId })
const successCount = data.succeeded.length
const failCount = data.failed.length
if (failCount > 0) {
toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
} else {
toast.success(`Transferred ${successCount} project(s) successfully.`)
}
onClose()
},
onError: (err) => toast.error(err.message),
})
// Build the transfer plan: for each selected assignment, determine destination
const transferPlan = useMemo(() => {
if (!candidateData) return []
const movable = candidateData.assignments.filter((a) => a.movable)
return movable.map((assignment) => {
const override = destOverrides[assignment.id]
// Default: first eligible candidate
const defaultDest = candidateData.candidates.find((c) =>
c.eligibleProjectIds.includes(assignment.projectId)
)
const destId = override || defaultDest?.userId || ''
const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
}).filter((t) => t.destinationJurorId)
}, [candidateData, destOverrides])
// Check if any destination is at or over cap
const anyOverCap = useMemo(() => {
if (!candidateData) return false
const destCounts = new Map<string, number>()
for (const t of transferPlan) {
destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
}
return candidateData.candidates.some((c) => {
const extraLoad = destCounts.get(c.userId) ?? 0
return c.currentLoad + extraLoad > c.cap
})
}, [candidateData, transferPlan])
const handleTransfer = () => {
transferMutation.mutate({
roundId,
sourceJurorId: sourceJuror.id,
transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
forceOverCap,
})
}
const isMovable = (a: any) => {
const status = a.evaluation?.status
return !status || status === 'NOT_STARTED' || status === 'DRAFT'
}
const movableAssignments = jurorAssignments.filter(isMovable)
const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
<DialogDescription>
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
</DialogDescription>
</DialogHeader>
{step === 1 && (
<div className="space-y-3">
{loadingAssignments ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : jurorAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
) : (
<>
<div className="flex items-center gap-2 pb-2 border-b">
<Checkbox
checked={allMovableSelected}
onCheckedChange={(checked) => {
if (checked) {
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
} else {
setSelectedIds(new Set())
}
}}
/>
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{jurorAssignments.map((a: any) => {
const movable = isMovable(a)
const status = a.evaluation?.status || 'No evaluation'
return (
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
<Checkbox
checked={selectedIds.has(a.id)}
disabled={!movable}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(a.id)
else next.delete(a.id)
setSelectedIds(next)
}}
/>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{status}
</Badge>
</div>
)
})}
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
disabled={selectedIds.size === 0}
onClick={() => { setStep(2); setDestOverrides({}) }}
>
Next ({selectedIds.size} selected)
</Button>
</DialogFooter>
</div>
)}
{step === 2 && (
<div className="space-y-3">
{loadingCandidates ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : !candidateData || candidateData.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
) : (
<>
<div className="flex items-center justify-end">
<Button variant="outline" size="sm" onClick={handleAutoAssign}>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Auto-assign
</Button>
</div>
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
const currentDest = destOverrides[assignment.id] ||
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
return (
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{assignment.projectTitle}</p>
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
</div>
<Select
value={currentDest}
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
>
<SelectTrigger className="w-[200px] h-8 text-xs">
<SelectValue placeholder="Select juror" />
</SelectTrigger>
<SelectContent>
{candidateData.candidates
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
.map((c) => (
<SelectItem key={c.userId} value={c.userId}>
<span>{c.name}</span>
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
</div>
{transferPlan.length > 0 && (
<p className="text-xs text-muted-foreground">
Transfer {transferPlan.length} project(s) from {sourceJuror.name}
</p>
)}
{anyOverCap && (
<div className="flex items-center gap-2 p-2 border border-amber-200 bg-amber-50 rounded-md">
<Checkbox
checked={forceOverCap}
onCheckedChange={(checked) => setForceOverCap(!!checked)}
/>
<span className="text-xs text-amber-800">Force over-cap: some destinations will exceed their assignment limit</span>
</div>
)}
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
<Button
disabled={transferPlan.length === 0 || transferMutation.isPending || (anyOverCap && !forceOverCap)}
onClick={handleTransfer}
>
{transferMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
Transfer {transferPlan.length} project(s)
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -2936,7 +2512,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
Reassignment History
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
</CardTitle>
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
<CardDescription>Juror dropout and COI reassignment audit trail</CardDescription>
</CardHeader>
{expanded && (
<CardContent>
@@ -2955,7 +2531,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
{event.type === 'DROPOUT' ? 'Juror Dropout' : 'COI Reassignment'}
</Badge>
<span className="text-sm font-medium">
{event.droppedJuror.name}
@@ -3046,19 +2622,19 @@ function ScoreDistribution({ roundId }: { roundId: string }) {
No evaluations submitted yet
</p>
) : (
<div className="flex gap-1 h-32">
<div className="flex items-end gap-1 h-32">
{dist.globalDistribution.map((bucket) => {
const heightPct = (bucket.count / maxCount) * 100
return (
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1 h-full">
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
<div className="w-full flex-1 relative">
<div className="w-full relative rounded-t" style={{ height: `${Math.max(heightPct, 2)}%` }}>
<div className={cn(
'absolute inset-x-0 bottom-0 rounded-t transition-all',
'absolute inset-0 rounded-t transition-all',
bucket.score <= 3 ? 'bg-red-400' :
bucket.score <= 6 ? 'bg-amber-400' :
'bg-emerald-400',
)} style={{ height: `${Math.max(heightPct, 4)}%` }} />
)} />
</div>
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
</div>

View File

@@ -205,14 +205,14 @@ export default function RoundsPage() {
}
const startEditSettings = () => {
if (!comp) return
if (!comp || !compDetail) return
setEditingCompId(comp.id)
setCompetitionEdits({
name: comp.name,
categoryMode: (comp as any).categoryMode,
startupFinalistCount: (comp as any).startupFinalistCount,
conceptFinalistCount: (comp as any).conceptFinalistCount,
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
name: compDetail.name,
categoryMode: compDetail.categoryMode,
startupFinalistCount: compDetail.startupFinalistCount,
conceptFinalistCount: compDetail.conceptFinalistCount,
notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
})
setSettingsOpen(true)
}

View File

@@ -12,6 +12,7 @@ export default async function AdminLayout({
// Fetch all editions (programs) for the edition selector
const editions = await prisma.program.findMany({
where: { isTest: false },
select: {
id: true,
name: true,

View File

@@ -0,0 +1,180 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { FileUploadSlot } from '@/components/applicant/file-upload-slot'
import { ArrowLeft, Lock, Clock, Calendar, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
export default function ApplicantSubmissionWindowPage() {
const params = useParams()
const router = useRouter()
const windowId = params.windowId as string
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File>>({})
const { data: window, isLoading } = trpc.round.getById.useQuery(
{ id: windowId },
{ enabled: !!windowId }
)
const { data: deadlineStatus } = trpc.round.checkDeadline.useQuery(
{ windowId },
{
enabled: !!windowId,
refetchInterval: 60000, // Refresh every minute
}
)
const handleUpload = (requirementId: string, file: File) => {
setUploadedFiles(prev => ({ ...prev, [requirementId]: file }))
toast.success(`File "${file.name}" selected for upload`)
// In a real implementation, this would trigger file upload
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96" />
</div>
)
}
if (!window) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant/competitions' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Submission window not found</p>
</CardContent>
</Card>
</div>
)
}
const isLocked = deadlineStatus?.status === 'LOCKED' || deadlineStatus?.status === 'CLOSED'
const deadline = window.windowCloseAt
? new Date(window.windowCloseAt)
: null
const timeRemaining = deadline ? deadline.getTime() - Date.now() : null
const daysRemaining = timeRemaining ? Math.floor(timeRemaining / (1000 * 60 * 60 * 24)) : null
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant/competitions' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">{window.name}</h1>
<p className="text-muted-foreground mt-1">
Upload required documents for this submission window
</p>
</div>
</div>
{/* Deadline card */}
{deadline && (
<Card className={isLocked ? 'border-red-200 bg-red-50/50' : 'border-l-4 border-l-amber-500'}>
<CardContent className="flex items-start gap-3 p-4">
{isLocked ? (
<>
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm text-red-900">Submission Window Closed</p>
<p className="text-sm text-red-700 mt-1">
This submission window closed on {deadline.toLocaleDateString()}. No further
uploads are allowed.
</p>
</div>
</>
) : (
<>
<Clock className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Deadline Countdown</p>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold tabular-nums text-amber-600">
{daysRemaining !== null ? daysRemaining : '—'}
</span>
<span className="text-sm text-muted-foreground">
day{daysRemaining !== 1 ? 's' : ''} remaining
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Due: {deadline.toLocaleString()}
</p>
</div>
</>
)}
</CardContent>
</Card>
)}
{/* File requirements */}
<Card>
<CardHeader>
<CardTitle>File Requirements</CardTitle>
<CardDescription>
Upload the required files below. {isLocked && 'Viewing only - window is closed.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* File requirements would be fetched separately in a real implementation */}
{false ? (
[].map((req: any) => (
<FileUploadSlot
key={req.id}
requirement={{
id: req.id,
label: req.label,
description: req.description,
mimeTypes: req.mimeTypes || [],
maxSizeMb: req.maxSizeMb,
required: req.required || false,
}}
isLocked={isLocked}
onUpload={(file) => handleUpload(req.id, file)}
/>
))
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
No file requirements defined for this window
</p>
</div>
)}
</CardContent>
</Card>
{!isLocked && (
<div className="flex justify-end gap-3">
<Button variant="outline">Save Draft</Button>
<Button className="bg-brand-blue hover:bg-brand-blue-light">
Submit All Files
</Button>
</div>
)}
</div>
)
}

View File

@@ -4,13 +4,14 @@ import { useSession } from 'next-auth/react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
import { toast } from 'sonner'
export default function ApplicantCompetitionPage() {
export default function ApplicantCompetitionsPage() {
const { data: session } = useSession()
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: !!session,
@@ -25,7 +26,7 @@ export default function ApplicantCompetitionPage() {
)
}
const hasProject = !!myProject?.project
const competitionId = myProject?.project?.programId
return (
<div className="space-y-6">
@@ -44,7 +45,7 @@ export default function ApplicantCompetitionPage() {
</Button>
</div>
{!hasProject ? (
{!competitionId ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
@@ -58,7 +59,7 @@ export default function ApplicantCompetitionPage() {
) : (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<ApplicantCompetitionTimeline />
<ApplicantCompetitionTimeline competitionId={competitionId} />
</div>
<div className="space-y-4">
<Card>

View File

@@ -1,147 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Star, MessageSquare } from 'lucide-react'
export default function ApplicantEvaluationsPage() {
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
</div>
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardContent className="py-6">
<Skeleton className="h-6 w-48 mb-4" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
const hasEvaluations = rounds && rounds.length > 0
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground">
Anonymous evaluations from jury members
</p>
</div>
{!hasEvaluations ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
<p className="text-muted-foreground text-center max-w-md">
Evaluations will appear here once jury review is complete and results are published.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{rounds.map((round) => (
<Card key={round.roundId}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{round.roundName}</CardTitle>
<Badge variant="secondary">
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{round.evaluations.map((ev, idx) => (
<div
key={ev.id}
className="rounded-lg border p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
Evaluator #{idx + 1}
</span>
{ev.submittedAt && (
<span className="text-xs text-muted-foreground">
{new Date(ev.submittedAt).toLocaleDateString()}
</span>
)}
</div>
{ev.globalScore !== null && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-lg font-semibold">{ev.globalScore}</span>
<span className="text-sm text-muted-foreground">/ 100</span>
</div>
)}
{ev.criterionScores && ev.criteria && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
<div className="grid gap-2">
{(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
const scores = ev.criterionScores as Record<string, number>
return criteria
.filter((c) => c.id || c.label || c.name)
.map((c, ci) => {
const key = c.id || String(ci)
const score = scores[key]
return (
<div key={ci} className="flex items-center justify-between text-sm">
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-medium">
{score !== undefined ? score : '—'}
{c.maxScore ? ` / ${c.maxScore}` : ''}
</span>
</div>
)
})
})()}
</div>
</div>
)}
{ev.feedbackText && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
Written Feedback
</div>
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
{ev.feedbackText}
</blockquote>
</div>
)}
</div>
))}
</CardContent>
</Card>
))}
<p className="text-xs text-muted-foreground text-center">
Evaluator identities are kept confidential.
</p>
</div>
)}
</div>
)
}

View File

@@ -9,26 +9,25 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { StatusTracker } from '@/components/shared/status-tracker'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
FileText,
Calendar,
Clock,
AlertCircle,
CheckCircle,
Users,
Crown,
MessageSquare,
Upload,
ArrowRight,
Star,
AlertCircle,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
@@ -50,18 +49,6 @@ export default function ApplicantDashboardPage() {
enabled: isAuthenticated,
})
const { data: deadlines } = trpc.applicant.getUpcomingDeadlines.useQuery(undefined, {
enabled: isAuthenticated,
})
const { data: docCompleteness } = trpc.applicant.getDocumentCompleteness.useQuery(undefined, {
enabled: isAuthenticated,
})
const { data: evaluations } = trpc.applicant.getMyEvaluations.useQuery(undefined, {
enabled: isAuthenticated,
})
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="space-y-6">
@@ -111,11 +98,10 @@ export default function ApplicantDashboardPage() {
)
}
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
const { project, timeline, currentStatus, openRounds } = data
const isDraft = !project.submittedAt
const programYear = project.program?.year
const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
return (
<div className="space-y-6">
@@ -227,7 +213,7 @@ export default function ApplicantDashboardPage() {
{/* Quick actions */}
<AnimatedCard index={1}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-3">
<Link href={"/applicant/documents" as Route} 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-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
@@ -254,7 +240,6 @@ export default function ApplicantDashboardPage() {
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
{project.mentorAssignment && (
<Link href={"/applicant/mentor" as Route} 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-green-500/30 hover:bg-green-500/5">
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
@@ -262,100 +247,34 @@ export default function ApplicantDashboardPage() {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment.mentor?.name || 'Assigned'}
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
)}
</div>
</AnimatedCard>
{/* Document Completeness */}
{docCompleteness && docCompleteness.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Document Progress
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{docCompleteness.map((dc) => (
<div key={dc.roundId}>
<div className="flex items-center justify-between text-sm mb-1.5">
<span className="font-medium">{dc.roundName}</span>
<span className="text-muted-foreground">
{dc.uploaded}/{dc.required} files
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${dc.required > 0 ? Math.round((dc.uploaded / dc.required) * 100) : 0}%` }}
/>
</div>
</div>
))}
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Competition timeline or status tracker */}
<AnimatedCard index={3}>
{/* Status timeline */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle>
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
</CardTitle>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
{hasPassedIntake ? (
<CompetitionTimelineSidebar />
) : (
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Jury Feedback Card */}
{totalEvaluations > 0 && (
<AnimatedCard index={4}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Star className="h-5 w-5" />
Jury Feedback
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={"/applicant/evaluations" as Route}>
View All
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
</p>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Team overview */}
<AnimatedCard index={5}>
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -407,32 +326,8 @@ export default function ApplicantDashboardPage() {
</Card>
</AnimatedCard>
{/* Upcoming Deadlines */}
{deadlines && deadlines.length > 0 && (
<AnimatedCard index={6}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Upcoming Deadlines
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{deadlines.map((dl, i) => (
<div key={i} className="flex items-center justify-between text-sm">
<span className="font-medium truncate mr-2">{dl.roundName}</span>
<span className="text-muted-foreground shrink-0">
{new Date(dl.windowCloseAt).toLocaleDateString()}
</span>
</div>
))}
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Key dates */}
<AnimatedCard index={7}>
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
@@ -452,6 +347,12 @@ export default function ApplicantDashboardPage() {
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
{openRounds.length > 0 && openRounds[0].windowCloseAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<span>{new Date(openRounds[0].windowCloseAt).toLocaleDateString()}</span>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>

View File

@@ -1,122 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
ArrowLeft,
Download,
ExternalLink,
AlertCircle,
} from 'lucide-react'
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
export default function ApplicantResourceDetailPage() {
const params = useParams()
const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const logAccess = trpc.learningResource.logAccess.useMutation()
const utils = trpc.useUtils()
// Log access on mount
useEffect(() => {
if (resourceId) {
logAccess.mutate({ id: resourceId })
}
}, [resourceId])
const handleDownload = async () => {
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch {
// silently fail
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-20" />
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
This resource may have been removed or you don&apos;t have access.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/applicant/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/applicant/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open Link
</Button>
</a>
)}
{resource.objectKey && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
)}
</div>
</div>
{/* Content */}
<ResourceRenderer
title={resource.title}
description={resource.description}
contentJson={resource.contentJson}
/>
</div>
)
}

View File

@@ -1,146 +0,0 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
export default function ApplicantResourcesPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
const utils = trpc.useUtils()
const handleDownload = async (resourceId: string) => {
setDownloadingId(resourceId)
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch (error) {
console.error('Download failed:', error)
} finally {
setDownloadingId(null)
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Resources</h1>
<p className="text-muted-foreground">
Resources and materials for applicants
</p>
</div>
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
const resources = data?.resources || []
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Resources</h1>
<p className="text-muted-foreground">
Resources and materials for applicants
</p>
</div>
{resources.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No resources available</h3>
<p className="text-muted-foreground">
Check back later for learning materials
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
{resource.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{resource.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/applicant/resources/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
)}
{resource.objectKey && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -280,15 +280,6 @@ export default function ApplicantTeamPage() {
/>
</div>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"

View File

@@ -1,122 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
ArrowLeft,
Download,
ExternalLink,
AlertCircle,
} from 'lucide-react'
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
export default function JuryResourceDetailPage() {
const params = useParams()
const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const logAccess = trpc.learningResource.logAccess.useMutation()
const utils = trpc.useUtils()
// Log access on mount
useEffect(() => {
if (resourceId) {
logAccess.mutate({ id: resourceId })
}
}, [resourceId])
const handleDownload = async () => {
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch {
// silently fail
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-20" />
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
This resource may have been removed or you don&apos;t have access.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/jury/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open Link
</Button>
</a>
)}
{resource.objectKey && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
)}
</div>
</div>
{/* Content */}
<ResourceRenderer
title={resource.title}
description={resource.description}
contentJson={resource.contentJson}
/>
</div>
)
}

View File

@@ -1,22 +1,41 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function JuryLearningPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
@@ -62,6 +81,7 @@ export default function JuryLearningPage() {
}
const resources = data?.resources || []
const userCohortLevel = data?.userCohortLevel || 'ALL'
return (
<div className="space-y-6">
@@ -70,6 +90,11 @@ export default function JuryLearningPage() {
<p className="text-muted-foreground">
Educational resources for jury members
</p>
{userCohortLevel !== 'ALL' && (
<Badge className={cohortColors[userCohortLevel]} variant="outline">
Your access level: {userCohortLevel}
</Badge>
)}
</div>
{resources.length === 0 ? (
@@ -85,14 +110,14 @@ export default function JuryLearningPage() {
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType]
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-5 w-5" />
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
@@ -101,39 +126,36 @@ export default function JuryLearningPage() {
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/jury/learning/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
</div>
<div>
{resource.externalUrl ? (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<Button>
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
)}
{resource.objectKey && (
) : resource.objectKey ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
)}
) : null}
</div>
</CardContent>
</Card>

View File

@@ -1,122 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
ArrowLeft,
Download,
ExternalLink,
AlertCircle,
} from 'lucide-react'
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
export default function MentorResourceDetailPage() {
const params = useParams()
const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const logAccess = trpc.learningResource.logAccess.useMutation()
const utils = trpc.useUtils()
// Log access on mount
useEffect(() => {
if (resourceId) {
logAccess.mutate({ id: resourceId })
}
}, [resourceId])
const handleDownload = async () => {
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch {
// silently fail
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-20" />
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
This resource may have been removed or you don&apos;t have access.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/mentor/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/mentor/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open Link
</Button>
</a>
)}
{resource.objectKey && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
)}
</div>
</div>
{/* Content */}
<ResourceRenderer
title={resource.title}
description={resource.description}
contentJson={resource.contentJson}
/>
</div>
)
}

View File

@@ -1,22 +1,38 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors: Record<string, string> = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function MentorResourcesPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
@@ -85,14 +101,14 @@ export default function MentorResourcesPage() {
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-5 w-5" />
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
@@ -101,39 +117,36 @@ export default function MentorResourcesPage() {
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel] || cohortColors.ALL}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/mentor/resources/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
</div>
<div>
{resource.externalUrl ? (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<Button>
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
)}
{resource.objectKey && (
) : resource.objectKey ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
)}
) : null}
</div>
</CardContent>
</Card>

View File

@@ -13,8 +13,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const now = new Date()
// Delete projects where isDraft=true AND draftExpiresAt has passed
// Exclude test projects — they are managed separately
const result = await prisma.project.deleteMany({
where: {
isTest: false,
isDraft: true,
draftExpiresAt: {
lt: now,

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
export const metadata: Metadata = {
title: {
@@ -22,7 +23,10 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers>
<Providers>
<ImpersonationBanner />
{children}
</Providers>
<Toaster
position="top-right"
toastOptions={{

View File

@@ -1,654 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
type SubmissionWindowManagerProps = {
competitionId: string
roundId: string
}
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingWindow, setEditingWindow] = useState<string | null>(null)
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
// Create form state
const [createForm, setCreateForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
})
// Edit form state
const [editForm, setEditForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
sortOrder: 1,
})
const utils = trpc.useUtils()
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window created')
setIsCreateOpen(false)
// Reset form
setCreateForm({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE',
graceHours: 0,
lockOnClose: true,
})
},
onError: (err) => toast.error(err.message),
})
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window updated')
setEditingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window deleted')
setDeletingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window opened')
},
onError: (err) => toast.error(err.message),
})
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window closed')
},
onError: (err) => toast.error(err.message),
})
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window locked')
},
onError: (err) => toast.error(err.message),
})
const handleCreateNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setCreateForm({ ...createForm, name: value, slug: autoSlug })
}
const handleEditNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setEditForm({ ...editForm, name: value, slug: autoSlug })
}
const handleCreate = () => {
if (!createForm.name || !createForm.slug) {
toast.error('Name and slug are required')
return
}
createWindowMutation.mutate({
competitionId,
name: createForm.name,
slug: createForm.slug,
roundNumber: createForm.roundNumber,
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
deadlinePolicy: createForm.deadlinePolicy,
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
lockOnClose: createForm.lockOnClose,
})
}
const handleEdit = () => {
if (!editingWindow) return
if (!editForm.name || !editForm.slug) {
toast.error('Name and slug are required')
return
}
updateWindowMutation.mutate({
id: editingWindow,
name: editForm.name,
slug: editForm.slug,
roundNumber: editForm.roundNumber,
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
deadlinePolicy: editForm.deadlinePolicy,
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
lockOnClose: editForm.lockOnClose,
sortOrder: editForm.sortOrder,
})
}
const handleDelete = () => {
if (!deletingWindow) return
deleteWindowMutation.mutate({ id: deletingWindow })
}
const openEditDialog = (window: any) => {
setEditForm({
name: window.name,
slug: window.slug,
roundNumber: window.roundNumber,
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
graceHours: window.graceHours ?? 0,
lockOnClose: window.lockOnClose ?? true,
sortOrder: window.sortOrder ?? 1,
})
setEditingWindow(window.id)
}
const formatDate = (date: Date | null | undefined) => {
if (!date) return 'Not set'
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
const windows = competition?.submissionWindows ?? []
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base">Submission Windows</CardTitle>
<p className="text-sm text-muted-foreground">
File upload windows for this round
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
Create Window
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-name">Window Name</Label>
<Input
id="create-name"
placeholder="e.g., Round 1 Submissions"
value={createForm.name}
onChange={(e) => handleCreateNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<Input
id="create-slug"
placeholder="e.g., round-1-submissions"
value={createForm.slug}
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-roundNumber">Round Number</Label>
<Input
id="create-roundNumber"
type="number"
min={1}
value={createForm.roundNumber}
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
<Input
id="create-windowOpenAt"
type="datetime-local"
value={createForm.windowOpenAt}
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
<Input
id="create-windowCloseAt"
type="datetime-local"
value={createForm.windowCloseAt}
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
<Select
value={createForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setCreateForm({ ...createForm, deadlinePolicy: value })
}
>
<SelectTrigger id="create-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{createForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="create-graceHours">Grace Hours</Label>
<Input
id="create-graceHours"
type="number"
min={0}
value={createForm.graceHours}
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="create-lockOnClose"
checked={createForm.lockOnClose}
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
/>
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleCreate}
disabled={createWindowMutation.isPending}
>
{createWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading windows...
</div>
) : windows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No submission windows yet. Create one to enable file uploads.
</div>
) : (
<div className="space-y-2">
{windows.map((window) => {
const isPending = !window.windowOpenAt
const isOpen = window.windowOpenAt && !window.windowCloseAt
const isClosed = window.windowCloseAt && !window.isLocked
const isLocked = window.isLocked
return (
<div
key={window.id}
className="flex flex-col gap-3 border rounded-lg p-3"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{window.name}</p>
{isPending && (
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
Pending
</Badge>
)}
{isOpen && (
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
Open
</Badge>
)}
{isClosed && (
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
Closed
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
Locked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Round {window.roundNumber}</span>
<span></span>
<span>{window._count.fileRequirements} requirements</span>
<span></span>
<span>{window._count.projectFiles} files</span>
</div>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Open: {formatDate(window.windowOpenAt)}</span>
<span></span>
<span>Close: {formatDate(window.windowCloseAt)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(window)}
className="h-8 px-2"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeletingWindow(window.id)}
className="h-8 px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
disabled={openWindowMutation.isPending}
>
{openWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Unlock className="h-3 w-3 mr-1" />
)}
Open
</Button>
)}
{isOpen && (
<Button
size="sm"
variant="outline"
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
disabled={closeWindowMutation.isPending}
>
{closeWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Lock className="h-3 w-3 mr-1" />
)}
Close
</Button>
)}
{isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
disabled={lockWindowMutation.isPending}
>
{lockWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<LockKeyhole className="h-3 w-3 mr-1" />
)}
Lock
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Window Name</Label>
<Input
id="edit-name"
placeholder="e.g., Round 1 Submissions"
value={editForm.name}
onChange={(e) => handleEditNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-slug">Slug</Label>
<Input
id="edit-slug"
placeholder="e.g., round-1-submissions"
value={editForm.slug}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-roundNumber">Round Number</Label>
<Input
id="edit-roundNumber"
type="number"
min={1}
value={editForm.roundNumber}
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
<Input
id="edit-windowOpenAt"
type="datetime-local"
value={editForm.windowOpenAt}
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
<Input
id="edit-windowCloseAt"
type="datetime-local"
value={editForm.windowCloseAt}
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
<Select
value={editForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setEditForm({ ...editForm, deadlinePolicy: value })
}
>
<SelectTrigger id="edit-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{editForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="edit-graceHours">Grace Hours</Label>
<Input
id="edit-graceHours"
type="number"
min={0}
value={editForm.graceHours}
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="edit-lockOnClose"
checked={editForm.lockOnClose}
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
/>
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sortOrder">Sort Order</Label>
<Input
id="edit-sortOrder"
type="number"
min={1}
value={editForm.sortOrder}
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setEditingWindow(null)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleEdit}
disabled={updateWindowMutation.isPending}
>
{updateWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Save Changes
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Submission Window</DialogTitle>
<DialogDescription>
Are you sure you want to delete this submission window? This action cannot be undone.
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
<span className="block mt-2 text-destructive font-medium">
Warning: This window has uploaded files and cannot be deleted until they are removed.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeletingWindow(null)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteWindowMutation.isPending}
>
{deleteWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -25,14 +25,6 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
update('advancementConfig', { ...advancementConfig, [key]: value })
}
const visConfig = (config.applicantVisibility as {
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean
}) ?? {}
const updateVisibility = (key: string, value: unknown) => {
update('applicantVisibility', { ...visConfig, [key]: value })
}
return (
<div className="space-y-6">
{/* Scoring */}
@@ -210,71 +202,6 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
</CardContent>
</Card>
{/* Applicant Feedback Visibility */}
<Card>
<CardHeader>
<CardTitle className="text-base">Applicant Feedback Visibility</CardTitle>
<CardDescription>Control what evaluation data applicants can see after this round closes</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="applicantVisEnabled">Show Evaluations to Applicants</Label>
<p className="text-xs text-muted-foreground">Master switch when off, nothing is visible to applicants</p>
</div>
<Switch
id="applicantVisEnabled"
checked={visConfig.enabled ?? false}
onCheckedChange={(v) => updateVisibility('enabled', v)}
/>
</div>
{visConfig.enabled && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showGlobalScore">Show Global Score</Label>
<p className="text-xs text-muted-foreground">Display the overall score for each evaluation</p>
</div>
<Switch
id="showGlobalScore"
checked={visConfig.showGlobalScore ?? false}
onCheckedChange={(v) => updateVisibility('showGlobalScore', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showCriterionScores">Show Per-Criterion Scores</Label>
<p className="text-xs text-muted-foreground">Display individual criterion scores and names</p>
</div>
<Switch
id="showCriterionScores"
checked={visConfig.showCriterionScores ?? false}
onCheckedChange={(v) => updateVisibility('showCriterionScores', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showFeedbackText">Show Written Feedback</Label>
<p className="text-xs text-muted-foreground">Display jury members&apos; written comments</p>
</div>
<Switch
id="showFeedbackText"
checked={visConfig.showFeedbackText ?? false}
onCheckedChange={(v) => updateVisibility('showFeedbackText', v)}
/>
</div>
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
Evaluations are only visible to applicants after this round closes.
</p>
</div>
)}
</CardContent>
</Card>
{/* Advancement */}
<Card>
<CardHeader>

View File

@@ -4,17 +4,36 @@ import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
import { CheckCircle2, Circle, Clock } from 'lucide-react'
import { toast } from 'sonner'
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
ROUND_ACTIVE: { label: 'In Progress', variant: 'default' },
ROUND_CLOSED: { label: 'Completed', variant: 'default' },
ROUND_ARCHIVED: { label: 'Completed', variant: 'default' },
interface ApplicantCompetitionTimelineProps {
competitionId: string
}
export function ApplicantCompetitionTimeline() {
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
const statusIcons: Record<string, React.ElementType> = {
completed: CheckCircle2,
current: Clock,
upcoming: Circle,
}
const statusColors: Record<string, string> = {
completed: 'text-emerald-600',
current: 'text-brand-blue',
upcoming: 'text-muted-foreground',
}
const statusBgColors: Record<string, string> = {
completed: 'bg-emerald-50',
current: 'bg-brand-blue/10',
upcoming: 'bg-muted',
}
export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompetitionTimelineProps) {
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: !!competitionId }
)
if (isLoading) {
return (
@@ -33,7 +52,7 @@ export function ApplicantCompetitionTimeline() {
)
}
if (!data || data.entries.length === 0) {
if (!competition || !competition.rounds || competition.rounds.length === 0) {
return (
<Card>
<CardHeader>
@@ -41,117 +60,77 @@ export function ApplicantCompetitionTimeline() {
</CardHeader>
<CardContent className="text-center py-8">
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No rounds available yet</p>
<p className="text-sm text-muted-foreground">No rounds available</p>
</CardContent>
</Card>
)
}
const rounds = competition.rounds || []
const currentRoundIndex = rounds.findIndex(r => r.status === 'ROUND_ACTIVE')
return (
<Card>
<CardHeader>
<CardTitle>Competition Timeline</CardTitle>
{data.competitionName && (
<p className="text-sm text-muted-foreground">{data.competitionName}</p>
)}
</CardHeader>
<CardContent>
<div className="relative space-y-6">
{/* Vertical connecting line */}
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
{data.entries.map((entry) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
// Determine icon
let Icon = Circle
let iconBg = 'bg-muted'
let iconColor = 'text-muted-foreground'
if (isRejected) {
Icon = XCircle
iconBg = 'bg-red-50'
iconColor = 'text-red-600'
} else if (isGrandFinale && isCompleted) {
Icon = Trophy
iconBg = 'bg-yellow-50'
iconColor = 'text-yellow-600'
} else if (isCompleted) {
Icon = CheckCircle2
iconBg = 'bg-emerald-50'
iconColor = 'text-emerald-600'
} else if (isActive) {
Icon = Clock
iconBg = 'bg-brand-blue/10'
iconColor = 'text-brand-blue'
}
// Project state display
let stateLabel: string | null = null
if (entry.projectState === 'REJECTED') {
stateLabel = 'Not Selected'
} else if (entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED') {
stateLabel = 'Advanced'
} else if (entry.projectState === 'IN_PROGRESS') {
stateLabel = 'Under Review'
} else if (entry.projectState === 'PENDING') {
stateLabel = 'Pending'
}
const statusInfo = roundStatusDisplay[entry.status] ?? { label: 'Upcoming', variant: 'secondary' as const }
{rounds.map((round, index) => {
const isActive = round.status === 'ROUND_ACTIVE'
const isCompleted = index < currentRoundIndex || round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
const isCurrent = index === currentRoundIndex || isActive
const status = isCompleted ? 'completed' : isCurrent ? 'current' : 'upcoming'
const Icon = statusIcons[status]
return (
<div key={entry.id} className="relative flex items-start gap-4">
<div key={round.id} className="relative flex items-start gap-4">
{/* Icon */}
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${iconBg} shrink-0`}
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${statusBgColors[status]} shrink-0`}
>
<Icon className={`h-5 w-5 ${iconColor}`} />
<Icon className={`h-5 w-5 ${statusColors[status]}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-6">
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
<div>
<h3 className="font-semibold">{entry.label}</h3>
<h3 className="font-semibold">{round.name}</h3>
</div>
<div className="flex items-center gap-2">
{stateLabel && (
<Badge
variant="outline"
className={
isRejected
? 'border-red-200 text-red-700 bg-red-50'
: entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: ''
variant={
status === 'completed'
? 'default'
: status === 'current'
? 'default'
: 'secondary'
}
>
{stateLabel}
</Badge>
)}
<Badge
variant={statusInfo.variant}
className={
isCompleted
status === 'completed'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: isActive
: status === 'current'
? 'bg-brand-blue text-white'
: ''
}
>
{statusInfo.label}
{status === 'completed' && 'Completed'}
{status === 'current' && 'In Progress'}
{status === 'upcoming' && 'Upcoming'}
</Badge>
</div>
</div>
{entry.windowOpenAt && entry.windowCloseAt && (
{round.windowOpenAt && round.windowCloseAt && (
<div className="text-sm text-muted-foreground space-y-1">
<p>Opens: {new Date(entry.windowOpenAt).toLocaleDateString()}</p>
<p>Closes: {new Date(entry.windowCloseAt).toLocaleDateString()}</p>
<p>
Opens: {new Date(round.windowOpenAt).toLocaleDateString()}
</p>
<p>
Closes: {new Date(round.windowCloseAt).toLocaleDateString()}
</p>
</div>
)}
</div>
@@ -163,76 +142,3 @@ export function ApplicantCompetitionTimeline() {
</Card>
)
}
/**
* Compact sidebar variant for the dashboard.
* Shows dots + labels, no date details.
*/
export function CompetitionTimelineSidebar() {
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
if (isLoading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-6" />
))}
</div>
)
}
if (!data || data.entries.length === 0) {
return <p className="text-sm text-muted-foreground">No rounds available</p>
}
return (
<div className="space-y-0">
{data.entries.map((entry, index) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
const isLast = index === data.entries.length - 1
let dotColor = 'border-2 border-muted bg-background'
if (isRejected) dotColor = 'bg-destructive'
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
else if (isCompleted) dotColor = 'bg-primary'
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
return (
<div key={entry.id} className="relative flex gap-3">
{/* Connecting line */}
{!isLast && (
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
)}
{/* Dot */}
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
{/* Label */}
<div className="flex-1 pb-4">
<p
className={`text-sm font-medium ${
isRejected
? 'text-destructive'
: isCompleted || isActive
? 'text-foreground'
: 'text-muted-foreground'
}`}
>
{entry.label}
</p>
{isRejected && (
<p className="text-xs text-destructive">Not Selected</p>
)}
{isActive && (
<p className="text-xs text-primary">In Progress</p>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -226,7 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
(item.href !== '/admin' && pathname.startsWith(item.href)) ||
(item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
return (
<div key={item.name}>
<Link
@@ -258,12 +259,24 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
Administration
</p>
{dynamicAdminNav.map((item) => {
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
let isActive = pathname.startsWith(item.href)
if (item.activeMatch) {
isActive = pathname.includes(item.activeMatch)
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
isActive = false
}
if (isDisabled) {
return (
<span
key={item.name}
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
>
<item.icon className="h-4 w-4 text-muted-foreground" />
{item.name}
</span>
)
}
return (
<Link
key={item.name}

View File

@@ -1,7 +1,6 @@
'use client'
import { Home, Users, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ApplicantNavProps {
@@ -9,22 +8,32 @@ interface ApplicantNavProps {
}
export function ApplicantNav({ user }: ApplicantNavProps) {
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
staleTime: 60_000,
})
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/applicant', icon: Home },
{ name: 'Team', href: '/applicant/team', icon: Users },
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
...(flags?.hasEvaluationRounds
? [{ name: 'Evaluations', href: '/applicant/evaluations', icon: Star }]
: []),
...(flags?.hasMentor
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
: []),
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
{
name: 'Dashboard',
href: '/applicant',
icon: Home,
},
{
name: 'Team',
href: '/applicant/team',
icon: Users,
},
{
name: 'Competitions',
href: '/applicant/competitions',
icon: Layers,
},
{
name: 'Documents',
href: '/applicant/documents',
icon: FileText,
},
{
name: 'Mentoring',
href: '/applicant/mentor',
icon: MessageSquare,
},
]
return (

View File

@@ -25,11 +25,13 @@ import {
ShieldAlert,
Webhook,
MessageCircle,
FlaskConical,
} from 'lucide-react'
import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card'
import { TestEnvironmentPanel } from './test-environment-panel'
import { BrandingSettingsForm } from './branding-settings-form'
import { EmailSettingsForm } from './email-settings-form'
import { StorageSettingsForm } from './storage-settings-form'
@@ -226,6 +228,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks
</Link>
)}
{isSuperAdmin && (
<TabsTrigger value="testenv" className="gap-2 shrink-0">
<FlaskConical className="h-4 w-4" />
Test Env
</TabsTrigger>
)}
</TabsList>
<div className="lg:flex lg:gap-8">
@@ -319,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</Link>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5 mt-1">
<TabsTrigger value="testenv" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<FlaskConical className="h-4 w-4" />
Test Env
</TabsTrigger>
</TabsList>
</div>
)}
</nav>
@@ -513,6 +527,28 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</AnimatedCard>
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="testenv" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlaskConical className="h-5 w-5" />
Test Environment
</CardTitle>
<CardDescription>
Create a sandboxed test competition with dummy data for testing all roles and workflows.
Fully isolated from production data.
</CardDescription>
</CardHeader>
<CardContent>
<TestEnvironmentPanel />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
</div>{/* end content area */}
</div>{/* end lg:flex */}
</Tabs>

View File

@@ -0,0 +1,297 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
FlaskConical,
Plus,
Trash2,
ExternalLink,
Loader2,
Users,
UserCog,
CheckCircle2,
AlertTriangle,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
const ROLE_LABELS: Record<string, string> = {
JURY_MEMBER: 'Jury Member',
APPLICANT: 'Applicant',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
AWARD_MASTER: 'Award Master',
PROGRAM_ADMIN: 'Program Admin',
}
const ROLE_COLORS: Record<string, string> = {
JURY_MEMBER: 'bg-blue-100 text-blue-800',
APPLICANT: 'bg-green-100 text-green-800',
MENTOR: 'bg-purple-100 text-purple-800',
OBSERVER: 'bg-orange-100 text-orange-800',
AWARD_MASTER: 'bg-yellow-100 text-yellow-800',
PROGRAM_ADMIN: 'bg-red-100 text-red-800',
}
const ROLE_LANDING: Record<string, string> = {
JURY_MEMBER: '/jury',
APPLICANT: '/applicant',
MENTOR: '/mentor',
OBSERVER: '/observer',
AWARD_MASTER: '/admin',
PROGRAM_ADMIN: '/admin',
}
export function TestEnvironmentPanel() {
const { update } = useSession()
const router = useRouter()
const utils = trpc.useUtils()
const { data: status, isLoading } = trpc.testEnvironment.status.useQuery()
const createMutation = trpc.testEnvironment.create.useMutation({
onSuccess: () => utils.testEnvironment.status.invalidate(),
})
const tearDownMutation = trpc.testEnvironment.tearDown.useMutation({
onSuccess: () => utils.testEnvironment.status.invalidate(),
})
const [confirmText, setConfirmText] = useState('')
const [tearDownOpen, setTearDownOpen] = useState(false)
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
// No test environment — show creation card
if (!status?.active) {
return (
<div className="space-y-4">
<div className="rounded-lg border-2 border-dashed p-8 text-center">
<FlaskConical className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-semibold">No Test Environment</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
Create a sandboxed test competition with dummy users, projects, jury assignments,
and partial evaluations. All test data is fully isolated from production.
</p>
<Button
className="mt-6"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating test environment...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Create Test Competition
</>
)}
</Button>
{createMutation.isError && (
<p className="mt-3 text-sm text-destructive">
{createMutation.error.message}
</p>
)}
</div>
</div>
)
}
// Test environment is active
const { competition, rounds, users, emailRedirect } = status
// Group users by role for impersonation cards
const roleGroups = users.reduce(
(acc, u) => {
const role = u.role as string
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
},
{} as Record<string, typeof users>
)
async function handleImpersonate(userId: string, role: UserRole) {
await update({ impersonateUserId: userId })
router.push((ROLE_LANDING[role] || '/admin') as any)
router.refresh()
}
function handleTearDown() {
if (confirmText !== 'DELETE TEST') return
tearDownMutation.mutate(undefined, {
onSuccess: () => {
setTearDownOpen(false)
setConfirmText('')
},
})
}
return (
<div className="space-y-6">
{/* Status header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Test Active
</Badge>
<span className="text-sm text-muted-foreground">
{competition.name}
</span>
</div>
<Button variant="outline" size="sm" asChild>
<a href={`/admin/competitions/${competition.id}`} target="_blank" rel="noopener">
View Competition
<ExternalLink className="ml-1.5 h-3 w-3" />
</a>
</Button>
</div>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold">{rounds.length}</p>
<p className="text-xs text-muted-foreground">Rounds</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold">{users.length}</p>
<p className="text-xs text-muted-foreground">Test Users</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold truncate text-sm font-mono">
{emailRedirect || '—'}
</p>
<p className="text-xs text-muted-foreground">Email Redirect</p>
</div>
</div>
{/* Impersonation section */}
<div>
<div className="flex items-center gap-2 mb-3">
<UserCog className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold">Impersonate Test User</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(roleGroups).map(([role, roleUsers]) => (
<Card key={role} className="overflow-hidden">
<CardHeader className="py-2 px-3">
<div className="flex items-center justify-between">
<Badge variant="secondary" className={ROLE_COLORS[role] || ''}>
{ROLE_LABELS[role] || role}
</Badge>
<span className="text-xs text-muted-foreground">
{roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
</span>
</div>
</CardHeader>
<CardContent className="py-2 px-3 space-y-1.5">
{roleUsers.slice(0, 3).map((u) => (
<button
key={u.id}
onClick={() => handleImpersonate(u.id, u.role as UserRole)}
className="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm hover:bg-muted transition-colors text-left"
>
<span className="truncate">{u.name || u.email}</span>
<span className="text-xs text-muted-foreground shrink-0 ml-2">
Impersonate
</span>
</button>
))}
{roleUsers.length > 3 && (
<p className="text-xs text-muted-foreground px-2">
+{roleUsers.length - 3} more (switch via banner)
</p>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Tear down */}
<div className="border-t pt-4">
<AlertDialog open={tearDownOpen} onOpenChange={setTearDownOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Tear Down Test Environment
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Destroy Test Environment
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete ALL test data: users, projects, competitions,
assignments, evaluations, and files. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm font-medium">
Type <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">DELETE TEST</code> to confirm:
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE TEST"
className="font-mono"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmText('')}>
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleTearDown}
disabled={confirmText !== 'DELETE TEST' || tearDownMutation.isPending}
>
{tearDownMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Tearing down...
</>
) : (
'Destroy Test Environment'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}

View File

@@ -2,9 +2,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useCreateBlockNote } from '@blocknote/react'
import { BlockNoteView } from '@blocknote/shadcn'
import { BlockNoteView } from '@blocknote/mantine'
import '@blocknote/core/fonts/inter.css'
import '@blocknote/shadcn/style.css'
import '@blocknote/mantine/style.css'
import type { PartialBlock } from '@blocknote/core'

View File

@@ -0,0 +1,149 @@
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
import type { UserRole } from '@prisma/client'
const ROLE_LABELS: Record<string, string> = {
JURY_MEMBER: 'Jury Member',
APPLICANT: 'Applicant',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
AWARD_MASTER: 'Award Master',
PROGRAM_ADMIN: 'Program Admin',
SUPER_ADMIN: 'Super Admin',
}
const ROLE_LANDING: Record<string, string> = {
JURY_MEMBER: '/jury',
APPLICANT: '/applicant',
MENTOR: '/mentor',
OBSERVER: '/observer',
AWARD_MASTER: '/admin',
PROGRAM_ADMIN: '/admin',
SUPER_ADMIN: '/admin',
}
export function ImpersonationBanner() {
const { data: session, update } = useSession()
const router = useRouter()
const [switching, setSwitching] = useState(false)
// Only fetch test users when impersonating (realRole check happens server-side)
const { data: testEnv } = trpc.testEnvironment.status.useQuery(undefined, {
enabled: !!session?.user?.isImpersonating,
staleTime: 60_000,
})
if (!session?.user?.isImpersonating) return null
const currentRole = session.user.role
const currentName = session.user.impersonatedName || session.user.name || 'Unknown'
// Group available test users by role (exclude currently impersonated user)
const availableUsers = testEnv?.active
? testEnv.users.filter((u) => u.id !== session.user.id)
: []
const roleGroups = availableUsers.reduce(
(acc, u) => {
const role = u.role as string
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
},
{} as Record<string, typeof availableUsers>
)
async function handleSwitch(userId: string, role: UserRole) {
setSwitching(true)
await update({ impersonateUserId: userId })
router.push((ROLE_LANDING[role] || '/admin') as any)
router.refresh()
setSwitching(false)
}
async function handleStopImpersonation() {
setSwitching(true)
await update({ stopImpersonation: true })
router.push('/admin/settings' as any)
router.refresh()
setSwitching(false)
}
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-amber-950 shadow-md">
<div className="mx-auto flex items-center justify-between px-4 py-1.5 text-sm font-medium">
<div className="flex items-center gap-2">
<UserCog className="h-4 w-4" />
<span>
Viewing as <strong>{currentName}</strong>{' '}
<span className="rounded bg-amber-600/30 px-1.5 py-0.5 text-xs font-semibold">
{ROLE_LABELS[currentRole] || currentRole}
</span>
</span>
</div>
<div className="flex items-center gap-2">
{/* Quick-switch dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
disabled={switching}
>
Switch Role
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{Object.entries(roleGroups).map(([role, users]) => (
<div key={role}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{ROLE_LABELS[role] || role}
</DropdownMenuLabel>
{users.map((u) => (
<DropdownMenuItem
key={u.id}
onClick={() => handleSwitch(u.id, u.role as UserRole)}
disabled={switching}
>
<span className="truncate">{u.name || u.email}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Return to admin */}
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
onClick={handleStopImpersonation}
disabled={switching}
>
<LogOut className="h-3 w-3" />
Return to Admin
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,71 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
const BlockViewer = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockViewer),
{
ssr: false,
loading: () => (
<div className="min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
interface ResourceRendererProps {
title: string
description?: string | null
contentJson: unknown // BlockNote PartialBlock[] stored as JSON
coverImageUrl?: string | null
className?: string
}
export function ResourceRenderer({
title,
description,
contentJson,
coverImageUrl,
className,
}: ResourceRendererProps) {
const contentString =
typeof contentJson === 'string' ? contentJson : JSON.stringify(contentJson)
return (
<article className={`mx-auto max-w-3xl ${className ?? ''}`}>
{/* Cover image */}
{coverImageUrl && (
<div className="mb-8 overflow-hidden rounded-lg">
<img
src={coverImageUrl}
alt=""
className="h-auto w-full object-cover"
/>
</div>
)}
{/* Title */}
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
{title}
</h1>
{/* Description */}
{description && (
<p className="mt-3 text-lg text-muted-foreground leading-relaxed">
{description}
</p>
)}
{/* Divider */}
<hr className="my-6 border-border" />
{/* Content */}
{contentJson ? (
<div className="prose-renderer">
<BlockViewer content={contentString} />
</div>
) : (
<p className="text-muted-foreground italic">No content</p>
)}
</article>
)
}

View File

@@ -10,6 +10,11 @@ declare module 'next-auth' {
name?: string | null
role: UserRole
mustSetPassword?: boolean
// Impersonation fields
isImpersonating?: boolean
realUserId?: string
realRole?: UserRole
impersonatedName?: string | null
}
}
@@ -24,6 +29,12 @@ declare module '@auth/core/jwt' {
id: string
role: UserRole
mustSetPassword?: boolean
// Impersonation fields
impersonatedUserId?: string
impersonatedRole?: UserRole
impersonatedName?: string | null
realUserId?: string
realRole?: UserRole
}
}

View File

@@ -190,7 +190,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
],
callbacks: {
...authConfig.callbacks,
async jwt({ token, user, trigger }) {
async jwt({ token, user, trigger, session: sessionUpdate }) {
// Initial sign in
if (user) {
token.id = user.id as string
@@ -198,8 +198,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.mustSetPassword = user.mustSetPassword
}
// On session update, refresh from database
// On session update
if (trigger === 'update') {
// Handle impersonation request
if (sessionUpdate?.impersonateUserId) {
const testUser = await prisma.user.findUnique({
where: { id: sessionUpdate.impersonateUserId },
select: { id: true, name: true, email: true, role: true, isTest: true },
})
// Only allow impersonating test users with @test.local emails
if (testUser?.isTest && testUser.email.endsWith('@test.local')) {
// Preserve original identity (only set once in case of quick-switch)
if (!token.realUserId) {
token.realUserId = token.id as string
token.realRole = token.role as UserRole
}
token.id = testUser.id
token.role = testUser.role
token.impersonatedUserId = testUser.id
token.impersonatedRole = testUser.role
token.impersonatedName = testUser.name
}
}
// Handle stop impersonation
else if (sessionUpdate?.stopImpersonation && token.realUserId) {
token.id = token.realUserId
token.role = token.realRole!
delete token.impersonatedUserId
delete token.impersonatedRole
delete token.impersonatedName
delete token.realUserId
delete token.realRole
}
// Normal session refresh (only when not impersonating)
else if (!token.impersonatedUserId) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, mustSetPassword: true },
@@ -209,6 +241,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.mustSetPassword = dbUser.mustSetPassword
}
}
}
return token
},
@@ -217,6 +250,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session.user.id = token.id as string
session.user.role = token.role as UserRole
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
// Impersonation state
session.user.isImpersonating = !!token.impersonatedUserId
if (token.realUserId) {
session.user.realUserId = token.realUserId as string
session.user.realRole = token.realRole as UserRole
}
if (token.impersonatedName !== undefined) {
session.user.impersonatedName = token.impersonatedName as string | null
}
}
return session
},

View File

@@ -7,6 +7,32 @@ let cachedTransporter: Transporter | null = null
let cachedConfigHash = ''
let cachedFrom = ''
/**
* Resolve test email recipients: @test.local emails are redirected
* to the admin's email (from test_email_redirect setting) and
* the subject is prefixed with [TEST]. Real emails are never affected.
*/
async function resolveTestEmailRecipient(
to: string,
subject: string
): Promise<{ to: string; subject: string }> {
if (!to.endsWith('@test.local')) {
return { to, subject }
}
const redirect = await prisma.systemSettings.findUnique({
where: { key: 'test_email_redirect' },
select: { value: true },
})
if (redirect?.value) {
return {
to: redirect.value,
subject: `[TEST] ${subject}`,
}
}
// No redirect configured — suppress the email entirely
return { to: '', subject }
}
/**
* Get SMTP transporter using database settings with env var fallback.
* Caches the transporter and rebuilds it when settings change.
@@ -47,12 +73,31 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
}
// Create new transporter
cachedTransporter = nodemailer.createTransport({
const rawTransporter = nodemailer.createTransport({
host,
port: parseInt(port),
secure: port === '465',
auth: { user, pass },
})
// Wrap sendMail to auto-redirect @test.local emails
const originalSendMail = rawTransporter.sendMail.bind(rawTransporter)
rawTransporter.sendMail = async function (mailOptions: any) {
if (mailOptions.to && typeof mailOptions.to === 'string') {
const resolved = await resolveTestEmailRecipient(
mailOptions.to,
mailOptions.subject || ''
)
if (!resolved.to) {
// Suppress email entirely (no redirect configured for test)
return { messageId: 'suppressed-test-email' }
}
mailOptions = { ...mailOptions, to: resolved.to, subject: resolved.subject }
}
return originalSendMail(mailOptions)
} as any
cachedTransporter = rawTransporter
cachedConfigHash = configHash
cachedFrom = from

View File

@@ -51,6 +51,7 @@ import { roundEngineRouter } from './roundEngine'
import { roundAssignmentRouter } from './roundAssignment'
import { deliberationRouter } from './deliberation'
import { resultLockRouter } from './resultLock'
import { testEnvironmentRouter } from './testEnvironment'
/**
* Root tRPC router that combines all domain routers
@@ -108,6 +109,8 @@ export const appRouter = router({
roundAssignment: roundAssignmentRouter,
deliberation: deliberationRouter,
resultLock: resultLockRouter,
// Test environment
testEnvironment: testEnvironmentRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -12,8 +12,8 @@ const editionOrRoundInput = z.object({
})
function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } }
return { programId: input.programId! }
if (input.roundId) return { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } }
return { isTest: false, programId: input.programId! }
}
function assignmentWhere(input: { roundId?: string; programId?: string }) {
@@ -263,7 +263,7 @@ export const analyticsRouter = router({
if (round?.roundType === 'EVALUATION') {
// For evaluation rounds, break down by evaluation status per project
const projects = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
where: { roundId: input.roundId, project: { isTest: false } },
select: {
projectId: true,
project: {
@@ -309,7 +309,7 @@ export const analyticsRouter = router({
// Non-evaluation rounds: use ProjectRoundState
const states = await ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { roundId: input.roundId },
where: { roundId: input.roundId, project: { isTest: false } },
_count: true,
})
return states.map((s) => ({
@@ -469,8 +469,8 @@ export const analyticsRouter = router({
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { assignments: { some: { roundId: input.roundId } } }
: { programId: input.programId }
? { isTest: false, assignments: { some: { roundId: input.roundId } } }
: { isTest: false, programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({
by: ['country'],
@@ -537,7 +537,7 @@ export const analyticsRouter = router({
// Count distinct projects per round via assignments
const projectAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } },
where: { roundId: { in: roundIds }, project: { isTest: false } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
})
@@ -714,12 +714,14 @@ export const analyticsRouter = router({
const roundId = input?.roundId
const projectFilter = roundId
? { projectRoundStates: { some: { roundId } } }
: {}
const assignmentFilter = roundId ? { roundId } : {}
? { isTest: false, projectRoundStates: { some: { roundId } } }
: { isTest: false }
const assignmentFilter = roundId
? { roundId }
: { round: { competition: { isTest: false } } }
const evalFilter = roundId
? { assignment: { roundId }, status: 'SUBMITTED' as const }
: { status: 'SUBMITTED' as const }
: { assignment: { round: { competition: { isTest: false } } }, status: 'SUBMITTED' as const }
const [
programCount,
@@ -730,9 +732,9 @@ export const analyticsRouter = router({
totalAssignments,
evaluationScores,
] = await Promise.all([
ctx.prisma.program.count(),
ctx.prisma.program.count({ where: { isTest: false } }),
ctx.prisma.round.findMany({
where: { status: 'ROUND_ACTIVE' },
where: { status: 'ROUND_ACTIVE', competition: { isTest: false } },
select: { id: true, name: true },
take: 5,
}),
@@ -743,7 +745,7 @@ export const analyticsRouter = router({
select: { userId: true },
distinct: ['userId'],
}).then((rows) => rows.length)
: ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
: ctx.prisma.user.count({ where: { isTest: false, role: 'JURY_MEMBER', status: 'ACTIVE' } }),
ctx.prisma.evaluation.count({ where: evalFilter }),
ctx.prisma.assignment.count({ where: assignmentFilter }),
ctx.prisma.evaluation.findMany({
@@ -988,7 +990,7 @@ export const analyticsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
const where: Record<string, unknown> = { isTest: false }
if (input.roundId) {
where.projectRoundStates = { some: { roundId: input.roundId } }
@@ -1151,15 +1153,15 @@ export const analyticsRouter = router({
switch (roundType) {
case 'INTAKE': {
const [total, byState, byCategory] = await Promise.all([
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, project: { isTest: false } } }),
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { roundId: input.roundId },
where: { roundId: input.roundId, project: { isTest: false } },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { projectRoundStates: { some: { roundId: input.roundId } } },
where: { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } },
_count: true,
}),
])
@@ -1395,7 +1397,7 @@ export const analyticsRouter = router({
// Get competition rounds for file grouping
let competitionRounds: { id: string; name: string; roundType: string }[] = []
const competition = await ctx.prisma.competition.findFirst({
where: { programId: projectRaw.programId },
where: { programId: projectRaw.programId, isTest: false },
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
})
if (competition) {
@@ -1478,9 +1480,23 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => {
const limit = input?.limit ?? 10
// Exclude actions performed by test users
const testUserIds = await ctx.prisma.user.findMany({
where: { isTest: true },
select: { id: true },
}).then((users) => users.map((u) => u.id))
const entries = await ctx.prisma.decisionAuditLog.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
...(testUserIds.length > 0 && {
where: {
OR: [
{ actorId: null },
{ actorId: { notIn: testUserIds } },
],
},
}),
select: {
id: true,
eventType: true,
@@ -1496,7 +1512,7 @@ export const analyticsRouter = router({
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
const actors = actorIds.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: actorIds } },
where: { id: { in: actorIds }, isTest: false },
select: { id: true, name: true },
})
: []

View File

@@ -7,8 +7,6 @@ import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/em
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
import { checkRequirementsAndTransition } from '../services/round-engine'
import { EvaluationConfigSchema } from '@/types/competition-configs'
import type { Prisma } from '@prisma/client'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
@@ -1280,12 +1278,6 @@ export const applicantRouter = router({
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
// Check if project has passed intake
const passedIntake = await ctx.prisma.projectRoundState.findFirst({
where: { projectId: project.id, state: 'PASSED', round: { roundType: 'INTAKE' } },
select: { id: true },
})
return {
project: {
...project,
@@ -1295,461 +1287,6 @@ export const applicantRouter = router({
openRounds,
timeline,
currentStatus,
hasPassedIntake: !!passedIntake,
}
}),
/**
* Lightweight flags for conditional nav rendering.
*/
getNavFlags: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
}
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: {
id: true,
programId: true,
mentorAssignment: { select: { id: true } },
},
})
if (!project) {
return { hasMentor: false, hasEvaluationRounds: false }
}
// Check if mentor is assigned
const hasMentor = !!project.mentorAssignment
// Check if there are EVALUATION rounds (CLOSED/ARCHIVED) with applicantVisibility.enabled
let hasEvaluationRounds = false
if (project.programId) {
const closedEvalRounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: project.programId },
roundType: 'EVALUATION',
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
},
select: { configJson: true },
})
hasEvaluationRounds = closedEvalRounds.some((r) => {
const parsed = EvaluationConfigSchema.safeParse(r.configJson)
return parsed.success && parsed.data.applicantVisibility.enabled
})
}
return { hasMentor, hasEvaluationRounds }
}),
/**
* Filtered competition timeline showing only EVALUATION + Grand Finale.
* Hides FILTERING/INTAKE/SUBMISSION/MENTORING from applicants.
*/
getMyCompetitionTimeline: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
}
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true, programId: true },
})
if (!project?.programId) {
return { competitionName: null, entries: [] }
}
// Find competition via programId (fixes the programId/competitionId bug)
const competition = await ctx.prisma.competition.findFirst({
where: { programId: project.programId },
select: { id: true, name: true },
})
if (!competition) {
return { competitionName: null, entries: [] }
}
// Get all rounds ordered by sortOrder
const rounds = await ctx.prisma.round.findMany({
where: { competitionId: competition.id },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
roundType: true,
status: true,
windowOpenAt: true,
windowCloseAt: true,
},
})
// Get all ProjectRoundState for this project
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { projectId: project.id },
select: { roundId: true, state: true },
})
const stateMap = new Map(projectStates.map((ps) => [ps.roundId, ps.state]))
type TimelineEntry = {
id: string
label: string
roundType: 'EVALUATION' | 'GRAND_FINALE'
status: string
windowOpenAt: Date | null
windowCloseAt: Date | null
projectState: string | null
isSynthesizedRejection: boolean
}
const entries: TimelineEntry[] = []
// Build lookup for filtering rounds and their next evaluation round
const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
const evalRounds = rounds.filter((r) => r.roundType === 'EVALUATION')
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
// Process EVALUATION rounds
for (const evalRound of evalRounds) {
const actualState = stateMap.get(evalRound.id) ?? null
// Check if a FILTERING round before this eval round rejected the project
let projectState = actualState
let isSynthesizedRejection = false
// Find FILTERING rounds that come before this eval round in sortOrder
const evalSortOrder = rounds.findIndex((r) => r.id === evalRound.id)
const precedingFilterRounds = filteringRounds.filter((fr) => {
const frIdx = rounds.findIndex((r) => r.id === fr.id)
return frIdx < evalSortOrder
})
for (const fr of precedingFilterRounds) {
const filterState = stateMap.get(fr.id)
if (filterState === 'REJECTED') {
projectState = 'REJECTED'
isSynthesizedRejection = true
break
}
if ((filterState === 'IN_PROGRESS' || filterState === 'PENDING') && !actualState) {
projectState = 'IN_PROGRESS'
isSynthesizedRejection = true
}
}
entries.push({
id: evalRound.id,
label: evalRound.name,
roundType: 'EVALUATION',
status: evalRound.status,
windowOpenAt: evalRound.windowOpenAt,
windowCloseAt: evalRound.windowCloseAt,
projectState,
isSynthesizedRejection,
})
}
// Grand Finale: combine LIVE_FINAL + DELIBERATION
if (liveFinalRounds.length > 0 || deliberationRounds.length > 0) {
const grandFinaleRounds = [...liveFinalRounds, ...deliberationRounds]
// Project state: prefer LIVE_FINAL state, then DELIBERATION
let gfState: string | null = null
for (const lfr of liveFinalRounds) {
const s = stateMap.get(lfr.id)
if (s) { gfState = s; break }
}
if (!gfState) {
for (const dr of deliberationRounds) {
const s = stateMap.get(dr.id)
if (s) { gfState = s; break }
}
}
// Status: most advanced status among grouped rounds
const statusPriority: Record<string, number> = {
ROUND_ARCHIVED: 3,
ROUND_CLOSED: 2,
ROUND_ACTIVE: 1,
ROUND_DRAFT: 0,
}
let gfStatus = 'ROUND_DRAFT'
for (const r of grandFinaleRounds) {
if ((statusPriority[r.status] ?? 0) > (statusPriority[gfStatus] ?? 0)) {
gfStatus = r.status
}
}
// Use earliest window open and latest window close
const openDates = grandFinaleRounds.map((r) => r.windowOpenAt).filter(Boolean) as Date[]
const closeDates = grandFinaleRounds.map((r) => r.windowCloseAt).filter(Boolean) as Date[]
// Check if a prior filtering rejection should propagate
let isSynthesizedRejection = false
const gfSortOrder = Math.min(
...grandFinaleRounds.map((r) => rounds.findIndex((rr) => rr.id === r.id))
)
for (const fr of filteringRounds) {
const frIdx = rounds.findIndex((r) => r.id === fr.id)
if (frIdx < gfSortOrder && stateMap.get(fr.id) === 'REJECTED') {
gfState = 'REJECTED'
isSynthesizedRejection = true
break
}
}
entries.push({
id: 'grand-finale',
label: 'Grand Finale',
roundType: 'GRAND_FINALE',
status: gfStatus,
windowOpenAt: openDates.length > 0 ? new Date(Math.min(...openDates.map((d) => d.getTime()))) : null,
windowCloseAt: closeDates.length > 0 ? new Date(Math.max(...closeDates.map((d) => d.getTime()))) : null,
projectState: gfState,
isSynthesizedRejection,
})
}
// Handle projects manually created at a non-intake round:
// If a project has state in a later round but not earlier, mark prior rounds as PASSED.
// Find the earliest visible entry (EVALUATION or GRAND_FINALE) that has a real state.
const firstEntryWithState = entries.findIndex(
(e) => e.projectState !== null && !e.isSynthesizedRejection
)
if (firstEntryWithState > 0) {
// All entries before the first real state should show as PASSED (if the round is closed/archived)
for (let i = 0; i < firstEntryWithState; i++) {
const entry = entries[i]
if (!entry.projectState) {
const roundClosed = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
if (roundClosed) {
entry.projectState = 'PASSED'
entry.isSynthesizedRejection = false // not a rejection, it's a synthesized pass
}
}
}
}
// If the project was rejected in filtering and there are entries after,
// null-out states for entries after the rejection point
let foundRejection = false
for (const entry of entries) {
if (foundRejection) {
entry.projectState = null
}
if (entry.projectState === 'REJECTED' && entry.isSynthesizedRejection) {
foundRejection = true
}
}
return { competitionName: competition.name, entries }
}),
/**
* Get anonymous jury evaluations visible to the applicant.
* Respects per-round applicantVisibility config. NEVER leaks juror identity.
*/
getMyEvaluations: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
}
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true, programId: true },
})
if (!project?.programId) return []
// Get closed/archived EVALUATION rounds for this competition
const evalRounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: project.programId },
roundType: 'EVALUATION',
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
},
select: {
id: true,
name: true,
configJson: true,
},
orderBy: { sortOrder: 'asc' },
})
const results: Array<{
roundId: string
roundName: string
evaluationCount: number
evaluations: Array<{
id: string
submittedAt: Date | null
globalScore: number | null
criterionScores: Prisma.JsonValue | null
feedbackText: string | null
criteria: Prisma.JsonValue | null
}>
}> = []
for (const round of evalRounds) {
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
const vis = parsed.data.applicantVisibility
// Get evaluations via assignments — NEVER select userId or user relation
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: {
projectId: project.id,
roundId: round.id,
},
status: { in: ['SUBMITTED', 'LOCKED'] },
},
select: {
id: true,
submittedAt: true,
globalScore: vis.showGlobalScore,
criterionScoresJson: vis.showCriterionScores,
feedbackText: vis.showFeedbackText,
form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
},
orderBy: { submittedAt: 'asc' },
})
results.push({
roundId: round.id,
roundName: round.name,
evaluationCount: evaluations.length,
evaluations: evaluations.map((ev) => ({
id: ev.id,
submittedAt: ev.submittedAt,
globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
})),
})
}
return results
}),
/**
* Upcoming deadlines for dashboard card.
*/
getUpcomingDeadlines: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
}
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { programId: true },
})
if (!project?.programId) return []
const now = new Date()
const rounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: project.programId },
status: 'ROUND_ACTIVE',
windowCloseAt: { gt: now },
},
select: {
id: true,
name: true,
windowCloseAt: true,
},
orderBy: { windowCloseAt: 'asc' },
})
return rounds.map((r) => ({
roundName: r.name,
windowCloseAt: r.windowCloseAt!,
}))
}),
/**
* Document completeness progress for dashboard card.
*/
getDocumentCompleteness: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
}
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true, programId: true },
})
if (!project?.programId) return []
// Find active rounds with file requirements
const rounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: project.programId },
status: 'ROUND_ACTIVE',
fileRequirements: { some: {} },
},
select: {
id: true,
name: true,
fileRequirements: {
select: { id: true },
},
},
orderBy: { sortOrder: 'asc' },
})
const results: Array<{ roundId: string; roundName: string; required: number; uploaded: number }> = []
for (const round of rounds) {
const requirementIds = round.fileRequirements.map((fr) => fr.id)
if (requirementIds.length === 0) continue
const uploaded = await ctx.prisma.projectFile.count({
where: {
projectId: project.id,
requirementId: { in: requirementIds },
},
})
results.push({
roundId: round.id,
roundName: round.name,
required: requirementIds.length,
uploaded,
})
}
return results
}),
})

View File

@@ -105,7 +105,7 @@ export const applicationRouter = router({
if (input.mode === 'edition') {
// Edition-wide application mode
const program = await ctx.prisma.program.findFirst({
where: { slug: input.slug },
where: { slug: input.slug, isTest: false },
})
if (!program) {
@@ -687,6 +687,7 @@ export const applicationRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
isDraft: true,
isTest: false,
},
})
@@ -837,6 +838,7 @@ export const applicationRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
isDraft: true,
isTest: false,
},
})

View File

@@ -560,6 +560,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
isTest: false,
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
@@ -1255,6 +1256,7 @@ export const assignmentRouter = router({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
isTest: false,
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
@@ -2069,762 +2071,6 @@ export const assignmentRouter = router({
})
}),
/**
* Get transfer candidates: which of the source juror's assignments can be moved,
* and which other jurors are eligible to receive them.
*/
getTransferCandidates: adminProcedure
.input(z.object({
roundId: z.string(),
sourceJurorId: z.string(),
assignmentIds: z.array(z.string()),
}))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true },
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Fetch requested assignments — must belong to source juror
const requestedAssignments = await ctx.prisma.assignment.findMany({
where: {
id: { in: input.assignmentIds },
roundId: input.roundId,
userId: input.sourceJurorId,
},
select: {
id: true,
projectId: true,
project: { select: { title: true } },
evaluation: { select: { status: true } },
},
})
// Filter to movable only
const assignments = requestedAssignments.map((a) => ({
id: a.id,
projectId: a.projectId,
projectTitle: a.project.title,
evalStatus: a.evaluation?.status ?? null,
movable: !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number]),
}))
const movableProjectIds = assignments
.filter((a) => a.movable)
.map((a) => a.projectId)
// Build candidate juror pool — same pattern as reassignDroppedJurorAssignments
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: {
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
},
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.sourceJurorId)
.map((m) => m.user)
} else {
const roundJurorIds = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== input.sourceJurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
const candidateIds = candidateJurors.map((j) => j.id)
// Existing assignments, loads, COI pairs
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
// Completed evaluations count per candidate
const completedEvals = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
status: 'SUBMITTED',
},
select: { assignment: { select: { userId: true } } },
})
const completedCounts = new Map<string, number>()
for (const e of completedEvals) {
const uid = e.assignment.userId
completedCounts.set(uid, (completedCounts.get(uid) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
hasConflict: true,
userId: { in: candidateIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
// Build candidate list with eligibility per project
const candidates = candidateJurors.map((j) => {
const load = currentLoads.get(j.id) ?? 0
const cap = j.maxAssignments ?? fallbackCap
const completed = completedCounts.get(j.id) ?? 0
const allCompleted = load > 0 && completed === load
const eligibleProjectIds = movableProjectIds.filter((pid) =>
!alreadyAssigned.has(`${j.id}:${pid}`) &&
!coiPairs.has(`${j.id}:${pid}`) &&
load < cap
)
return {
userId: j.id,
name: j.name || j.email,
email: j.email,
currentLoad: load,
cap,
allCompleted,
eligibleProjectIds,
}
})
// Sort: not-all-done first, then by lowest load
candidates.sort((a, b) => {
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
return a.currentLoad - b.currentLoad
})
return { assignments, candidates }
}),
/**
* Transfer specific assignments from one juror to destination jurors.
*/
transferAssignments: adminProcedure
.input(z.object({
roundId: z.string(),
sourceJurorId: z.string(),
transfers: z.array(z.object({
assignmentId: z.string(),
destinationJurorId: z.string(),
})),
forceOverCap: z.boolean().default(false),
}))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true },
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Verify all assignments belong to source juror and are movable
const assignmentIds = input.transfers.map((t) => t.assignmentId)
const sourceAssignments = await ctx.prisma.assignment.findMany({
where: {
id: { in: assignmentIds },
roundId: input.roundId,
userId: input.sourceJurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
select: {
id: true,
projectId: true,
juryGroupId: true,
isRequired: true,
project: { select: { title: true } },
},
})
const sourceMap = new Map(sourceAssignments.map((a) => [a.id, a]))
// Build candidate pool data
const destinationIds = [...new Set(input.transfers.map((t) => t.destinationJurorId))]
const destinationUsers = await ctx.prisma.user.findMany({
where: { id: { in: destinationIds } },
select: { id: true, name: true, email: true, maxAssignments: true },
})
const destUserMap = new Map(destinationUsers.map((u) => [u.id, u]))
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
hasConflict: true,
userId: { in: destinationIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
// Validate each transfer
type PlannedMove = {
assignmentId: string
projectId: string
projectTitle: string
destinationJurorId: string
juryGroupId: string | null
isRequired: boolean
}
const plannedMoves: PlannedMove[] = []
const failed: { assignmentId: string; reason: string }[] = []
for (const transfer of input.transfers) {
const assignment = sourceMap.get(transfer.assignmentId)
if (!assignment) {
failed.push({ assignmentId: transfer.assignmentId, reason: 'Assignment not found or not movable' })
continue
}
const destUser = destUserMap.get(transfer.destinationJurorId)
if (!destUser) {
failed.push({ assignmentId: transfer.assignmentId, reason: 'Destination juror not found' })
continue
}
if (alreadyAssigned.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is already assigned to this project` })
continue
}
if (coiPairs.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} has a COI with this project` })
continue
}
const destCap = destUser.maxAssignments ?? fallbackCap
const destLoad = currentLoads.get(transfer.destinationJurorId) ?? 0
if (destLoad >= destCap && !input.forceOverCap) {
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is at cap (${destLoad}/${destCap})` })
continue
}
plannedMoves.push({
assignmentId: assignment.id,
projectId: assignment.projectId,
projectTitle: assignment.project.title,
destinationJurorId: transfer.destinationJurorId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
isRequired: assignment.isRequired,
})
// Track updated load for subsequent transfers to same destination
alreadyAssigned.add(`${transfer.destinationJurorId}:${assignment.projectId}`)
currentLoads.set(transfer.destinationJurorId, destLoad + 1)
}
// Execute in transaction with TOCTOU guard
const actualMoves: (PlannedMove & { newAssignmentId: string })[] = []
if (plannedMoves.length > 0) {
await ctx.prisma.$transaction(async (tx) => {
for (const move of plannedMoves) {
const deleted = await tx.assignment.deleteMany({
where: {
id: move.assignmentId,
userId: input.sourceJurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
})
if (deleted.count === 0) {
failed.push({ assignmentId: move.assignmentId, reason: 'Assignment was modified concurrently' })
continue
}
const created = await tx.assignment.create({
data: {
roundId: input.roundId,
projectId: move.projectId,
userId: move.destinationJurorId,
juryGroupId: move.juryGroupId ?? undefined,
isRequired: move.isRequired,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
actualMoves.push({ ...move, newAssignmentId: created.id })
}
})
}
// Notify destination jurors
if (actualMoves.length > 0) {
const destCounts: Record<string, number> = {}
for (const move of actualMoves) {
destCounts[move.destinationJurorId] = (destCounts[move.destinationJurorId] ?? 0) + 1
}
await createBulkNotifications({
userIds: Object.keys(destCounts),
type: NotificationTypes.BATCH_ASSIGNED,
title: 'Additional Projects Assigned',
message: `You have received additional project assignments via transfer in ${round.name}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, reason: 'assignment_transfer' },
})
// Notify admins
const sourceJuror = await ctx.prisma.user.findUnique({
where: { id: input.sourceJurorId },
select: { name: true, email: true },
})
const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'
const topReceivers = Object.entries(destCounts)
.map(([jurorId, count]) => {
const u = destUserMap.get(jurorId)
return `${u?.name || u?.email || jurorId} (${count})`
})
.join(', ')
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'Assignment Transfer',
message: `Transferred ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failed.length > 0 ? ` ${failed.length} transfer(s) failed.` : ''}`,
linkUrl: `/admin/rounds/${round.id}`,
linkLabel: 'View Round',
metadata: {
roundId: round.id,
sourceJurorId: input.sourceJurorId,
movedCount: actualMoves.length,
failedCount: failed.length,
},
})
// Audit
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ASSIGNMENT_TRANSFER',
entityType: 'Round',
entityId: round.id,
detailsJson: {
sourceJurorId: input.sourceJurorId,
sourceJurorName: sourceName,
movedCount: actualMoves.length,
failedCount: failed.length,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.destinationJurorId,
newJurorName: destUserMap.get(m.destinationJurorId)?.name || destUserMap.get(m.destinationJurorId)?.email || m.destinationJurorId,
})),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
return {
succeeded: actualMoves.map((m) => ({
assignmentId: m.assignmentId,
projectId: m.projectId,
destinationJurorId: m.destinationJurorId,
})),
failed,
}
}),
/**
* Preview the impact of lowering a juror's cap below their current load.
*/
getOverCapPreview: adminProcedure
.input(z.object({
roundId: z.string(),
jurorId: z.string(),
newCap: z.number().int().min(1),
}))
.query(async ({ ctx, input }) => {
const total = await ctx.prisma.assignment.count({
where: { roundId: input.roundId, userId: input.jurorId },
})
const immovableCount = await ctx.prisma.assignment.count({
where: {
roundId: input.roundId,
userId: input.jurorId,
evaluation: { status: { notIn: [...MOVABLE_EVAL_STATUSES] } },
},
})
const movableCount = total - immovableCount
const overCapCount = Math.max(0, total - input.newCap)
return {
total,
overCapCount,
movableOverCap: Math.min(overCapCount, movableCount),
immovableOverCap: Math.max(0, overCapCount - movableCount),
}
}),
/**
* Redistribute over-cap assignments after lowering a juror's cap.
* Moves the newest/least-progressed movable assignments to other eligible jurors.
*/
redistributeOverCap: adminProcedure
.input(z.object({
roundId: z.string(),
jurorId: z.string(),
newCap: z.number().int().min(1),
}))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true },
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Get juror's assignments sorted: null eval first, then DRAFT, newest first
const jurorAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId, userId: input.jurorId },
select: {
id: true,
projectId: true,
juryGroupId: true,
isRequired: true,
createdAt: true,
project: { select: { title: true } },
evaluation: { select: { status: true } },
},
orderBy: { createdAt: 'desc' },
})
const overCapCount = Math.max(0, jurorAssignments.length - input.newCap)
if (overCapCount === 0) {
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
}
// Separate movable and immovable, pick the newest movable ones for redistribution
const movable = jurorAssignments.filter(
(a) => !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number])
)
// Sort movable: null eval first, then DRAFT, then by createdAt descending (newest first to remove)
movable.sort((a, b) => {
const statusOrder = (s: string | null) => s === null ? 0 : s === 'NOT_STARTED' ? 1 : s === 'DRAFT' ? 2 : 3
const diff = statusOrder(a.evaluation?.status ?? null) - statusOrder(b.evaluation?.status ?? null)
if (diff !== 0) return diff
return b.createdAt.getTime() - a.createdAt.getTime()
})
const assignmentsToMove = movable.slice(0, overCapCount)
if (assignmentsToMove.length === 0) {
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
}
// Build candidate pool — same pattern as reassignDroppedJurorAssignments
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: {
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
},
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId)
.map((m) => m.user)
} else {
const roundJurorIds = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== input.jurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
if (candidateJurors.length === 0) {
return {
redistributed: 0,
failed: assignmentsToMove.length,
failedProjects: assignmentsToMove.map((a) => a.project.title),
moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[],
}
}
const candidateIds = candidateJurors.map((j) => j.id)
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
hasConflict: true,
userId: { in: candidateIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
const caps = new Map<string, number>()
for (const juror of candidateJurors) {
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
}
// Check which candidates have completed all their evaluations
const completedEvals = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
status: 'SUBMITTED',
},
select: { assignment: { select: { userId: true } } },
})
const completedCounts = new Map<string, number>()
for (const e of completedEvals) {
completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
}
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
type PlannedMove = {
assignmentId: string
projectId: string
projectTitle: string
newJurorId: string
juryGroupId: string | null
isRequired: boolean
}
const plannedMoves: PlannedMove[] = []
const failedProjects: string[] = []
for (const assignment of assignmentsToMove) {
const eligible = candidateIds
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
.sort((a, b) => {
// Prefer jurors who haven't completed all their work
const aLoad = currentLoads.get(a) ?? 0
const bLoad = currentLoads.get(b) ?? 0
const aComplete = aLoad > 0 && (completedCounts.get(a) ?? 0) === aLoad
const bComplete = bLoad > 0 && (completedCounts.get(b) ?? 0) === bLoad
if (aComplete !== bComplete) return aComplete ? 1 : -1
const loadDiff = aLoad - bLoad
if (loadDiff !== 0) return loadDiff
return a.localeCompare(b)
})
if (eligible.length === 0) {
failedProjects.push(assignment.project.title)
continue
}
const selectedJurorId = eligible[0]
plannedMoves.push({
assignmentId: assignment.id,
projectId: assignment.projectId,
projectTitle: assignment.project.title,
newJurorId: selectedJurorId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
isRequired: assignment.isRequired,
})
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
}
// Execute in transaction with TOCTOU guard
const actualMoves: PlannedMove[] = []
if (plannedMoves.length > 0) {
await ctx.prisma.$transaction(async (tx) => {
for (const move of plannedMoves) {
const deleted = await tx.assignment.deleteMany({
where: {
id: move.assignmentId,
userId: input.jurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
})
if (deleted.count === 0) {
failedProjects.push(move.projectTitle)
continue
}
await tx.assignment.create({
data: {
roundId: input.roundId,
projectId: move.projectId,
userId: move.newJurorId,
juryGroupId: move.juryGroupId ?? undefined,
isRequired: move.isRequired,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
actualMoves.push(move)
}
})
}
// Notify destination jurors
if (actualMoves.length > 0) {
const destCounts: Record<string, number> = {}
for (const move of actualMoves) {
destCounts[move.newJurorId] = (destCounts[move.newJurorId] ?? 0) + 1
}
await createBulkNotifications({
userIds: Object.keys(destCounts),
type: NotificationTypes.BATCH_ASSIGNED,
title: 'Additional Projects Assigned',
message: `You have received additional project assignments due to a cap adjustment in ${round.name}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, reason: 'cap_redistribute' },
})
const juror = await ctx.prisma.user.findUnique({
where: { id: input.jurorId },
select: { name: true, email: true },
})
const jurorName = juror?.name || juror?.email || 'Unknown'
const topReceivers = Object.entries(destCounts)
.map(([jurorId, count]) => {
const u = candidateMeta.get(jurorId)
return `${u?.name || u?.email || jurorId} (${count})`
})
.join(', ')
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'Cap Redistribution',
message: `Redistributed ${actualMoves.length} project(s) from ${jurorName} (cap lowered to ${input.newCap}) to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} project(s) could not be reassigned.` : ''}`,
linkUrl: `/admin/rounds/${round.id}`,
linkLabel: 'View Round',
metadata: {
roundId: round.id,
jurorId: input.jurorId,
newCap: input.newCap,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CAP_REDISTRIBUTE',
entityType: 'Round',
entityId: round.id,
detailsJson: {
jurorId: input.jurorId,
jurorName,
newCap: input.newCap,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
failedProjects,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.newJurorId,
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
})),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
return {
redistributed: actualMoves.length,
failed: failedProjects.length,
failedProjects,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.newJurorId,
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
})),
}
}),
/**
* Get reshuffle history for a round — shows all dropout/COI reassignment events
* with per-project detail of where each project was moved to.
@@ -2836,7 +2082,7 @@ export const assignmentRouter = router({
const auditEntries = await ctx.prisma.auditLog.findMany({
where: {
entityType: { in: ['Round', 'Assignment'] },
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] },
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT'] },
entityId: input.roundId,
},
orderBy: { timestamp: 'desc' },
@@ -2880,7 +2126,7 @@ export const assignmentRouter = router({
type ReshuffleEvent = {
id: string
type: 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
type: 'DROPOUT' | 'COI'
timestamp: Date
performedBy: { name: string | null; email: string }
droppedJuror: { id: string; name: string }
@@ -2935,44 +2181,6 @@ export const assignmentRouter = router({
failedProjects: (details.failedProjects as string[]) || [],
moves: reconstructedMoves,
})
} else if (entry.action === 'ASSIGNMENT_TRANSFER') {
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
events.push({
id: entry.id,
type: 'TRANSFER',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: (details.sourceJurorId as string) || '',
name: (details.sourceJurorName as string) || 'Unknown',
},
movedCount: (details.movedCount as number) || 0,
failedCount: (details.failedCount as number) || 0,
failedProjects: (details.failedProjects as string[]) || [],
moves,
})
} else if (entry.action === 'CAP_REDISTRIBUTE') {
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
events.push({
id: entry.id,
type: 'CAP_REDISTRIBUTE',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: (details.jurorId as string) || '',
name: (details.jurorName as string) || 'Unknown',
},
movedCount: (details.movedCount as number) || 0,
failedCount: (details.failedCount as number) || 0,
failedProjects: (details.failedProjects as string[]) || [],
moves,
})
}
}

View File

@@ -142,7 +142,7 @@ export const competitionRouter = router({
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.competition.findMany({
where: { programId: input.programId },
where: { programId: input.programId, isTest: false },
orderBy: { createdAt: 'desc' },
include: {
_count: {
@@ -254,7 +254,7 @@ export const competitionRouter = router({
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
if (competitionIds.length === 0) return []
return ctx.prisma.competition.findMany({
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' }, isTest: false },
include: {
rounds: {
orderBy: { sortOrder: 'asc' },

View File

@@ -172,18 +172,19 @@ export const dashboardRouter = router({
// 7. Project count
ctx.prisma.project.count({
where: { programId: editionId },
where: { programId: editionId, isTest: false },
}),
// 8. New projects this week
ctx.prisma.project.count({
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
where: { programId: editionId, isTest: false, createdAt: { gte: sevenDaysAgo } },
}),
// 9. Total jurors
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
isTest: false,
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } },
},
@@ -193,6 +194,7 @@ export const dashboardRouter = router({
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
isTest: false,
status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } },
},
@@ -212,7 +214,7 @@ export const dashboardRouter = router({
// 13. Latest projects
ctx.prisma.project.findMany({
where: { programId: editionId },
where: { programId: editionId, isTest: false },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
@@ -232,20 +234,20 @@ export const dashboardRouter = router({
// 14. Category breakdown
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { programId: editionId },
where: { programId: editionId, isTest: false },
_count: true,
}),
// 15. Ocean issue breakdown
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
where: { programId: editionId },
where: { programId: editionId, isTest: false },
_count: true,
}),
// 16. Recent activity
// 16. Recent activity (exclude test user actions)
ctx.prisma.auditLog.findMany({
where: { timestamp: { gte: sevenDaysAgo } },
where: { timestamp: { gte: sevenDaysAgo }, user: { isTest: false } },
orderBy: { timestamp: 'desc' },
take: 8,
select: {

View File

@@ -105,6 +105,7 @@ export const exportRouter = router({
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: {
isTest: false,
assignments: { some: { roundId: input.roundId } },
},
include: {
@@ -355,7 +356,7 @@ export const exportRouter = router({
}
const logs = await ctx.prisma.auditLog.findMany({
where,
where: { ...where, user: { isTest: false } },
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
@@ -431,7 +432,7 @@ export const exportRouter = router({
if (includeSection('summary')) {
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
ctx.prisma.project.count({
where: { assignments: { some: { roundId: input.roundId } } },
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
}),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({
@@ -486,7 +487,7 @@ export const exportRouter = router({
// Rankings
if (includeSection('rankings')) {
const projects = await ctx.prisma.project.findMany({
where: { assignments: { some: { roundId: input.roundId } } },
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
select: {
id: true,
title: true,

View File

@@ -994,6 +994,7 @@ export const fileRouter = router({
// Build project filter
const projectWhere: Record<string, unknown> = {
programId: window.competition.programId,
isTest: false,
}
if (input.search) {
projectWhere.OR = [
@@ -1303,6 +1304,7 @@ export const fileRouter = router({
// Build project filter
const projectWhere: Record<string, unknown> = {
programId: round.competition.programId,
isTest: false,
}
if (input.search) {
projectWhere.OR = [

View File

@@ -115,6 +115,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
roundId,
exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] },
project: { isTest: false },
},
include: {
project: {

View File

@@ -1,6 +1,5 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import {
router,
protectedProcedure,
@@ -12,69 +11,6 @@ import { logAudit } from '../utils/audit'
// Bucket for learning resources
export const LEARNING_BUCKET = 'mopc-learning'
// Access rule schema for fine-grained access control
const accessRuleSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('everyone') }),
z.object({ type: z.literal('roles'), roles: z.array(z.string()) }),
z.object({ type: z.literal('jury_group'), juryGroupIds: z.array(z.string()) }),
z.object({ type: z.literal('round'), roundIds: z.array(z.string()) }),
])
type AccessRule = z.infer<typeof accessRuleSchema>
/**
* Evaluate whether a user can access a resource based on its accessJson rules.
* null/empty = everyone. Rules are OR-combined (match ANY rule = access).
*/
async function canUserAccessResource(
prisma: { juryGroupMember: { findFirst: Function }; assignment: { findFirst: Function } },
userId: string,
userRole: string,
accessJson: unknown,
): Promise<boolean> {
// null or empty = everyone
if (!accessJson) return true
let rules: AccessRule[]
try {
const parsed = accessJson as unknown[]
if (!Array.isArray(parsed) || parsed.length === 0) return true
rules = parsed as AccessRule[]
} catch {
return true
}
for (const rule of rules) {
if (rule.type === 'everyone') return true
if (rule.type === 'roles') {
if (rule.roles.includes(userRole)) return true
}
if (rule.type === 'jury_group') {
const membership = await prisma.juryGroupMember.findFirst({
where: {
userId,
juryGroupId: { in: rule.juryGroupIds },
},
})
if (membership) return true
}
if (rule.type === 'round') {
const assignment = await prisma.assignment.findFirst({
where: {
userId,
roundId: { in: rule.roundIds },
},
})
if (assignment) return true
}
}
return false
}
export const learningResourceRouter = router({
/**
* List all resources (admin view)
@@ -83,9 +19,11 @@ export const learningResourceRouter = router({
.input(
z.object({
programId: z.string().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
isPublished: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
perPage: z.number().int().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
@@ -94,6 +32,12 @@ export const learningResourceRouter = router({
if (input.programId !== undefined) {
where.programId = input.programId
}
if (input.resourceType) {
where.resourceType = input.resourceType
}
if (input.cohortLevel) {
where.cohortLevel = input.cohortLevel
}
if (input.isPublished !== undefined) {
where.isPublished = input.isPublished
}
@@ -123,41 +67,71 @@ export const learningResourceRouter = router({
}),
/**
* Get resources accessible to the current user (jury/mentor/observer view)
* Get resources accessible to the current user (jury view)
*/
myResources: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
})
)
.query(async ({ ctx, input }) => {
// Determine user's cohort level based on their assignments
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: {
select: {
status: true,
},
},
},
})
// Determine highest cohort level
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
const projectStatus = assignment.project.status
if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
// Build query based on cohort level
const cohortLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
cohortLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
cohortLevels.push('FINALIST')
}
const where: Record<string, unknown> = {
isPublished: true,
cohortLevel: { in: cohortLevels },
}
if (input.programId) {
where.OR = [{ programId: input.programId }, { programId: null }]
}
if (input.resourceType) {
where.resourceType = input.resourceType
}
const resources = await ctx.prisma.learningResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
})
// Filter by access rules in application code (small dataset)
const accessible = []
for (const resource of resources) {
const allowed = await canUserAccessResource(
ctx.prisma,
ctx.user.id,
ctx.user.role,
resource.accessJson,
)
if (allowed) accessible.push(resource)
return {
resources,
userCohortLevel,
}
return { resources: accessible }
}),
/**
@@ -175,8 +149,7 @@ export const learningResourceRouter = router({
})
// Check access for non-admins
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
if (ctx.user.role === 'JURY_MEMBER') {
if (!resource.isPublished) {
throw new TRPCError({
code: 'FORBIDDEN',
@@ -184,13 +157,39 @@ export const learningResourceRouter = router({
})
}
const allowed = await canUserAccessResource(
ctx.prisma,
ctx.user.id,
ctx.user.role,
resource.accessJson,
)
if (!allowed) {
// Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: {
select: {
status: true,
},
},
},
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
const projectStatus = assignment.project.status
if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
const accessibleLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
accessibleLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
accessibleLevels.push('FINALIST')
}
if (!accessibleLevels.includes(resource.cohortLevel)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this resource',
@@ -203,6 +202,7 @@ export const learningResourceRouter = router({
/**
* Get download URL for a resource file
* Checks cohort level access for non-admin users
*/
getDownloadUrl: protectedProcedure
.input(z.object({ id: z.string() }))
@@ -228,13 +228,39 @@ export const learningResourceRouter = router({
})
}
const allowed = await canUserAccessResource(
ctx.prisma,
ctx.user.id,
ctx.user.role,
resource.accessJson,
)
if (!allowed) {
// Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: {
select: {
status: true,
},
},
},
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
const projectStatus = assignment.project.status
if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
const accessibleLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
accessibleLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
accessibleLevels.push('FINALIST')
}
if (!accessibleLevels.includes(resource.cohortLevel)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this resource',
@@ -255,22 +281,6 @@ export const learningResourceRouter = router({
return { url }
}),
/**
* Log access when user opens a resource detail page
*/
logAccess: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.resourceAccess.create({
data: {
resourceId: input.id,
userId: ctx.user.id,
ipAddress: ctx.ip,
},
})
return { success: true }
}),
/**
* Create a new resource (admin only)
*/
@@ -281,9 +291,9 @@ export const learningResourceRouter = router({
title: z.string().min(1).max(255),
description: z.string().optional(),
contentJson: z.any().optional(), // BlockNote document structure
accessJson: accessRuleSchema.array().nullable().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).default('ALL'),
externalUrl: z.string().url().optional(),
coverImageKey: z.string().optional(),
sortOrder: z.number().int().default(0),
isPublished: z.boolean().default(false),
// File info (set after upload)
@@ -295,12 +305,9 @@ export const learningResourceRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { accessJson, ...rest } = input
const resource = await ctx.prisma.learningResource.create({
data: {
...rest,
accessJson: accessJson === null ? Prisma.JsonNull : accessJson ?? undefined,
...input,
createdById: ctx.user.id,
},
})
@@ -312,7 +319,7 @@ export const learningResourceRouter = router({
action: 'CREATE',
entityType: 'LearningResource',
entityId: resource.id,
detailsJson: { title: input.title },
detailsJson: { title: input.title, resourceType: input.resourceType },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -328,12 +335,11 @@ export const learningResourceRouter = router({
z.object({
id: z.string(),
title: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
description: z.string().optional(),
contentJson: z.any().optional(), // BlockNote document structure
accessJson: accessRuleSchema.array().nullable().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
externalUrl: z.string().url().optional().nullable(),
coverImageKey: z.string().optional().nullable(),
programId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
isPublished: z.boolean().optional(),
// File info (set after upload)
@@ -345,15 +351,7 @@ export const learningResourceRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { id, accessJson, ...rest } = input
// Prisma requires Prisma.JsonNull for nullable JSON fields instead of raw null
const data = {
...rest,
...(accessJson !== undefined && {
accessJson: accessJson === null ? Prisma.JsonNull : accessJson,
}),
}
const { id, ...data } = input
const resource = await ctx.prisma.learningResource.update({
where: { id },
@@ -367,7 +365,7 @@ export const learningResourceRouter = router({
action: 'UPDATE',
entityType: 'LearningResource',
entityId: id,
detailsJson: rest,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})

View File

@@ -420,6 +420,7 @@ export const mentorRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
programId: input.programId,
isTest: false,
mentorAssignment: null,
wantsMentorship: true,
},

View File

@@ -402,7 +402,7 @@ async function resolveRecipients(
const role = filter?.role as string
if (!role) return []
const users = await prisma.user.findMany({
where: { role: role as any, status: 'ACTIVE' },
where: { role: role as any, status: 'ACTIVE', isTest: false },
select: { id: true },
})
return users.map((u) => u.id)
@@ -412,7 +412,7 @@ async function resolveRecipients(
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
const assignments = await prisma.assignment.findMany({
where: { roundId: targetRoundId },
where: { roundId: targetRoundId, user: { isTest: false } },
select: { userId: true },
distinct: ['userId'],
})
@@ -423,7 +423,7 @@ async function resolveRecipients(
const programId = filter?.programId as string
if (!programId) return []
const projects = await prisma.project.findMany({
where: { programId },
where: { programId, isTest: false },
select: { submittedByUserId: true },
})
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
@@ -432,7 +432,7 @@ async function resolveRecipients(
case 'ALL': {
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
where: { status: 'ACTIVE', isTest: false },
select: { id: true },
})
return users.map((u) => u.id)

View File

@@ -22,7 +22,7 @@ export const programRouter = router({
const includeStages = input?.includeStages || false
const programs = await ctx.prisma.program.findMany({
where: input?.status ? { status: input.status } : undefined,
where: input?.status ? { isTest: false, status: input.status } : { isTest: false },
orderBy: { year: 'desc' },
include: includeStages
? {

View File

@@ -103,6 +103,7 @@ export const projectPoolRouter = router({
// Build where clause
const where: Record<string, unknown> = {
isTest: false,
programId,
}
@@ -317,6 +318,7 @@ export const projectPoolRouter = router({
// Find projects to assign
const where: Record<string, unknown> = {
isTest: false,
programId,
}

View File

@@ -84,7 +84,9 @@ export const projectRouter = router({
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {}
const where: Record<string, unknown> = {
isTest: false,
}
// Filter by program
if (programId) where.programId = programId
@@ -219,7 +221,9 @@ export const projectRouter = router({
wantsMentorship, hasFiles, hasAssignments,
} = input
const where: Record<string, unknown> = {}
const where: Record<string, unknown> = {
isTest: false,
}
if (programId) where.programId = programId
if (roundId) {
@@ -357,19 +361,19 @@ export const projectRouter = router({
.query(async ({ ctx }) => {
const [countries, categories, issues] = await Promise.all([
ctx.prisma.project.findMany({
where: { country: { not: null } },
where: { isTest: false, country: { not: null } },
select: { country: true },
distinct: ['country'],
orderBy: { country: 'asc' },
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { competitionCategory: { not: null } },
where: { isTest: false, competitionCategory: { not: null } },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
where: { oceanIssue: { not: null } },
where: { isTest: false, oceanIssue: { not: null } },
_count: true,
}),
])
@@ -838,7 +842,7 @@ export const projectRouter = router({
)
.mutation(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
where: { id: { in: input.ids }, isTest: false },
select: { id: true, title: true, status: true },
})
@@ -948,11 +952,13 @@ export const projectRouter = router({
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
const where: Record<string, unknown> = {
isTest: false,
}
if (input.programId) where.programId = input.programId
const projects = await ctx.prisma.project.findMany({
where: Object.keys(where).length > 0 ? where : undefined,
where,
select: { tags: true },
})
@@ -984,6 +990,7 @@ export const projectRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
isTest: false,
},
select: { id: true, title: true },
})
@@ -1102,6 +1109,7 @@ export const projectRouter = router({
const where: Record<string, unknown> = {
programId,
isTest: false,
projectRoundStates: { none: {} }, // Projects not assigned to any round
}

View File

@@ -93,7 +93,7 @@ export const specialAwardRouter = router({
let { competition } = award
if (!competition && award.programId) {
const comp = await ctx.prisma.competition.findFirst({
where: { programId: award.programId },
where: { programId: award.programId, isTest: false },
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
})
@@ -141,7 +141,7 @@ export const specialAwardRouter = router({
let competitionId = input.competitionId
if (!competitionId) {
const comp = await ctx.prisma.competition.findFirst({
where: { programId: input.programId },
where: { programId: input.programId, isTest: false },
orderBy: { createdAt: 'desc' },
select: { id: true },
})
@@ -217,7 +217,7 @@ export const specialAwardRouter = router({
})
if (existing && !existing.competitionId) {
const comp = await ctx.prisma.competition.findFirst({
where: { programId: existing.programId },
where: { programId: existing.programId, isTest: false },
orderBy: { createdAt: 'desc' },
select: { id: true },
})
@@ -404,7 +404,7 @@ export const specialAwardRouter = router({
const { awardId, eligibleOnly, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = { awardId }
const where: Record<string, unknown> = { awardId, project: { isTest: false } }
if (eligibleOnly) where.eligible = true
const [eligibilities, total] = await Promise.all([

View File

@@ -53,7 +53,7 @@ async function runTaggingJob(jobId: string, userId: string) {
if (!job.programId) {
throw new Error('Job must have a programId')
}
const whereClause = { programId: job.programId }
const whereClause = { programId: job.programId, isTest: false }
const allProjects = await prisma.project.findMany({
where: whereClause,
@@ -196,11 +196,13 @@ export const tagRouter = router({
const userCount = await ctx.prisma.user.count({
where: {
expertiseTags: { has: tag.name },
isTest: false,
},
})
const projectCount = await ctx.prisma.project.count({
where: {
tags: { has: tag.name },
isTest: false,
},
})
return {
@@ -228,10 +230,10 @@ export const tagRouter = router({
// Get usage counts
const [userCount, projectCount] = await Promise.all([
ctx.prisma.user.count({
where: { expertiseTags: { has: tag.name } },
where: { expertiseTags: { has: tag.name }, isTest: false },
}),
ctx.prisma.project.count({
where: { tags: { has: tag.name } },
where: { tags: { has: tag.name }, isTest: false },
}),
])
@@ -354,7 +356,7 @@ export const tagRouter = router({
// Update users
const usersWithTag = await ctx.prisma.user.findMany({
where: { expertiseTags: { has: oldTag.name } },
where: { expertiseTags: { has: oldTag.name }, isTest: false },
select: { id: true, expertiseTags: true },
})
@@ -371,7 +373,7 @@ export const tagRouter = router({
// Update projects
const projectsWithTag = await ctx.prisma.project.findMany({
where: { tags: { has: oldTag.name } },
where: { tags: { has: oldTag.name }, isTest: false },
select: { id: true, tags: true },
})
@@ -412,9 +414,9 @@ export const tagRouter = router({
where: { id: input.id },
})
// Remove tag from all users
// Remove tag from all users (excluding test users)
const usersWithTag = await ctx.prisma.user.findMany({
where: { expertiseTags: { has: tag.name } },
where: { expertiseTags: { has: tag.name }, isTest: false },
select: { id: true, expertiseTags: true },
})
@@ -427,9 +429,9 @@ export const tagRouter = router({
})
}
// Remove tag from all projects
// Remove tag from all projects (excluding test projects)
const projectsWithTag = await ctx.prisma.project.findMany({
where: { tags: { has: tag.name } },
where: { tags: { has: tag.name }, isTest: false },
select: { id: true, tags: true },
})

View File

@@ -0,0 +1,92 @@
import { router, superAdminProcedure, protectedProcedure } from '../trpc'
import { TRPCError } from '@trpc/server'
import { createTestEnvironment, tearDownTestEnvironment } from '../services/test-environment'
export const testEnvironmentRouter = router({
/**
* Get the current test environment status.
* Uses a custom auth check: allows access if realRole OR role is SUPER_ADMIN.
* This enables the impersonation banner to fetch test users while impersonating.
*/
status: protectedProcedure.query(async ({ ctx }) => {
// Allow access if the user's actual role (or impersonated-from role) is SUPER_ADMIN
const effectiveRole = (ctx.session?.user as any)?.realRole || ctx.user.role
if (effectiveRole !== 'SUPER_ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Super admin access required' })
}
const competition = await ctx.prisma.competition.findFirst({
where: { isTest: true },
select: {
id: true,
name: true,
status: true,
createdAt: true,
program: {
select: { id: true, name: true },
},
rounds: {
select: { id: true, name: true, roundType: true, status: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
},
},
})
if (!competition) {
return { active: false as const }
}
// Get test users grouped by role
const testUsers = await ctx.prisma.user.findMany({
where: { isTest: true },
select: { id: true, name: true, email: true, role: true },
orderBy: [{ role: 'asc' }, { name: 'asc' }],
})
// Get email redirect setting
const emailRedirect = await ctx.prisma.systemSettings.findUnique({
where: { key: 'test_email_redirect' },
select: { value: true },
})
return {
active: true as const,
competition: {
id: competition.id,
name: competition.name,
status: competition.status,
createdAt: competition.createdAt,
programId: competition.program.id,
programName: competition.program.name,
},
rounds: competition.rounds,
users: testUsers,
emailRedirect: emailRedirect?.value || null,
}
}),
/**
* Create a test environment. Idempotent — tears down existing first.
*/
create: superAdminProcedure.mutation(async ({ ctx }) => {
const result = await createTestEnvironment(ctx.prisma, ctx.user.email || '')
return result
}),
/**
* Tear down the test environment.
*/
tearDown: superAdminProcedure.mutation(async ({ ctx }) => {
const program = await ctx.prisma.program.findFirst({
where: { isTest: true },
select: { id: true },
})
if (!program) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'No test environment found' })
}
await tearDownTestEnvironment(ctx.prisma, program.id)
return { success: true }
}),
})

View File

@@ -249,7 +249,9 @@ export const userRouter = router({
const { role, roles, status, search, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
const where: Record<string, unknown> = {
isTest: false,
}
if (roles && roles.length > 0) {
where.role = { in: roles }
@@ -316,6 +318,7 @@ export const userRouter = router({
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
isTest: false,
status: { in: ['NONE', 'INVITED'] },
}
@@ -929,6 +932,7 @@ export const userRouter = router({
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
isTest: false,
role: 'JURY_MEMBER',
status: 'ACTIVE',
}

View File

@@ -290,6 +290,7 @@ export async function generateSummary({
select: {
id: true,
title: true,
isTest: true,
},
})
@@ -297,6 +298,10 @@ export async function generateSummary({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
if (project.isTest) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot generate AI summaries for test projects' })
}
// Fetch submitted evaluations for this project in this round
const evaluations = await prisma.evaluation.findMany({
where: {

View File

@@ -103,6 +103,7 @@ async function generateCategoryShortlist(
const projects = await prisma.project.findMany({
where: {
competitionCategory: category,
isTest: false,
assignments: { some: { roundId } },
},
include: {

View File

@@ -473,6 +473,10 @@ export async function tagProject(
throw new Error(`Project not found: ${projectId}`)
}
if ((project as any).isTest) {
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
}
// Get available tags
const availableTags = await getAvailableTags()
if (availableTags.length === 0) {
@@ -574,7 +578,7 @@ export async function tagProjectsBatch(
// Fetch full project data for all projects at once (single DB query)
const fullProjects = await prisma.project.findMany({
where: { id: { in: projects.map((p) => p.id) } },
where: { id: { in: projects.map((p) => p.id) }, isTest: false },
include: {
projectTags: true,
files: { select: { fileType: true } },
@@ -712,6 +716,10 @@ export async function getTagSuggestions(
throw new Error(`Project not found: ${projectId}`)
}
if ((project as any).isTest) {
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
}
// Get available tags
const availableTags = await getAvailableTags()
if (availableTags.length === 0) {

View File

@@ -88,6 +88,7 @@ export async function processEligibilityJob(
where: {
id: { in: passedIds },
programId: award.programId,
isTest: false,
},
select: projectSelect,
})
@@ -99,6 +100,7 @@ export async function processEligibilityJob(
projects = await prisma.project.findMany({
where: {
programId: award.programId,
isTest: false,
status: { in: [...statusFilter] },
},
select: projectSelect,

View File

@@ -320,7 +320,7 @@ export async function analyzeAllUnanalyzed(): Promise<{
total: number
}> {
const files = await prisma.projectFile.findMany({
where: { analyzedAt: null },
where: { analyzedAt: null, project: { isTest: false } },
select: {
id: true,
objectKey: true,

View File

@@ -32,6 +32,7 @@ export async function processDigests(
// Find users who opted in for this digest frequency
const users = await prisma.user.findMany({
where: {
isTest: false,
digestFrequency: type,
status: 'ACTIVE',
},

View File

@@ -56,7 +56,7 @@ export async function sendManualReminders(roundId: string): Promise<ReminderResu
if (usersToNotify.length === 0) return { sent, errors }
const users = await prisma.user.findMany({
where: { id: { in: usersToNotify } },
where: { id: { in: usersToNotify }, isTest: false },
select: { id: true, name: true, email: true },
})
@@ -133,6 +133,7 @@ export async function processEvaluationReminders(roundId?: string): Promise<Remi
status: 'ROUND_ACTIVE' as const,
windowCloseAt: { gt: now },
windowOpenAt: { lte: now },
competition: { isTest: false },
...(roundId && { id: roundId }),
},
select: {
@@ -213,7 +214,7 @@ async function sendRemindersForRound(
// Get user details and their pending counts
const users = await prisma.user.findMany({
where: { id: { in: usersToNotify } },
where: { id: { in: usersToNotify }, isTest: false },
select: { id: true, name: true, email: true },
})

View File

@@ -297,6 +297,7 @@ export async function notifyAdmins(params: {
where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: 'ACTIVE',
isTest: false,
},
select: { id: true },
})

View File

@@ -232,6 +232,7 @@ export async function getAIMentorSuggestionsBatch(
{ role: 'JURY_MEMBER' },
],
status: 'ACTIVE',
isTest: false,
},
select: {
id: true,
@@ -458,6 +459,7 @@ export async function getRoundRobinMentor(
{ role: 'JURY_MEMBER' },
],
status: 'ACTIVE',
isTest: false,
id: { notIn: excludeMentorIds },
},
select: {

View File

@@ -40,7 +40,7 @@ export async function sendNotification(
): Promise<NotificationResult> {
// Get user with notification preferences
const user = await prisma.user.findUnique({
where: { id: userId },
where: { id: userId, isTest: false },
select: {
id: true,
email: true,

View File

@@ -94,7 +94,7 @@ export async function previewRoundAssignment(
// Load jury group members
const members = await db.juryGroupMember.findMany({
where: { juryGroupId: ctx.juryGroup.id },
where: { juryGroupId: ctx.juryGroup.id, user: { isTest: false } },
include: {
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
},

View File

@@ -16,7 +16,7 @@ export async function processScheduledRounds(): Promise<{
// Find a SUPER_ADMIN to use as the actor for audit logging
const systemActor = await prisma.user.findFirst({
where: { role: 'SUPER_ADMIN' },
where: { role: 'SUPER_ADMIN', isTest: false },
select: { id: true },
})
@@ -29,7 +29,7 @@ export async function processScheduledRounds(): Promise<{
where: {
status: 'ROUND_DRAFT',
windowOpenAt: { lte: now },
competition: { status: { not: 'ARCHIVED' } },
competition: { status: { not: 'ARCHIVED' }, isTest: false },
},
select: { id: true, name: true },
})
@@ -49,6 +49,7 @@ export async function processScheduledRounds(): Promise<{
where: {
status: 'ROUND_ACTIVE',
windowCloseAt: { lte: now },
competition: { isTest: false },
},
select: { id: true, name: true },
})

View File

@@ -362,6 +362,7 @@ export async function getSmartSuggestions(options: {
where: {
role,
status: 'ACTIVE',
isTest: false,
},
select: {
id: true,
@@ -683,6 +684,7 @@ export async function getMentorSuggestionsForProject(
where: {
role: 'MENTOR',
status: 'ACTIVE',
isTest: false,
},
select: {
id: true,

View File

@@ -0,0 +1,676 @@
import type { PrismaClient } from '@prisma/client'
// ─── Test Data Constants ─────────────────────────────────────────────────────
const TEST_JURY = [
{ name: 'Sophie Laurent', email: 'sophie.laurent@test.local', country: 'France', expertiseTags: ['Marine Biology', 'Conservation'] },
{ name: 'Marco Bianchi', email: 'marco.bianchi@test.local', country: 'Italy', expertiseTags: ['Sustainable Fishing', 'Policy'] },
{ name: 'Elena Petrova', email: 'elena.petrova@test.local', country: 'Germany', expertiseTags: ['Ocean Technology', 'Engineering'] },
{ name: 'James Chen', email: 'james.chen@test.local', country: 'Singapore', expertiseTags: ['Blue Economy', 'Innovation'] },
{ name: 'Aisha Diallo', email: 'aisha.diallo@test.local', country: 'Senegal', expertiseTags: ['Community Development', 'Education'] },
{ name: 'Carlos Rivera', email: 'carlos.rivera@test.local', country: 'Spain', expertiseTags: ['Climate Science', 'Renewable Energy'] },
]
const TEST_APPLICANTS_OWNERS = [
{ name: 'Léa Moreau', email: 'lea.moreau@test.local', country: 'France' },
{ name: 'Henrik Johansson', email: 'henrik.johansson@test.local', country: 'Sweden' },
{ name: 'Fatima Al-Rashid', email: 'fatima.alrashid@test.local', country: 'UAE' },
{ name: 'Yuki Tanaka', email: 'yuki.tanaka@test.local', country: 'Japan' },
{ name: 'Priya Sharma', email: 'priya.sharma@test.local', country: 'India' },
{ name: 'Lucas Oliveira', email: 'lucas.oliveira@test.local', country: 'Brazil' },
{ name: 'Nadia Kowalski', email: 'nadia.kowalski@test.local', country: 'Poland' },
{ name: 'Samuel Okonkwo', email: 'samuel.okonkwo@test.local', country: 'Nigeria' },
{ name: 'Ingrid Hansen', email: 'ingrid.hansen@test.local', country: 'Norway' },
{ name: 'Diego Fernández', email: 'diego.fernandez@test.local', country: 'Mexico' },
{ name: 'Amira Benali', email: 'amira.benali@test.local', country: 'Morocco' },
{ name: 'Thomas Müller', email: 'thomas.muller@test.local', country: 'Germany' },
]
const TEST_APPLICANTS_TEAMMATES = [
{ name: 'Marie Dubois', email: 'marie.dubois@test.local', country: 'France' },
{ name: 'Kenji Watanabe', email: 'kenji.watanabe@test.local', country: 'Japan' },
{ name: 'Ana Costa', email: 'ana.costa@test.local', country: 'Brazil' },
{ name: 'Erik Lindqvist', email: 'erik.lindqvist@test.local', country: 'Sweden' },
]
const TEST_OTHER_ROLES = [
{ name: 'Dr. Catherine Blanc', email: 'catherine.blanc@test.local', role: 'MENTOR' as const, country: 'Monaco' },
{ name: 'Oliver Schmidt', email: 'oliver.schmidt@test.local', role: 'OBSERVER' as const, country: 'Switzerland' },
{ name: 'Isabella Romano', email: 'isabella.romano@test.local', role: 'AWARD_MASTER' as const, country: 'Italy' },
{ name: 'Philippe Durand', email: 'philippe.durand@test.local', role: 'PROGRAM_ADMIN' as const, country: 'Monaco' },
]
const TEST_PROJECTS = [
{
title: 'OceanGuard: AI-Powered Marine Debris Detection',
teamName: 'OceanGuard Technologies',
description: 'Using satellite imagery and deep learning to identify and track marine debris across the Mediterranean. Our proprietary algorithm achieves 94% accuracy in detecting microplastic concentration zones, enabling targeted cleanup operations.',
country: 'France',
category: 'STARTUP' as const,
oceanIssue: 'POLLUTION_REDUCTION' as const,
},
{
title: 'Blue Carbon Ventures: Seagrass Restoration at Scale',
teamName: 'Blue Carbon Ventures',
description: 'Developing cost-effective seagrass meadow restoration techniques for Mediterranean coastal zones. Our approach combines drone-based seed dispersal with AI monitoring to restore 500+ hectares of seagrass by 2030.',
country: 'Italy',
category: 'STARTUP' as const,
oceanIssue: 'BLUE_CARBON' as const,
},
{
title: 'Reef Resilience: Coral Thermal Adaptation Program',
teamName: 'Reef Resilience Lab',
description: 'Selective breeding of thermally tolerant coral genotypes combined with biofilm-enhanced substrate technology to accelerate reef recovery in warming waters. Currently operating pilot programs in 3 Mediterranean sites.',
country: 'Monaco',
category: 'STARTUP' as const,
oceanIssue: 'HABITAT_RESTORATION' as const,
},
{
title: 'WaveHarvest: Ocean Energy for Coastal Communities',
teamName: 'WaveHarvest Energy',
description: 'Modular wave energy converters designed for small island nations and remote coastal communities. Our patented oscillating water column system delivers reliable 50kW power at 1/3 the cost of competing technologies.',
country: 'Norway',
category: 'STARTUP' as const,
oceanIssue: 'CLIMATE_MITIGATION' as const,
},
{
title: 'SailCargo: Zero-Emission Maritime Logistics',
teamName: 'SailCargo Collective',
description: 'Reviving wind-powered shipping for short-sea routes in the Mediterranean using modernized sailing vessel designs with solar-electric auxiliary propulsion. Connecting ports along the French and Italian Riviera.',
country: 'Spain',
category: 'STARTUP' as const,
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
},
{
title: 'Neptune Analytics: Smart Fisheries Management',
teamName: 'Neptune Analytics',
description: 'IoT sensor network and machine learning platform for real-time fisheries monitoring. Tracks fish stock health, migration patterns, and fishing vessel compliance to support science-based quota management.',
country: 'Portugal',
category: 'STARTUP' as const,
oceanIssue: 'SUSTAINABLE_FISHING' as const,
},
{
title: 'AquaLens: Underwater Environmental Monitoring',
teamName: 'AquaLens Research',
description: 'A network of autonomous underwater drones equipped with hyperspectral cameras for continuous marine ecosystem monitoring. Real-time data on water quality, biodiversity indices, and pollutant levels.',
country: 'Germany',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'TECHNOLOGY_INNOVATION' as const,
},
{
title: 'TidalConnect: Ocean Literacy Education Platform',
teamName: 'TidalConnect Foundation',
description: 'Interactive mobile platform bringing ocean science education to underserved coastal communities in West Africa. Features gamified learning modules, local language support, and citizen science data collection.',
country: 'Senegal',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'COMMUNITY_CAPACITY' as const,
},
{
title: 'BioFouling Solutions: Eco-Friendly Marine Coatings',
teamName: 'BioFouling Solutions',
description: 'Biomimetic antifouling coatings inspired by shark skin microstructure. Our non-toxic coating reduces fuel consumption by 12% while eliminating the release of harmful biocides into marine environments.',
country: 'Sweden',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'POLLUTION_REDUCTION' as const,
},
{
title: 'Kelp Climate: Industrial-Scale Kelp Farming',
teamName: 'Kelp Climate Co.',
description: 'Offshore macroalgae cultivation combining carbon sequestration with sustainable biomaterials production. Our proprietary deep-water cultivation system supports 10x faster growth rates than nearshore farms.',
country: 'Japan',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'BLUE_CARBON' as const,
},
{
title: 'MarineTrack: Vessel Emission Compliance System',
teamName: 'MarineTrack Systems',
description: 'Real-time satellite and AIS-based monitoring platform for enforcing IMO 2030 emission standards. Automatically detects scrubber discharge violations and sulfur emission exceedances across Mediterranean shipping lanes.',
country: 'Greece',
category: 'STARTUP' as const,
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
},
{
title: 'Mangrove Guardians: Community-Led Restoration',
teamName: 'Mangrove Guardians Network',
description: 'Empowering coastal fishing communities in Southeast Asia to restore and manage mangrove ecosystems through microfinance-linked conservation incentives and satellite-verified carbon credits.',
country: 'India',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'HABITAT_RESTORATION' as const,
},
]
const ROUND_DEFINITIONS = [
{ name: 'Intake', slug: 'intake', roundType: 'INTAKE' as const, sortOrder: 0 },
{ name: 'Eligibility Screening', slug: 'filtering', roundType: 'FILTERING' as const, sortOrder: 1 },
{ name: 'Expert Evaluation', slug: 'evaluation', roundType: 'EVALUATION' as const, sortOrder: 2 },
{ name: 'Document Submission', slug: 'submission', roundType: 'SUBMISSION' as const, sortOrder: 3 },
{ name: 'Mentoring Phase', slug: 'mentoring', roundType: 'MENTORING' as const, sortOrder: 4 },
{ name: 'Live Finals', slug: 'live-final', roundType: 'LIVE_FINAL' as const, sortOrder: 5 },
{ name: 'Final Deliberation', slug: 'deliberation', roundType: 'DELIBERATION' as const, sortOrder: 6 },
]
// ─── Public API ──────────────────────────────────────────────────────────────
export interface TestEnvironmentResult {
programId: string
competitionId: string
users: Array<{ id: string; name: string; email: string; role: string }>
projectCount: number
roundCount: number
}
/**
* Create a complete test environment with realistic data.
* Idempotent — tears down existing test env first.
*/
export async function createTestEnvironment(
prisma: PrismaClient,
adminEmail: string
): Promise<TestEnvironmentResult> {
// Tear down existing if any
const existing = await prisma.competition.findFirst({ where: { isTest: true } })
if (existing) {
const existingProgram = await prisma.program.findFirst({ where: { isTest: true } })
if (existingProgram) {
await tearDownTestEnvironment(prisma, existingProgram.id)
}
}
// 1. Email redirect setting
await prisma.systemSettings.upsert({
where: { key: 'test_email_redirect' },
update: { value: adminEmail },
create: { key: 'test_email_redirect', value: adminEmail, category: 'DEFAULTS' },
})
// 2. Program
const program = await prisma.program.create({
data: {
name: '[TEST] Test Environment 2026',
year: 2026,
status: 'ACTIVE',
description: 'Test environment for role impersonation and feature testing',
isTest: true,
},
})
// 3. Create test users
const createdUsers: Array<{ id: string; name: string; email: string; role: string }> = []
// Jury members
const juryUsers = await Promise.all(
TEST_JURY.map((j) =>
prisma.user.create({
data: {
name: j.name,
email: j.email,
role: 'JURY_MEMBER',
status: 'ACTIVE',
country: j.country,
expertiseTags: j.expertiseTags,
isTest: true,
mustSetPassword: false,
},
})
)
)
juryUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// Applicant owners
const ownerUsers = await Promise.all(
TEST_APPLICANTS_OWNERS.map((a) =>
prisma.user.create({
data: {
name: a.name,
email: a.email,
role: 'APPLICANT',
status: 'ACTIVE',
country: a.country,
isTest: true,
mustSetPassword: false,
},
})
)
)
ownerUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// Applicant teammates
const teammateUsers = await Promise.all(
TEST_APPLICANTS_TEAMMATES.map((a) =>
prisma.user.create({
data: {
name: a.name,
email: a.email,
role: 'APPLICANT',
status: 'ACTIVE',
country: a.country,
isTest: true,
mustSetPassword: false,
},
})
)
)
teammateUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// Other roles
const otherUsers = await Promise.all(
TEST_OTHER_ROLES.map((r) =>
prisma.user.create({
data: {
name: r.name,
email: r.email,
role: r.role,
status: 'ACTIVE',
country: r.country,
isTest: true,
mustSetPassword: false,
},
})
)
)
otherUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// 4. Create projects (one per owner)
const projects = await Promise.all(
TEST_PROJECTS.map((p, i) =>
prisma.project.create({
data: {
programId: program.id,
title: p.title,
teamName: p.teamName,
description: p.description,
country: p.country,
competitionCategory: p.category,
oceanIssue: p.oceanIssue,
status: 'SUBMITTED',
submissionSource: 'MANUAL',
submittedAt: new Date(),
submittedByUserId: ownerUsers[i].id,
isTest: true,
},
})
)
)
// 5. Add teammates to projects 0, 1, and 3
const teammateAssignments = [
{ projectIdx: 0, teammateIdx: 0 },
{ projectIdx: 0, teammateIdx: 1 },
{ projectIdx: 1, teammateIdx: 2 },
{ projectIdx: 3, teammateIdx: 3 },
]
await Promise.all(
teammateAssignments.map((ta) =>
prisma.teamMember.create({
data: {
projectId: projects[ta.projectIdx].id,
userId: teammateUsers[ta.teammateIdx].id,
role: 'MEMBER',
},
})
)
)
// Also add owners as team members (OWNER role)
await Promise.all(
projects.map((proj, i) =>
prisma.teamMember.create({
data: {
projectId: proj.id,
userId: ownerUsers[i].id,
role: 'LEAD',
},
})
)
)
// 6. Competition
const competition = await prisma.competition.create({
data: {
programId: program.id,
name: 'Test Competition 2026',
slug: `test-env-${Date.now()}`,
status: 'ACTIVE',
isTest: true,
},
})
// 7. Jury Group
const observerUser = otherUsers.find((u) => u.role === 'OBSERVER')
const juryGroup = await prisma.juryGroup.create({
data: {
competitionId: competition.id,
name: 'Test Jury Panel',
slug: 'test-jury-panel',
},
})
// Add jury members to group
await Promise.all(
juryUsers.map((ju) =>
prisma.juryGroupMember.create({
data: {
juryGroupId: juryGroup.id,
userId: ju.id,
role: 'MEMBER',
},
})
)
)
// Add observer to jury group
if (observerUser) {
await prisma.juryGroupMember.create({
data: {
juryGroupId: juryGroup.id,
userId: observerUser.id,
role: 'OBSERVER',
},
})
}
// 8. Rounds (INTAKE=ACTIVE, rest=DRAFT)
const rounds = await Promise.all(
ROUND_DEFINITIONS.map((rd) =>
prisma.round.create({
data: {
competitionId: competition.id,
name: rd.name,
slug: rd.slug,
roundType: rd.roundType,
status: rd.sortOrder === 0 ? 'ROUND_ACTIVE' : 'ROUND_DRAFT',
sortOrder: rd.sortOrder,
juryGroupId: rd.roundType === 'EVALUATION' ? juryGroup.id : undefined,
windowOpenAt: rd.sortOrder === 0 ? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) : undefined,
windowCloseAt: rd.sortOrder === 0 ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
},
})
)
)
const intakeRound = rounds[0]
const evaluationRound = rounds[2]
// 9. Evaluation form on the evaluation round
const evalForm = await prisma.evaluationForm.create({
data: {
roundId: evaluationRound.id,
version: 1,
isActive: true,
criteriaJson: [
{ id: 'innovation', label: 'Innovation & Originality', description: 'Novelty of approach and potential impact', scale: '1-10', weight: 30, required: true },
{ id: 'feasibility', label: 'Technical Feasibility', description: 'Viability of implementation and scalability', scale: '1-10', weight: 40, required: true },
{ id: 'ocean_impact', label: 'Ocean Impact Potential', description: 'Direct benefit to marine ecosystems', scale: '1-10', weight: 30, required: true },
],
scalesJson: { '1-10': { min: 1, max: 10, labels: { 1: 'Poor', 5: 'Average', 10: 'Excellent' } } },
},
})
// 10. ProjectRoundState for all projects in INTAKE round (PENDING)
await Promise.all(
projects.map((proj) =>
prisma.projectRoundState.create({
data: {
projectId: proj.id,
roundId: intakeRound.id,
state: 'PENDING',
},
})
)
)
// 11. Assignments — 5 projects per jury member in evaluation round (round-robin)
const assignmentsPerJury = 5
const assignmentData: Array<{ userId: string; projectId: string; roundId: string; juryGroupId: string }> = []
for (let j = 0; j < juryUsers.length; j++) {
for (let a = 0; a < assignmentsPerJury; a++) {
const projectIdx = (j * 2 + a) % projects.length
const key = `${juryUsers[j].id}-${projects[projectIdx].id}`
// Avoid duplicate assignments
if (!assignmentData.some((ad) => ad.userId === juryUsers[j].id && ad.projectId === projects[projectIdx].id)) {
assignmentData.push({
userId: juryUsers[j].id,
projectId: projects[projectIdx].id,
roundId: evaluationRound.id,
juryGroupId: juryGroup.id,
})
}
}
}
const assignments = await Promise.all(
assignmentData.map((ad) =>
prisma.assignment.create({
data: {
userId: ad.userId,
projectId: ad.projectId,
roundId: ad.roundId,
juryGroupId: ad.juryGroupId,
method: 'MANUAL',
isCompleted: false,
},
})
)
)
// 12. Partial evaluations — first 2 jury members score their first 3-4 assignments
const evalJurors = juryUsers.slice(0, 2)
for (const juror of evalJurors) {
const jurorAssignments = assignments.filter((a) => a.userId === juror.id).slice(0, 3 + Math.round(Math.random()))
for (const assignment of jurorAssignments) {
const innovationScore = 5 + Math.floor(Math.random() * 5) // 5-9
const feasibilityScore = 4 + Math.floor(Math.random() * 5) // 4-8
const impactScore = 5 + Math.floor(Math.random() * 4) // 5-8
const globalScore = Math.round((innovationScore * 0.3 + feasibilityScore * 0.4 + impactScore * 0.3))
await prisma.evaluation.create({
data: {
assignmentId: assignment.id,
formId: evalForm.id,
status: 'SUBMITTED',
criterionScoresJson: {
innovation: innovationScore,
feasibility: feasibilityScore,
ocean_impact: impactScore,
},
globalScore,
binaryDecision: globalScore >= 6,
feedbackText: `Strong project with notable ${innovationScore >= 7 ? 'innovation' : 'potential'}. ${feasibilityScore >= 7 ? 'Highly feasible approach.' : 'Implementation timeline needs refinement.'}`,
submittedAt: new Date(Date.now() - Math.floor(Math.random() * 3 * 24 * 60 * 60 * 1000)),
},
})
// Mark assignment as completed
await prisma.assignment.update({
where: { id: assignment.id },
data: { isCompleted: true },
})
}
}
// 13. Advancement rules linking consecutive rounds
for (let i = 0; i < rounds.length - 1; i++) {
await prisma.advancementRule.create({
data: {
roundId: rounds[i].id,
ruleType: 'ADMIN_SELECTION',
configJson: {},
},
})
}
return {
programId: program.id,
competitionId: competition.id,
users: createdUsers,
projectCount: projects.length,
roundCount: rounds.length,
}
}
/**
* Tear down the test environment — delete all test data.
* Follows reverse-dependency order to avoid FK constraint errors.
*/
export async function tearDownTestEnvironment(
prisma: PrismaClient,
programId: string
): Promise<void> {
// Verify this is actually a test program
const program = await prisma.program.findUnique({
where: { id: programId },
select: { isTest: true },
})
if (!program?.isTest) {
throw new Error('Cannot tear down a non-test program')
}
// Get all test competition IDs
const competitions = await prisma.competition.findMany({
where: { programId, isTest: true },
select: { id: true },
})
const competitionIds = competitions.map((c) => c.id)
// Get all round IDs
const rounds = await prisma.round.findMany({
where: { competitionId: { in: competitionIds } },
select: { id: true },
})
const roundIds = rounds.map((r) => r.id)
// Get all test project IDs
const projects = await prisma.project.findMany({
where: { programId, isTest: true },
select: { id: true },
})
const projectIds = projects.map((p) => p.id)
// Get all test user IDs
const users = await prisma.user.findMany({
where: { isTest: true },
select: { id: true },
})
const userIds = users.map((u) => u.id)
// Delete in reverse-dependency order
// Deliberation data
if (roundIds.length > 0) {
await prisma.deliberationVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.deliberationResult.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.deliberationParticipant.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.deliberationSession.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Decision/Audit
if (competitionIds.length > 0) {
await prisma.resultLock.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
if (roundIds.length > 0) {
await prisma.decisionAuditLog.deleteMany({
where: { entityType: 'Round', entityId: { in: roundIds } },
})
}
// Evaluations → Assignments
if (roundIds.length > 0) {
await prisma.evaluation.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
await prisma.assignmentException.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
await prisma.assignment.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Project round states
if (roundIds.length > 0) {
await prisma.projectRoundState.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Filtering
if (roundIds.length > 0) {
await prisma.filteringResult.deleteMany({ where: { roundId: { in: roundIds } } })
await prisma.filteringRule.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Evaluation forms & advancement rules
if (roundIds.length > 0) {
await prisma.evaluationForm.deleteMany({ where: { roundId: { in: roundIds } } })
await prisma.advancementRule.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Live voting
if (roundIds.length > 0) {
await prisma.liveVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.liveVotingSession.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Assignment intents and policies
if (roundIds.length > 0) {
await prisma.assignmentIntent.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Reminder logs
if (roundIds.length > 0) {
await prisma.reminderLog.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Jury groups
if (competitionIds.length > 0) {
await prisma.juryGroupMember.deleteMany({ where: { juryGroup: { competitionId: { in: competitionIds } } } })
await prisma.juryGroup.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Submission windows
if (competitionIds.length > 0) {
await prisma.roundSubmissionVisibility.deleteMany({ where: { submissionWindow: { competitionId: { in: competitionIds } } } })
await prisma.submissionWindow.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Rounds
if (competitionIds.length > 0) {
await prisma.round.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Project-related data
if (projectIds.length > 0) {
await prisma.evaluationSummary.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.evaluationDiscussion.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.awardEligibility.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.awardVote.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.projectTag.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.projectStatusHistory.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.mentorMessage.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.mentorAssignment.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.cohortProject.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.teamMember.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.projectFile.deleteMany({ where: { projectId: { in: projectIds } } })
}
// Special awards
if (competitionIds.length > 0) {
await prisma.specialAward.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Competitions
await prisma.competition.deleteMany({ where: { programId, isTest: true } })
// Projects
await prisma.project.deleteMany({ where: { programId, isTest: true } })
// Audit logs from test users
if (userIds.length > 0) {
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
await prisma.notificationLog.deleteMany({ where: { userId: { in: userIds } } })
}
// Users
await prisma.user.deleteMany({ where: { isTest: true } })
// Program
await prisma.program.deleteMany({ where: { id: programId, isTest: true } })
// Clean up email redirect setting
await prisma.systemSettings.deleteMany({ where: { key: 'test_email_redirect' } })
}

View File

@@ -109,20 +109,6 @@ export const EvaluationConfigSchema = z.object({
generateAiShortlist: z.boolean().default(false),
aiParseFiles: z.boolean().default(false),
applicantVisibility: z
.object({
enabled: z.boolean().default(false),
showGlobalScore: z.boolean().default(false),
showCriterionScores: z.boolean().default(false),
showFeedbackText: z.boolean().default(false),
})
.default({
enabled: false,
showGlobalScore: false,
showCriterionScores: false,
showFeedbackText: false,
}),
advancementMode: z
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
.default('admin_selection'),