Compare commits
8 Commits
a8b8643936
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b40fe7726 | |||
| 1c78ecf21d | |||
| 1356809cb1 | |||
| 1ebdf5f9c9 | |||
| a68ec3fb45 | |||
| 6f55fdf81f | |||
| 94cbfec70a | |||
| b85a9b9a7b |
@@ -69,5 +69,8 @@ EXPOSE 7600
|
|||||||
ENV PORT=7600
|
ENV PORT=7600
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:7600/api/health || exit 1
|
||||||
|
|
||||||
# Run via entrypoint (migrate then start)
|
# Run via entrypoint (migrate then start)
|
||||||
CMD ["/app/docker-entrypoint.sh"]
|
CMD ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}?connection_limit=10&pool_timeout=30
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||||
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
|
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
|
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc?connection_limit=10&pool_timeout=30
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
|
|||||||
@@ -59,4 +59,18 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Starting application..."
|
echo "==> Starting application..."
|
||||||
exec node server.js
|
|
||||||
|
# Graceful shutdown: forward SIGTERM/SIGINT to the Node process
|
||||||
|
# so in-flight requests can complete before the container exits.
|
||||||
|
shutdown() {
|
||||||
|
echo "==> Received shutdown signal, stopping gracefully..."
|
||||||
|
kill -TERM "$NODE_PID" 2>/dev/null
|
||||||
|
wait "$NODE_PID"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
trap shutdown TERM INT
|
||||||
|
|
||||||
|
node server.js &
|
||||||
|
NODE_PID=$!
|
||||||
|
wait "$NODE_PID"
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ const nextConfig: NextConfig = {
|
|||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||||
typescript: {
|
typescript: {
|
||||||
// We run tsc --noEmit separately before each push
|
ignoreBuildErrors: false,
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: [
|
optimizePackageImports: [
|
||||||
|
|||||||
263
package-lock.json
generated
263
package-lock.json
generated
@@ -3830,9 +3830,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -3844,9 +3844,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3858,9 +3858,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3872,9 +3872,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3886,9 +3886,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3900,9 +3900,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3914,9 +3914,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -3928,9 +3928,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -3942,9 +3942,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3956,9 +3956,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3970,9 +3970,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -3984,9 +3984,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -3998,9 +3998,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -4012,9 +4012,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -4026,9 +4026,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -4040,9 +4040,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -4054,9 +4054,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -4068,9 +4068,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4082,9 +4082,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4096,9 +4096,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4110,9 +4110,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4124,9 +4124,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4138,9 +4138,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -4152,9 +4152,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4166,9 +4166,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5426,13 +5426,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -5967,9 +5967,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7504,11 +7504,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@@ -8603,9 +8606,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "4.5.3",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
|
||||||
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
|
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8614,7 +8617,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"strnum": "^1.1.1"
|
"strnum": "^1.0.5"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
@@ -10325,12 +10328,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jspdf": {
|
"node_modules/jspdf": {
|
||||||
"version": "4.1.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
|
||||||
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
|
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.6",
|
||||||
"fast-png": "^6.2.0",
|
"fast-png": "^6.2.0",
|
||||||
"fflate": "^0.8.1"
|
"fflate": "^0.8.1"
|
||||||
},
|
},
|
||||||
@@ -10936,9 +10939,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/markdown-it": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.0",
|
"version": "14.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
@@ -11877,9 +11880,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14149,9 +14152,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14165,31 +14168,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.0",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.57.0",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.0",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.0",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.0",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.0",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.0",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.0",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.0",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.0",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.0",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.0",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.0",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.0",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.0",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.0",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.0",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.0",
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.0",
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.0",
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -15637,9 +15640,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/underscore": {
|
"node_modules/underscore": {
|
||||||
"version": "1.13.7",
|
"version": "1.13.8",
|
||||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||||
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'APPLICANT';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Assignment_roundId_isCompleted_idx" ON "Assignment"("roundId", "isCompleted");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConflictOfInterest_projectId_idx" ON "ConflictOfInterest"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConflictOfInterest_userId_hasConflict_idx" ON "ConflictOfInterest"("userId", "hasConflict");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_type_status_idx" ON "NotificationLog"("type", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectRoundState_roundId_state_idx" ON "ProjectRoundState"("roundId", "state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RankingSnapshot_roundId_createdAt_idx" ON "RankingSnapshot"("roundId", "createdAt");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AdvancementRule" DROP CONSTRAINT "AdvancementRule_roundId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_approvedById_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_assignmentId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ConflictOfInterest" DROP COLUMN "roundId";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Evaluation" DROP COLUMN "version";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" DROP COLUMN "roundId";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "AdvancementRule";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "AssignmentException";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "NotificationPolicy";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "OverrideAction";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "AdvancementRuleType";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "OverrideReasonCode";
|
||||||
@@ -11,6 +11,10 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
|
||||||
|
// ?connection_limit=10&pool_timeout=30
|
||||||
|
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
|
||||||
|
// Override in .env for production to prevent connection exhaustion.
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,13 +134,6 @@ enum PartnerType {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OverrideReasonCode {
|
|
||||||
DATA_CORRECTION
|
|
||||||
POLICY_EXCEPTION
|
|
||||||
JURY_CONFLICT
|
|
||||||
SPONSOR_DECISION
|
|
||||||
ADMIN_DISCRETION
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE ENUMS
|
// COMPETITION / ROUND ENGINE ENUMS
|
||||||
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
|
|||||||
WITHDRAWN
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdvancementRuleType {
|
|
||||||
AUTO_ADVANCE
|
|
||||||
SCORE_THRESHOLD
|
|
||||||
TOP_N
|
|
||||||
ADMIN_SELECTION
|
|
||||||
AI_RECOMMENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CapMode {
|
enum CapMode {
|
||||||
HARD
|
HARD
|
||||||
@@ -427,7 +417,6 @@ model User {
|
|||||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||||
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
|
|
||||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||||
|
|
||||||
@@ -559,7 +548,6 @@ model EvaluationForm {
|
|||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String
|
programId String
|
||||||
roundId String?
|
|
||||||
status ProjectStatus @default(SUBMITTED)
|
status ProjectStatus @default(SUBMITTED)
|
||||||
|
|
||||||
// Core fields
|
// Core fields
|
||||||
@@ -759,7 +747,6 @@ model Assignment {
|
|||||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
evaluation Evaluation?
|
evaluation Evaluation?
|
||||||
conflictOfInterest ConflictOfInterest?
|
conflictOfInterest ConflictOfInterest?
|
||||||
exceptions AssignmentException[]
|
|
||||||
|
|
||||||
@@unique([userId, projectId, roundId])
|
@@unique([userId, projectId, roundId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@ -768,6 +755,7 @@ model Assignment {
|
|||||||
@@index([isCompleted])
|
@@index([isCompleted])
|
||||||
@@index([projectId, userId])
|
@@index([projectId, userId])
|
||||||
@@index([juryGroupId])
|
@@index([juryGroupId])
|
||||||
|
@@index([roundId, isCompleted])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Evaluation {
|
model Evaluation {
|
||||||
@@ -785,11 +773,6 @@ model Evaluation {
|
|||||||
binaryDecision Boolean? // Yes/No for semi-finalist
|
binaryDecision Boolean? // Yes/No for semi-finalist
|
||||||
feedbackText String? @db.Text
|
feedbackText String? @db.Text
|
||||||
|
|
||||||
// Versioning (currently unused - evaluations are updated in-place.
|
|
||||||
// TODO: Implement proper versioning by creating new rows on re-submission
|
|
||||||
// if version history is needed for audit purposes)
|
|
||||||
version Int @default(1)
|
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -964,6 +947,7 @@ model NotificationLog {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([batchId])
|
@@index([batchId])
|
||||||
@@index([email])
|
@@index([email])
|
||||||
|
@@index([type, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1494,6 +1478,7 @@ model RankingSnapshot {
|
|||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([triggeredById])
|
@@index([triggeredById])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([roundId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracks progress of long-running AI tagging jobs
|
// Tracks progress of long-running AI tagging jobs
|
||||||
@@ -1722,7 +1707,6 @@ model ConflictOfInterest {
|
|||||||
assignmentId String @unique
|
assignmentId String @unique
|
||||||
userId String
|
userId String
|
||||||
projectId String
|
projectId String
|
||||||
roundId String? // Legacy — kept for historical data
|
|
||||||
hasConflict Boolean @default(false)
|
hasConflict Boolean @default(false)
|
||||||
conflictType String? // "financial", "personal", "organizational", "other"
|
conflictType String? // "financial", "personal", "organizational", "other"
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
@@ -1740,6 +1724,8 @@ model ConflictOfInterest {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([hasConflict])
|
@@index([hasConflict])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([userId, hasConflict])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2102,24 +2088,6 @@ model LiveProgressCursor {
|
|||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model OverrideAction {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
entityType String // ProjectRoundState, FilteringResult, AwardEligibility, etc.
|
|
||||||
entityId String
|
|
||||||
previousValue Json? @db.JsonB
|
|
||||||
newValueJson Json @db.JsonB
|
|
||||||
reasonCode OverrideReasonCode
|
|
||||||
reasonText String? @db.Text
|
|
||||||
actorId String
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([entityType, entityId])
|
|
||||||
@@index([actorId])
|
|
||||||
@@index([reasonCode])
|
|
||||||
@@index([createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DecisionAuditLog {
|
model DecisionAuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
||||||
@@ -2137,21 +2105,6 @@ model DecisionAuditLog {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NotificationPolicy {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
eventType String @unique // stage.transitioned, filtering.completed, etc.
|
|
||||||
channel String @default("EMAIL") // EMAIL, IN_APP, BOTH, NONE
|
|
||||||
templateId String? // Optional reference to MessageTemplate
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
configJson Json? @db.JsonB // Additional config (delay, batch, etc.)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([eventType])
|
|
||||||
@@index([isActive])
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
|
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2227,7 +2180,6 @@ model Round {
|
|||||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||||
projectRoundStates ProjectRoundState[]
|
projectRoundStates ProjectRoundState[]
|
||||||
advancementRules AdvancementRule[]
|
|
||||||
visibleSubmissionWindows RoundSubmissionVisibility[]
|
visibleSubmissionWindows RoundSubmissionVisibility[]
|
||||||
assignmentIntents AssignmentIntent[]
|
assignmentIntents AssignmentIntent[]
|
||||||
deliberationSessions DeliberationSession[]
|
deliberationSessions DeliberationSession[]
|
||||||
@@ -2283,24 +2235,7 @@ model ProjectRoundState {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([state])
|
@@index([state])
|
||||||
}
|
@@index([roundId, state])
|
||||||
|
|
||||||
model AdvancementRule {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
roundId String
|
|
||||||
targetRoundId String?
|
|
||||||
ruleType AdvancementRuleType
|
|
||||||
configJson Json @db.JsonB
|
|
||||||
isDefault Boolean @default(true)
|
|
||||||
sortOrder Int @default(0)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([roundId, sortOrder])
|
|
||||||
@@index([roundId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2479,22 +2414,6 @@ model AssignmentIntent {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AssignmentException {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
assignmentId String
|
|
||||||
reason String @db.Text
|
|
||||||
overCapBy Int
|
|
||||||
approvedById String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
|
||||||
approvedBy User @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])
|
|
||||||
|
|
||||||
@@index([assignmentId])
|
|
||||||
@@index([approvedById])
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MENTORING WORKSPACE MODELS (NEW)
|
// MENTORING WORKSPACE MODELS (NEW)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
RoundStatus,
|
RoundStatus,
|
||||||
CapMode,
|
CapMode,
|
||||||
JuryGroupMemberRole,
|
JuryGroupMemberRole,
|
||||||
AdvancementRuleType,
|
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
|
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
|
||||||
@@ -858,24 +857,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
||||||
|
|
||||||
// --- Advancement Rules (auto-advance between rounds) ---
|
|
||||||
for (let i = 0; i < rounds.length - 1; i++) {
|
|
||||||
await prisma.advancementRule.upsert({
|
|
||||||
where: {
|
|
||||||
roundId_sortOrder: { roundId: rounds[i].id, sortOrder: 0 },
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
roundId: rounds[i].id,
|
|
||||||
ruleType: AdvancementRuleType.AUTO_ADVANCE,
|
|
||||||
sortOrder: 0,
|
|
||||||
targetRoundId: rounds[i + 1].id,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
console.log(` ✓ ${rounds.length - 1} advancement rules created`)
|
|
||||||
|
|
||||||
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
|
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
|
||||||
const intakeRound = rounds[0]
|
const intakeRound = rounds[0]
|
||||||
const allProjects = await prisma.project.findMany({
|
const allProjects = await prisma.project.findMany({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|||||||
@@ -210,11 +210,16 @@ export default function WebhooksPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredHeaders = formData.headers.filter((h) => h.key)
|
||||||
|
const headersRecord = filteredHeaders.length > 0
|
||||||
|
? Object.fromEntries(filteredHeaders.map((h) => [h.key, h.value]))
|
||||||
|
: undefined
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
url: formData.url,
|
url: formData.url,
|
||||||
events: formData.events,
|
events: formData.events,
|
||||||
headers: formData.headers.filter((h) => h.key) as Record<string, string>[] | undefined,
|
headers: headersRecord,
|
||||||
maxRetries: formData.maxRetries,
|
maxRetries: formData.maxRetries,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,10 +94,10 @@ export default function ProfileSettingsPage() {
|
|||||||
setExpertiseTags(user.expertiseTags || [])
|
setExpertiseTags(user.expertiseTags || [])
|
||||||
setDigestFrequency(user.digestFrequency || 'none')
|
setDigestFrequency(user.digestFrequency || 'none')
|
||||||
setPreferredWorkload(user.preferredWorkload ?? null)
|
setPreferredWorkload(user.preferredWorkload ?? null)
|
||||||
const avail = user.availabilityJson as { startDate?: string; endDate?: string } | null
|
const avail = user.availabilityJson as Array<{ start?: string; end?: string }> | null
|
||||||
if (avail) {
|
if (avail && avail.length > 0) {
|
||||||
setAvailabilityStart(avail.startDate || '')
|
setAvailabilityStart(avail[0].start || '')
|
||||||
setAvailabilityEnd(avail.endDate || '')
|
setAvailabilityEnd(avail[0].end || '')
|
||||||
}
|
}
|
||||||
setProfileLoaded(true)
|
setProfileLoaded(true)
|
||||||
}
|
}
|
||||||
@@ -114,10 +114,10 @@ export default function ProfileSettingsPage() {
|
|||||||
expertiseTags,
|
expertiseTags,
|
||||||
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
|
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
|
||||||
preferredWorkload: preferredWorkload ?? undefined,
|
preferredWorkload: preferredWorkload ?? undefined,
|
||||||
availabilityJson: (availabilityStart || availabilityEnd) ? {
|
availabilityJson: (availabilityStart || availabilityEnd) ? [{
|
||||||
startDate: availabilityStart || undefined,
|
start: availabilityStart || '',
|
||||||
endDate: availabilityEnd || undefined,
|
end: availabilityEnd || '',
|
||||||
} : undefined,
|
}] : undefined,
|
||||||
})
|
})
|
||||||
toast.success('Profile updated successfully')
|
toast.success('Profile updated successfully')
|
||||||
refetch()
|
refetch()
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { checkRateLimit } from '@/lib/rate-limit'
|
|||||||
const AUTH_RATE_LIMIT = 10 // requests per window
|
const AUTH_RATE_LIMIT = 10 // requests per window
|
||||||
const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||||
|
|
||||||
|
const CSRF_RATE_LIMIT = 20 // requests per window
|
||||||
|
const CSRF_RATE_WINDOW_MS = 15 * 60 * 1000 // 15 minutes
|
||||||
|
|
||||||
function getClientIp(req: Request): string {
|
function getClientIp(req: Request): string {
|
||||||
return (
|
return (
|
||||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||||
@@ -12,15 +15,35 @@ function getClientIp(req: Request): string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function withRateLimit(handler: (req: Request) => Promise<Response>) {
|
function withPostRateLimit(handler: (req: Request) => Promise<Response>) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
// Only rate limit POST requests (sign-in, magic link sends)
|
const ip = getClientIp(req)
|
||||||
if (req.method === 'POST') {
|
const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withGetRateLimit(handler: (req: Request) => Promise<Response>) {
|
||||||
|
return async (req: Request) => {
|
||||||
|
// Rate-limit the CSRF token endpoint to prevent token farming
|
||||||
|
const url = new URL(req.url)
|
||||||
|
if (url.pathname.endsWith('/csrf')) {
|
||||||
const ip = getClientIp(req)
|
const ip = getClientIp(req)
|
||||||
const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS)
|
const { success, resetAt } = checkRateLimit(`csrf:${ip}`, CSRF_RATE_LIMIT, CSRF_RATE_WINDOW_MS)
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
|
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -34,5 +57,5 @@ function withRateLimit(handler: (req: Request) => Promise<Response>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handlers.GET
|
export const GET = withGetRateLimit(handlers.GET as (req: Request) => Promise<Response>)
|
||||||
export const POST = withRateLimit(handlers.POST as (req: Request) => Promise<Response>)
|
export const POST = withPostRateLimit(handlers.POST as (req: Request) => Promise<Response>)
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-check whether an email exists before sending a magic link.
|
* Pre-check whether an email exists before sending a magic link.
|
||||||
* This is a closed platform (no self-registration) so revealing
|
* This is a closed platform (no self-registration) so revealing
|
||||||
* email existence is acceptable and helps users who mistype.
|
* email existence is acceptable and helps users who mistype.
|
||||||
|
* Rate-limited to 10 requests per 15 minutes per IP.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||||
|
const rateResult = checkRateLimit(`check-email:${ip}`, 10, 15 * 60 * 1000)
|
||||||
|
if (!rateResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ exists: false, error: 'Too many requests' },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const { email } = await req.json()
|
const { email } = await req.json()
|
||||||
if (!email || typeof email !== 'string') {
|
if (!email || typeof email !== 'string') {
|
||||||
return NextResponse.json({ exists: false }, { status: 400 })
|
return NextResponse.json({ exists: false }, { status: 400 })
|
||||||
|
|||||||
@@ -933,12 +933,14 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
// Group files by round
|
// Group files by round
|
||||||
type FileItem = (typeof project.files)[number]
|
type FileItem = (typeof project.files)[number]
|
||||||
const roundMap = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: FileItem[] }>()
|
const roundMap = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: FileItem[] }>()
|
||||||
|
// Build roundId→round lookup from competitionRounds
|
||||||
|
const roundLookup = new Map(competitionRounds.map((r, idx) => [r.id, { name: r.name, sortOrder: idx }]))
|
||||||
for (const f of project.files) {
|
for (const f of project.files) {
|
||||||
const key = (f as any).roundId ?? '__none__'
|
const key = f.roundId ?? '__none__'
|
||||||
if (!roundMap.has(key)) {
|
if (!roundMap.has(key)) {
|
||||||
const round = (f as any).round as { id: string; name: string; sortOrder: number } | null
|
const round = f.roundId ? roundLookup.get(f.roundId) : null
|
||||||
roundMap.set(key, {
|
roundMap.set(key, {
|
||||||
roundId: round?.id ?? null,
|
roundId: f.roundId ?? null,
|
||||||
roundName: round?.name ?? 'Other Files',
|
roundName: round?.name ?? 'Other Files',
|
||||||
sortOrder: round?.sortOrder ?? 999,
|
sortOrder: round?.sortOrder ?? 999,
|
||||||
files: [],
|
files: [],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -18,15 +17,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
smtp_host: z.string().min(1, 'SMTP host is required'),
|
smtp_host: z.string().min(1, 'SMTP host is required'),
|
||||||
@@ -51,8 +41,6 @@ interface EmailSettingsFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
||||||
const [testDialogOpen, setTestDialogOpen] = useState(false)
|
|
||||||
const [testEmail, setTestEmail] = useState('')
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@@ -77,17 +65,16 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendTestEmail = trpc.settings.testEmailConnection.useMutation({
|
const verifyConnection = trpc.settings.testEmailConnection.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setTestDialogOpen(false)
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success('Test email sent successfully')
|
toast.success('SMTP connection verified successfully')
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Failed to send test email: ${result.error}`)
|
toast.error(`SMTP verification failed: ${result.error}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(`Test failed: ${error.message}`)
|
toast.error(`Verification failed: ${error.message}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -107,12 +94,8 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
|||||||
updateSettings.mutate({ settings: settingsToUpdate })
|
updateSettings.mutate({ settings: settingsToUpdate })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendTest = () => {
|
const handleVerifyConnection = () => {
|
||||||
if (!testEmail) {
|
verifyConnection.mutate()
|
||||||
toast.error('Please enter an email address')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendTestEmail.mutate({ testEmail })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -243,49 +226,24 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={testDialogOpen} onOpenChange={setTestDialogOpen}>
|
<Button
|
||||||
<DialogTrigger asChild>
|
type="button"
|
||||||
<Button type="button" variant="outline">
|
variant="outline"
|
||||||
|
onClick={handleVerifyConnection}
|
||||||
|
disabled={verifyConnection.isPending}
|
||||||
|
>
|
||||||
|
{verifyConnection.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
Send Test Email
|
Verify Connection
|
||||||
</Button>
|
</>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent>
|
</Button>
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Send Test Email</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Enter an email address to receive a test email
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="test@example.com"
|
|
||||||
value={testEmail}
|
|
||||||
onChange={(e) => setTestEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setTestDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendTest}
|
|
||||||
disabled={sendTestEmail.isPending}
|
|
||||||
>
|
|
||||||
{sendTestEmail.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Sending...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Send Test'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
171
src/lib/email.ts
171
src/lib/email.ts
@@ -107,6 +107,19 @@ const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.c
|
|||||||
// Helpers
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape user-supplied strings for safe injection into HTML email templates.
|
||||||
|
* Prevents XSS if email content is rendered in a webmail client.
|
||||||
|
*/
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the base URL for links in emails.
|
* Get the base URL for links in emails.
|
||||||
* Uses NEXTAUTH_URL with a safe production fallback.
|
* Uses NEXTAUTH_URL with a safe production fallback.
|
||||||
@@ -266,7 +279,7 @@ function ctaButton(url: string, text: string): string {
|
|||||||
* Generate styled section title
|
* Generate styled section title
|
||||||
*/
|
*/
|
||||||
function sectionTitle(text: string): string {
|
function sectionTitle(text: string): string {
|
||||||
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${text}</h2>`
|
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${escapeHtml(text)}</h2>`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,8 +318,8 @@ function statCard(label: string, value: string | number): string {
|
|||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${label}</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${escapeHtml(label)}</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${value}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${escapeHtml(String(value))}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -462,7 +475,7 @@ function getEvaluationReminderTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -470,7 +483,7 @@ function getEvaluationReminderTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${statCard('Pending Evaluations', pendingCount)}
|
${statCard('Pending Evaluations', pendingCount)}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')}
|
${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')}
|
||||||
@@ -512,18 +525,14 @@ function getAnnouncementTemplate(
|
|||||||
const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : ''
|
const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : ''
|
||||||
|
|
||||||
// Escape HTML in message but preserve line breaks
|
// Escape HTML in message but preserve line breaks
|
||||||
const formattedMessage = message
|
const formattedMessage = escapeHtml(message).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
|
|
||||||
// Title card with success styling
|
// Title card with success styling
|
||||||
const titleCard = `
|
const titleCard = `
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<td style="background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${title}</h3>
|
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(title)}</h3>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -567,7 +576,7 @@ function getJuryInvitationTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
|
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
|
||||||
${ctaButton(url, 'Accept Invitation')}
|
${ctaButton(url, 'Accept Invitation')}
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
||||||
@@ -608,13 +617,13 @@ function getApplicationConfirmationTemplate(
|
|||||||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
const customMessageHtml = customMessage
|
const customMessageHtml = customMessage
|
||||||
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${customMessage.replace(/\n/g, '<br>')}</div>`
|
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${escapeHtml(customMessage).replace(/\n/g, '<br>')}</div>`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>!`)}
|
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>!`)}
|
||||||
${infoBox(`Your project "<strong>${projectName}</strong>" has been successfully received.`, 'success')}
|
${infoBox(`Your project "<strong>${escapeHtml(projectName)}</strong>" has been successfully received.`, 'success')}
|
||||||
${customMessageHtml}
|
${customMessageHtml}
|
||||||
${paragraph('Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.')}
|
${paragraph('Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.')}
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
||||||
@@ -656,7 +665,7 @@ function getTeamMemberInviteTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`<strong>${teamLeadName}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${projectName}</strong>" on the Monaco Ocean Protection Challenge platform.`)}
|
${paragraph(`<strong>${escapeHtml(teamLeadName)}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${escapeHtml(projectName)}</strong>" on the Monaco Ocean Protection Challenge platform.`)}
|
||||||
${paragraph('Click the button below to accept the invitation and set up your account.')}
|
${paragraph('Click the button below to accept the invitation and set up your account.')}
|
||||||
${ctaButton(inviteUrl, 'Accept Invitation')}
|
${ctaButton(inviteUrl, 'Accept Invitation')}
|
||||||
${infoBox('This invitation link will expire in 30 days.', 'info')}
|
${infoBox('This invitation link will expire in 30 days.', 'info')}
|
||||||
@@ -729,9 +738,9 @@ function getAdvancedSemifinalTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected to advance to the semi-finals of ${programName}.`)}
|
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected to advance to the semi-finals of ${escapeHtml(programName)}.`)}
|
||||||
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
|
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
|
||||||
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${nextSteps}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
|
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -778,9 +787,9 @@ function getAdvancedFinalTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as a <strong>Finalist</strong> in ${programName}.`)}
|
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as a <strong>Finalist</strong> in ${escapeHtml(programName)}.`)}
|
||||||
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
|
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
|
||||||
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${nextSteps}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
|
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -818,8 +827,8 @@ function getMentorAssignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${mentorName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(mentorName)}</p>
|
||||||
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${mentorBio}</p>` : ''}
|
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(mentorBio)}</p>` : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -827,7 +836,7 @@ function getMentorAssignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong>.`)}
|
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong>.`)}
|
||||||
${mentorCard}
|
${mentorCard}
|
||||||
${paragraph('Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.')}
|
${paragraph('Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.')}
|
||||||
${infoBox('Mentorship is a valuable opportunity - make the most of their expertise!', 'info')}
|
${infoBox('Mentorship is a valuable opportunity - make the most of their expertise!', 'info')}
|
||||||
@@ -867,11 +876,11 @@ function getNotSelectedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for participating in ${roundName} with your project <strong>"${projectName}"</strong>.`)}
|
${paragraph(`Thank you for participating in ${escapeHtml(roundName)} with your project <strong>"${escapeHtml(projectName)}"</strong>.`)}
|
||||||
${paragraph('After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.')}
|
${paragraph('After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.')}
|
||||||
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
|
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
|
||||||
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
|
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
|
||||||
${paragraph(encouragement || 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
|
${paragraph(encouragement ? escapeHtml(encouragement) : 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
||||||
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||||||
</p>
|
</p>
|
||||||
@@ -919,7 +928,7 @@ function getWinnerAnnouncementTemplate(
|
|||||||
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
|
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
|
||||||
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
||||||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
|
||||||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${awardName}</h2>
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(awardName)}</h2>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -928,9 +937,9 @@ function getWinnerAnnouncementTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${trophyBanner}
|
${trophyBanner}
|
||||||
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as the winner of the <strong>${awardName}</strong>!`)}
|
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as the winner of the <strong>${escapeHtml(awardName)}</strong>!`)}
|
||||||
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
|
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
|
||||||
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${prizeDetails}`) : ''}
|
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${escapeHtml(prizeDetails)}`) : ''}
|
||||||
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
|
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -972,7 +981,7 @@ function getAssignedToProjectTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Assignment</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Assignment</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -983,7 +992,7 @@ function getAssignedToProjectTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -991,7 +1000,7 @@ function getAssignedToProjectTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${projectCard}
|
${projectCard}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
|
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
|
||||||
@@ -1037,7 +1046,7 @@ function getCOIReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Reassigned Project</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Reassigned Project</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1048,7 +1057,7 @@ function getCOIReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1056,7 +1065,7 @@ function getCOIReassignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>, because the previously assigned juror declared a conflict of interest.`)}
|
${paragraph(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>, because the previously assigned juror declared a conflict of interest.`)}
|
||||||
${projectCard}
|
${projectCard}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph('Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.')}
|
${paragraph('Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.')}
|
||||||
@@ -1104,7 +1113,7 @@ function getManualReassignedTemplate(
|
|||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1115,7 +1124,7 @@ function getManualReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1123,7 +1132,7 @@ function getManualReassignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${projectList}
|
${projectList}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph(`Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
${paragraph(`Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
||||||
@@ -1174,7 +1183,7 @@ function getDropoutReassignedTemplate(
|
|||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1185,7 +1194,7 @@ function getDropoutReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1193,10 +1202,10 @@ function getDropoutReassignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${projectList}
|
${projectList}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${escapeHtml(droppedJurorName)}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
||||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -1241,7 +1250,7 @@ function getBatchAssignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1249,7 +1258,7 @@ function getBatchAssignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${statCard('Projects Assigned', projectCount)}
|
${statCard('Projects Assigned', projectCount)}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph('Please review each project and submit your evaluations before the deadline. Your expert assessment is crucial to identifying the most promising ocean protection initiatives.')}
|
${paragraph('Please review each project and submit your evaluations before the deadline. Your expert assessment is crucial to identifying the most promising ocean protection initiatives.')}
|
||||||
@@ -1294,7 +1303,7 @@ function getRoundNowOpenTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background: linear-gradient(135deg, ${BRAND.teal} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
<td style="background: linear-gradient(135deg, ${BRAND.teal} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||||||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Evaluation Round</p>
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Evaluation Round</p>
|
||||||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${roundName} is Now Open</h2>
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(roundName)} is Now Open</h2>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1305,7 +1314,7 @@ function getRoundNowOpenTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1367,9 +1376,9 @@ function getReminder24HTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${urgentBox}
|
${urgentBox}
|
||||||
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 24 hours.`)}
|
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 24 hours.`)}
|
||||||
${statCard('Pending Evaluations', pendingCount)}
|
${statCard('Pending Evaluations', pendingCount)}
|
||||||
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(deadline)}`, 'warning')}
|
||||||
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
||||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||||||
`
|
`
|
||||||
@@ -1421,9 +1430,9 @@ function getReminder3DaysTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${urgentBox}
|
${urgentBox}
|
||||||
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
|
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 3 days.`)}
|
||||||
${statCard('Pending Evaluations', pendingCount)}
|
${statCard('Pending Evaluations', pendingCount)}
|
||||||
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(deadline)}`, 'warning')}
|
||||||
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
||||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||||||
`
|
`
|
||||||
@@ -1476,7 +1485,7 @@ function getReminder1HTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${urgentBanner}
|
${urgentBanner}
|
||||||
${paragraph(`<strong style="color: ${BRAND.red};">${roundName}</strong> closes in <strong>1 hour</strong>.`)}
|
${paragraph(`<strong style="color: ${BRAND.red};">${escapeHtml(roundName)}</strong> closes in <strong>1 hour</strong>.`)}
|
||||||
${statCard('Evaluations Still Pending', pendingCount)}
|
${statCard('Evaluations Still Pending', pendingCount)}
|
||||||
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
|
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
|
||||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
|
||||||
@@ -1521,7 +1530,7 @@ function getAwardVotingOpenTemplate(
|
|||||||
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||||||
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">🏆</p>
|
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">🏆</p>
|
||||||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
|
||||||
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${awardName}</h2>
|
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${escapeHtml(awardName)}</h2>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1530,9 +1539,9 @@ function getAwardVotingOpenTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${awardBanner}
|
${awardBanner}
|
||||||
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${awardName}</strong>.`)}
|
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`)}
|
||||||
${statCard('Finalists', finalistCount)}
|
${statCard('Finalists', finalistCount)}
|
||||||
${deadline ? infoBox(`<strong>Voting closes:</strong> ${deadline}`, 'warning') : ''}
|
${deadline ? infoBox(`<strong>Voting closes:</strong> ${escapeHtml(deadline)}`, 'warning') : ''}
|
||||||
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
|
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
|
||||||
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
|
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
|
||||||
`
|
`
|
||||||
@@ -1576,9 +1585,9 @@ function getMenteeAssignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
||||||
<strong>Team Lead:</strong> ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
|
<strong>Team Lead:</strong> ${escapeHtml(teamLeadName)}${teamLeadEmail ? ` (${escapeHtml(teamLeadEmail)})` : ''}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1630,9 +1639,9 @@ function getMenteeAdvancedTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${infoBox('Great news about your mentee!', 'success')}
|
${infoBox('Great news about your mentee!', 'success')}
|
||||||
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has advanced to the next stage!`)}
|
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has advanced to the next stage!`)}
|
||||||
${statCard('Advanced From', roundName)}
|
${statCard('Advanced From', roundName)}
|
||||||
${nextRoundName ? paragraph(`They will now compete in <strong>${nextRoundName}</strong>.`) : ''}
|
${nextRoundName ? paragraph(`They will now compete in <strong>${escapeHtml(nextRoundName)}</strong>.`) : ''}
|
||||||
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
|
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -1680,7 +1689,7 @@ function getMenteeWonTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${trophyBanner}
|
${trophyBanner}
|
||||||
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has won the <strong>${awardName}</strong>!`)}
|
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has won the <strong>${escapeHtml(awardName)}</strong>!`)}
|
||||||
${infoBox('Your mentorship played a vital role in their success.', 'success')}
|
${infoBox('Your mentorship played a vital role in their success.', 'success')}
|
||||||
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
|
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
|
||||||
`
|
`
|
||||||
@@ -1719,9 +1728,9 @@ function getNewApplicationTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Application</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Application</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
||||||
<strong>Applicant:</strong> ${applicantName} (${applicantEmail})
|
<strong>Applicant:</strong> ${escapeHtml(applicantName)} (${escapeHtml(applicantEmail)})
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1730,7 +1739,7 @@ function getNewApplicationTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle('New Application Received')}
|
${sectionTitle('New Application Received')}
|
||||||
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>.`)}
|
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>.`)}
|
||||||
${applicationCard}
|
${applicationCard}
|
||||||
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
|
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
|
||||||
`
|
`
|
||||||
@@ -1770,11 +1779,7 @@ export function getAdvancementNotificationTemplate(
|
|||||||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
const escapedMessage = customMessage
|
||||||
? customMessage
|
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Full custom body mode: only the custom message inside the branded wrapper
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
@@ -1807,8 +1812,8 @@ export function getAdvancementNotificationTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
${infoBox(`<strong>"${projectName}"</strong>`, 'success')}
|
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong>`, 'success')}
|
||||||
${infoBox(`Advanced from <strong>${fromRoundName}</strong> to <strong>${toRoundName}</strong>`, 'info')}
|
${infoBox(`Advanced from <strong>${escapeHtml(fromRoundName)}</strong> to <strong>${escapeHtml(toRoundName)}</strong>`, 'info')}
|
||||||
${
|
${
|
||||||
escapedMessage
|
escapedMessage
|
||||||
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
||||||
@@ -1857,11 +1862,7 @@ export function getRejectionNotificationTemplate(
|
|||||||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
const escapedMessage = customMessage
|
||||||
? customMessage
|
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Full custom body mode: only the custom message inside the branded wrapper
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
@@ -1882,7 +1883,7 @@ export function getRejectionNotificationTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
${paragraph(`Thank you for your participation in <strong>${escapeHtml(roundName)}</strong> with your project <strong>"${escapeHtml(projectName)}"</strong>.`)}
|
||||||
${infoBox('After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.', 'info')}
|
${infoBox('After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.', 'info')}
|
||||||
${
|
${
|
||||||
escapedMessage
|
escapedMessage
|
||||||
@@ -1942,17 +1943,13 @@ export function getAwardSelectionNotificationTemplate(
|
|||||||
`
|
`
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
const escapedMessage = customMessage
|
||||||
? customMessage
|
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${announcementBanner}
|
${announcementBanner}
|
||||||
${infoBox(`<strong>"${projectName}"</strong> has been shortlisted for consideration for the <strong>${awardName}</strong>.`, 'info')}
|
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong> has been shortlisted for consideration for the <strong>${escapeHtml(awardName)}</strong>.`, 'info')}
|
||||||
${paragraph('This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.')}
|
${paragraph('This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.')}
|
||||||
${
|
${
|
||||||
escapedMessage
|
escapedMessage
|
||||||
@@ -1994,11 +1991,7 @@ Together for a healthier ocean.
|
|||||||
* Generate a preview HTML wrapper for admin email previews
|
* Generate a preview HTML wrapper for admin email previews
|
||||||
*/
|
*/
|
||||||
export function getEmailPreviewHtml(subject: string, body: string): string {
|
export function getEmailPreviewHtml(subject: string, body: string): string {
|
||||||
const formattedBody = body
|
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(subject)}
|
${sectionTitle(subject)}
|
||||||
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
||||||
@@ -2021,7 +2014,7 @@ export function getAccountReminderTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Your project <strong>"${projectName}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
|
${paragraph(`Your project <strong>"${escapeHtml(projectName)}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
|
||||||
${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')}
|
${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')}
|
||||||
${ctaButton(accountUrl, 'Set Up Your Account')}
|
${ctaButton(accountUrl, 'Set Up Your Account')}
|
||||||
${paragraph('If you have any questions, please contact the MOPC team.')}
|
${paragraph('If you have any questions, please contact the MOPC team.')}
|
||||||
@@ -2454,11 +2447,7 @@ function getNotificationEmailTemplate(
|
|||||||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
// Format body text preserving line breaks
|
// Format body text preserving line breaks
|
||||||
const formattedBody = body
|
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature flag keys — used to control progressive rollout of new architecture.
|
|
||||||
* Stored as SystemSetting records with category FEATURE_FLAGS.
|
|
||||||
*/
|
|
||||||
export const FEATURE_FLAGS = {
|
|
||||||
/** Use Competition/Round model instead of Pipeline/Track/Stage */
|
|
||||||
USE_COMPETITION_MODEL: 'feature.useCompetitionModel',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a feature flag is enabled (server-side).
|
|
||||||
* Returns false if the flag doesn't exist in the database.
|
|
||||||
*/
|
|
||||||
export async function isFeatureEnabled(flag: FeatureFlagKey): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const setting = await prisma.systemSettings.findUnique({
|
|
||||||
where: { key: flag },
|
|
||||||
})
|
|
||||||
// Default to true for competition model (legacy Pipeline system removed)
|
|
||||||
if (!setting) return flag === FEATURE_FLAGS.USE_COMPETITION_MODEL ? true : false
|
|
||||||
return setting.value === 'true'
|
|
||||||
} catch {
|
|
||||||
return flag === FEATURE_FLAGS.USE_COMPETITION_MODEL ? true : false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a feature flag value (server-side, admin only).
|
|
||||||
*/
|
|
||||||
export async function setFeatureFlag(
|
|
||||||
flag: FeatureFlagKey,
|
|
||||||
enabled: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
await prisma.systemSettings.upsert({
|
|
||||||
where: { key: flag },
|
|
||||||
update: { value: String(enabled) },
|
|
||||||
create: {
|
|
||||||
key: flag,
|
|
||||||
value: String(enabled),
|
|
||||||
type: 'BOOLEAN',
|
|
||||||
category: 'FEATURE_FLAGS',
|
|
||||||
description: `Feature flag: ${flag}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export type FileTypeCategory = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
mimeTypes: string[]
|
|
||||||
extensions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
|
|
||||||
{ id: 'pdf', label: 'PDF', mimeTypes: ['application/pdf'], extensions: ['.pdf'] },
|
|
||||||
{ id: 'word', label: 'Word', mimeTypes: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], extensions: ['.doc', '.docx'] },
|
|
||||||
{ id: 'powerpoint', label: 'PowerPoint', mimeTypes: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], extensions: ['.ppt', '.pptx'] },
|
|
||||||
{ id: 'excel', label: 'Excel', mimeTypes: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], extensions: ['.xls', '.xlsx'] },
|
|
||||||
{ id: 'images', label: 'Images', mimeTypes: ['image/*'], extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'] },
|
|
||||||
{ id: 'videos', label: 'Videos', mimeTypes: ['video/*'], extensions: ['.mp4', '.mov', '.avi', '.webm'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Get active category IDs from a list of mime types */
|
|
||||||
export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] {
|
|
||||||
if (!mimeTypes || !Array.isArray(mimeTypes)) return []
|
|
||||||
return FILE_TYPE_CATEGORIES.filter((cat) =>
|
|
||||||
cat.mimeTypes.some((mime) => mimeTypes.includes(mime))
|
|
||||||
).map((cat) => cat.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert category IDs to flat mime type array */
|
|
||||||
export function categoriesToMimeTypes(categoryIds: string[]): string[] {
|
|
||||||
return FILE_TYPE_CATEGORIES.filter((cat) => categoryIds.includes(cat.id)).flatMap(
|
|
||||||
(cat) => cat.mimeTypes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -36,10 +36,7 @@ import { projectPoolRouter } from './project-pool'
|
|||||||
import { wizardTemplateRouter } from './wizard-template'
|
import { wizardTemplateRouter } from './wizard-template'
|
||||||
import { dashboardRouter } from './dashboard'
|
import { dashboardRouter } from './dashboard'
|
||||||
// Legacy round routers (kept)
|
// Legacy round routers (kept)
|
||||||
import { cohortRouter } from './cohort'
|
|
||||||
import { liveRouter } from './live'
|
import { liveRouter } from './live'
|
||||||
import { decisionRouter } from './decision'
|
|
||||||
import { awardRouter } from './award'
|
|
||||||
// Competition architecture routers (Phase 0+1)
|
// Competition architecture routers (Phase 0+1)
|
||||||
import { competitionRouter } from './competition'
|
import { competitionRouter } from './competition'
|
||||||
import { roundRouter } from './round'
|
import { roundRouter } from './round'
|
||||||
@@ -94,10 +91,7 @@ export const appRouter = router({
|
|||||||
wizardTemplate: wizardTemplateRouter,
|
wizardTemplate: wizardTemplateRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
// Legacy round routers (kept)
|
// Legacy round routers (kept)
|
||||||
cohort: cohortRouter,
|
|
||||||
live: liveRouter,
|
live: liveRouter,
|
||||||
decision: decisionRouter,
|
|
||||||
award: awardRouter,
|
|
||||||
// Competition architecture routers (Phase 0+1)
|
// Competition architecture routers (Phase 0+1)
|
||||||
competition: competitionRouter,
|
competition: competitionRouter,
|
||||||
round: roundRouter,
|
round: roundRouter,
|
||||||
|
|||||||
@@ -1379,11 +1379,10 @@ export const analyticsRouter = router({
|
|||||||
bucket: true, objectKey: true, pageCount: true, textPreview: true,
|
bucket: true, objectKey: true, pageCount: true, textPreview: true,
|
||||||
detectedLang: true, langConfidence: true, analyzedAt: true,
|
detectedLang: true, langConfidence: true, analyzedAt: true,
|
||||||
roundId: true,
|
roundId: true,
|
||||||
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
|
|
||||||
requirementId: true,
|
requirementId: true,
|
||||||
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
|
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ round: { sortOrder: 'asc' } }, { createdAt: 'asc' }],
|
orderBy: [{ createdAt: 'asc' }],
|
||||||
},
|
},
|
||||||
teamMembers: {
|
teamMembers: {
|
||||||
include: {
|
include: {
|
||||||
@@ -2259,7 +2258,7 @@ export const analyticsRouter = router({
|
|||||||
const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig
|
const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig
|
||||||
observerScoreVisibility = config.observerScoreVisibility ?? 'after_completion'
|
observerScoreVisibility = config.observerScoreVisibility ?? 'after_completion'
|
||||||
}
|
}
|
||||||
} catch { /* use default */ }
|
} catch (err) { console.error('Failed to parse LIVE_FINAL round config for observer score visibility:', err) /* use default */ }
|
||||||
|
|
||||||
const session = await ctx.prisma.liveVotingSession.findUnique({
|
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||||
where: { roundId: input.roundId },
|
where: { roundId: input.roundId },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { logAudit } from '@/server/utils/audit'
|
|||||||
import { createNotification } from '../services/in-app-notification'
|
import { createNotification } from '../services/in-app-notification'
|
||||||
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
||||||
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
||||||
import type { Prisma, RoundType } from '@prisma/client'
|
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
||||||
|
|
||||||
// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
|
// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
|
||||||
// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions,
|
// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions,
|
||||||
@@ -22,8 +22,7 @@ function generateInviteToken(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */
|
/** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
async function isProjectRejected(prisma: PrismaClient, projectId: string): Promise<boolean> {
|
||||||
async function isProjectRejected(prisma: any, projectId: string): Promise<boolean> {
|
|
||||||
const rejected = await prisma.projectRoundState.findFirst({
|
const rejected = await prisma.projectRoundState.findFirst({
|
||||||
where: { projectId, state: 'REJECTED' },
|
where: { projectId, state: 'REJECTED' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -1008,7 +1007,8 @@ export const applicantRouter = router({
|
|||||||
errorMsg: error instanceof Error ? error.message : 'Unknown error',
|
errorMsg: error instanceof Error ? error.message : 'Unknown error',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to log failed team invitation notification:', err)
|
||||||
// Never fail on notification logging
|
// Never fail on notification logging
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1043,8 @@ export const applicantRouter = router({
|
|||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to log sent team invitation notification:', err)
|
||||||
// Never fail on notification logging
|
// Never fail on notification logging
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,7 +1062,8 @@ export const applicantRouter = router({
|
|||||||
projectName: project.title,
|
projectName: project.title,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to create in-app team invitation notification:', err)
|
||||||
// Never fail invitation flow on in-app notification issues
|
// Never fail invitation flow on in-app notification issues
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -842,7 +842,8 @@ export const applicationRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for draft submission:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
473
src/server/routers/assignment/assignment-crud.ts
Normal file
473
src/server/routers/assignment/assignment-crud.ts
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { router, protectedProcedure, adminProcedure, userHasRole } from '../../trpc'
|
||||||
|
import { getUserAvatarUrl } from '../../utils/avatar-url'
|
||||||
|
import { createNotification, NotificationTypes } from '../../services/in-app-notification'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
import { buildBatchNotifications } from './shared'
|
||||||
|
|
||||||
|
export const assignmentCrudRouter = router({
|
||||||
|
listByStage: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
||||||
|
project: { select: { id: true, title: true, tags: true } },
|
||||||
|
evaluation: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
submittedAt: true,
|
||||||
|
criterionScoresJson: true,
|
||||||
|
form: { select: { criteriaJson: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List assignments for a project (admin only)
|
||||||
|
*/
|
||||||
|
listByProject: adminProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
||||||
|
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Attach avatar URLs
|
||||||
|
return Promise.all(
|
||||||
|
assignments.map(async (a) => ({
|
||||||
|
...a,
|
||||||
|
user: {
|
||||||
|
...a.user,
|
||||||
|
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get my assignments (for jury members)
|
||||||
|
*/
|
||||||
|
myAssignments: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string().optional(),
|
||||||
|
status: z.enum(['all', 'pending', 'completed']).default('all'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.roundId) {
|
||||||
|
where.roundId = input.roundId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.status === 'pending') {
|
||||||
|
where.isCompleted = false
|
||||||
|
} else if (input.status === 'completed') {
|
||||||
|
where.isCompleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.prisma.assignment.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
include: { files: true },
|
||||||
|
},
|
||||||
|
round: true,
|
||||||
|
evaluation: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assignment by ID
|
||||||
|
*/
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
||||||
|
where: { id: input.id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
project: { include: { files: true } },
|
||||||
|
round: { include: { evaluationForms: { where: { isActive: true } } } },
|
||||||
|
evaluation: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
if (
|
||||||
|
userHasRole(ctx.user, 'JURY_MEMBER') &&
|
||||||
|
!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') &&
|
||||||
|
assignment.userId !== ctx.user.id
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You do not have access to this assignment',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single assignment (admin only)
|
||||||
|
*/
|
||||||
|
create: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
roundId: z.string(),
|
||||||
|
isRequired: z.boolean().default(true),
|
||||||
|
forceOverride: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.prisma.assignment.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId_roundId: {
|
||||||
|
userId: input.userId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'This assignment already exists',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stage, user] = await Promise.all([
|
||||||
|
ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: input.userId },
|
||||||
|
select: { maxAssignments: true, name: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const maxAssignmentsPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
|
||||||
|
const currentCount = await ctx.prisma.assignment.count({
|
||||||
|
where: { userId: input.userId, roundId: input.roundId },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if at or over limit
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
if (!input.forceOverride) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `${user.name || 'Judge'} has reached their maximum limit of ${effectiveMax} projects. Use manual override to proceed.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Log the override in audit
|
||||||
|
console.log(`[Assignment] Manual override: Assigning ${user.name} beyond limit (${currentCount}/${effectiveMax})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { forceOverride: _override, ...assignmentData } = input
|
||||||
|
const assignment = await ctx.prisma.assignment.create({
|
||||||
|
data: {
|
||||||
|
...assignmentData,
|
||||||
|
method: 'MANUAL',
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
entityId: assignment.id,
|
||||||
|
detailsJson: input,
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [project, stageInfo] = await Promise.all([
|
||||||
|
ctx.prisma.project.findUnique({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: { title: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (project && stageInfo) {
|
||||||
|
const deadline = stageInfo.windowCloseAt
|
||||||
|
? new Date(stageInfo.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: input.userId,
|
||||||
|
type: NotificationTypes.ASSIGNED_TO_PROJECT,
|
||||||
|
title: 'New Project Assignment',
|
||||||
|
message: `You have been assigned to evaluate "${project.title}" for ${stageInfo.name}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignment',
|
||||||
|
metadata: {
|
||||||
|
projectName: project.title,
|
||||||
|
roundName: stageInfo.name,
|
||||||
|
deadline,
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk create assignments (admin only)
|
||||||
|
*/
|
||||||
|
bulkCreate: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
assignments: z.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Fetch per-juror maxAssignments and current counts for capacity checking
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId: input.roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
// Get stage default max
|
||||||
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true, name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// Track running counts to handle multiple assignments to the same juror in one batch
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out assignments that would exceed a juror's limit
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
const allowedAssignments = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true // unknown user, let createMany handle it
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment running count for subsequent assignments to same user
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await ctx.prisma.assignment.createMany({
|
||||||
|
data: allowedAssignments.map((a) => ({
|
||||||
|
...a,
|
||||||
|
roundId: input.roundId,
|
||||||
|
method: 'BULK',
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'BULK_CREATE',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
detailsJson: {
|
||||||
|
count: result.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send notifications to assigned jury members (grouped by user)
|
||||||
|
if (result.count > 0 && allowedAssignments.length > 0) {
|
||||||
|
// Group assignments by user to get counts
|
||||||
|
const userAssignmentCounts = allowedAssignments.reduce(
|
||||||
|
(acc, a) => {
|
||||||
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
)
|
||||||
|
|
||||||
|
const deadline = stage?.windowCloseAt
|
||||||
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
await buildBatchNotifications(userAssignmentCounts, stage?.name, deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: result.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skipped: input.assignments.length - result.count,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an assignment (admin only)
|
||||||
|
*/
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const assignment = await ctx.prisma.assignment.delete({
|
||||||
|
where: { id: input.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'DELETE',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
entityId: input.id,
|
||||||
|
detailsJson: {
|
||||||
|
userId: assignment.userId,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return assignment
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assignment statistics for a round
|
||||||
|
*/
|
||||||
|
getStats: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
|
|
||||||
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalAssignments,
|
||||||
|
completedAssignments,
|
||||||
|
assignmentsByUser,
|
||||||
|
projectCoverage,
|
||||||
|
] = await Promise.all([
|
||||||
|
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||||
|
ctx.prisma.assignment.count({
|
||||||
|
where: { roundId: input.roundId, isCompleted: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.assignment.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const projectsWithFullCoverage = projectCoverage.filter(
|
||||||
|
(p) => p._count.assignments >= requiredReviews
|
||||||
|
).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAssignments,
|
||||||
|
completedAssignments,
|
||||||
|
completionPercentage:
|
||||||
|
totalAssignments > 0
|
||||||
|
? Math.round((completedAssignments / totalAssignments) * 100)
|
||||||
|
: 0,
|
||||||
|
juryMembersAssigned: assignmentsByUser.length,
|
||||||
|
projectsWithFullCoverage,
|
||||||
|
totalProjects: projectCoverage.length,
|
||||||
|
coveragePercentage:
|
||||||
|
projectCoverage.length > 0
|
||||||
|
? Math.round(
|
||||||
|
(projectsWithFullCoverage / projectCoverage.length) * 100
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
138
src/server/routers/assignment/assignment-notifications.ts
Normal file
138
src/server/routers/assignment/assignment-notifications.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { router, adminProcedure } from '../../trpc'
|
||||||
|
import { createBulkNotifications, NotificationTypes } from '../../services/in-app-notification'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
|
export const assignmentNotificationsRouter = router({
|
||||||
|
/**
|
||||||
|
* Notify all jurors of their current assignments for a round (admin only).
|
||||||
|
* Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications).
|
||||||
|
*/
|
||||||
|
notifyJurorsOfAssignments: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all assignments grouped by user
|
||||||
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
return { sent: 0, jurorCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count assignments per user
|
||||||
|
const userCounts: Record<string, number> = {}
|
||||||
|
for (const a of assignments) {
|
||||||
|
userCounts[a.userId] = (userCounts[a.userId] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = round.windowCloseAt
|
||||||
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Create in-app notifications grouped by project count
|
||||||
|
const usersByProjectCount = new Map<number, string[]>()
|
||||||
|
for (const [userId, projectCount] of Object.entries(userCounts)) {
|
||||||
|
const existing = usersByProjectCount.get(projectCount) || []
|
||||||
|
existing.push(userId)
|
||||||
|
usersByProjectCount.set(projectCount, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSent = 0
|
||||||
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
||||||
|
if (userIds.length === 0) continue
|
||||||
|
await createBulkNotifications({
|
||||||
|
userIds,
|
||||||
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||||||
|
title: `${projectCount} Projects Assigned`,
|
||||||
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { projectCount, roundName: round.name, deadline },
|
||||||
|
})
|
||||||
|
totalSent += userIds.length
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
jurorCount: Object.keys(userCounts).length,
|
||||||
|
totalAssignments: assignments.length,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sent: totalSent, jurorCount: Object.keys(userCounts).length }
|
||||||
|
}),
|
||||||
|
|
||||||
|
notifySingleJurorOfAssignments: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), userId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId, userId: input.userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'No assignments found for this juror in this round' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCount = assignments.length
|
||||||
|
const deadline = round.windowCloseAt
|
||||||
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
await createBulkNotifications({
|
||||||
|
userIds: [input.userId],
|
||||||
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||||||
|
title: `${projectCount} Projects Assigned`,
|
||||||
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { projectCount, roundName: round.name, deadline },
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'NOTIFY_SINGLE_JUROR_OF_ASSIGNMENTS',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
targetUserId: input.userId,
|
||||||
|
assignmentCount: projectCount,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sent: 1, projectCount }
|
||||||
|
}),
|
||||||
|
})
|
||||||
1162
src/server/routers/assignment/assignment-redistribution.ts
Normal file
1162
src/server/routers/assignment/assignment-redistribution.ts
Normal file
File diff suppressed because it is too large
Load Diff
880
src/server/routers/assignment/assignment-suggestions.ts
Normal file
880
src/server/routers/assignment/assignment-suggestions.ts
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { router, adminProcedure, withAIRateLimit } from '../../trpc'
|
||||||
|
import {
|
||||||
|
generateAIAssignments,
|
||||||
|
type AssignmentProgressCallback,
|
||||||
|
} from '../../services/ai-assignment'
|
||||||
|
import { isOpenAIConfigured } from '@/lib/openai'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { notifyAdmins, NotificationTypes } from '../../services/in-app-notification'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
import { buildBatchNotifications } from './shared'
|
||||||
|
|
||||||
|
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||||
|
try {
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: 'RUNNING', startedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
configJson: true,
|
||||||
|
competitionId: true,
|
||||||
|
juryGroupId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
|
const minAssignmentsPerJuror =
|
||||||
|
(config.minLoadPerJuror as number) ??
|
||||||
|
(config.minAssignmentsPerJuror as number) ??
|
||||||
|
1
|
||||||
|
const maxAssignmentsPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// Scope jurors to jury group if the round has one assigned
|
||||||
|
let scopedJurorIds: string[] | undefined
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const groupMembers = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: round.juryGroupId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
scopedJurorIds = groupMembers.map((m) => m.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jurors = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
expertiseTags: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectRoundStates = await prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const projectIds = projectRoundStates.map((prs) => prs.projectId)
|
||||||
|
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
tags: true,
|
||||||
|
teamName: true,
|
||||||
|
projectTags: {
|
||||||
|
select: { tag: { select: { name: true } }, confidence: true },
|
||||||
|
},
|
||||||
|
_count: { select: { assignments: { where: { roundId } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enrich projects with tag confidence data for AI matching
|
||||||
|
const projectsWithConfidence = projects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
tagConfidences: p.projectTags.map((pt) => ({
|
||||||
|
name: pt.tag.name,
|
||||||
|
confidence: pt.confidence,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const existingAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Query COI records for this round to exclude conflicted juror-project pairs
|
||||||
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
|
where: {
|
||||||
|
assignment: { roundId },
|
||||||
|
hasConflict: true,
|
||||||
|
},
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const coiExclusions = new Set(
|
||||||
|
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate batch info
|
||||||
|
const BATCH_SIZE = 15
|
||||||
|
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
|
||||||
|
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { totalProjects: projects.length, totalBatches },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Progress callback
|
||||||
|
const onProgress: AssignmentProgressCallback = async (progress) => {
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
currentBatch: progress.currentBatch,
|
||||||
|
processedCount: progress.processedCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-juror limits map for jurors with personal maxAssignments
|
||||||
|
const jurorLimits: Record<string, number> = {}
|
||||||
|
for (const juror of jurors) {
|
||||||
|
if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
|
||||||
|
jurorLimits[juror.id] = juror.maxAssignments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
requiredReviewsPerProject: requiredReviews,
|
||||||
|
minAssignmentsPerJuror,
|
||||||
|
maxAssignmentsPerJuror,
|
||||||
|
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
|
||||||
|
existingAssignments: existingAssignments.map((a) => ({
|
||||||
|
jurorId: a.userId,
|
||||||
|
projectId: a.projectId,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateAIAssignments(
|
||||||
|
jurors,
|
||||||
|
projectsWithConfidence,
|
||||||
|
constraints,
|
||||||
|
userId,
|
||||||
|
roundId,
|
||||||
|
onProgress
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter out suggestions that conflict with COI declarations
|
||||||
|
const filteredSuggestions = coiExclusions.size > 0
|
||||||
|
? result.suggestions.filter((s) => !coiExclusions.has(`${s.jurorId}:${s.projectId}`))
|
||||||
|
: result.suggestions
|
||||||
|
|
||||||
|
// Enrich suggestions with names for storage
|
||||||
|
const enrichedSuggestions = filteredSuggestions.map((s) => {
|
||||||
|
const juror = jurors.find((j) => j.id === s.jurorId)
|
||||||
|
const project = projects.find((p) => p.id === s.projectId)
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
jurorName: juror?.name || juror?.email || 'Unknown',
|
||||||
|
projectTitle: project?.title || 'Unknown',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark job as completed and store suggestions
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completedAt: new Date(),
|
||||||
|
processedCount: projects.length,
|
||||||
|
suggestionsCount: filteredSuggestions.length,
|
||||||
|
suggestionsJson: enrichedSuggestions,
|
||||||
|
fallbackUsed: result.fallbackUsed ?? false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
||||||
|
title: 'AI Assignment Suggestions Ready',
|
||||||
|
message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||||
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
|
linkLabel: 'View Suggestions',
|
||||||
|
priority: 'high',
|
||||||
|
metadata: {
|
||||||
|
roundId,
|
||||||
|
jobId,
|
||||||
|
projectCount: projects.length,
|
||||||
|
suggestionsCount: filteredSuggestions.length,
|
||||||
|
fallbackUsed: result.fallbackUsed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AI Assignment Job] Error:', error)
|
||||||
|
|
||||||
|
// Mark job as failed
|
||||||
|
await prisma.assignmentJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assignmentSuggestionsRouter = router({
|
||||||
|
/**
|
||||||
|
* Get smart assignment suggestions using algorithm
|
||||||
|
*/
|
||||||
|
getSuggestions: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true, juryGroupId: true },
|
||||||
|
})
|
||||||
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
|
const minAssignmentsPerJuror =
|
||||||
|
(config.minLoadPerJuror as number) ??
|
||||||
|
(config.minAssignmentsPerJuror as number) ??
|
||||||
|
1
|
||||||
|
const maxAssignmentsPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// Extract category quotas if enabled
|
||||||
|
const categoryQuotasEnabled = config.categoryQuotasEnabled === true
|
||||||
|
const categoryQuotas = categoryQuotasEnabled
|
||||||
|
? (config.categoryQuotas as Record<string, { min: number; max: number }> | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Scope jurors to jury group if the round has one assigned
|
||||||
|
let scopedJurorIds: string[] | undefined
|
||||||
|
if (stage.juryGroupId) {
|
||||||
|
const groupMembers = await ctx.prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: stage.juryGroupId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
scopedJurorIds = groupMembers.map((m) => m.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jurors = await ctx.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
expertiseTags: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId: input.roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const projectIds = projectRoundStates.map((pss) => pss.projectId)
|
||||||
|
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
tags: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
projectTags: {
|
||||||
|
include: { tag: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
_count: { select: { assignments: { where: { roundId: input.roundId } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const assignmentSet = new Set(
|
||||||
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build per-juror category distribution for quota scoring
|
||||||
|
const jurorCategoryDistribution = new Map<string, Record<string, number>>()
|
||||||
|
if (categoryQuotas) {
|
||||||
|
const assignmentsWithCategory = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
project: { select: { competitionCategory: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (const a of assignmentsWithCategory) {
|
||||||
|
const cat = a.project.competitionCategory?.toLowerCase().trim()
|
||||||
|
if (!cat) continue
|
||||||
|
let catMap = jurorCategoryDistribution.get(a.userId)
|
||||||
|
if (!catMap) {
|
||||||
|
catMap = {}
|
||||||
|
jurorCategoryDistribution.set(a.userId, catMap)
|
||||||
|
}
|
||||||
|
catMap[cat] = (catMap[cat] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: Array<{
|
||||||
|
userId: string
|
||||||
|
jurorName: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
score: number
|
||||||
|
reasoning: string[]
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (project._count.assignments >= requiredReviews) continue
|
||||||
|
|
||||||
|
const neededAssignments = requiredReviews - project._count.assignments
|
||||||
|
|
||||||
|
const jurorScores = jurors
|
||||||
|
.filter((j) => {
|
||||||
|
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
|
||||||
|
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
if (j._count.assignments >= effectiveMax) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((juror) => {
|
||||||
|
const reasoning: string[] = []
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
const projectTagNames = project.projectTags.map((pt) => pt.tag.name.toLowerCase())
|
||||||
|
|
||||||
|
const matchingTags = projectTagNames.length > 0
|
||||||
|
? juror.expertiseTags.filter((tag) =>
|
||||||
|
projectTagNames.includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
: juror.expertiseTags.filter((tag) =>
|
||||||
|
project.tags.map((t) => t.toLowerCase()).includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalTags = projectTagNames.length > 0 ? projectTagNames.length : project.tags.length
|
||||||
|
const expertiseScore =
|
||||||
|
matchingTags.length > 0
|
||||||
|
? matchingTags.length / Math.max(totalTags, 1)
|
||||||
|
: 0
|
||||||
|
score += expertiseScore * 35
|
||||||
|
if (matchingTags.length > 0) {
|
||||||
|
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMax = juror.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
const loadScore = 1 - juror._count.assignments / effectiveMax
|
||||||
|
score += loadScore * 20
|
||||||
|
|
||||||
|
const underMinBonus =
|
||||||
|
juror._count.assignments < minAssignmentsPerJuror
|
||||||
|
? (minAssignmentsPerJuror - juror._count.assignments) * 3
|
||||||
|
: 0
|
||||||
|
score += Math.min(15, underMinBonus)
|
||||||
|
|
||||||
|
if (juror._count.assignments < minAssignmentsPerJuror) {
|
||||||
|
reasoning.push(
|
||||||
|
`Under target: ${juror._count.assignments}/${minAssignmentsPerJuror} min`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reasoning.push(
|
||||||
|
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Category quota scoring
|
||||||
|
if (categoryQuotas) {
|
||||||
|
const jurorCategoryCounts = jurorCategoryDistribution.get(juror.id) || {}
|
||||||
|
const normalizedCat = project.competitionCategory?.toLowerCase().trim()
|
||||||
|
if (normalizedCat) {
|
||||||
|
const quota = Object.entries(categoryQuotas).find(
|
||||||
|
([key]) => key.toLowerCase().trim() === normalizedCat
|
||||||
|
)
|
||||||
|
if (quota) {
|
||||||
|
const [, { min, max }] = quota
|
||||||
|
const currentCount = jurorCategoryCounts[normalizedCat] || 0
|
||||||
|
if (currentCount >= max) {
|
||||||
|
score -= 25
|
||||||
|
reasoning.push(`Category quota exceeded (-25)`)
|
||||||
|
} else if (currentCount < min) {
|
||||||
|
const otherAboveMin = Object.entries(categoryQuotas).some(([key, q]) => {
|
||||||
|
if (key.toLowerCase().trim() === normalizedCat) return false
|
||||||
|
return (jurorCategoryCounts[key.toLowerCase().trim()] || 0) >= q.min
|
||||||
|
})
|
||||||
|
if (otherAboveMin) {
|
||||||
|
score += 10
|
||||||
|
reasoning.push(`Category quota bonus (+10)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: juror.id,
|
||||||
|
jurorName: juror.name || juror.email || 'Unknown',
|
||||||
|
projectId: project.id,
|
||||||
|
projectTitle: project.title || 'Unknown',
|
||||||
|
score,
|
||||||
|
reasoning,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, neededAssignments)
|
||||||
|
|
||||||
|
suggestions.push(...jurorScores)
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.sort((a, b) => b.score - a.score)
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if AI assignment is available
|
||||||
|
*/
|
||||||
|
isAIAvailable: adminProcedure.query(async () => {
|
||||||
|
return isOpenAIConfigured()
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI-powered assignment suggestions (retrieves from completed job)
|
||||||
|
*/
|
||||||
|
getAISuggestions: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
useAI: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const completedJob = await ctx.prisma.assignmentJob.findFirst({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
},
|
||||||
|
orderBy: { completedAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
suggestionsJson: true,
|
||||||
|
fallbackUsed: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (completedJob?.suggestionsJson) {
|
||||||
|
const suggestions = completedJob.suggestionsJson as Array<{
|
||||||
|
jurorId: string
|
||||||
|
jurorName: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
confidenceScore: number
|
||||||
|
expertiseMatchScore: number
|
||||||
|
reasoning: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const assignmentSet = new Set(
|
||||||
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredSuggestions = suggestions.filter(
|
||||||
|
(s) => !assignmentSet.has(`${s.jurorId}-${s.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
suggestions: filteredSuggestions,
|
||||||
|
fallbackUsed: completedJob.fallbackUsed,
|
||||||
|
error: null,
|
||||||
|
generatedAt: completedJob.completedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
suggestions: [],
|
||||||
|
fallbackUsed: false,
|
||||||
|
error: null,
|
||||||
|
generatedAt: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply AI-suggested assignments
|
||||||
|
*/
|
||||||
|
applyAISuggestions: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
assignments: z.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
confidenceScore: z.number().optional(),
|
||||||
|
expertiseMatchScore: z.number().optional(),
|
||||||
|
reasoning: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
usedAI: z.boolean().default(false),
|
||||||
|
forceOverride: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
let assignmentsToCreate = input.assignments
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
|
||||||
|
// Capacity check (unless forceOverride)
|
||||||
|
if (!input.forceOverride) {
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId: input.roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsToCreate = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ctx.prisma.assignment.createMany({
|
||||||
|
data: assignmentsToCreate.map((a) => ({
|
||||||
|
userId: a.userId,
|
||||||
|
projectId: a.projectId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
|
||||||
|
aiConfidenceScore: a.confidenceScore,
|
||||||
|
expertiseMatchScore: a.expertiseMatchScore,
|
||||||
|
aiReasoning: a.reasoning,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
detailsJson: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
count: created.count,
|
||||||
|
usedAI: input.usedAI,
|
||||||
|
forceOverride: input.forceOverride,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||||
|
(acc, a) => {
|
||||||
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
)
|
||||||
|
|
||||||
|
const stage = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const deadline = stage?.windowCloseAt
|
||||||
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
await buildBatchNotifications(userAssignmentCounts, stage?.name, deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: created.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply suggested assignments
|
||||||
|
*/
|
||||||
|
applySuggestions: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
assignments: z.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
reasoning: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
forceOverride: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
let assignmentsToCreate = input.assignments
|
||||||
|
let skippedDueToCapacity = 0
|
||||||
|
|
||||||
|
// Capacity check (unless forceOverride)
|
||||||
|
if (!input.forceOverride) {
|
||||||
|
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assignments: { where: { roundId: input.roundId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||||
|
|
||||||
|
const stageData = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { configJson: true },
|
||||||
|
})
|
||||||
|
const config = (stageData.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const stageMaxPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
const runningCounts = new Map<string, number>()
|
||||||
|
for (const u of users) {
|
||||||
|
runningCounts.set(u.id, u._count.assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsToCreate = input.assignments.filter((a) => {
|
||||||
|
const user = userMap.get(a.userId)
|
||||||
|
if (!user) return true
|
||||||
|
|
||||||
|
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
|
||||||
|
const currentCount = runningCounts.get(a.userId) ?? 0
|
||||||
|
|
||||||
|
if (currentCount >= effectiveMax) {
|
||||||
|
skippedDueToCapacity++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runningCounts.set(a.userId, currentCount + 1)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ctx.prisma.assignment.createMany({
|
||||||
|
data: assignmentsToCreate.map((a) => ({
|
||||||
|
userId: a.userId,
|
||||||
|
projectId: a.projectId,
|
||||||
|
roundId: input.roundId,
|
||||||
|
method: 'ALGORITHM',
|
||||||
|
aiReasoning: a.reasoning,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'APPLY_SUGGESTIONS',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
detailsJson: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
count: created.count,
|
||||||
|
forceOverride: input.forceOverride,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (created.count > 0) {
|
||||||
|
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||||
|
(acc, a) => {
|
||||||
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
)
|
||||||
|
|
||||||
|
const stage = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const deadline = stage?.windowCloseAt
|
||||||
|
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
await buildBatchNotifications(userAssignmentCounts, stage?.name, deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: created.count,
|
||||||
|
requested: input.assignments.length,
|
||||||
|
skippedDueToCapacity,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an AI assignment job (background processing)
|
||||||
|
*/
|
||||||
|
startAIAssignmentJob: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existingJob = await ctx.prisma.assignmentJob.findFirst({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
status: { in: ['PENDING', 'RUNNING'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingJob) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'An AI assignment job is already running for this stage',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpenAIConfigured()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'OpenAI API is not configured',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await ctx.prisma.assignmentJob.create({
|
||||||
|
data: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
|
||||||
|
|
||||||
|
return { jobId: job.id }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI assignment job status (for polling)
|
||||||
|
*/
|
||||||
|
getAIAssignmentJobStatus: adminProcedure
|
||||||
|
.input(z.object({ jobId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({
|
||||||
|
where: { id: input.jobId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
totalProjects: job.totalProjects,
|
||||||
|
totalBatches: job.totalBatches,
|
||||||
|
currentBatch: job.currentBatch,
|
||||||
|
processedCount: job.processedCount,
|
||||||
|
suggestionsCount: job.suggestionsCount,
|
||||||
|
fallbackUsed: job.fallbackUsed,
|
||||||
|
errorMessage: job.errorMessage,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest AI assignment job for a round
|
||||||
|
*/
|
||||||
|
getLatestAIAssignmentJob: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const job = await ctx.prisma.assignmentJob.findFirst({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!job) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
totalProjects: job.totalProjects,
|
||||||
|
totalBatches: job.totalBatches,
|
||||||
|
currentBatch: job.currentBatch,
|
||||||
|
processedCount: job.processedCount,
|
||||||
|
suggestionsCount: job.suggestionsCount,
|
||||||
|
fallbackUsed: job.fallbackUsed,
|
||||||
|
errorMessage: job.errorMessage,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
15
src/server/routers/assignment/index.ts
Normal file
15
src/server/routers/assignment/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { router } from '../../trpc'
|
||||||
|
import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../../services/juror-reassignment'
|
||||||
|
import { assignmentCrudRouter } from './assignment-crud'
|
||||||
|
import { assignmentSuggestionsRouter } from './assignment-suggestions'
|
||||||
|
import { assignmentNotificationsRouter } from './assignment-notifications'
|
||||||
|
import { assignmentRedistributionRouter } from './assignment-redistribution'
|
||||||
|
|
||||||
|
export { reassignAfterCOI, reassignDroppedJurorAssignments }
|
||||||
|
|
||||||
|
export const assignmentRouter = router({
|
||||||
|
...assignmentCrudRouter._def.procedures,
|
||||||
|
...assignmentSuggestionsRouter._def.procedures,
|
||||||
|
...assignmentNotificationsRouter._def.procedures,
|
||||||
|
...assignmentRedistributionRouter._def.procedures,
|
||||||
|
})
|
||||||
93
src/server/routers/assignment/shared.ts
Normal file
93
src/server/routers/assignment/shared.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
import { createBulkNotifications, NotificationTypes } from '../../services/in-app-notification'
|
||||||
|
|
||||||
|
/** Evaluation statuses that are safe to move (not yet finalized). */
|
||||||
|
export const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups a per-user assignment count map into batches by count, then sends
|
||||||
|
* BATCH_ASSIGNED notifications via createBulkNotifications.
|
||||||
|
*
|
||||||
|
* @param userAssignmentCounts - map of userId → number of newly-assigned projects
|
||||||
|
* @param stageName - display name of the round (for the notification message)
|
||||||
|
* @param deadline - formatted deadline string (optional)
|
||||||
|
*/
|
||||||
|
export async function buildBatchNotifications(
|
||||||
|
userAssignmentCounts: Record<string, number>,
|
||||||
|
stageName: string | null | undefined,
|
||||||
|
deadline: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const usersByProjectCount = new Map<number, string[]>()
|
||||||
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
||||||
|
const existing = usersByProjectCount.get(projectCount) || []
|
||||||
|
existing.push(userId)
|
||||||
|
usersByProjectCount.set(projectCount, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
||||||
|
if (userIds.length === 0) continue
|
||||||
|
await createBulkNotifications({
|
||||||
|
userIds,
|
||||||
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||||||
|
title: `${projectCount} Projects Assigned`,
|
||||||
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stageName || 'this stage'}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: {
|
||||||
|
projectCount,
|
||||||
|
roundName: stageName,
|
||||||
|
deadline,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CandidateJuror = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
maxAssignments: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the candidate juror pool for a round, scoped to the jury group if one
|
||||||
|
* is assigned, otherwise falling back to all active JURY_MEMBER users who have
|
||||||
|
* at least one assignment in the round.
|
||||||
|
*
|
||||||
|
* @param prisma - Prisma client (or transaction client)
|
||||||
|
* @param roundId - round being processed
|
||||||
|
* @param juryGroupId - optional jury group id from the round
|
||||||
|
* @param excludeUserId - userId to exclude from results (the source / dropped juror)
|
||||||
|
*/
|
||||||
|
export async function getCandidateJurors(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
roundId: string,
|
||||||
|
juryGroupId: string | null | undefined,
|
||||||
|
excludeUserId: string,
|
||||||
|
): Promise<CandidateJuror[]> {
|
||||||
|
if (juryGroupId) {
|
||||||
|
const members = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return members
|
||||||
|
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== excludeUserId)
|
||||||
|
.map((m) => m.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== excludeUserId)
|
||||||
|
|
||||||
|
if (ids.length === 0) return []
|
||||||
|
|
||||||
|
return prisma.user.findMany({
|
||||||
|
where: { id: { in: ids }, roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' },
|
||||||
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -41,8 +41,8 @@ export const assignmentPolicyRouter = router({
|
|||||||
role: member.role,
|
role: member.role,
|
||||||
policy,
|
policy,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Member may not be linked to this round's jury group
|
console.error('[AssignmentPolicy] Failed to resolve member context:', err)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -103,8 +103,8 @@ export const assignmentPolicyRouter = router({
|
|||||||
remaining: policy.remainingCapacity,
|
remaining: policy.remainingCapacity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Skip members that can't be resolved
|
console.error('[AssignmentPolicy] Failed to evaluate policy for member:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ export const auditRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for retention config update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { router } from '../trpc'
|
|
||||||
|
|
||||||
// NOTE: All award procedures have been temporarily disabled because they depended on
|
|
||||||
// deleted models: Pipeline, Track (AWARD kind), SpecialAward linked via Track.
|
|
||||||
// This router will need complete reimplementation with the new Competition/Round/Award architecture.
|
|
||||||
// The SpecialAward model still exists and is linked directly to Competition (competitionId FK).
|
|
||||||
|
|
||||||
export const awardRouter = router({
|
|
||||||
// TODO: Reimplement award procedures with new Competition/Round architecture
|
|
||||||
// Procedures to reimplement:
|
|
||||||
// - createAwardTrack → createAward (link SpecialAward to Competition directly)
|
|
||||||
// - configureGovernance → configureAwardGovernance
|
|
||||||
// - routeProjects → setAwardEligibility
|
|
||||||
// - finalizeWinners → finalizeAwardWinner
|
|
||||||
// - getTrackProjects → getAwardProjects
|
|
||||||
})
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
import { TRPCError } from '@trpc/server'
|
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
||||||
import { logAudit } from '@/server/utils/audit'
|
|
||||||
|
|
||||||
export const cohortRouter = router({
|
|
||||||
/**
|
|
||||||
* Create a new cohort within a round
|
|
||||||
*/
|
|
||||||
create: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
roundId: z.string(),
|
|
||||||
name: z.string().min(1).max(255),
|
|
||||||
votingMode: z.enum(['simple', 'criteria', 'ranked']).default('simple'),
|
|
||||||
windowOpenAt: z.date().optional(),
|
|
||||||
windowCloseAt: z.date().optional(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
// Verify round exists
|
|
||||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
||||||
where: { id: input.roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Validate window dates
|
|
||||||
if (input.windowOpenAt && input.windowCloseAt) {
|
|
||||||
if (input.windowCloseAt <= input.windowOpenAt) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: 'Window close date must be after open date',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cohort = await ctx.prisma.cohort.create({
|
|
||||||
data: {
|
|
||||||
roundId: input.roundId,
|
|
||||||
name: input.name,
|
|
||||||
votingMode: input.votingMode,
|
|
||||||
windowOpenAt: input.windowOpenAt ?? null,
|
|
||||||
windowCloseAt: input.windowCloseAt ?? null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the create
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'CREATE',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: cohort.id,
|
|
||||||
detailsJson: {
|
|
||||||
roundId: input.roundId,
|
|
||||||
name: input.name,
|
|
||||||
votingMode: input.votingMode,
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
|
|
||||||
return cohort
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign projects to a cohort
|
|
||||||
*/
|
|
||||||
assignProjects: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
cohortId: z.string(),
|
|
||||||
projectIds: z.array(z.string()).min(1).max(200),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
// Verify cohort exists
|
|
||||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
|
||||||
where: { id: input.cohortId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (cohort.isOpen) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'Cannot modify projects while voting is open',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current max sortOrder
|
|
||||||
const maxOrder = await ctx.prisma.cohortProject.aggregate({
|
|
||||||
where: { cohortId: input.cohortId },
|
|
||||||
_max: { sortOrder: true },
|
|
||||||
})
|
|
||||||
let nextOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
|
||||||
|
|
||||||
// Create cohort project entries (skip duplicates)
|
|
||||||
const created = await ctx.prisma.cohortProject.createMany({
|
|
||||||
data: input.projectIds.map((projectId) => ({
|
|
||||||
cohortId: input.cohortId,
|
|
||||||
projectId,
|
|
||||||
sortOrder: nextOrder++,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'COHORT_PROJECTS_ASSIGNED',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: input.cohortId,
|
|
||||||
detailsJson: {
|
|
||||||
projectCount: created.count,
|
|
||||||
requested: input.projectIds.length,
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { assigned: created.count, requested: input.projectIds.length }
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open voting for a cohort
|
|
||||||
*/
|
|
||||||
openVoting: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
cohortId: z.string(),
|
|
||||||
durationMinutes: z.number().int().min(1).max(1440).optional(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
|
||||||
where: { id: input.cohortId },
|
|
||||||
include: { _count: { select: { projects: true } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (cohort.isOpen) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'CONFLICT',
|
|
||||||
message: 'Voting is already open for this cohort',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cohort._count.projects === 0) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'Cohort must have at least one project before opening voting',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const closeAt = input.durationMinutes
|
|
||||||
? new Date(now.getTime() + input.durationMinutes * 60 * 1000)
|
|
||||||
: cohort.windowCloseAt
|
|
||||||
|
|
||||||
const updated = await ctx.prisma.cohort.update({
|
|
||||||
where: { id: input.cohortId },
|
|
||||||
data: {
|
|
||||||
isOpen: true,
|
|
||||||
windowOpenAt: now,
|
|
||||||
windowCloseAt: closeAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the voting open
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'COHORT_VOTING_OPENED',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: input.cohortId,
|
|
||||||
detailsJson: {
|
|
||||||
openedAt: now.toISOString(),
|
|
||||||
closesAt: closeAt?.toISOString() ?? null,
|
|
||||||
projectCount: cohort._count.projects,
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
|
|
||||||
return updated
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close voting for a cohort
|
|
||||||
*/
|
|
||||||
closeVoting: adminProcedure
|
|
||||||
.input(z.object({ cohortId: z.string() }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
|
||||||
where: { id: input.cohortId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cohort.isOpen) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'Voting is not currently open for this cohort',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
const updated = await ctx.prisma.cohort.update({
|
|
||||||
where: { id: input.cohortId },
|
|
||||||
data: {
|
|
||||||
isOpen: false,
|
|
||||||
windowCloseAt: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the voting close
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'COHORT_VOTING_CLOSED',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: input.cohortId,
|
|
||||||
detailsJson: {
|
|
||||||
closedAt: now.toISOString(),
|
|
||||||
wasOpenSince: cohort.windowOpenAt?.toISOString(),
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
|
|
||||||
return updated
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List cohorts for a round
|
|
||||||
*/
|
|
||||||
list: protectedProcedure
|
|
||||||
.input(z.object({ roundId: z.string() }))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
return ctx.prisma.cohort.findMany({
|
|
||||||
where: { roundId: input.roundId },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
include: {
|
|
||||||
_count: { select: { projects: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cohort with projects and vote summary
|
|
||||||
*/
|
|
||||||
get: protectedProcedure
|
|
||||||
.input(z.object({ id: z.string() }))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
|
||||||
where: { id: input.id },
|
|
||||||
include: {
|
|
||||||
round: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
competition: { select: { id: true, name: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projects: {
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
teamName: true,
|
|
||||||
tags: true,
|
|
||||||
description: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get vote counts per project in the cohort's round session
|
|
||||||
const projectIds = cohort.projects.map((p) => p.projectId)
|
|
||||||
const voteSummary =
|
|
||||||
projectIds.length > 0
|
|
||||||
? await ctx.prisma.liveVote.groupBy({
|
|
||||||
by: ['projectId'],
|
|
||||||
where: {
|
|
||||||
projectId: { in: projectIds },
|
|
||||||
session: { roundId: cohort.round.id },
|
|
||||||
},
|
|
||||||
_count: true,
|
|
||||||
_avg: { score: true },
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
const voteMap = new Map(
|
|
||||||
voteSummary.map((v) => [
|
|
||||||
v.projectId,
|
|
||||||
{ voteCount: v._count, avgScore: v._avg?.score ?? 0 },
|
|
||||||
])
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...cohort,
|
|
||||||
projects: cohort.projects.map((cp) => ({
|
|
||||||
...cp,
|
|
||||||
votes: voteMap.get(cp.projectId) ?? { voteCount: 0, avgScore: 0 },
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
import { TRPCError } from '@trpc/server'
|
|
||||||
import { Prisma, FilteringOutcome, ProjectRoundStateValue } from '@prisma/client'
|
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
||||||
import { logAudit } from '@/server/utils/audit'
|
|
||||||
|
|
||||||
export const decisionRouter = router({
|
|
||||||
/**
|
|
||||||
* Override a project's stage state or filtering result
|
|
||||||
*/
|
|
||||||
override: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
entityType: z.enum([
|
|
||||||
'ProjectRoundState',
|
|
||||||
'FilteringResult',
|
|
||||||
'AwardEligibility',
|
|
||||||
]),
|
|
||||||
entityId: z.string(),
|
|
||||||
newValue: z.record(z.unknown()),
|
|
||||||
reasonCode: z.enum([
|
|
||||||
'DATA_CORRECTION',
|
|
||||||
'POLICY_EXCEPTION',
|
|
||||||
'JURY_CONFLICT',
|
|
||||||
'SPONSOR_DECISION',
|
|
||||||
'ADMIN_DISCRETION',
|
|
||||||
]),
|
|
||||||
reasonText: z.string().max(2000).optional(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
let previousValue: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
// Fetch current value based on entity type
|
|
||||||
switch (input.entityType) {
|
|
||||||
case 'ProjectRoundState': {
|
|
||||||
const prs = await ctx.prisma.projectRoundState.findUniqueOrThrow({
|
|
||||||
where: { id: input.entityId },
|
|
||||||
})
|
|
||||||
previousValue = {
|
|
||||||
state: prs.state,
|
|
||||||
metadataJson: prs.metadataJson,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the new state
|
|
||||||
const newState = input.newValue.state as string | undefined
|
|
||||||
if (
|
|
||||||
newState &&
|
|
||||||
!['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'ROUTED', 'COMPLETED', 'WITHDRAWN'].includes(newState)
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: `Invalid state: ${newState}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.prisma.$transaction(async (tx) => {
|
|
||||||
await tx.projectRoundState.update({
|
|
||||||
where: { id: input.entityId },
|
|
||||||
data: {
|
|
||||||
state: newState ? (newState as ProjectRoundStateValue) : prs.state,
|
|
||||||
metadataJson: {
|
|
||||||
...(prs.metadataJson as Record<string, unknown> ?? {}),
|
|
||||||
lastOverride: {
|
|
||||||
by: ctx.user.id,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
reason: input.reasonCode,
|
|
||||||
},
|
|
||||||
} as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await tx.overrideAction.create({
|
|
||||||
data: {
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
previousValue: previousValue as Prisma.InputJsonValue,
|
|
||||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
reasonText: input.reasonText ?? null,
|
|
||||||
actorId: ctx.user.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await tx.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'override.applied',
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
actorId: ctx.user.id,
|
|
||||||
detailsJson: {
|
|
||||||
previousValue,
|
|
||||||
newValue: input.newValue,
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
reasonText: input.reasonText,
|
|
||||||
} as Prisma.InputJsonValue,
|
|
||||||
snapshotJson: previousValue as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the override
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'DECISION_OVERRIDE',
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
detailsJson: {
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
reasonText: input.reasonText,
|
|
||||||
previousState: previousValue.state,
|
|
||||||
newState: input.newValue.state,
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'FilteringResult': {
|
|
||||||
const fr = await ctx.prisma.filteringResult.findUniqueOrThrow({
|
|
||||||
where: { id: input.entityId },
|
|
||||||
})
|
|
||||||
previousValue = {
|
|
||||||
outcome: fr.outcome,
|
|
||||||
aiScreeningJson: fr.aiScreeningJson,
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOutcome = input.newValue.outcome as string | undefined
|
|
||||||
|
|
||||||
await ctx.prisma.$transaction(async (tx) => {
|
|
||||||
if (newOutcome) {
|
|
||||||
await tx.filteringResult.update({
|
|
||||||
where: { id: input.entityId },
|
|
||||||
data: { finalOutcome: newOutcome as FilteringOutcome },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.overrideAction.create({
|
|
||||||
data: {
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
previousValue: previousValue as Prisma.InputJsonValue,
|
|
||||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
reasonText: input.reasonText ?? null,
|
|
||||||
actorId: ctx.user.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await tx.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'override.applied',
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
actorId: ctx.user.id,
|
|
||||||
detailsJson: {
|
|
||||||
previousValue,
|
|
||||||
newValue: input.newValue,
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
} as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the override
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'DECISION_OVERRIDE',
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
detailsJson: {
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
previousOutcome: (previousValue as Record<string, unknown>).outcome,
|
|
||||||
newOutcome,
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'AwardEligibility': {
|
|
||||||
const ae = await ctx.prisma.awardEligibility.findUniqueOrThrow({
|
|
||||||
where: { id: input.entityId },
|
|
||||||
})
|
|
||||||
previousValue = {
|
|
||||||
eligible: ae.eligible,
|
|
||||||
method: ae.method,
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEligible = input.newValue.eligible as boolean | undefined
|
|
||||||
|
|
||||||
await ctx.prisma.$transaction(async (tx) => {
|
|
||||||
if (newEligible !== undefined) {
|
|
||||||
await tx.awardEligibility.update({
|
|
||||||
where: { id: input.entityId },
|
|
||||||
data: {
|
|
||||||
eligible: newEligible,
|
|
||||||
method: 'MANUAL',
|
|
||||||
overriddenBy: ctx.user.id,
|
|
||||||
overriddenAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.overrideAction.create({
|
|
||||||
data: {
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
previousValue: previousValue as Prisma.InputJsonValue,
|
|
||||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
reasonText: input.reasonText ?? null,
|
|
||||||
actorId: ctx.user.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await tx.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'override.applied',
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
actorId: ctx.user.id,
|
|
||||||
detailsJson: {
|
|
||||||
previousValue,
|
|
||||||
newValue: input.newValue,
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
} as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit outside transaction so failures don't roll back the override
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'DECISION_OVERRIDE',
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
detailsJson: {
|
|
||||||
reasonCode: input.reasonCode,
|
|
||||||
previousEligible: previousValue.eligible,
|
|
||||||
newEligible,
|
|
||||||
},
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, entityType: input.entityType, entityId: input.entityId }
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the full decision audit timeline for an entity
|
|
||||||
*/
|
|
||||||
auditTimeline: protectedProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
entityType: z.string(),
|
|
||||||
entityId: z.string(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const [decisionLogs, overrideActions] = await Promise.all([
|
|
||||||
ctx.prisma.decisionAuditLog.findMany({
|
|
||||||
where: {
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
}),
|
|
||||||
ctx.prisma.overrideAction.findMany({
|
|
||||||
where: {
|
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Merge and sort by timestamp
|
|
||||||
const timeline = [
|
|
||||||
...decisionLogs.map((dl) => ({
|
|
||||||
type: 'decision' as const,
|
|
||||||
id: dl.id,
|
|
||||||
eventType: dl.eventType,
|
|
||||||
actorId: dl.actorId,
|
|
||||||
details: dl.detailsJson,
|
|
||||||
snapshot: dl.snapshotJson,
|
|
||||||
createdAt: dl.createdAt,
|
|
||||||
})),
|
|
||||||
...overrideActions.map((oa) => ({
|
|
||||||
type: 'override' as const,
|
|
||||||
id: oa.id,
|
|
||||||
eventType: `override.${oa.reasonCode}`,
|
|
||||||
actorId: oa.actorId,
|
|
||||||
details: {
|
|
||||||
previousValue: oa.previousValue,
|
|
||||||
newValue: oa.newValueJson,
|
|
||||||
reasonCode: oa.reasonCode,
|
|
||||||
reasonText: oa.reasonText,
|
|
||||||
},
|
|
||||||
snapshot: null,
|
|
||||||
createdAt: oa.createdAt,
|
|
||||||
})),
|
|
||||||
].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
||||||
|
|
||||||
return { entityType: input.entityType, entityId: input.entityId, timeline }
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get override actions (paginated, admin only)
|
|
||||||
*/
|
|
||||||
getOverrides: adminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
entityType: z.string().optional(),
|
|
||||||
reasonCode: z
|
|
||||||
.enum([
|
|
||||||
'DATA_CORRECTION',
|
|
||||||
'POLICY_EXCEPTION',
|
|
||||||
'JURY_CONFLICT',
|
|
||||||
'SPONSOR_DECISION',
|
|
||||||
'ADMIN_DISCRETION',
|
|
||||||
])
|
|
||||||
.optional(),
|
|
||||||
cursor: z.string().optional(),
|
|
||||||
limit: z.number().int().min(1).max(100).default(50),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const where: Prisma.OverrideActionWhereInput = {}
|
|
||||||
if (input.entityType) where.entityType = input.entityType
|
|
||||||
if (input.reasonCode) where.reasonCode = input.reasonCode
|
|
||||||
|
|
||||||
const items = await ctx.prisma.overrideAction.findMany({
|
|
||||||
where,
|
|
||||||
take: input.limit + 1,
|
|
||||||
cursor: input.cursor ? { id: input.cursor } : undefined,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
let nextCursor: string | undefined
|
|
||||||
if (items.length > input.limit) {
|
|
||||||
const next = items.pop()
|
|
||||||
nextCursor = next?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, nextCursor }
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@@ -118,6 +118,14 @@ export const deliberationRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Enforce that jury members can only vote as themselves
|
||||||
|
if (input.juryMemberId !== ctx.user.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'You can only submit votes as yourself',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const vote = await submitVote(input, ctx.prisma)
|
const vote = await submitVote(input, ctx.prisma)
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, protectedProcedure, adminProcedure, juryProcedure, userHasRole } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, juryProcedure, userHasRole, withAIRateLimit } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
||||||
import { reassignAfterCOI } from './assignment'
|
import { reassignAfterCOI } from '../services/juror-reassignment'
|
||||||
import { sendManualReminders } from '../services/evaluation-reminders'
|
import { sendManualReminders } from '../services/evaluation-reminders'
|
||||||
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||||
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
||||||
@@ -94,7 +94,8 @@ async function triggerAutoRankIfComplete(
|
|||||||
message: `Auto-ranking failed for round (ID: ${roundId}). Please trigger manually.`,
|
message: `Auto-ranking failed for round (ID: ${roundId}). Please trigger manually.`,
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to send AI ranking failure notification to admins:', err)
|
||||||
// Even notification failure must not propagate
|
// Even notification failure must not propagate
|
||||||
}
|
}
|
||||||
console.error('[auto-rank] triggerAutoRankIfComplete failed:', error)
|
console.error('[auto-rank] triggerAutoRankIfComplete failed:', error)
|
||||||
@@ -377,7 +378,9 @@ export const evaluationRouter = router({
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited)
|
// Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited)
|
||||||
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
|
triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id).catch((err) => {
|
||||||
|
console.error('[Evaluation] triggerAutoRankIfComplete failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
// Auto-transition: mark project IN_PROGRESS and check if all evaluations are done
|
// Auto-transition: mark project IN_PROGRESS and check if all evaluations are done
|
||||||
const projectId = evaluation.assignment.projectId
|
const projectId = evaluation.assignment.projectId
|
||||||
@@ -794,6 +797,7 @@ export const evaluationRouter = router({
|
|||||||
* Generate an AI-powered evaluation summary for a project (admin only)
|
* Generate an AI-powered evaluation summary for a project (admin only)
|
||||||
*/
|
*/
|
||||||
generateSummary: adminProcedure
|
generateSummary: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
@@ -834,6 +838,7 @@ export const evaluationRouter = router({
|
|||||||
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
|
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
|
||||||
*/
|
*/
|
||||||
generateBulkSummaries: adminProcedure
|
generateBulkSummaries: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Find all projects with at least 1 submitted evaluation in this stage
|
// Find all projects with at least 1 submitted evaluation in this stage
|
||||||
@@ -1239,7 +1244,8 @@ export const evaluationRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for discussion comment creation:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,7 +1282,8 @@ export const evaluationRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for discussion close:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -679,7 +679,8 @@ export const exportRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for round export:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -902,7 +902,9 @@ export const fileRouter = router({
|
|||||||
entityId: requirement.id,
|
entityId: requirement.id,
|
||||||
detailsJson: { name: input.name, roundId: input.roundId },
|
detailsJson: { name: input.name, roundId: input.roundId },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[File] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return requirement
|
return requirement
|
||||||
}),
|
}),
|
||||||
@@ -938,7 +940,9 @@ export const fileRouter = router({
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: data,
|
detailsJson: data,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[File] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return requirement
|
return requirement
|
||||||
}),
|
}),
|
||||||
@@ -961,7 +965,9 @@ export const fileRouter = router({
|
|||||||
entityType: 'FileRequirement',
|
entityType: 'FileRequirement',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[File] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
@@ -1594,7 +1600,8 @@ export const fileRouter = router({
|
|||||||
try {
|
try {
|
||||||
await client.statObject(bucket, objectKey)
|
await client.statObject(bucket, objectKey)
|
||||||
results[objectKey] = true
|
results[objectKey] = true
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to stat MinIO object during existence check:', err)
|
||||||
results[objectKey] = false
|
results[objectKey] = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma, PrismaClient } from '@prisma/client'
|
import { Prisma, PrismaClient } from '@prisma/client'
|
||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure, withAIRateLimit } from '../trpc'
|
||||||
import { executeFilteringRules, type ProgressCallback, type AwardCriteriaInput, type AwardMatchResult } from '../services/ai-filtering'
|
import { executeFilteringRules, type ProgressCallback, type AwardCriteriaInput, type AwardMatchResult } from '../services/ai-filtering'
|
||||||
import { sanitizeUserInput } from '../services/ai-prompt-guard'
|
import { sanitizeUserInput } from '../services/ai-prompt-guard'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
@@ -652,6 +652,7 @@ export const filteringRouter = router({
|
|||||||
* Start a filtering job (runs in background)
|
* Start a filtering job (runs in background)
|
||||||
*/
|
*/
|
||||||
startJob: adminProcedure
|
startJob: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existingJob = await ctx.prisma.filteringJob.findFirst({
|
const existingJob = await ctx.prisma.filteringJob.findFirst({
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ async function canUserAccessResource(
|
|||||||
const parsed = accessJson as unknown[]
|
const parsed = accessJson as unknown[]
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return true
|
if (!Array.isArray(parsed) || parsed.length === 0) return true
|
||||||
rules = parsed as AccessRule[]
|
rules = parsed as AccessRule[]
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to parse learning resource access rules JSON:', err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -739,8 +739,8 @@ export const liveVotingRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Audit log errors should never break the operation
|
console.error('[LiveVoting] Audit log failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -538,7 +538,8 @@ export const mentorRouter = router({
|
|||||||
} else {
|
} else {
|
||||||
failed++
|
failed++
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to send mentor assignment notifications:', err)
|
||||||
failed++
|
failed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,8 +867,8 @@ export const mentorRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Audit log errors should never break the operation
|
console.error('[Mentor] Audit log failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return note
|
return note
|
||||||
@@ -1081,8 +1082,8 @@ export const mentorRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Audit log errors should never break the operation
|
console.error('[Mentor] Audit log failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { completion, allRequiredDone }
|
return { completion, allRequiredDone }
|
||||||
@@ -1342,7 +1343,7 @@ export const mentorRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return workspaceSendMessage(
|
return workspaceSendMessage(
|
||||||
{
|
{
|
||||||
mentorAssignmentId: input.mentorAssignmentId,
|
workspaceId: input.mentorAssignmentId,
|
||||||
senderId: ctx.user.id,
|
senderId: ctx.user.id,
|
||||||
message: input.message,
|
message: input.message,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
@@ -1388,7 +1389,7 @@ export const mentorRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return workspaceUploadFile(
|
return workspaceUploadFile(
|
||||||
{
|
{
|
||||||
mentorAssignmentId: input.mentorAssignmentId,
|
workspaceId: input.mentorAssignmentId,
|
||||||
uploadedByUserId: ctx.user.id,
|
uploadedByUserId: ctx.user.id,
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ export const messageRouter = router({
|
|||||||
scheduled: isScheduled,
|
scheduled: isScheduled,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
@@ -334,7 +336,9 @@ export const messageRouter = router({
|
|||||||
entityId: template.id,
|
entityId: template.id,
|
||||||
detailsJson: { name: input.name, category: input.category },
|
detailsJson: { name: input.name, category: input.category },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return template
|
return template
|
||||||
}),
|
}),
|
||||||
@@ -378,7 +382,9 @@ export const messageRouter = router({
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: { updatedFields: Object.keys(data) },
|
detailsJson: { updatedFields: Object.keys(data) },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return template
|
return template
|
||||||
}),
|
}),
|
||||||
@@ -402,7 +408,9 @@ export const messageRouter = router({
|
|||||||
entityType: 'MessageTemplate',
|
entityType: 'MessageTemplate',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return template
|
return template
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -505,7 +505,8 @@ export const projectRouter = router({
|
|||||||
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
|
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
|
||||||
orderBy: { confidence: 'desc' },
|
orderBy: { confidence: 'desc' },
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch project tags:', err)
|
||||||
// ProjectTag table may not exist yet
|
// ProjectTag table may not exist yet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,10 +638,6 @@ export const projectRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
await tx.project.update({
|
|
||||||
where: { id: created.id },
|
|
||||||
data: { roundId: input.roundId },
|
|
||||||
})
|
|
||||||
await tx.projectRoundState.create({
|
await tx.projectRoundState.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: created.id,
|
projectId: created.id,
|
||||||
@@ -746,12 +743,13 @@ export const projectRouter = router({
|
|||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to log invitation notification for project team member:', err)
|
||||||
// Never fail on notification logging
|
// Never fail on notification logging
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Email sending failure should not break project creation
|
// Email sending failure should not break project creation
|
||||||
console.error(`Failed to send invite to ${member.email}`)
|
console.error(`Failed to send invite to ${member.email}:`, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1568,9 +1566,9 @@ export const projectRouter = router({
|
|||||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||||
await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
|
await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Email sending failure should not block member creation
|
// Email sending failure should not block member creation
|
||||||
console.error(`Failed to send invite to ${email}`)
|
console.error(`Failed to send invite to ${email}:`, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure, withAIRateLimit } from '../trpc'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
@@ -69,6 +69,7 @@ export const rankingRouter = router({
|
|||||||
* RANK-05, RANK-06, RANK-08.
|
* RANK-05, RANK-06, RANK-08.
|
||||||
*/
|
*/
|
||||||
executeRanking: adminProcedure
|
executeRanking: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
@@ -260,6 +261,7 @@ export const rankingRouter = router({
|
|||||||
* Reads ranking criteria from round configJson and executes quickRank.
|
* Reads ranking criteria from round configJson and executes quickRank.
|
||||||
*/
|
*/
|
||||||
triggerAutoRank: adminProcedure
|
triggerAutoRank: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId } = input
|
const { roundId } = input
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export const roundRouter = router({
|
|||||||
submissionWindow: {
|
submissionWindow: {
|
||||||
include: { fileRequirements: true },
|
include: { fileRequirements: true },
|
||||||
},
|
},
|
||||||
advancementRules: { orderBy: { sortOrder: 'asc' } },
|
|
||||||
visibleSubmissionWindows: {
|
visibleSubmissionWindows: {
|
||||||
include: { submissionWindow: true },
|
include: { submissionWindow: true },
|
||||||
},
|
},
|
||||||
@@ -1264,33 +1263,53 @@ export const roundRouter = router({
|
|||||||
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
|
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
|
||||||
const expiryMs = expiryHours * 60 * 60 * 1000
|
const expiryMs = expiryHours * 60 * 60 * 1000
|
||||||
|
|
||||||
let invited = 0
|
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let failed = 0
|
let failed = 0
|
||||||
|
|
||||||
|
// Phase 1: Batch all DB writes — generate tokens and update users
|
||||||
|
const toInvite: Array<{ id: string; email: string; name: string | null; role: string; token: string }> = []
|
||||||
for (const [, user] of users) {
|
for (const [, user] of users) {
|
||||||
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
|
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
toInvite.push({ ...user, token: generateInviteToken() })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (toInvite.length > 0) {
|
||||||
const token = generateInviteToken()
|
await ctx.prisma.$transaction(
|
||||||
await ctx.prisma.user.update({
|
toInvite.map((u) =>
|
||||||
where: { id: user.id },
|
ctx.prisma.user.update({
|
||||||
data: {
|
where: { id: u.id },
|
||||||
status: 'INVITED',
|
data: {
|
||||||
inviteToken: token,
|
status: 'INVITED',
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
inviteToken: u.token,
|
||||||
},
|
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Send emails with concurrency pool of 10
|
||||||
|
const CONCURRENCY = 10
|
||||||
|
let invited = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < toInvite.length; i += CONCURRENCY) {
|
||||||
|
const batch = toInvite.slice(i, i + CONCURRENCY)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map((u) => {
|
||||||
|
const inviteUrl = `${baseUrl}/accept-invite?token=${u.token}`
|
||||||
|
return sendInvitationEmail(u.email, u.name, inviteUrl, u.role, expiryHours)
|
||||||
})
|
})
|
||||||
|
)
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
for (const result of results) {
|
||||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
if (result.status === 'fulfilled') {
|
||||||
invited++
|
invited++
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
|
console.error('[bulkInviteTeamMembers] Email send failed:', result.reason)
|
||||||
failed++
|
failed++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,14 +322,14 @@ export const settingsRouter = router({
|
|||||||
* Test email connection
|
* Test email connection
|
||||||
*/
|
*/
|
||||||
testEmailConnection: superAdminProcedure
|
testEmailConnection: superAdminProcedure
|
||||||
.input(z.object({ testEmail: z.string().email() }))
|
.mutation(async () => {
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
try {
|
try {
|
||||||
const { sendTestEmail } = await import('@/lib/email')
|
const { verifyEmailConnection } = await import('@/lib/email')
|
||||||
const success = await sendTestEmail(input.testEmail)
|
const success = await verifyEmailConnection()
|
||||||
return { success, error: success ? null : 'Failed to send test email' }
|
return { success, error: success ? null : 'SMTP connection verification failed' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: 'Email configuration error' }
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return { success: false, error: `Email configuration error: ${message}` }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -642,7 +642,8 @@ export const settingsRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for digest settings update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +702,8 @@ export const settingsRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for analytics settings update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,7 +762,8 @@ export const settingsRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for audit settings update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -365,11 +365,13 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Fire and forget - process in background
|
// Fire and forget - process in background
|
||||||
void processEligibilityJob(
|
processEligibilityJob(
|
||||||
input.awardId,
|
input.awardId,
|
||||||
input.includeSubmitted ?? false,
|
input.includeSubmitted ?? false,
|
||||||
ctx.user.id
|
ctx.user.id
|
||||||
)
|
).catch((err) => {
|
||||||
|
console.error('[SpecialAward] processEligibilityJob failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
return { started: true }
|
return { started: true }
|
||||||
}),
|
}),
|
||||||
@@ -913,12 +915,14 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Fire and forget - process in background with round scoping
|
// Fire and forget - process in background with round scoping
|
||||||
void processEligibilityJob(
|
processEligibilityJob(
|
||||||
input.awardId,
|
input.awardId,
|
||||||
true, // include submitted
|
true, // include submitted
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
input.roundId
|
input.roundId
|
||||||
)
|
).catch((err) => {
|
||||||
|
console.error('[SpecialAward] processEligibilityJob (round) failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
return { started: true }
|
return { started: true }
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const userRouter = router({
|
|||||||
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
||||||
expertiseTags: z.array(z.string()).max(15).optional(),
|
expertiseTags: z.array(z.string()).max(15).optional(),
|
||||||
digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
|
digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
|
||||||
availabilityJson: z.any().optional(),
|
availabilityJson: z.array(z.object({ start: z.string(), end: z.string() })).optional(),
|
||||||
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
|
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -206,7 +206,10 @@ export const userRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user
|
// TODO: This delete will fail with a FK violation for any user with activity
|
||||||
|
// (COI declarations, mentor assignments, messages, evaluations, etc.).
|
||||||
|
// A proper purge flow with pre-deletion cleanup or soft-delete is needed
|
||||||
|
// before hard-delete can work reliably for active users.
|
||||||
await ctx.prisma.user.delete({
|
await ctx.prisma.user.delete({
|
||||||
where: { id: ctx.user.id },
|
where: { id: ctx.user.id },
|
||||||
})
|
})
|
||||||
@@ -539,7 +542,7 @@ export const userRouter = router({
|
|||||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||||
expertiseTags: z.array(z.string()).optional(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||||
availabilityJson: z.any().optional(),
|
availabilityJson: z.array(z.object({ start: z.string(), end: z.string() })).optional(),
|
||||||
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
|
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -657,6 +660,10 @@ export const userRouter = router({
|
|||||||
select: { email: true },
|
select: { email: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: This delete will fail with a FK violation for any user with activity
|
||||||
|
// (COI declarations, mentor assignments, messages, evaluations, etc.).
|
||||||
|
// A proper purge flow with pre-deletion cleanup or soft-delete is needed
|
||||||
|
// before hard-delete can work reliably for active users.
|
||||||
const user = await ctx.prisma.user.delete({
|
const user = await ctx.prisma.user.delete({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, superAdminProcedure } from '../trpc'
|
import { router, superAdminProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import {
|
import {
|
||||||
@@ -80,7 +81,7 @@ export const webhookRouter = router({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
events: z.array(z.string()).min(1),
|
events: z.array(z.string()).min(1),
|
||||||
headers: z.any().optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
maxRetries: z.number().int().min(0).max(10).default(3),
|
maxRetries: z.number().int().min(0).max(10).default(3),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -108,7 +109,9 @@ export const webhookRouter = router({
|
|||||||
entityId: webhook.id,
|
entityId: webhook.id,
|
||||||
detailsJson: { name: input.name, url: input.url, events: input.events },
|
detailsJson: { name: input.name, url: input.url, events: input.events },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return webhook
|
return webhook
|
||||||
}),
|
}),
|
||||||
@@ -123,7 +126,7 @@ export const webhookRouter = router({
|
|||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200).optional(),
|
||||||
url: z.string().url().optional(),
|
url: z.string().url().optional(),
|
||||||
events: z.array(z.string()).min(1).optional(),
|
events: z.array(z.string()).min(1).optional(),
|
||||||
headers: z.any().optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
maxRetries: z.number().int().min(0).max(10).optional(),
|
maxRetries: z.number().int().min(0).max(10).optional(),
|
||||||
})
|
})
|
||||||
@@ -152,7 +155,9 @@ export const webhookRouter = router({
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: { updatedFields: Object.keys(data) },
|
detailsJson: { updatedFields: Object.keys(data) },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return webhook
|
return webhook
|
||||||
}),
|
}),
|
||||||
@@ -176,7 +181,9 @@ export const webhookRouter = router({
|
|||||||
entityType: 'Webhook',
|
entityType: 'Webhook',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
@@ -192,7 +199,7 @@ export const webhookRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
throw new Error('Webhook not found')
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Webhook not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const testPayload = {
|
const testPayload = {
|
||||||
@@ -231,7 +238,9 @@ export const webhookRouter = router({
|
|||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
detailsJson: { deliveryStatus: result?.status },
|
detailsJson: { deliveryStatus: result?.status },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}),
|
}),
|
||||||
@@ -292,7 +301,9 @@ export const webhookRouter = router({
|
|||||||
entityType: 'Webhook',
|
entityType: 'Webhook',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return webhook
|
return webhook
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/open
|
|||||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||||
import { classifyAIError, logAIError } from './ai-errors'
|
import { classifyAIError, logAIError } from './ai-errors'
|
||||||
import { extractMultipleFileContents } from './file-content-extractor'
|
import { extractMultipleFileContents } from './file-content-extractor'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient, CompetitionCategory } from '@prisma/client'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -95,14 +95,14 @@ async function generateCategoryShortlist(
|
|||||||
rubric?: string
|
rubric?: string
|
||||||
aiParseFiles: boolean
|
aiParseFiles: boolean
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> {
|
): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> {
|
||||||
const { roundId, category, topN, rubric, aiParseFiles } = params
|
const { roundId, category, topN, rubric, aiParseFiles } = params
|
||||||
|
|
||||||
// Load projects with evaluations for this category
|
// Load projects with evaluations for this category
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
competitionCategory: category,
|
competitionCategory: category as CompetitionCategory,
|
||||||
assignments: { some: { roundId } },
|
assignments: { some: { roundId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -320,7 +320,7 @@ export async function generateShortlist(
|
|||||||
rubric?: string
|
rubric?: string
|
||||||
aiParseFiles?: boolean
|
aiParseFiles?: boolean
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<ShortlistResult> {
|
): Promise<ShortlistResult> {
|
||||||
const {
|
const {
|
||||||
roundId,
|
roundId,
|
||||||
|
|||||||
@@ -284,9 +284,9 @@ export async function processEligibilityJob(
|
|||||||
eligibilityJobError: errorMessage,
|
eligibilityJobError: errorMessage,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (updateErr) {
|
||||||
// If we can't even update the status, log and give up
|
// If we can't even update the status, log and give up
|
||||||
console.error('Failed to update eligibility job status:', error)
|
console.error('Failed to update eligibility job status:', updateErr, 'Original error:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
578
src/server/services/juror-reassignment.ts
Normal file
578
src/server/services/juror-reassignment.ts
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
createNotification,
|
||||||
|
notifyAdmins,
|
||||||
|
NotificationTypes,
|
||||||
|
} from './in-app-notification'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign a project after a juror declares COI.
|
||||||
|
* Deletes the old assignment, finds an eligible replacement juror, and creates a new assignment.
|
||||||
|
* Returns the new juror info or null if no eligible juror found.
|
||||||
|
*/
|
||||||
|
export async function reassignAfterCOI(params: {
|
||||||
|
assignmentId: string
|
||||||
|
auditUserId?: string
|
||||||
|
auditIp?: string
|
||||||
|
auditUserAgent?: string
|
||||||
|
}): Promise<{ newJurorId: string; newJurorName: string; newAssignmentId: string } | null> {
|
||||||
|
const assignment = await prisma.assignment.findUnique({
|
||||||
|
where: { id: params.assignmentId },
|
||||||
|
include: {
|
||||||
|
round: { select: { id: true, name: true, configJson: true, juryGroupId: true } },
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!assignment) return null
|
||||||
|
|
||||||
|
const { roundId, projectId } = assignment
|
||||||
|
const config = (assignment.round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const maxAssignmentsPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// ── Build exclusion set: jurors who must NEVER get this project ──────────
|
||||||
|
|
||||||
|
// 1. Currently assigned to this project in ANY round (not just current)
|
||||||
|
const allProjectAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId))
|
||||||
|
|
||||||
|
// 2. COI records for this project (any juror who declared conflict, ever)
|
||||||
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
|
where: { projectId, hasConflict: true },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
for (const c of coiRecords) excludedUserIds.add(c.userId)
|
||||||
|
|
||||||
|
// 3. Historical: jurors who previously had this project but were removed
|
||||||
|
// (via COI reassignment or admin transfer — tracked in audit logs)
|
||||||
|
const historicalAuditLogs = await prisma.decisionAuditLog.findMany({
|
||||||
|
where: {
|
||||||
|
eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] },
|
||||||
|
detailsJson: { path: ['projectId'], equals: projectId },
|
||||||
|
},
|
||||||
|
select: { detailsJson: true },
|
||||||
|
})
|
||||||
|
for (const log of historicalAuditLogs) {
|
||||||
|
const details = log.detailsJson as Record<string, unknown> | null
|
||||||
|
if (!details) continue
|
||||||
|
// COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it
|
||||||
|
if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string)
|
||||||
|
// ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project
|
||||||
|
if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string)
|
||||||
|
// Transfer logs may have a moves array with per-project details
|
||||||
|
if (Array.isArray(details.moves)) {
|
||||||
|
for (const move of details.moves as Array<Record<string, unknown>>) {
|
||||||
|
if (move.projectId === projectId && move.newJurorId) {
|
||||||
|
// The juror who received via past transfer also had it
|
||||||
|
excludedUserIds.add(move.newJurorId as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find candidate jurors ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||||
|
|
||||||
|
if (assignment.round.juryGroupId) {
|
||||||
|
const members = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: assignment.round.juryGroupId },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } },
|
||||||
|
})
|
||||||
|
candidateJurors = members
|
||||||
|
.filter((m) => m.user.status === 'ACTIVE')
|
||||||
|
.map((m) => m.user)
|
||||||
|
} else {
|
||||||
|
// No jury group — scope to jurors already assigned to this round
|
||||||
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
const activeRoundJurorIds = roundJurorIds.map((a) => a.userId)
|
||||||
|
|
||||||
|
candidateJurors = activeRoundJurorIds.length > 0
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: activeRoundJurorIds },
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out all excluded jurors (current assignments, COI, historical)
|
||||||
|
const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id))
|
||||||
|
|
||||||
|
if (eligible.length === 0) return null
|
||||||
|
|
||||||
|
// ── Score eligible jurors: prefer those with incomplete evaluations ──────
|
||||||
|
|
||||||
|
const eligibleIds = eligible.map((j) => j.id)
|
||||||
|
|
||||||
|
// Get assignment counts and evaluation completion for eligible jurors in this round
|
||||||
|
const roundAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { roundId, userId: { in: eligibleIds } },
|
||||||
|
select: { userId: true, evaluation: { select: { status: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build per-juror stats: total assignments, completed evaluations
|
||||||
|
const jurorStats = new Map<string, { total: number; completed: number }>()
|
||||||
|
for (const a of roundAssignments) {
|
||||||
|
const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 }
|
||||||
|
stats.total++
|
||||||
|
if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') {
|
||||||
|
stats.completed++
|
||||||
|
}
|
||||||
|
jurorStats.set(a.userId, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank jurors: under cap, then prefer those still working (completed < total)
|
||||||
|
const ranked = eligible
|
||||||
|
.map((j) => {
|
||||||
|
const stats = jurorStats.get(j.id) || { total: 0, completed: 0 }
|
||||||
|
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
const hasIncomplete = stats.completed < stats.total
|
||||||
|
return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete }
|
||||||
|
})
|
||||||
|
.filter((j) => j.currentCount < j.effectiveMax)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// 1. Prefer jurors with incomplete evaluations (still active)
|
||||||
|
if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1
|
||||||
|
// 2. Then fewest current assignments (load balancing)
|
||||||
|
return a.currentCount - b.currentCount
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ranked.length === 0) return null
|
||||||
|
|
||||||
|
const replacement = ranked[0]
|
||||||
|
|
||||||
|
// Delete old assignment and create replacement atomically.
|
||||||
|
// Cascade deletes COI record and any draft evaluation.
|
||||||
|
const newAssignment = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.assignment.delete({ where: { id: params.assignmentId } })
|
||||||
|
return tx.assignment.create({
|
||||||
|
data: {
|
||||||
|
userId: replacement.id,
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined,
|
||||||
|
isRequired: assignment.isRequired,
|
||||||
|
method: 'MANUAL',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify the replacement juror (COI-specific notification)
|
||||||
|
await createNotification({
|
||||||
|
userId: replacement.id,
|
||||||
|
type: NotificationTypes.COI_REASSIGNED,
|
||||||
|
title: 'Project Reassigned to You (COI)',
|
||||||
|
message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignment',
|
||||||
|
metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify admins of the reassignment
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||||
|
title: 'COI Auto-Reassignment',
|
||||||
|
message: `Project "${assignment.project.title}" was reassigned from ${assignment.user.name || assignment.user.email} to ${replacement.name || replacement.email} due to conflict of interest.`,
|
||||||
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
|
linkLabel: 'View Round',
|
||||||
|
metadata: {
|
||||||
|
projectId,
|
||||||
|
oldJurorId: assignment.userId,
|
||||||
|
newJurorId: replacement.id,
|
||||||
|
reason: 'COI',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
if (params.auditUserId) {
|
||||||
|
await logAudit({
|
||||||
|
prisma,
|
||||||
|
userId: params.auditUserId,
|
||||||
|
action: 'COI_REASSIGNMENT',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
entityId: newAssignment.id,
|
||||||
|
detailsJson: {
|
||||||
|
oldAssignmentId: params.assignmentId,
|
||||||
|
oldJurorId: assignment.userId,
|
||||||
|
newJurorId: replacement.id,
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
},
|
||||||
|
ipAddress: params.auditIp,
|
||||||
|
userAgent: params.auditUserAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newJurorId: replacement.id,
|
||||||
|
newJurorName: replacement.name || replacement.email,
|
||||||
|
newAssignmentId: newAssignment.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluation statuses that are safe to move (not yet finalized). */
|
||||||
|
const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
|
||||||
|
|
||||||
|
export async function reassignDroppedJurorAssignments(params: {
|
||||||
|
roundId: string
|
||||||
|
droppedJurorId: string
|
||||||
|
auditUserId?: string
|
||||||
|
auditIp?: string
|
||||||
|
auditUserAgent?: string
|
||||||
|
}) {
|
||||||
|
const round = await prisma.round.findUnique({
|
||||||
|
where: { id: params.roundId },
|
||||||
|
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const droppedJuror = await prisma.user.findUnique({
|
||||||
|
where: { id: params.droppedJurorId },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!droppedJuror) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const fallbackCap =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// Only pick assignments with no evaluation or evaluation still in draft/not-started.
|
||||||
|
// Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched.
|
||||||
|
const assignmentsToMove = await prisma.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: params.roundId,
|
||||||
|
userId: params.droppedJurorId,
|
||||||
|
OR: [
|
||||||
|
{ evaluation: null },
|
||||||
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
juryGroupId: true,
|
||||||
|
isRequired: true,
|
||||||
|
createdAt: true,
|
||||||
|
project: { select: { title: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignmentsToMove.length === 0) {
|
||||||
|
return {
|
||||||
|
movedCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
failedProjects: [] as string[],
|
||||||
|
reassignedTo: {} as Record<string, number>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||||
|
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const members = await 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 !== params.droppedJurorId)
|
||||||
|
.map((m) => m.user)
|
||||||
|
} else {
|
||||||
|
// No jury group configured — scope to jurors already assigned to this round
|
||||||
|
// (the de facto jury pool). This prevents assigning to random JURY_MEMBER
|
||||||
|
// accounts that aren't part of this round's jury.
|
||||||
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
|
where: { roundId: params.roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
const activeRoundJurorIds = roundJurorIds
|
||||||
|
.map((a) => a.userId)
|
||||||
|
.filter((id) => id !== params.droppedJurorId)
|
||||||
|
|
||||||
|
candidateJurors = activeRoundJurorIds.length > 0
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: activeRoundJurorIds },
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateJurors.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateIds = candidateJurors.map((j) => j.id)
|
||||||
|
|
||||||
|
const existingAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { roundId: params.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 prisma.conflictOfInterest.findMany({
|
||||||
|
where: {
|
||||||
|
assignment: { roundId: params.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||||
|
const plannedMoves: {
|
||||||
|
assignmentId: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
newJurorId: string
|
||||||
|
juryGroupId: string | null
|
||||||
|
isRequired: boolean
|
||||||
|
}[] = []
|
||||||
|
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) => {
|
||||||
|
const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0)
|
||||||
|
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 moves inside a transaction with per-move TOCTOU guard.
|
||||||
|
// Uses conditional deleteMany so a concurrent evaluation submission
|
||||||
|
// (which sets status to SUBMITTED) causes the delete to return count=0
|
||||||
|
// instead of cascade-destroying the submitted evaluation.
|
||||||
|
const actualMoves: typeof plannedMoves = []
|
||||||
|
const skippedProjects: string[] = []
|
||||||
|
|
||||||
|
if (plannedMoves.length > 0) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const move of plannedMoves) {
|
||||||
|
// Guard: only delete if the assignment still belongs to the dropped juror
|
||||||
|
// AND its evaluation (if any) is still in a movable state.
|
||||||
|
// If a juror submitted between our read and now, count will be 0.
|
||||||
|
const deleted = await tx.assignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: move.assignmentId,
|
||||||
|
userId: params.droppedJurorId,
|
||||||
|
OR: [
|
||||||
|
{ evaluation: null },
|
||||||
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (deleted.count === 0) {
|
||||||
|
// Assignment was already moved, deleted, or its evaluation was submitted
|
||||||
|
skippedProjects.push(move.projectTitle)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.assignment.create({
|
||||||
|
data: {
|
||||||
|
roundId: params.roundId,
|
||||||
|
projectId: move.projectId,
|
||||||
|
userId: move.newJurorId,
|
||||||
|
juryGroupId: move.juryGroupId ?? undefined,
|
||||||
|
isRequired: move.isRequired,
|
||||||
|
method: 'MANUAL',
|
||||||
|
createdBy: params.auditUserId ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
actualMoves.push(move)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add skipped projects to the failed list
|
||||||
|
failedProjects.push(...skippedProjects)
|
||||||
|
|
||||||
|
const reassignedTo: Record<string, number> = {}
|
||||||
|
for (const move of actualMoves) {
|
||||||
|
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualMoves.length > 0) {
|
||||||
|
// Build per-juror project name lists for proper emails
|
||||||
|
const destProjectNames: Record<string, string[]> = {}
|
||||||
|
for (const move of actualMoves) {
|
||||||
|
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||||||
|
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
const droppedName = droppedJuror.name || droppedJuror.email
|
||||||
|
|
||||||
|
// Fetch round deadline for email
|
||||||
|
const roundFull = await prisma.round.findUnique({
|
||||||
|
where: { id: params.roundId },
|
||||||
|
select: { windowCloseAt: true },
|
||||||
|
})
|
||||||
|
const deadline = roundFull?.windowCloseAt
|
||||||
|
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
||||||
|
const count = projectNames.length
|
||||||
|
await createNotification({
|
||||||
|
userId: jurorId,
|
||||||
|
type: NotificationTypes.DROPOUT_REASSIGNED,
|
||||||
|
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||||||
|
message: count === 1
|
||||||
|
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
|
||||||
|
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const topReceivers = Object.entries(reassignedTo)
|
||||||
|
.map(([jurorId, count]) => {
|
||||||
|
const juror = candidateMeta.get(jurorId)
|
||||||
|
return `${juror?.name || juror?.email || jurorId} (${count})`
|
||||||
|
})
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||||
|
title: 'Juror Dropout Reshuffle',
|
||||||
|
message: `Reassigned ${actualMoves.length} project(s) from ${droppedName} to: ${topReceivers}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned successfully.'}`,
|
||||||
|
linkUrl: `/admin/rounds/${round.id}`,
|
||||||
|
linkLabel: 'View Round',
|
||||||
|
metadata: {
|
||||||
|
roundId: round.id,
|
||||||
|
droppedJurorId: droppedJuror.id,
|
||||||
|
movedCount: actualMoves.length,
|
||||||
|
failedCount: failedProjects.length,
|
||||||
|
topReceivers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the dropped juror from the jury group so they can't be re-assigned
|
||||||
|
// in future assignment runs for this round's competition.
|
||||||
|
let removedFromGroup = false
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const deleted = await prisma.juryGroupMember.deleteMany({
|
||||||
|
where: {
|
||||||
|
juryGroupId: round.juryGroupId,
|
||||||
|
userId: params.droppedJurorId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
removedFromGroup = deleted.count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.auditUserId) {
|
||||||
|
// Build per-project move detail for audit trail
|
||||||
|
const moveDetails = actualMoves.map((move) => {
|
||||||
|
const juror = candidateMeta.get(move.newJurorId)
|
||||||
|
return {
|
||||||
|
projectId: move.projectId,
|
||||||
|
projectTitle: move.projectTitle,
|
||||||
|
newJurorId: move.newJurorId,
|
||||||
|
newJurorName: juror?.name || juror?.email || move.newJurorId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma,
|
||||||
|
userId: params.auditUserId,
|
||||||
|
action: 'JUROR_DROPOUT_RESHUFFLE',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: round.id,
|
||||||
|
detailsJson: {
|
||||||
|
droppedJurorId: droppedJuror.id,
|
||||||
|
droppedJurorName: droppedJuror.name || droppedJuror.email,
|
||||||
|
movedCount: actualMoves.length,
|
||||||
|
failedCount: failedProjects.length,
|
||||||
|
failedProjects,
|
||||||
|
skippedProjects,
|
||||||
|
reassignedTo,
|
||||||
|
removedFromGroup,
|
||||||
|
moves: moveDetails,
|
||||||
|
},
|
||||||
|
ipAddress: params.auditIp,
|
||||||
|
userAgent: params.auditUserAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
movedCount: actualMoves.length,
|
||||||
|
failedCount: failedProjects.length,
|
||||||
|
failedProjects,
|
||||||
|
reassignedTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live Control Service
|
|
||||||
*
|
|
||||||
* Manages real-time control of live final events within a round.
|
|
||||||
* Handles session management, project cursor navigation, queue reordering,
|
|
||||||
* pause/resume, and cohort voting windows.
|
|
||||||
*
|
|
||||||
* The LiveProgressCursor tracks the current position in a live presentation
|
|
||||||
* sequence. Cohorts group projects for voting with configurable windows.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PrismaClient } from '@prisma/client'
|
|
||||||
import { logAudit } from '@/server/utils/audit'
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface SessionResult {
|
|
||||||
success: boolean
|
|
||||||
sessionId: string | null
|
|
||||||
cursorId: string | null
|
|
||||||
errors?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CursorState {
|
|
||||||
roundId: string
|
|
||||||
sessionId: string
|
|
||||||
activeProjectId: string | null
|
|
||||||
activeOrderIndex: number
|
|
||||||
isPaused: boolean
|
|
||||||
totalProjects: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function generateSessionId(): string {
|
|
||||||
const timestamp = Date.now().toString(36)
|
|
||||||
const random = Math.random().toString(36).substring(2, 8)
|
|
||||||
return `live-${timestamp}-${random}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Start Session ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create or reset a LiveProgressCursor for a round. If a cursor already exists,
|
|
||||||
* it is reset to the beginning. A new sessionId is always generated.
|
|
||||||
*/
|
|
||||||
export async function startSession(
|
|
||||||
roundId: string,
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any
|
|
||||||
): Promise<SessionResult> {
|
|
||||||
try {
|
|
||||||
// Verify round exists and is a LIVE_FINAL type
|
|
||||||
const round = await prisma.round.findUnique({
|
|
||||||
where: { id: roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!round) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
sessionId: null,
|
|
||||||
cursorId: null,
|
|
||||||
errors: [`Round ${roundId} not found`],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (round.roundType !== 'LIVE_FINAL') {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
sessionId: null,
|
|
||||||
cursorId: null,
|
|
||||||
errors: [
|
|
||||||
`Round "${round.name}" is type ${round.roundType}, expected LIVE_FINAL`,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first project in the first cohort
|
|
||||||
const firstCohortProject = await prisma.cohortProject.findFirst({
|
|
||||||
where: {
|
|
||||||
cohort: { roundId },
|
|
||||||
},
|
|
||||||
orderBy: { sortOrder: 'asc' as const },
|
|
||||||
select: { projectId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const sessionId = generateSessionId()
|
|
||||||
|
|
||||||
// Upsert the cursor (one per round)
|
|
||||||
const cursor = await prisma.liveProgressCursor.upsert({
|
|
||||||
where: { roundId },
|
|
||||||
create: {
|
|
||||||
roundId,
|
|
||||||
sessionId,
|
|
||||||
activeProjectId: firstCohortProject?.projectId ?? null,
|
|
||||||
activeOrderIndex: 0,
|
|
||||||
isPaused: false,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
sessionId,
|
|
||||||
activeProjectId: firstCohortProject?.projectId ?? null,
|
|
||||||
activeOrderIndex: 0,
|
|
||||||
isPaused: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Decision audit log
|
|
||||||
await prisma.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'live.session_started',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
actorId,
|
|
||||||
detailsJson: {
|
|
||||||
roundId,
|
|
||||||
sessionId,
|
|
||||||
firstProjectId: firstCohortProject?.projectId ?? null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: actorId,
|
|
||||||
action: 'LIVE_SESSION_STARTED',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
detailsJson: { roundId, sessionId },
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
sessionId,
|
|
||||||
cursorId: cursor.id,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LiveControl] Failed to start session:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
sessionId: null,
|
|
||||||
cursorId: null,
|
|
||||||
errors: [
|
|
||||||
error instanceof Error ? error.message : 'Failed to start live session',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Set Active Project ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the currently active project in the live session.
|
|
||||||
* Validates that the project belongs to a cohort in this round and performs
|
|
||||||
* a version check on the cursor's sessionId to prevent stale updates.
|
|
||||||
*/
|
|
||||||
export async function setActiveProject(
|
|
||||||
roundId: string,
|
|
||||||
projectId: string,
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any
|
|
||||||
): Promise<{ success: boolean; errors?: string[] }> {
|
|
||||||
try {
|
|
||||||
// Verify cursor exists
|
|
||||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
|
||||||
where: { roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cursor) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: ['No live session found for this round. Start a session first.'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify project is in a cohort for this round
|
|
||||||
const cohortProject = await prisma.cohortProject.findFirst({
|
|
||||||
where: {
|
|
||||||
projectId,
|
|
||||||
cohort: { roundId },
|
|
||||||
},
|
|
||||||
select: { id: true, sortOrder: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cohortProject) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
`Project ${projectId} is not in any cohort for round ${roundId}`,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cursor
|
|
||||||
await prisma.liveProgressCursor.update({
|
|
||||||
where: { roundId },
|
|
||||||
data: {
|
|
||||||
activeProjectId: projectId,
|
|
||||||
activeOrderIndex: cohortProject.sortOrder,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit
|
|
||||||
await prisma.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'live.cursor_updated',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
actorId,
|
|
||||||
detailsJson: {
|
|
||||||
roundId,
|
|
||||||
projectId,
|
|
||||||
orderIndex: cohortProject.sortOrder,
|
|
||||||
action: 'setActiveProject',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: actorId,
|
|
||||||
action: 'LIVE_SET_ACTIVE_PROJECT',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
detailsJson: { projectId, orderIndex: cohortProject.sortOrder },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LiveControl] Failed to set active project:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Failed to set active project',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Jump to Project ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jump to a project by its order index in the cohort queue.
|
|
||||||
*/
|
|
||||||
export async function jumpToProject(
|
|
||||||
roundId: string,
|
|
||||||
orderIndex: number,
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any
|
|
||||||
): Promise<{ success: boolean; projectId?: string; errors?: string[] }> {
|
|
||||||
try {
|
|
||||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
|
||||||
where: { roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cursor) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: ['No live session found for this round'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the CohortProject at the given sort order
|
|
||||||
const cohortProject = await prisma.cohortProject.findFirst({
|
|
||||||
where: {
|
|
||||||
cohort: { roundId },
|
|
||||||
sortOrder: orderIndex,
|
|
||||||
},
|
|
||||||
select: { projectId: true, sortOrder: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cohortProject) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [`No project found at order index ${orderIndex}`],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cursor
|
|
||||||
await prisma.liveProgressCursor.update({
|
|
||||||
where: { roundId },
|
|
||||||
data: {
|
|
||||||
activeProjectId: cohortProject.projectId,
|
|
||||||
activeOrderIndex: orderIndex,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'live.cursor_updated',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
actorId,
|
|
||||||
detailsJson: {
|
|
||||||
roundId,
|
|
||||||
projectId: cohortProject.projectId,
|
|
||||||
orderIndex,
|
|
||||||
action: 'jumpToProject',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: actorId,
|
|
||||||
action: 'LIVE_JUMP_TO_PROJECT',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
detailsJson: { orderIndex, projectId: cohortProject.projectId },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true, projectId: cohortProject.projectId }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LiveControl] Failed to jump to project:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
error instanceof Error ? error.message : 'Failed to jump to project',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Reorder Queue ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder the presentation queue by updating CohortProject sortOrder values.
|
|
||||||
* newOrder is an array of cohortProjectIds in the desired order.
|
|
||||||
*/
|
|
||||||
export async function reorderQueue(
|
|
||||||
roundId: string,
|
|
||||||
newOrder: string[],
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any
|
|
||||||
): Promise<{ success: boolean; errors?: string[] }> {
|
|
||||||
try {
|
|
||||||
// Verify all provided IDs belong to cohorts in this round
|
|
||||||
const cohortProjects = await prisma.cohortProject.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: newOrder },
|
|
||||||
cohort: { roundId },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const validIds = new Set(cohortProjects.map((cp: any) => cp.id))
|
|
||||||
const invalidIds = newOrder.filter((id) => !validIds.has(id))
|
|
||||||
|
|
||||||
if (invalidIds.length > 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
`CohortProject IDs not found in round ${roundId}: ${invalidIds.join(', ')}`,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update sortOrder for each item
|
|
||||||
await prisma.$transaction(
|
|
||||||
newOrder.map((cohortProjectId, index) =>
|
|
||||||
prisma.cohortProject.update({
|
|
||||||
where: { id: cohortProjectId },
|
|
||||||
data: { sortOrder: index },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
|
||||||
where: { roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (cursor) {
|
|
||||||
await prisma.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'live.queue_reordered',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
actorId,
|
|
||||||
detailsJson: {
|
|
||||||
roundId,
|
|
||||||
newOrderCount: newOrder.length,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: actorId,
|
|
||||||
action: 'LIVE_REORDER_QUEUE',
|
|
||||||
entityType: 'Round',
|
|
||||||
entityId: roundId,
|
|
||||||
detailsJson: { reorderedCount: newOrder.length },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LiveControl] Failed to reorder queue:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
error instanceof Error ? error.message : 'Failed to reorder queue',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pause / Resume ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the pause state of a live session.
|
|
||||||
*/
|
|
||||||
export async function pauseResume(
|
|
||||||
roundId: string,
|
|
||||||
isPaused: boolean,
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any
|
|
||||||
): Promise<{ success: boolean; errors?: string[] }> {
|
|
||||||
try {
|
|
||||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
|
||||||
where: { roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cursor) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: ['No live session found for this round'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.liveProgressCursor.update({
|
|
||||||
where: { roundId },
|
|
||||||
data: { isPaused },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: isPaused ? 'live.session_paused' : 'live.session_resumed',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
actorId,
|
|
||||||
detailsJson: {
|
|
||||||
roundId,
|
|
||||||
isPaused,
|
|
||||||
sessionId: cursor.sessionId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: actorId,
|
|
||||||
action: isPaused ? 'LIVE_SESSION_PAUSED' : 'LIVE_SESSION_RESUMED',
|
|
||||||
entityType: 'LiveProgressCursor',
|
|
||||||
entityId: cursor.id,
|
|
||||||
detailsJson: { roundId, isPaused },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LiveControl] Failed to pause/resume:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Failed to toggle pause state',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Cohort Window Management ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a cohort's voting window. Sets isOpen to true and records the
|
|
||||||
* window open timestamp.
|
|
||||||
*/
|
|
||||||
export async function openCohortWindow(
|
|
||||||
cohortId: string,
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any
|
|
||||||
): Promise<{ success: boolean; errors?: string[] }> {
|
|
||||||
try {
|
|
||||||
const cohort = await prisma.cohort.findUnique({
|
|
||||||
where: { id: cohortId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cohort) {
|
|
||||||
return { success: false, errors: [`Cohort ${cohortId} not found`] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cohort.isOpen) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [`Cohort "${cohort.name}" is already open`],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
await prisma.cohort.update({
|
|
||||||
where: { id: cohortId },
|
|
||||||
data: {
|
|
||||||
isOpen: true,
|
|
||||||
windowOpenAt: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'live.cohort_opened',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: cohortId,
|
|
||||||
actorId,
|
|
||||||
detailsJson: {
|
|
||||||
cohortName: cohort.name,
|
|
||||||
roundId: cohort.roundId,
|
|
||||||
openedAt: now.toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: actorId,
|
|
||||||
action: 'LIVE_COHORT_OPENED',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: cohortId,
|
|
||||||
detailsJson: { cohortName: cohort.name, roundId: cohort.roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LiveControl] Failed to open cohort window:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Failed to open cohort window',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close a cohort's voting window. Sets isOpen to false and records the
|
|
||||||
* window close timestamp.
|
|
||||||
*/
|
|
||||||
export async function closeCohortWindow(
|
|
||||||
cohortId: string,
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any
|
|
||||||
): Promise<{ success: boolean; errors?: string[] }> {
|
|
||||||
try {
|
|
||||||
const cohort = await prisma.cohort.findUnique({
|
|
||||||
where: { id: cohortId },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cohort) {
|
|
||||||
return { success: false, errors: [`Cohort ${cohortId} not found`] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cohort.isOpen) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [`Cohort "${cohort.name}" is already closed`],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
await prisma.cohort.update({
|
|
||||||
where: { id: cohortId },
|
|
||||||
data: {
|
|
||||||
isOpen: false,
|
|
||||||
windowCloseAt: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.decisionAuditLog.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'live.cohort_closed',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: cohortId,
|
|
||||||
actorId,
|
|
||||||
detailsJson: {
|
|
||||||
cohortName: cohort.name,
|
|
||||||
roundId: cohort.roundId,
|
|
||||||
closedAt: now.toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: actorId,
|
|
||||||
action: 'LIVE_COHORT_CLOSED',
|
|
||||||
entityType: 'Cohort',
|
|
||||||
entityId: cohortId,
|
|
||||||
detailsJson: { cohortName: cohort.name, roundId: cohort.roundId },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LiveControl] Failed to close cohort window:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errors: [
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Failed to close cohort window',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,13 +19,13 @@ type WorkspaceResult = { success: boolean; errors?: string[] }
|
|||||||
* Activate a mentor workspace for a given assignment.
|
* Activate a mentor workspace for a given assignment.
|
||||||
*/
|
*/
|
||||||
export async function activateWorkspace(
|
export async function activateWorkspace(
|
||||||
mentorAssignmentId: string,
|
workspaceId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WorkspaceResult> {
|
): Promise<WorkspaceResult> {
|
||||||
try {
|
try {
|
||||||
const assignment = await prisma.mentorAssignment.findUnique({
|
const assignment = await prisma.mentorAssignment.findUnique({
|
||||||
where: { id: mentorAssignmentId },
|
where: { id: workspaceId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
@@ -36,9 +36,9 @@ export async function activateWorkspace(
|
|||||||
return { success: false, errors: ['Workspace is already enabled'] }
|
return { success: false, errors: ['Workspace is already enabled'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.mentorAssignment.update({
|
await tx.mentorAssignment.update({
|
||||||
where: { id: mentorAssignmentId },
|
where: { id: workspaceId },
|
||||||
data: {
|
data: {
|
||||||
workspaceEnabled: true,
|
workspaceEnabled: true,
|
||||||
workspaceOpenAt: new Date(),
|
workspaceOpenAt: new Date(),
|
||||||
@@ -49,7 +49,7 @@ export async function activateWorkspace(
|
|||||||
data: {
|
data: {
|
||||||
eventType: 'mentor_workspace.activated',
|
eventType: 'mentor_workspace.activated',
|
||||||
entityType: 'MentorAssignment',
|
entityType: 'MentorAssignment',
|
||||||
entityId: mentorAssignmentId,
|
entityId: workspaceId,
|
||||||
actorId,
|
actorId,
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
projectId: assignment.projectId,
|
projectId: assignment.projectId,
|
||||||
@@ -64,7 +64,7 @@ export async function activateWorkspace(
|
|||||||
userId: actorId,
|
userId: actorId,
|
||||||
action: 'WORKSPACE_ACTIVATE',
|
action: 'WORKSPACE_ACTIVATE',
|
||||||
entityType: 'MentorAssignment',
|
entityType: 'MentorAssignment',
|
||||||
entityId: mentorAssignmentId,
|
entityId: workspaceId,
|
||||||
detailsJson: { projectId: assignment.projectId },
|
detailsJson: { projectId: assignment.projectId },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -86,15 +86,15 @@ export async function activateWorkspace(
|
|||||||
*/
|
*/
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
params: {
|
params: {
|
||||||
mentorAssignmentId: string
|
workspaceId: string
|
||||||
senderId: string
|
senderId: string
|
||||||
message: string
|
message: string
|
||||||
role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE'
|
role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE'
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const assignment = await prisma.mentorAssignment.findUnique({
|
const assignment = await prisma.mentorAssignment.findUnique({
|
||||||
where: { id: params.mentorAssignmentId },
|
where: { id: params.workspaceId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
@@ -107,11 +107,11 @@ export async function sendMessage(
|
|||||||
|
|
||||||
return prisma.mentorMessage.create({
|
return prisma.mentorMessage.create({
|
||||||
data: {
|
data: {
|
||||||
mentorAssignmentId: params.mentorAssignmentId,
|
workspaceId: params.workspaceId,
|
||||||
projectId: assignment.projectId,
|
projectId: assignment.projectId,
|
||||||
senderId: params.senderId,
|
senderId: params.senderId,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
role: params.role,
|
senderRole: params.role,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
sender: { select: { id: true, name: true, email: true } },
|
sender: { select: { id: true, name: true, email: true } },
|
||||||
@@ -123,11 +123,11 @@ export async function sendMessage(
|
|||||||
* Get messages for a workspace.
|
* Get messages for a workspace.
|
||||||
*/
|
*/
|
||||||
export async function getMessages(
|
export async function getMessages(
|
||||||
mentorAssignmentId: string,
|
workspaceId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.mentorMessage.findMany({
|
return prisma.mentorMessage.findMany({
|
||||||
where: { mentorAssignmentId },
|
where: { workspaceId },
|
||||||
include: {
|
include: {
|
||||||
sender: { select: { id: true, name: true, email: true, role: true } },
|
sender: { select: { id: true, name: true, email: true, role: true } },
|
||||||
},
|
},
|
||||||
@@ -140,7 +140,7 @@ export async function getMessages(
|
|||||||
*/
|
*/
|
||||||
export async function markRead(
|
export async function markRead(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await prisma.mentorMessage.update({
|
await prisma.mentorMessage.update({
|
||||||
where: { id: messageId },
|
where: { id: messageId },
|
||||||
@@ -155,7 +155,7 @@ export async function markRead(
|
|||||||
*/
|
*/
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
params: {
|
params: {
|
||||||
mentorAssignmentId: string
|
workspaceId: string
|
||||||
uploadedByUserId: string
|
uploadedByUserId: string
|
||||||
fileName: string
|
fileName: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
@@ -164,10 +164,10 @@ export async function uploadFile(
|
|||||||
objectKey: string
|
objectKey: string
|
||||||
description?: string
|
description?: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const assignment = await prisma.mentorAssignment.findUnique({
|
const assignment = await prisma.mentorAssignment.findUnique({
|
||||||
where: { id: params.mentorAssignmentId },
|
where: { id: params.workspaceId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
@@ -180,7 +180,7 @@ export async function uploadFile(
|
|||||||
|
|
||||||
return prisma.mentorFile.create({
|
return prisma.mentorFile.create({
|
||||||
data: {
|
data: {
|
||||||
mentorAssignmentId: params.mentorAssignmentId,
|
mentorAssignmentId: params.workspaceId,
|
||||||
uploadedByUserId: params.uploadedByUserId,
|
uploadedByUserId: params.uploadedByUserId,
|
||||||
fileName: params.fileName,
|
fileName: params.fileName,
|
||||||
mimeType: params.mimeType,
|
mimeType: params.mimeType,
|
||||||
@@ -205,7 +205,7 @@ export async function addFileComment(
|
|||||||
content: string
|
content: string
|
||||||
parentCommentId?: string
|
parentCommentId?: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.mentorFileComment.create({
|
return prisma.mentorFileComment.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -233,7 +233,7 @@ export async function promoteFile(
|
|||||||
slotKey: string
|
slotKey: string
|
||||||
promotedById: string
|
promotedById: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ success: boolean; errors?: string[] }> {
|
): Promise<{ success: boolean; errors?: string[] }> {
|
||||||
try {
|
try {
|
||||||
const file = await prisma.mentorFile.findUnique({
|
const file = await prisma.mentorFile.findUnique({
|
||||||
@@ -251,7 +251,7 @@ export async function promoteFile(
|
|||||||
return { success: false, errors: ['File is already promoted'] }
|
return { success: false, errors: ['File is already promoted'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Mark file as promoted
|
// Mark file as promoted
|
||||||
await tx.mentorFile.update({
|
await tx.mentorFile.update({
|
||||||
where: { id: params.mentorFileId },
|
where: { id: params.mentorFileId },
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ export async function sendNotification(
|
|||||||
|
|
||||||
// Overall success if at least one channel succeeded
|
// Overall success if at least one channel succeeded
|
||||||
result.success =
|
result.success =
|
||||||
(result.channels.email?.success ?? true) ||
|
(result.channels.email?.success ?? false) ||
|
||||||
(result.channels.whatsapp?.success ?? true)
|
(result.channels.whatsapp?.success ?? false)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function lockResults(
|
|||||||
lockedById: string
|
lockedById: string
|
||||||
resultSnapshot: unknown
|
resultSnapshot: unknown
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<LockResult> {
|
): Promise<LockResult> {
|
||||||
try {
|
try {
|
||||||
// Validate deliberation is finalized
|
// Validate deliberation is finalized
|
||||||
@@ -84,7 +84,7 @@ export async function lockResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lock = await prisma.$transaction(async (tx: any) => {
|
const lock = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const created = await tx.resultLock.create({
|
const created = await tx.resultLock.create({
|
||||||
data: {
|
data: {
|
||||||
competitionId: params.competitionId,
|
competitionId: params.competitionId,
|
||||||
@@ -109,7 +109,7 @@ export async function lockResults(
|
|||||||
snapshotJson: {
|
snapshotJson: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
emittedBy: 'result-lock',
|
emittedBy: 'result-lock',
|
||||||
resultSnapshot: params.resultSnapshot,
|
resultSnapshot: params.resultSnapshot as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -155,7 +155,7 @@ export async function unlockResults(
|
|||||||
unlockedById: string
|
unlockedById: string
|
||||||
reason: string
|
reason: string
|
||||||
},
|
},
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<UnlockResult> {
|
): Promise<UnlockResult> {
|
||||||
try {
|
try {
|
||||||
const lock = await prisma.resultLock.findUnique({
|
const lock = await prisma.resultLock.findUnique({
|
||||||
@@ -166,7 +166,7 @@ export async function unlockResults(
|
|||||||
return { success: false, errors: ['Result lock not found'] }
|
return { success: false, errors: ['Result lock not found'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await prisma.$transaction(async (tx: any) => {
|
const event = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const created = await tx.resultUnlockEvent.create({
|
const created = await tx.resultUnlockEvent.create({
|
||||||
data: {
|
data: {
|
||||||
resultLockId: params.resultLockId,
|
resultLockId: params.resultLockId,
|
||||||
@@ -226,7 +226,7 @@ export async function isLocked(
|
|||||||
competitionId: string,
|
competitionId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
category: CompetitionCategory,
|
category: CompetitionCategory,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<LockStatus> {
|
): Promise<LockStatus> {
|
||||||
const lock = await prisma.resultLock.findFirst({
|
const lock = await prisma.resultLock.findFirst({
|
||||||
where: { competitionId, roundId, category },
|
where: { competitionId, roundId, category },
|
||||||
@@ -265,7 +265,7 @@ export async function isLocked(
|
|||||||
*/
|
*/
|
||||||
export async function getLockHistory(
|
export async function getLockHistory(
|
||||||
competitionId: string,
|
competitionId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.resultLock.findMany({
|
return prisma.resultLock.findMany({
|
||||||
where: { competitionId },
|
where: { competitionId },
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
|
|||||||
export async function previewRoundAssignment(
|
export async function previewRoundAssignment(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
config?: { honorIntents?: boolean; requiredReviews?: number },
|
config?: { honorIntents?: boolean; requiredReviews?: number },
|
||||||
prisma?: PrismaClient | any,
|
prisma?: PrismaClient,
|
||||||
): Promise<AssignmentPreview> {
|
): Promise<AssignmentPreview> {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
const honorIntents = config?.honorIntents ?? true
|
const honorIntents = config?.honorIntents ?? true
|
||||||
@@ -390,7 +390,7 @@ export async function executeRoundAssignment(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
assignments: Array<{ userId: string; projectId: string }>,
|
assignments: Array<{ userId: string; projectId: string }>,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ created: number; errors: string[] }> {
|
): Promise<{ created: number; errors: string[] }> {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
@@ -398,7 +398,7 @@ export async function executeRoundAssignment(
|
|||||||
|
|
||||||
for (const assignment of assignments) {
|
for (const assignment of assignments) {
|
||||||
try {
|
try {
|
||||||
await db.$transaction(async (tx: any) => {
|
await db.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Create assignment record
|
// Create assignment record
|
||||||
await tx.assignment.create({
|
await tx.assignment.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -483,7 +483,7 @@ export async function executeRoundAssignment(
|
|||||||
export async function getRoundCoverageReport(
|
export async function getRoundCoverageReport(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
requiredReviews: number = 3,
|
requiredReviews: number = 3,
|
||||||
prisma?: PrismaClient | any,
|
prisma?: PrismaClient,
|
||||||
): Promise<CoverageReport> {
|
): Promise<CoverageReport> {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
|
|
||||||
@@ -558,7 +558,7 @@ export async function getRoundCoverageReport(
|
|||||||
export async function getUnassignedQueue(
|
export async function getUnassignedQueue(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
requiredReviews: number = 3,
|
requiredReviews: number = 3,
|
||||||
prisma?: PrismaClient | any,
|
prisma?: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
const db = prisma ?? (await import('@/lib/prisma')).prisma
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
|||||||
export async function activateRound(
|
export async function activateRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult> {
|
): Promise<RoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
@@ -127,7 +127,7 @@ export async function activateRound(
|
|||||||
windowData.windowOpenAt = now
|
windowData.windowOpenAt = now
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx: any) => {
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const result = await tx.round.update({
|
const result = await tx.round.update({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
data: { status: 'ROUND_ACTIVE', ...windowData },
|
data: { status: 'ROUND_ACTIVE', ...windowData },
|
||||||
@@ -234,7 +234,7 @@ export async function activateRound(
|
|||||||
export async function closeRound(
|
export async function closeRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult> {
|
): Promise<RoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
@@ -267,7 +267,7 @@ export async function closeRound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx: any) => {
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const result = await tx.round.update({
|
const result = await tx.round.update({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
data: { status: 'ROUND_CLOSED' },
|
data: { status: 'ROUND_CLOSED' },
|
||||||
@@ -383,7 +383,7 @@ export async function closeRound(
|
|||||||
export async function archiveRound(
|
export async function archiveRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult> {
|
): Promise<RoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
||||||
@@ -399,7 +399,7 @@ export async function archiveRound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx: any) => {
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const result = await tx.round.update({
|
const result = await tx.round.update({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
data: { status: 'ROUND_ARCHIVED' },
|
data: { status: 'ROUND_ARCHIVED' },
|
||||||
@@ -456,7 +456,7 @@ export async function archiveRound(
|
|||||||
export async function reopenRound(
|
export async function reopenRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
|
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
@@ -475,7 +475,7 @@ export async function reopenRound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Pause any subsequent active rounds in the same competition
|
// Pause any subsequent active rounds in the same competition
|
||||||
const subsequentActiveRounds = await tx.round.findMany({
|
const subsequentActiveRounds = await tx.round.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -601,7 +601,7 @@ export async function transitionProject(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
newState: ProjectRoundStateValue,
|
newState: ProjectRoundStateValue,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
options?: { adminOverride?: boolean },
|
options?: { adminOverride?: boolean },
|
||||||
): Promise<ProjectRoundTransitionResult> {
|
): Promise<ProjectRoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
@@ -624,7 +624,7 @@ export async function transitionProject(
|
|||||||
return { success: false, errors: [`Project ${projectId} not found`] }
|
return { success: false, errors: [`Project ${projectId} not found`] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// Upsert ProjectRoundState
|
// Upsert ProjectRoundState
|
||||||
@@ -722,7 +722,7 @@ export async function batchTransitionProjects(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
newState: ProjectRoundStateValue,
|
newState: ProjectRoundStateValue,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
options?: { adminOverride?: boolean },
|
options?: { adminOverride?: boolean },
|
||||||
): Promise<BatchProjectTransitionResult> {
|
): Promise<BatchProjectTransitionResult> {
|
||||||
const succeeded: string[] = []
|
const succeeded: string[] = []
|
||||||
@@ -754,7 +754,7 @@ export async function batchTransitionProjects(
|
|||||||
|
|
||||||
export async function getProjectRoundStates(
|
export async function getProjectRoundStates(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const states = await prisma.projectRoundState.findMany({
|
const states = await prisma.projectRoundState.findMany({
|
||||||
where: { roundId },
|
where: { roundId },
|
||||||
@@ -803,7 +803,7 @@ export async function getProjectRoundStates(
|
|||||||
export async function getProjectRoundState(
|
export async function getProjectRoundState(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.projectRoundState.findUnique({
|
return prisma.projectRoundState.findUnique({
|
||||||
where: { projectId_roundId: { projectId, roundId } },
|
where: { projectId_roundId: { projectId, roundId } },
|
||||||
@@ -823,7 +823,7 @@ export async function checkRequirementsAndTransition(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||||
try {
|
try {
|
||||||
// Get all required FileRequirements for this round (legacy model)
|
// Get all required FileRequirements for this round (legacy model)
|
||||||
@@ -939,13 +939,97 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
projectIds: string[],
|
projectIds: string[],
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
||||||
const transitioned: string[] = []
|
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
||||||
|
|
||||||
|
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
||||||
|
const [requirements, round] = await Promise.all([
|
||||||
|
prisma.fileRequirement.findMany({
|
||||||
|
where: { roundId, isRequired: true },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.round.findUnique({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: { submissionWindowId: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
let submissionRequirements: Array<{ id: string }> = []
|
||||||
|
if (round?.submissionWindowId) {
|
||||||
|
submissionRequirements = await prisma.submissionFileRequirement.findMany({
|
||||||
|
where: { submissionWindowId: round.submissionWindowId, required: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no requirements at all, nothing to check
|
||||||
|
if (requirements.length === 0 && submissionRequirements.length === 0) {
|
||||||
|
return { transitionedCount: 0, projectIds: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load all project files and current states in batch
|
||||||
|
type FileRow = { projectId: string; requirementId: string | null; submissionFileRequirementId: string | null }
|
||||||
|
type StateRow = { projectId: string; state: string }
|
||||||
|
|
||||||
|
const [allFiles, allStates] = await Promise.all([
|
||||||
|
prisma.projectFile.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: { in: projectIds },
|
||||||
|
roundId,
|
||||||
|
},
|
||||||
|
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
|
||||||
|
}) as Promise<FileRow[]>,
|
||||||
|
prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId, projectId: { in: projectIds } },
|
||||||
|
select: { projectId: true, state: true },
|
||||||
|
}) as Promise<StateRow[]>,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Build per-project lookup maps
|
||||||
|
const filesByProject = new Map<string, FileRow[]>()
|
||||||
|
for (const f of allFiles) {
|
||||||
|
const arr = filesByProject.get(f.projectId) ?? []
|
||||||
|
arr.push(f)
|
||||||
|
filesByProject.set(f.projectId, arr)
|
||||||
|
}
|
||||||
|
const stateByProject = new Map(allStates.map((s) => [s.projectId, s.state]))
|
||||||
|
|
||||||
|
// Determine which projects have all requirements met and are eligible for transition
|
||||||
|
const eligibleStates = ['PENDING', 'IN_PROGRESS']
|
||||||
|
const toTransition: string[] = []
|
||||||
|
|
||||||
for (const projectId of projectIds) {
|
for (const projectId of projectIds) {
|
||||||
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
|
const currentState = stateByProject.get(projectId)
|
||||||
if (result.transitioned) {
|
if (!currentState || !eligibleStates.includes(currentState)) continue
|
||||||
|
|
||||||
|
const files = filesByProject.get(projectId) ?? []
|
||||||
|
|
||||||
|
// Check legacy requirements
|
||||||
|
if (requirements.length > 0) {
|
||||||
|
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
|
||||||
|
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check submission requirements
|
||||||
|
if (submissionRequirements.length > 0) {
|
||||||
|
const fulfilledSubIds = new Set(files.map((f) => f.submissionFileRequirementId).filter(Boolean))
|
||||||
|
if (!submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
toTransition.push(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition eligible projects (still uses transitionProject for state machine correctness)
|
||||||
|
const transitioned: string[] = []
|
||||||
|
for (const projectId of toTransition) {
|
||||||
|
const currentState = stateByProject.get(projectId)
|
||||||
|
// If PENDING, first move to IN_PROGRESS
|
||||||
|
if (currentState === 'PENDING') {
|
||||||
|
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
|
||||||
|
}
|
||||||
|
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
||||||
|
if (result.success) {
|
||||||
transitioned.push(projectId)
|
transitioned.push(projectId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -967,7 +1051,7 @@ export async function triggerInProgressOnActivity(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const prs = await prisma.projectRoundState.findUnique({
|
const prs = await prisma.projectRoundState.findUnique({
|
||||||
@@ -994,7 +1078,7 @@ export async function checkEvaluationCompletionAndTransition(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitioned: boolean }> {
|
): Promise<{ transitioned: boolean }> {
|
||||||
try {
|
try {
|
||||||
const prs = await prisma.projectRoundState.findUnique({
|
const prs = await prisma.projectRoundState.findUnique({
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export type ConfirmFinalizationResult = {
|
|||||||
export async function processRoundClose(
|
export async function processRoundClose(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ processed: number }> {
|
): Promise<{ processed: number }> {
|
||||||
const round = await prisma.round.findUnique({
|
const round = await prisma.round.findUnique({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
@@ -170,14 +170,29 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 1: Compute target states and proposed outcomes in-memory ──
|
||||||
|
type StateUpdate = {
|
||||||
|
prsId: string
|
||||||
|
projectId: string
|
||||||
|
currentState: string
|
||||||
|
targetState: ProjectRoundStateValue
|
||||||
|
proposedOutcome: ProjectRoundStateValue
|
||||||
|
needsTransition: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: StateUpdate[] = []
|
||||||
|
|
||||||
for (const prs of projectStates) {
|
for (const prs of projectStates) {
|
||||||
// Skip already-terminal states
|
// Skip already-terminal states
|
||||||
if (isTerminalState(prs.state)) {
|
if (isTerminalState(prs.state)) {
|
||||||
// Set proposed outcome to match current state for display
|
|
||||||
if (!prs.proposedOutcome) {
|
if (!prs.proposedOutcome) {
|
||||||
await prisma.projectRoundState.update({
|
updates.push({
|
||||||
where: { id: prs.id },
|
prsId: prs.id,
|
||||||
data: { proposedOutcome: prs.state },
|
projectId: prs.projectId,
|
||||||
|
currentState: prs.state,
|
||||||
|
targetState: prs.state as ProjectRoundStateValue,
|
||||||
|
proposedOutcome: prs.state as ProjectRoundStateValue,
|
||||||
|
needsTransition: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
processed++
|
processed++
|
||||||
@@ -190,7 +205,6 @@ export async function processRoundClose(
|
|||||||
switch (round.roundType as RoundType) {
|
switch (round.roundType as RoundType) {
|
||||||
case 'INTAKE':
|
case 'INTAKE':
|
||||||
case 'SUBMISSION': {
|
case 'SUBMISSION': {
|
||||||
// Projects with activity → COMPLETED, purely PENDING → REJECTED
|
|
||||||
if (prs.state === 'PENDING') {
|
if (prs.state === 'PENDING') {
|
||||||
targetState = 'REJECTED' as ProjectRoundStateValue
|
targetState = 'REJECTED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
||||||
@@ -202,7 +216,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'EVALUATION': {
|
case 'EVALUATION': {
|
||||||
// Use ranking scores to determine pass/reject
|
|
||||||
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
|
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
|
||||||
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
|
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
|
||||||
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
|
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
|
||||||
@@ -218,7 +231,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'FILTERING': {
|
case 'FILTERING': {
|
||||||
// Use FilteringResult to determine outcome for each project
|
|
||||||
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
|
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
|
||||||
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
|
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
|
||||||
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
|
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
|
||||||
@@ -229,12 +241,10 @@ export async function processRoundClose(
|
|||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||||
} else if (prs.state === 'PENDING') {
|
} else if (prs.state === 'PENDING') {
|
||||||
// PENDING projects in filtering: check FilteringResult
|
|
||||||
if (fr) {
|
if (fr) {
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||||
} else {
|
} else {
|
||||||
// No filtering result at all → reject
|
|
||||||
targetState = 'REJECTED' as ProjectRoundStateValue
|
targetState = 'REJECTED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
||||||
}
|
}
|
||||||
@@ -243,7 +253,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'MENTORING': {
|
case 'MENTORING': {
|
||||||
// Projects already PASSED (pass-through) stay PASSED
|
|
||||||
if (prs.state === 'PASSED') {
|
if (prs.state === 'PASSED') {
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
} else if (prs.state === 'IN_PROGRESS') {
|
} else if (prs.state === 'IN_PROGRESS') {
|
||||||
@@ -252,7 +261,6 @@ export async function processRoundClose(
|
|||||||
} else if (prs.state === 'COMPLETED') {
|
} else if (prs.state === 'COMPLETED') {
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
} else if (prs.state === 'PENDING') {
|
} else if (prs.state === 'PENDING') {
|
||||||
// Pending = never requested mentoring, pass through
|
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
}
|
}
|
||||||
@@ -260,7 +268,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'LIVE_FINAL': {
|
case 'LIVE_FINAL': {
|
||||||
// All presented projects → COMPLETED
|
|
||||||
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
@@ -271,7 +278,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'DELIBERATION': {
|
case 'DELIBERATION': {
|
||||||
// All voted projects → COMPLETED
|
|
||||||
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
@@ -282,28 +288,113 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transition project if needed (admin override for non-standard paths)
|
const needsTransition = targetState !== prs.state && !isTerminalState(prs.state)
|
||||||
if (targetState !== prs.state && !isTerminalState(prs.state)) {
|
updates.push({
|
||||||
// Need to handle multi-step transitions
|
prsId: prs.id,
|
||||||
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
|
projectId: prs.projectId,
|
||||||
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
currentState: prs.state,
|
||||||
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
targetState,
|
||||||
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
|
proposedOutcome,
|
||||||
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
needsTransition,
|
||||||
} else {
|
|
||||||
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set proposed outcome
|
|
||||||
await prisma.projectRoundState.update({
|
|
||||||
where: { id: prs.id },
|
|
||||||
data: { proposedOutcome },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
processed++
|
processed++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: Batch state transitions in a single transaction ──
|
||||||
|
const transitionUpdates = updates.filter((u) => u.needsTransition)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (transitionUpdates.length > 0) {
|
||||||
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
|
// Step through intermediate states in bulk
|
||||||
|
// PENDING → IN_PROGRESS for projects going to COMPLETED
|
||||||
|
const pendingToCompleted = transitionUpdates.filter(
|
||||||
|
(u) => u.currentState === 'PENDING' && u.targetState === ('COMPLETED' as string),
|
||||||
|
)
|
||||||
|
if (pendingToCompleted.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: pendingToCompleted.map((u) => u.prsId) } },
|
||||||
|
data: { state: 'IN_PROGRESS' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IN_PROGRESS → COMPLETED (includes those just moved from PENDING)
|
||||||
|
const toCompleted = transitionUpdates.filter(
|
||||||
|
(u) => u.targetState === ('COMPLETED' as string) &&
|
||||||
|
(u.currentState === 'PENDING' || u.currentState === 'IN_PROGRESS'),
|
||||||
|
)
|
||||||
|
if (toCompleted.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: toCompleted.map((u) => u.prsId) } },
|
||||||
|
data: { state: 'COMPLETED' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PENDING → REJECTED (direct terminal transition)
|
||||||
|
const pendingToRejected = transitionUpdates.filter(
|
||||||
|
(u) => u.currentState === 'PENDING' && u.targetState === ('REJECTED' as string),
|
||||||
|
)
|
||||||
|
if (pendingToRejected.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: pendingToRejected.map((u) => u.prsId) } },
|
||||||
|
data: { state: 'REJECTED', exitedAt: now },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other single-step transitions (e.g., IN_PROGRESS → COMPLETED already handled)
|
||||||
|
const otherTransitions = transitionUpdates.filter(
|
||||||
|
(u) =>
|
||||||
|
!(u.currentState === 'PENDING' && (u.targetState === ('COMPLETED' as string) || u.targetState === ('REJECTED' as string))) &&
|
||||||
|
!(u.currentState === 'IN_PROGRESS' && u.targetState === ('COMPLETED' as string)),
|
||||||
|
)
|
||||||
|
if (otherTransitions.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: otherTransitions.map((u) => u.prsId) } },
|
||||||
|
data: { state: otherTransitions[0].targetState },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch create audit logs for all transitions
|
||||||
|
await tx.decisionAuditLog.createMany({
|
||||||
|
data: transitionUpdates.map((u) => ({
|
||||||
|
eventType: 'project_round.transitioned',
|
||||||
|
entityType: 'ProjectRoundState',
|
||||||
|
entityId: u.prsId,
|
||||||
|
actorId,
|
||||||
|
detailsJson: {
|
||||||
|
projectId: u.projectId,
|
||||||
|
roundId,
|
||||||
|
previousState: u.currentState,
|
||||||
|
newState: u.targetState,
|
||||||
|
batchProcessed: true,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
snapshotJson: {
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
emittedBy: 'round-finalization',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: Batch update proposed outcomes ──
|
||||||
|
const outcomeUpdates = updates.filter((u) => u.proposedOutcome)
|
||||||
|
// Group by proposed outcome for efficient updateMany calls
|
||||||
|
const outcomeGroups = new Map<ProjectRoundStateValue, string[]>()
|
||||||
|
for (const u of outcomeUpdates) {
|
||||||
|
const ids = outcomeGroups.get(u.proposedOutcome) ?? []
|
||||||
|
ids.push(u.prsId)
|
||||||
|
outcomeGroups.set(u.proposedOutcome, ids)
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(outcomeGroups.entries()).map(([outcome, ids]) =>
|
||||||
|
prisma.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { proposedOutcome: outcome },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return { processed }
|
return { processed }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +402,7 @@ export async function processRoundClose(
|
|||||||
|
|
||||||
export async function getFinalizationSummary(
|
export async function getFinalizationSummary(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<FinalizationSummary> {
|
): Promise<FinalizationSummary> {
|
||||||
const round = await prisma.round.findUniqueOrThrow({
|
const round = await prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
@@ -477,7 +568,7 @@ export async function confirmFinalization(
|
|||||||
rejectionMessage?: string
|
rejectionMessage?: string
|
||||||
},
|
},
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<ConfirmFinalizationResult> {
|
): Promise<ConfirmFinalizationResult> {
|
||||||
// Validate: round is CLOSED, not already finalized, grace period expired
|
// Validate: round is CLOSED, not already finalized, grace period expired
|
||||||
const round = await prisma.round.findUniqueOrThrow({
|
const round = await prisma.round.findUniqueOrThrow({
|
||||||
@@ -521,7 +612,7 @@ export async function confirmFinalization(
|
|||||||
: 'Next Round'
|
: 'Next Round'
|
||||||
|
|
||||||
// Execute finalization in a transaction
|
// Execute finalization in a transaction
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const projectStates = await tx.projectRoundState.findMany({
|
const projectStates = await tx.projectRoundState.findMany({
|
||||||
where: { roundId, proposedOutcome: { not: null } },
|
where: { roundId, proposedOutcome: { not: null } },
|
||||||
include: {
|
include: {
|
||||||
@@ -701,6 +792,8 @@ export async function confirmFinalization(
|
|||||||
const inviteTokenMap = new Map<string, string>() // userId → token
|
const inviteTokenMap = new Map<string, string>() // userId → token
|
||||||
const expiryMs = await getInviteExpiryMs(prisma)
|
const expiryMs = await getInviteExpiryMs(prisma)
|
||||||
|
|
||||||
|
// Collect all passwordless users needing invite tokens, then batch update
|
||||||
|
const tokenUpdates: Array<{ userId: string; token: string }> = []
|
||||||
for (const prs of finalizedStates) {
|
for (const prs of finalizedStates) {
|
||||||
if (prs.state !== 'PASSED') continue
|
if (prs.state !== 'PASSED') continue
|
||||||
const users = prs.project.teamMembers.length > 0
|
const users = prs.project.teamMembers.length > 0
|
||||||
@@ -710,17 +803,26 @@ export async function confirmFinalization(
|
|||||||
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
|
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
|
||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
inviteTokenMap.set(user.id, token)
|
inviteTokenMap.set(user.id, token)
|
||||||
await prisma.user.update({
|
tokenUpdates.push({ userId: user.id, token })
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
inviteToken: token,
|
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
|
||||||
status: 'INVITED',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Batch update all invite tokens concurrently
|
||||||
|
if (tokenUpdates.length > 0) {
|
||||||
|
const tokenExpiry = new Date(Date.now() + expiryMs)
|
||||||
|
await Promise.all(
|
||||||
|
tokenUpdates.map((t) =>
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: t.userId },
|
||||||
|
data: {
|
||||||
|
inviteToken: t.token,
|
||||||
|
inviteTokenExpiresAt: tokenExpiry,
|
||||||
|
status: 'INVITED',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const advancedUserIds = new Set<string>()
|
const advancedUserIds = new Set<string>()
|
||||||
const rejectedUserIds = new Set<string>()
|
const rejectedUserIds = new Set<string>()
|
||||||
@@ -801,7 +903,7 @@ export async function confirmFinalization(
|
|||||||
|
|
||||||
// Create in-app notifications
|
// Create in-app notifications
|
||||||
if (advancedUserIds.size > 0) {
|
if (advancedUserIds.size > 0) {
|
||||||
void createBulkNotifications({
|
createBulkNotifications({
|
||||||
userIds: [...advancedUserIds],
|
userIds: [...advancedUserIds],
|
||||||
type: 'project_advanced',
|
type: 'project_advanced',
|
||||||
title: 'Your project has advanced!',
|
title: 'Your project has advanced!',
|
||||||
@@ -810,11 +912,13 @@ export async function confirmFinalization(
|
|||||||
linkLabel: 'View Dashboard',
|
linkLabel: 'View Dashboard',
|
||||||
icon: 'Trophy',
|
icon: 'Trophy',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[Finalization] createBulkNotifications (advanced) failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rejectedUserIds.size > 0) {
|
if (rejectedUserIds.size > 0) {
|
||||||
void createBulkNotifications({
|
createBulkNotifications({
|
||||||
userIds: [...rejectedUserIds],
|
userIds: [...rejectedUserIds],
|
||||||
type: 'project_rejected',
|
type: 'project_rejected',
|
||||||
title: 'Competition Update',
|
title: 'Competition Update',
|
||||||
@@ -823,6 +927,8 @@ export async function confirmFinalization(
|
|||||||
linkLabel: 'View Dashboard',
|
linkLabel: 'View Dashboard',
|
||||||
icon: 'Info',
|
icon: 'Info',
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[Finalization] createBulkNotifications (rejected) failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type SubmissionValidationResult = {
|
|||||||
export async function openWindow(
|
export async function openWindow(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WindowLifecycleResult> {
|
): Promise<WindowLifecycleResult> {
|
||||||
try {
|
try {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
@@ -47,7 +47,7 @@ export async function openWindow(
|
|||||||
return { success: false, errors: ['Cannot open a locked window'] }
|
return { success: false, errors: ['Cannot open a locked window'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.submissionWindow.update({
|
await tx.submissionWindow.update({
|
||||||
where: { id: windowId },
|
where: { id: windowId },
|
||||||
data: {
|
data: {
|
||||||
@@ -93,7 +93,7 @@ export async function openWindow(
|
|||||||
export async function closeWindow(
|
export async function closeWindow(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WindowLifecycleResult> {
|
): Promise<WindowLifecycleResult> {
|
||||||
try {
|
try {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
@@ -102,7 +102,7 @@ export async function closeWindow(
|
|||||||
return { success: false, errors: [`Submission window ${windowId} not found`] }
|
return { success: false, errors: [`Submission window ${windowId} not found`] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = {
|
||||||
windowCloseAt: new Date(),
|
windowCloseAt: new Date(),
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ export async function closeWindow(
|
|||||||
export async function lockWindow(
|
export async function lockWindow(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<WindowLifecycleResult> {
|
): Promise<WindowLifecycleResult> {
|
||||||
try {
|
try {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
@@ -168,7 +168,7 @@ export async function lockWindow(
|
|||||||
return { success: false, errors: ['Window is already locked'] }
|
return { success: false, errors: ['Window is already locked'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.submissionWindow.update({
|
await tx.submissionWindow.update({
|
||||||
where: { id: windowId },
|
where: { id: windowId },
|
||||||
data: { isLocked: true },
|
data: { isLocked: true },
|
||||||
@@ -212,7 +212,7 @@ export async function lockWindow(
|
|||||||
*/
|
*/
|
||||||
export async function checkDeadlinePolicy(
|
export async function checkDeadlinePolicy(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<DeadlineStatus> {
|
): Promise<DeadlineStatus> {
|
||||||
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ export async function validateSubmission(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
windowId: string,
|
windowId: string,
|
||||||
files: Array<{ mimeType: string; size: number; requirementId?: string }>,
|
files: Array<{ mimeType: string; size: number; requirementId?: string }>,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<SubmissionValidationResult> {
|
): Promise<SubmissionValidationResult> {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ export async function validateSubmission(
|
|||||||
*/
|
*/
|
||||||
export async function isWindowReadOnly(
|
export async function isWindowReadOnly(
|
||||||
windowId: string,
|
windowId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const status = await checkDeadlinePolicy(windowId, prisma)
|
const status = await checkDeadlinePolicy(windowId, prisma)
|
||||||
return status.status === 'LOCKED' || status.status === 'CLOSED'
|
return status.status === 'LOCKED' || status.status === 'CLOSED'
|
||||||
@@ -340,7 +340,7 @@ export async function isWindowReadOnly(
|
|||||||
*/
|
*/
|
||||||
export async function getVisibleWindows(
|
export async function getVisibleWindows(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
const visibility = await prisma.roundSubmissionVisibility.findMany({
|
const visibility = await prisma.roundSubmissionVisibility.findMany({
|
||||||
where: { roundId, canView: true },
|
where: { roundId, canView: true },
|
||||||
|
|||||||
@@ -2,6 +2,46 @@ import crypto from 'crypto'
|
|||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MOPC Webhook Signature Verification
|
||||||
|
* ====================================
|
||||||
|
*
|
||||||
|
* Every outbound webhook delivery is signed with HMAC-SHA256 using the
|
||||||
|
* webhook's shared secret. The signature is sent in the `X-Webhook-Signature`
|
||||||
|
* header with a `sha256=` prefix.
|
||||||
|
*
|
||||||
|
* Additional headers included with each delivery:
|
||||||
|
* - X-Webhook-Event: the event type (e.g. "evaluation.submitted")
|
||||||
|
* - X-Webhook-Delivery: unique delivery ID (UUID)
|
||||||
|
*
|
||||||
|
* To verify a delivery on the consumer side:
|
||||||
|
*
|
||||||
|
* // Node.js example
|
||||||
|
* const crypto = require('crypto');
|
||||||
|
*
|
||||||
|
* function verifySignature(secret, body, signatureHeader) {
|
||||||
|
* const expected = 'sha256=' + crypto
|
||||||
|
* .createHmac('sha256', secret)
|
||||||
|
* .update(body, 'utf8') // raw request body string
|
||||||
|
* .digest('hex');
|
||||||
|
* return crypto.timingSafeEqual(
|
||||||
|
* Buffer.from(expected),
|
||||||
|
* Buffer.from(signatureHeader),
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // In your handler:
|
||||||
|
* const sig = req.headers['x-webhook-signature'];
|
||||||
|
* if (!verifySignature(WEBHOOK_SECRET, rawBody, sig)) {
|
||||||
|
* return res.status(401).send('Invalid signature');
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* IMPORTANT:
|
||||||
|
* - Always verify against the raw request body (before JSON parsing).
|
||||||
|
* - Use timing-safe comparison to prevent timing attacks.
|
||||||
|
* - The secret can be regenerated via the admin UI (Settings → Webhooks).
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch a webhook event to all active webhooks subscribed to this event.
|
* Dispatch a webhook event to all active webhooks subscribed to this event.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ZodError } from 'zod'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { Context } from './context'
|
import type { Context } from './context'
|
||||||
import type { UserRole } from '@prisma/client'
|
import type { UserRole } from '@prisma/client'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize tRPC with context type and configuration
|
* Initialize tRPC with context type and configuration
|
||||||
@@ -298,16 +299,62 @@ const withErrorAudit = middleware(async ({ ctx, next, path, type, getRawInput })
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Rate Limiting
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General rate limiter: 100 mutations per minute per user.
|
||||||
|
* Applied to all authenticated mutation procedures.
|
||||||
|
*/
|
||||||
|
const withRateLimit = middleware(async ({ ctx, next, type }) => {
|
||||||
|
// Only rate-limit mutations — queries are read-only
|
||||||
|
if (type !== 'mutation') return next()
|
||||||
|
|
||||||
|
const userId = ctx.session?.user?.id
|
||||||
|
if (!userId) return next()
|
||||||
|
|
||||||
|
const result = checkRateLimit(`trpc:${userId}`, 100, 60_000)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: 'Too many requests. Please try again shortly.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict rate limiter for AI-triggering procedures: 5 per hour per user.
|
||||||
|
* Protects against runaway OpenAI API costs.
|
||||||
|
*/
|
||||||
|
const withAIRateLimit = middleware(async ({ ctx, next }) => {
|
||||||
|
const userId = ctx.session?.user?.id
|
||||||
|
if (!userId) return next()
|
||||||
|
|
||||||
|
const result = checkRateLimit(`ai:${userId}`, 5, 60 * 60 * 1000)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: 'AI operation rate limit exceeded. Maximum 5 per hour.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Procedure Types
|
// Procedure Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected procedure - requires authenticated user.
|
* Protected procedure - requires authenticated user.
|
||||||
* Mutations auto-audited, errors (FORBIDDEN/UNAUTHORIZED/NOT_FOUND) tracked.
|
* Mutations rate-limited (100/min), auto-audited, errors tracked.
|
||||||
*/
|
*/
|
||||||
export const protectedProcedure = t.procedure
|
export const protectedProcedure = t.procedure
|
||||||
.use(isAuthenticated)
|
.use(isAuthenticated)
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -318,6 +365,7 @@ export const protectedProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const adminProcedure = t.procedure
|
export const adminProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -335,6 +383,7 @@ export const superAdminProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const juryProcedure = t.procedure
|
export const juryProcedure = t.procedure
|
||||||
.use(hasRole('JURY_MEMBER'))
|
.use(hasRole('JURY_MEMBER'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -344,6 +393,7 @@ export const juryProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const mentorProcedure = t.procedure
|
export const mentorProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -353,6 +403,7 @@ export const mentorProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const observerProcedure = t.procedure
|
export const observerProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -362,6 +413,7 @@ export const observerProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const awardMasterProcedure = t.procedure
|
export const awardMasterProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -371,5 +423,12 @@ export const awardMasterProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const audienceProcedure = t.procedure
|
export const audienceProcedure = t.procedure
|
||||||
.use(isAuthenticated)
|
.use(isAuthenticated)
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI rate limit middleware - apply to individual AI-triggering procedures.
|
||||||
|
* 5 operations per hour per user to protect OpenAI API costs.
|
||||||
|
*/
|
||||||
|
export { withAIRateLimit }
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
JuryGroupMember,
|
JuryGroupMember,
|
||||||
SubmissionWindow,
|
SubmissionWindow,
|
||||||
SubmissionFileRequirement,
|
SubmissionFileRequirement,
|
||||||
AdvancementRule,
|
|
||||||
RoundSubmissionVisibility,
|
RoundSubmissionVisibility,
|
||||||
ProjectRoundState,
|
ProjectRoundState,
|
||||||
DeliberationSession,
|
DeliberationSession,
|
||||||
@@ -36,7 +35,6 @@ export type RoundSummary = Pick<
|
|||||||
export type RoundWithRelations = Round & {
|
export type RoundWithRelations = Round & {
|
||||||
juryGroup: (JuryGroup & { members: JuryGroupMember[] }) | null
|
juryGroup: (JuryGroup & { members: JuryGroupMember[] }) | null
|
||||||
submissionWindow: (SubmissionWindow & { fileRequirements: SubmissionFileRequirement[] }) | null
|
submissionWindow: (SubmissionWindow & { fileRequirements: SubmissionFileRequirement[] }) | null
|
||||||
advancementRules: AdvancementRule[]
|
|
||||||
visibleSubmissionWindows: (RoundSubmissionVisibility & { submissionWindow: SubmissionWindow })[]
|
visibleSubmissionWindows: (RoundSubmissionVisibility & { submissionWindow: SubmissionWindow })[]
|
||||||
_count?: { projectRoundStates: number; assignments: number }
|
_count?: { projectRoundStates: number; assignments: number }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,7 +329,6 @@ export async function cleanupTestData(programId: string, userIds: string[] = [])
|
|||||||
// Delete in reverse dependency order — scoped by programId or userIds
|
// Delete in reverse dependency order — scoped by programId or userIds
|
||||||
if (userIds.length > 0) {
|
if (userIds.length > 0) {
|
||||||
await prisma.decisionAuditLog.deleteMany({ where: { actorId: { in: userIds } } })
|
await prisma.decisionAuditLog.deleteMany({ where: { actorId: { in: userIds } } })
|
||||||
await prisma.overrideAction.deleteMany({ where: { actorId: { in: userIds } } })
|
|
||||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||||
}
|
}
|
||||||
// Competition/Round cascade cleanup
|
// Competition/Round cascade cleanup
|
||||||
@@ -350,7 +349,6 @@ export async function cleanupTestData(programId: string, userIds: string[] = [])
|
|||||||
await prisma.evaluationSummary.deleteMany({ where: { round: { competition: { programId } } } })
|
await prisma.evaluationSummary.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
await prisma.evaluationDiscussion.deleteMany({ where: { round: { competition: { programId } } } })
|
await prisma.evaluationDiscussion.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId } } } })
|
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId } } } })
|
||||||
await prisma.advancementRule.deleteMany({ where: { round: { competition: { programId } } } })
|
|
||||||
await prisma.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
|
await prisma.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
|
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })
|
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||||
|
|||||||
Reference in New Issue
Block a user