Compare commits

...

8 Commits

Author SHA1 Message Date
6b40fe7726 refactor: tech debt batch 3 — type safety + assignment router split
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m4s
#5 — Replaced 55x PrismaClient | any with proper Prisma types across 8 files
- Service files: PrismaClient | any → PrismaClient, tx: any → Prisma.TransactionClient
- Fixed 4 real bugs uncovered by typing:
  - mentor-workspace.ts: wrong FK fields (mentorAssignmentId → workspaceId, role → senderRole)
  - ai-shortlist.ts: untyped string passed to CompetitionCategory enum filter
  - result-lock.ts: unknown passed where Prisma.InputJsonValue required

#9 — Split assignment.ts (2,775 lines) into 6 focused files:
  - shared.ts (93 lines) — MOVABLE_EVAL_STATUSES, buildBatchNotifications, getCandidateJurors
  - assignment-crud.ts (473 lines) — 8 core CRUD procedures
  - assignment-suggestions.ts (880 lines) — AI suggestions + job runner
  - assignment-notifications.ts (138 lines) — 2 notification procedures
  - assignment-redistribution.ts (1,162 lines) — 8 reassign/transfer procedures
  - index.ts (15 lines) — barrel export with router merge, zero frontend changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:47:06 +01:00
1c78ecf21d refactor: tech debt batch 2 — drop dead models, stale columns, schema cleanup
Schema:
- Drop 4 dead models: OverrideAction, NotificationPolicy, AssignmentException, AdvancementRule
- Drop 2 dead enums: OverrideReasonCode, AdvancementRuleType
- Drop 3 stale columns: Project.roundId, ConflictOfInterest.roundId, Evaluation.version
- Remove 3 back-relation fields from User, Assignment, Round

Code:
- Fix 6 COI queries in assignment.ts + 1 in juror-reassignment.ts
  (roundId filter → assignment.roundId after column drop)
- Remove orphaned Project.roundId write in project.ts createProject
- Remove advancementRules include from round.ts getById
- Remove AdvancementRule from RoundWithRelations type
- Clean up seed.ts (remove advancement rule seeding)
- Clean up tests/helpers.ts (remove dead model cleanup)
- Add TODO comments on user delete mutations (FK violation risk)

Migration: 20260308000000_drop_dead_models_and_stale_columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:35:23 +01:00
1356809cb1 fix: tech debt batch 1 — TS errors, vulnerabilities, dead code
- Fixed 12 TypeScript errors across analytics.ts, observer-project-detail.tsx, bulk-upload/page.tsx, settings/profile/page.tsx
- npm audit: 8 vulnerabilities resolved (1 critical, 4 high, 3 moderate)
- Deleted 3 dead files: live-control.ts (618 lines), feature-flags.ts, file-type-categories.ts
- Removed typescript.ignoreBuildErrors: true — TS errors now block builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:51:44 +01:00
1ebdf5f9c9 fix: batch 5 — input validation tightening + health check endpoint
- z.any() replaced with z.record(z.string()) on webhook headers
- availabilityJson typed with z.array(z.object({ start, end }))
- Frontend webhook headers converted from array to Record before API call
- Docker HEALTHCHECK added to Dockerfile (health endpoint already existed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:26:28 +01:00
a68ec3fb45 fix: batch 4 — connection pooling, graceful shutdown, email verification UX
- Prisma: connection_limit=10, pool_timeout=30 on DATABASE_URL in both compose files
- Graceful shutdown: SIGTERM/SIGINT forwarded to Node process in docker-entrypoint.sh
- testEmailConnection: replaced real email send with transporter.verify(), simplified UI to single button
- NotificationLog.userId index: confirmed already present, no change needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:16:29 +01:00
6f55fdf81f fix: batch 3 — webhook HMAC documentation + CSRF rate limiting
- Webhook HMAC: added consumer verification JSDoc with Node.js example using crypto.timingSafeEqual
- CSRF rate limiting: 20 requests/15min per IP on NextAuth /csrf endpoint
- Renamed withRateLimit to withPostRateLimit/withGetRateLimit for clarity
- 429 responses include Retry-After header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:05:42 +01:00
94cbfec70a fix: email XSS sanitization, bulk invite concurrency, error handling (code review batch 2)
- Add escapeHtml() helper and apply to all user-supplied variables in 20+ HTML email templates
- Auto-escape in sectionTitle() and statCard() helpers for defense-in-depth
- Replace 5 instances of incomplete manual escaping with escapeHtml()
- Refactor bulkInviteTeamMembers: batch all DB writes in $transaction, then send emails via Promise.allSettled with concurrency pool of 10
- Fix inner catch block in award-eligibility-job.ts to capture its own error variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:59:56 +01:00
b85a9b9a7b fix: security hardening + performance refactoring (code review batch 1)
- IDOR fix: deliberation vote now verifies juryMemberId === ctx.user.id
- Rate limiting: tRPC middleware (100/min), AI endpoints (5/hr), auth IP-based (10/15min)
- 6 compound indexes added to Prisma schema
- N+1 eliminated in processRoundClose (batch updateMany/createMany)
- N+1 eliminated in batchCheckRequirementsAndTransition (3 batch queries)
- Service extraction: juror-reassignment.ts (578 lines)
- Dead code removed: award.ts, cohort.ts, decision.ts (680 lines)
- 35 bare catch blocks replaced across 16 files
- Fire-and-forget async calls fixed
- Notification false positive bug fixed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:18:24 +01:00
67 changed files with 4305 additions and 5358 deletions

View File

@@ -69,5 +69,8 @@ EXPOSE 7600
ENV PORT=7600
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)
CMD ["/app/docker-entrypoint.sh"]

View File

@@ -68,7 +68,7 @@ services:
env_file:
- ../.env
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_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}

View File

@@ -23,7 +23,7 @@ services:
- .env
environment:
- 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_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${NEXTAUTH_SECRET}

View File

@@ -59,4 +59,18 @@ else
fi
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"

View File

@@ -4,8 +4,7 @@ const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['@prisma/client', 'minio'],
typescript: {
// We run tsc --noEmit separately before each push
ignoreBuildErrors: true,
ignoreBuildErrors: false,
},
experimental: {
optimizePackageImports: [

263
package-lock.json generated
View File

@@ -3830,9 +3830,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
"integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -3844,9 +3844,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
"integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -3858,9 +3858,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
"integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -3872,9 +3872,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
"integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -3886,9 +3886,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
"integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -3900,9 +3900,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
"integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -3914,9 +3914,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
"integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -3928,9 +3928,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
"integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -3942,9 +3942,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
"integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -3956,9 +3956,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
"integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -3970,9 +3970,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
"integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -3984,9 +3984,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
"integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -3998,9 +3998,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
"integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -4012,9 +4012,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
"integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -4026,9 +4026,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
"integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -4040,9 +4040,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
"integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -4054,9 +4054,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
"integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -4068,9 +4068,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -4082,9 +4082,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
"integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -4096,9 +4096,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
"integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -4110,9 +4110,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
"integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -4124,9 +4124,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
"integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -4138,9 +4138,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
"integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -4152,9 +4152,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
"integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -4166,9 +4166,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
"integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -5426,13 +5426,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -5967,9 +5967,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7504,11 +7504,14 @@
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -8603,9 +8606,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
"funding": [
{
"type": "github",
@@ -8614,7 +8617,7 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^1.1.1"
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -10325,12 +10328,12 @@
"license": "MIT"
},
"node_modules/jspdf": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
@@ -10936,9 +10939,9 @@
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
@@ -11877,9 +11880,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -14149,9 +14152,9 @@
}
},
"node_modules/rollup": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
"integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14165,31 +14168,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.0",
"@rollup/rollup-android-arm64": "4.57.0",
"@rollup/rollup-darwin-arm64": "4.57.0",
"@rollup/rollup-darwin-x64": "4.57.0",
"@rollup/rollup-freebsd-arm64": "4.57.0",
"@rollup/rollup-freebsd-x64": "4.57.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
"@rollup/rollup-linux-arm-musleabihf": "4.57.0",
"@rollup/rollup-linux-arm64-gnu": "4.57.0",
"@rollup/rollup-linux-arm64-musl": "4.57.0",
"@rollup/rollup-linux-loong64-gnu": "4.57.0",
"@rollup/rollup-linux-loong64-musl": "4.57.0",
"@rollup/rollup-linux-ppc64-gnu": "4.57.0",
"@rollup/rollup-linux-ppc64-musl": "4.57.0",
"@rollup/rollup-linux-riscv64-gnu": "4.57.0",
"@rollup/rollup-linux-riscv64-musl": "4.57.0",
"@rollup/rollup-linux-s390x-gnu": "4.57.0",
"@rollup/rollup-linux-x64-gnu": "4.57.0",
"@rollup/rollup-linux-x64-musl": "4.57.0",
"@rollup/rollup-openbsd-x64": "4.57.0",
"@rollup/rollup-openharmony-arm64": "4.57.0",
"@rollup/rollup-win32-arm64-msvc": "4.57.0",
"@rollup/rollup-win32-ia32-msvc": "4.57.0",
"@rollup/rollup-win32-x64-gnu": "4.57.0",
"@rollup/rollup-win32-x64-msvc": "4.57.0",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -15637,9 +15640,9 @@
}
},
"node_modules/underscore": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": {

View File

@@ -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");

View File

@@ -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";

View File

@@ -11,6 +11,10 @@ generator client {
datasource db {
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")
}
@@ -130,13 +134,6 @@ enum PartnerType {
OTHER
}
enum OverrideReasonCode {
DATA_CORRECTION
POLICY_EXCEPTION
JURY_CONFLICT
SPONSOR_DECISION
ADMIN_DISCRETION
}
// =============================================================================
// COMPETITION / ROUND ENGINE ENUMS
@@ -175,13 +172,6 @@ enum ProjectRoundStateValue {
WITHDRAWN
}
enum AdvancementRuleType {
AUTO_ADVANCE
SCORE_THRESHOLD
TOP_N
ADMIN_SELECTION
AI_RECOMMENDED
}
enum CapMode {
HARD
@@ -427,7 +417,6 @@ model User {
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
@@ -559,7 +548,6 @@ model EvaluationForm {
model Project {
id String @id @default(cuid())
programId String
roundId String?
status ProjectStatus @default(SUBMITTED)
// Core fields
@@ -759,7 +747,6 @@ model Assignment {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
evaluation Evaluation?
conflictOfInterest ConflictOfInterest?
exceptions AssignmentException[]
@@unique([userId, projectId, roundId])
@@index([roundId])
@@ -768,6 +755,7 @@ model Assignment {
@@index([isCompleted])
@@index([projectId, userId])
@@index([juryGroupId])
@@index([roundId, isCompleted])
}
model Evaluation {
@@ -785,11 +773,6 @@ model Evaluation {
binaryDecision Boolean? // Yes/No for semi-finalist
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
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -964,6 +947,7 @@ model NotificationLog {
@@index([projectId])
@@index([batchId])
@@index([email])
@@index([type, status])
}
// =============================================================================
@@ -1494,6 +1478,7 @@ model RankingSnapshot {
@@index([roundId])
@@index([triggeredById])
@@index([createdAt])
@@index([roundId, createdAt])
}
// Tracks progress of long-running AI tagging jobs
@@ -1722,7 +1707,6 @@ model ConflictOfInterest {
assignmentId String @unique
userId String
projectId String
roundId String? // Legacy — kept for historical data
hasConflict Boolean @default(false)
conflictType String? // "financial", "personal", "organizational", "other"
description String? @db.Text
@@ -1740,6 +1724,8 @@ model ConflictOfInterest {
@@index([userId])
@@index([hasConflict])
@@index([projectId])
@@index([userId, hasConflict])
}
// =============================================================================
@@ -2102,24 +2088,6 @@ model LiveProgressCursor {
@@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 {
id String @id @default(cuid())
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
@@ -2137,21 +2105,6 @@ model DecisionAuditLog {
@@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)
// =============================================================================
@@ -2227,7 +2180,6 @@ model Round {
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[]
advancementRules AdvancementRule[]
visibleSubmissionWindows RoundSubmissionVisibility[]
assignmentIntents AssignmentIntent[]
deliberationSessions DeliberationSession[]
@@ -2283,24 +2235,7 @@ model ProjectRoundState {
@@index([projectId])
@@index([roundId])
@@index([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])
@@index([roundId, state])
}
// =============================================================================
@@ -2479,22 +2414,6 @@ model AssignmentIntent {
@@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)
// =============================================================================

View File

@@ -15,7 +15,6 @@ import {
RoundStatus,
CapMode,
JuryGroupMemberRole,
AdvancementRuleType,
} from '@prisma/client'
import bcrypt from 'bcryptjs'
// 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)`)
// --- 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) ---
const intakeRound = rounds[0]
const allProjects = await prisma.project.findMany({

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'

View File

@@ -210,11 +210,16 @@ export default function WebhooksPage() {
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 = {
name: formData.name,
url: formData.url,
events: formData.events,
headers: formData.headers.filter((h) => h.key) as Record<string, string>[] | undefined,
headers: headersRecord,
maxRetries: formData.maxRetries,
}

View File

@@ -94,10 +94,10 @@ export default function ProfileSettingsPage() {
setExpertiseTags(user.expertiseTags || [])
setDigestFrequency(user.digestFrequency || 'none')
setPreferredWorkload(user.preferredWorkload ?? null)
const avail = user.availabilityJson as { startDate?: string; endDate?: string } | null
if (avail) {
setAvailabilityStart(avail.startDate || '')
setAvailabilityEnd(avail.endDate || '')
const avail = user.availabilityJson as Array<{ start?: string; end?: string }> | null
if (avail && avail.length > 0) {
setAvailabilityStart(avail[0].start || '')
setAvailabilityEnd(avail[0].end || '')
}
setProfileLoaded(true)
}
@@ -114,10 +114,10 @@ export default function ProfileSettingsPage() {
expertiseTags,
digestFrequency: digestFrequency as 'none' | 'daily' | 'weekly',
preferredWorkload: preferredWorkload ?? undefined,
availabilityJson: (availabilityStart || availabilityEnd) ? {
startDate: availabilityStart || undefined,
endDate: availabilityEnd || undefined,
} : undefined,
availabilityJson: (availabilityStart || availabilityEnd) ? [{
start: availabilityStart || '',
end: availabilityEnd || '',
}] : undefined,
})
toast.success('Profile updated successfully')
refetch()

View File

@@ -4,6 +4,9 @@ import { checkRateLimit } from '@/lib/rate-limit'
const AUTH_RATE_LIMIT = 10 // requests per window
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 {
return (
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) => {
// Only rate limit POST requests (sign-in, magic link sends)
if (req.method === 'POST') {
const ip = getClientIp(req)
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 { 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) {
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
return new Response(JSON.stringify({ error: 'Too many requests' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
@@ -34,5 +57,5 @@ function withRateLimit(handler: (req: Request) => Promise<Response>) {
}
}
export const GET = handlers.GET
export const POST = withRateLimit(handlers.POST as (req: Request) => Promise<Response>)
export const GET = withGetRateLimit(handlers.GET as (req: Request) => Promise<Response>)
export const POST = withPostRateLimit(handlers.POST as (req: Request) => Promise<Response>)

View File

@@ -1,13 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { checkRateLimit } from '@/lib/rate-limit'
/**
* Pre-check whether an email exists before sending a magic link.
* This is a closed platform (no self-registration) so revealing
* 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) {
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()
if (!email || typeof email !== 'string') {
return NextResponse.json({ exists: false }, { status: 400 })

View File

@@ -933,12 +933,14 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
// Group files by round
type FileItem = (typeof project.files)[number]
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) {
const key = (f as any).roundId ?? '__none__'
const key = f.roundId ?? '__none__'
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, {
roundId: round?.id ?? null,
roundId: f.roundId ?? null,
roundName: round?.name ?? 'Other Files',
sortOrder: round?.sortOrder ?? 999,
files: [],

View File

@@ -1,6 +1,5 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -18,15 +17,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
const formSchema = z.object({
smtp_host: z.string().min(1, 'SMTP host is required'),
@@ -51,8 +41,6 @@ interface EmailSettingsFormProps {
}
export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
const [testDialogOpen, setTestDialogOpen] = useState(false)
const [testEmail, setTestEmail] = useState('')
const utils = trpc.useUtils()
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) => {
setTestDialogOpen(false)
if (result.success) {
toast.success('Test email sent successfully')
toast.success('SMTP connection verified successfully')
} else {
toast.error(`Failed to send test email: ${result.error}`)
toast.error(`SMTP verification failed: ${result.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 })
}
const handleSendTest = () => {
if (!testEmail) {
toast.error('Please enter an email address')
return
}
sendTestEmail.mutate({ testEmail })
const handleVerifyConnection = () => {
verifyConnection.mutate()
}
return (
@@ -243,49 +226,24 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
)}
</Button>
<Dialog open={testDialogOpen} onOpenChange={setTestDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
<Button
type="button"
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 Test Email
</Button>
</DialogTrigger>
<DialogContent>
<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>
Verify Connection
</>
)}
</Button>
</div>
</form>
</Form>

View File

@@ -107,6 +107,19 @@ const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.c
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Get the base URL for links in emails.
* Uses NEXTAUTH_URL with a safe production fallback.
@@ -266,7 +279,7 @@ function ctaButton(url: string, text: string): string {
* Generate styled section title
*/
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;">
<tr>
<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.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${value}</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;">${escapeHtml(String(value))}</p>
</td>
</tr>
</table>
@@ -462,7 +475,7 @@ function getEvaluationReminderTemplate(
<tr>
<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: #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>
</tr>
</table>
@@ -470,7 +483,7 @@ function getEvaluationReminderTemplate(
const content = `
${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)}
${deadlineBox}
${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` : ''
// Escape HTML in message but preserve line breaks
const formattedMessage = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const formattedMessage = escapeHtml(message).replace(/\n/g, '<br>')
// Title card with success styling
const titleCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<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>
</tr>
</table>
@@ -567,7 +576,7 @@ function getJuryInvitationTemplate(
const content = `
${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.')}
${ctaButton(url, 'Accept Invitation')}
<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 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 = `
${sectionTitle(greeting)}
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>!`)}
${infoBox(`Your project "<strong>${projectName}</strong>" has been successfully received.`, 'success')}
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>!`)}
${infoBox(`Your project "<strong>${escapeHtml(projectName)}</strong>" has been successfully received.`, 'success')}
${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.')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
@@ -656,7 +665,7 @@ function getTeamMemberInviteTemplate(
const content = `
${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.')}
${ctaButton(inviteUrl, 'Accept Invitation')}
${infoBox('This invitation link will expire in 30 days.', 'info')}
@@ -729,9 +738,9 @@ function getAdvancedSemifinalTemplate(
const content = `
${sectionTitle(greeting)}
${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')}
${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 {
@@ -778,9 +787,9 @@ function getAdvancedFinalTemplate(
const content = `
${sectionTitle(greeting)}
${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')}
${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 {
@@ -818,8 +827,8 @@ function getMentorAssignedTemplate(
<tr>
<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.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${mentorName}</p>
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${mentorBio}</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;">${escapeHtml(mentorBio)}</p>` : ''}
</td>
</tr>
</table>
@@ -827,7 +836,7 @@ function getMentorAssignedTemplate(
const content = `
${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}
${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')}
@@ -867,11 +876,11 @@ function getNotSelectedTemplate(
const content = `
${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.')}
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
${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;">
Thank you for being part of the Monaco Ocean Protection Challenge community.
</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;">
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">&#127942;</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>
</tr>
</table>
@@ -928,9 +937,9 @@ function getWinnerAnnouncementTemplate(
const content = `
${sectionTitle(greeting)}
${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')}
${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.')}
`
@@ -972,7 +981,7 @@ function getAssignedToProjectTemplate(
<tr>
<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.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>
</tr>
</table>
@@ -983,7 +992,7 @@ function getAssignedToProjectTemplate(
<tr>
<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: #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>
</tr>
</table>
@@ -991,7 +1000,7 @@ function getAssignedToProjectTemplate(
const content = `
${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}
${deadlineBox}
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
@@ -1037,7 +1046,7 @@ function getCOIReassignedTemplate(
<tr>
<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.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>
</tr>
</table>
@@ -1048,7 +1057,7 @@ function getCOIReassignedTemplate(
<tr>
<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: #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>
</tr>
</table>
@@ -1056,7 +1065,7 @@ function getCOIReassignedTemplate(
const content = `
${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}
${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.')}
@@ -1104,7 +1113,7 @@ function getManualReassignedTemplate(
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
<tr>
<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>
</tr>
</table>
@@ -1115,7 +1124,7 @@ function getManualReassignedTemplate(
<tr>
<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: #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>
</tr>
</table>
@@ -1123,7 +1132,7 @@ function getManualReassignedTemplate(
const content = `
${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}
${deadlineBox}
${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;">
<tr>
<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>
</tr>
</table>
@@ -1185,7 +1194,7 @@ function getDropoutReassignedTemplate(
<tr>
<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: #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>
</tr>
</table>
@@ -1193,10 +1202,10 @@ function getDropoutReassignedTemplate(
const content = `
${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}
${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') : ''}
`
@@ -1241,7 +1250,7 @@ function getBatchAssignedTemplate(
<tr>
<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: #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>
</tr>
</table>
@@ -1249,7 +1258,7 @@ function getBatchAssignedTemplate(
const content = `
${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)}
${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.')}
@@ -1294,7 +1303,7 @@ function getRoundNowOpenTemplate(
<tr>
<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>
<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>
</tr>
</table>
@@ -1305,7 +1314,7 @@ function getRoundNowOpenTemplate(
<tr>
<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: #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>
</tr>
</table>
@@ -1367,9 +1376,9 @@ function getReminder24HTemplate(
const content = `
${sectionTitle(greeting)}
${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)}
${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.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
`
@@ -1421,9 +1430,9 @@ function getReminder3DaysTemplate(
const content = `
${sectionTitle(greeting)}
${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)}
${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.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
`
@@ -1476,7 +1485,7 @@ function getReminder1HTemplate(
const content = `
${sectionTitle(greeting)}
${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)}
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
${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;">
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">&#127942;</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>
</tr>
</table>
@@ -1530,9 +1539,9 @@ function getAwardVotingOpenTemplate(
const content = `
${sectionTitle(greeting)}
${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)}
${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.')}
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
`
@@ -1576,9 +1585,9 @@ function getMenteeAssignedTemplate(
<tr>
<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.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;">
<strong>Team Lead:</strong> ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
<strong>Team Lead:</strong> ${escapeHtml(teamLeadName)}${teamLeadEmail ? ` (${escapeHtml(teamLeadEmail)})` : ''}
</p>
</td>
</tr>
@@ -1630,9 +1639,9 @@ function getMenteeAdvancedTemplate(
const content = `
${sectionTitle(greeting)}
${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)}
${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.')}
`
@@ -1680,7 +1689,7 @@ function getMenteeWonTemplate(
const content = `
${sectionTitle(greeting)}
${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')}
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
`
@@ -1719,9 +1728,9 @@ function getNewApplicationTemplate(
<tr>
<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.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;">
<strong>Applicant:</strong> ${applicantName} (${applicantEmail})
<strong>Applicant:</strong> ${escapeHtml(applicantName)} (${escapeHtml(applicantEmail)})
</p>
</td>
</tr>
@@ -1730,7 +1739,7 @@ function getNewApplicationTemplate(
const content = `
${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}
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
`
@@ -1770,11 +1779,7 @@ export function getAdvancementNotificationTemplate(
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
? escapeHtml(customMessage).replace(/\n/g, '<br>')
: null
// Full custom body mode: only the custom message inside the branded wrapper
@@ -1807,8 +1812,8 @@ export function getAdvancementNotificationTemplate(
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${infoBox(`<strong>"${projectName}"</strong>`, 'success')}
${infoBox(`Advanced from <strong>${fromRoundName}</strong> to <strong>${toRoundName}</strong>`, 'info')}
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong>`, 'success')}
${infoBox(`Advanced from <strong>${escapeHtml(fromRoundName)}</strong> to <strong>${escapeHtml(toRoundName)}</strong>`, 'info')}
${
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>`
@@ -1857,11 +1862,7 @@ export function getRejectionNotificationTemplate(
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
? escapeHtml(customMessage).replace(/\n/g, '<br>')
: null
// Full custom body mode: only the custom message inside the branded wrapper
@@ -1882,7 +1883,7 @@ export function getRejectionNotificationTemplate(
const content = `
${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')}
${
escapedMessage
@@ -1942,17 +1943,13 @@ export function getAwardSelectionNotificationTemplate(
`
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
? escapeHtml(customMessage).replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${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.')}
${
escapedMessage
@@ -1994,11 +1991,7 @@ Together for a healthier ocean.
* Generate a preview HTML wrapper for admin email previews
*/
export function getEmailPreviewHtml(subject: string, body: string): string {
const formattedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
const content = `
${sectionTitle(subject)}
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
@@ -2021,7 +2014,7 @@ export function getAccountReminderTemplate(
const content = `
${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')}
${ctaButton(accountUrl, 'Set Up Your Account')}
${paragraph('If you have any questions, please contact the MOPC team.')}
@@ -2454,11 +2447,7 @@ function getNotificationEmailTemplate(
const greeting = name ? `Hello ${name},` : 'Hello,'
// Format body text preserving line breaks
const formattedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
const content = `
${sectionTitle(greeting)}

View File

@@ -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}`,
},
})
}

View File

@@ -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
)
}

View File

@@ -36,10 +36,7 @@ import { projectPoolRouter } from './project-pool'
import { wizardTemplateRouter } from './wizard-template'
import { dashboardRouter } from './dashboard'
// Legacy round routers (kept)
import { cohortRouter } from './cohort'
import { liveRouter } from './live'
import { decisionRouter } from './decision'
import { awardRouter } from './award'
// Competition architecture routers (Phase 0+1)
import { competitionRouter } from './competition'
import { roundRouter } from './round'
@@ -94,10 +91,7 @@ export const appRouter = router({
wizardTemplate: wizardTemplateRouter,
dashboard: dashboardRouter,
// Legacy round routers (kept)
cohort: cohortRouter,
live: liveRouter,
decision: decisionRouter,
award: awardRouter,
// Competition architecture routers (Phase 0+1)
competition: competitionRouter,
round: roundRouter,

View File

@@ -1379,11 +1379,10 @@ export const analyticsRouter = router({
bucket: true, objectKey: true, pageCount: true, textPreview: true,
detectedLang: true, langConfidence: true, analyzedAt: true,
roundId: true,
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
requirementId: true,
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
},
orderBy: [{ round: { sortOrder: 'asc' } }, { createdAt: 'asc' }],
orderBy: [{ createdAt: 'asc' }],
},
teamMembers: {
include: {
@@ -2259,7 +2258,7 @@ export const analyticsRouter = router({
const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig
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({
where: { roundId: input.roundId },

View File

@@ -10,7 +10,7 @@ import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
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).
// 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) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function isProjectRejected(prisma: any, projectId: string): Promise<boolean> {
async function isProjectRejected(prisma: PrismaClient, projectId: string): Promise<boolean> {
const rejected = await prisma.projectRoundState.findFirst({
where: { projectId, state: 'REJECTED' },
select: { id: true },
@@ -1008,7 +1007,8 @@ export const applicantRouter = router({
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
}
@@ -1043,7 +1043,8 @@ export const applicantRouter = router({
status: 'SENT',
},
})
} catch {
} catch (err) {
console.error('Failed to log sent team invitation notification:', err)
// Never fail on notification logging
}
@@ -1061,7 +1062,8 @@ export const applicantRouter = router({
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
}

View File

@@ -842,7 +842,8 @@ export const applicationRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for draft submission:', err)
// Never throw on audit failure
}

File diff suppressed because it is too large Load Diff

View 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,
}
}),
})

View 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 }
}),
})

File diff suppressed because it is too large Load Diff

View 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,
}
}),
})

View 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,
})

View 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 },
})
}

View File

@@ -41,8 +41,8 @@ export const assignmentPolicyRouter = router({
role: member.role,
policy,
}
} catch {
// Member may not be linked to this round's jury group
} catch (err) {
console.error('[AssignmentPolicy] Failed to resolve member context:', err)
return null
}
}),
@@ -103,8 +103,8 @@ export const assignmentPolicyRouter = router({
remaining: policy.remainingCapacity,
})
}
} catch {
// Skip members that can't be resolved
} catch (err) {
console.error('[AssignmentPolicy] Failed to evaluate policy for member:', err)
}
}

View File

@@ -330,7 +330,8 @@ export const auditRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for retention config update:', err)
// Never throw on audit failure
}

View File

@@ -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
})

View File

@@ -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 },
})),
}
}),
})

View File

@@ -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 }
}),
})

View File

@@ -118,6 +118,14 @@ export const deliberationRouter = router({
})
)
.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)
await logAudit({

View File

@@ -1,9 +1,9 @@
import { z } from 'zod'
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 { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { reassignAfterCOI } from './assignment'
import { reassignAfterCOI } from '../services/juror-reassignment'
import { sendManualReminders } from '../services/evaluation-reminders'
import { generateSummary } from '@/server/services/ai-evaluation-summary'
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.`,
priority: 'high',
})
} catch {
} catch (err) {
console.error('Failed to send AI ranking failure notification to admins:', err)
// Even notification failure must not propagate
}
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)
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
const projectId = evaluation.assignment.projectId
@@ -794,6 +797,7 @@ export const evaluationRouter = router({
* Generate an AI-powered evaluation summary for a project (admin only)
*/
generateSummary: adminProcedure
.use(withAIRateLimit)
.input(
z.object({
projectId: z.string(),
@@ -834,6 +838,7 @@ export const evaluationRouter = router({
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
*/
generateBulkSummaries: adminProcedure
.use(withAIRateLimit)
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Find all projects with at least 1 submitted evaluation in this stage
@@ -1239,7 +1244,8 @@ export const evaluationRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for discussion comment creation:', err)
// Never throw on audit failure
}
@@ -1276,7 +1282,8 @@ export const evaluationRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for discussion close:', err)
// Never throw on audit failure
}

View File

@@ -679,7 +679,8 @@ export const exportRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for round export:', err)
// Never throw on audit failure
}

View File

@@ -902,7 +902,9 @@ export const fileRouter = router({
entityId: requirement.id,
detailsJson: { name: input.name, roundId: input.roundId },
})
} catch {}
} catch (err) {
console.error('[File] Audit log failed:', err)
}
return requirement
}),
@@ -938,7 +940,9 @@ export const fileRouter = router({
entityId: id,
detailsJson: data,
})
} catch {}
} catch (err) {
console.error('[File] Audit log failed:', err)
}
return requirement
}),
@@ -961,7 +965,9 @@ export const fileRouter = router({
entityType: 'FileRequirement',
entityId: input.id,
})
} catch {}
} catch (err) {
console.error('[File] Audit log failed:', err)
}
return { success: true }
}),
@@ -1594,7 +1600,8 @@ export const fileRouter = router({
try {
await client.statObject(bucket, objectKey)
results[objectKey] = true
} catch {
} catch (err) {
console.error('Failed to stat MinIO object during existence check:', err)
results[objectKey] = false
}
})

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
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 { sanitizeUserInput } from '../services/ai-prompt-guard'
import { logAudit } from '../utils/audit'
@@ -652,6 +652,7 @@ export const filteringRouter = router({
* Start a filtering job (runs in background)
*/
startJob: adminProcedure
.use(withAIRateLimit)
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const existingJob = await ctx.prisma.filteringJob.findFirst({

View File

@@ -40,7 +40,8 @@ async function canUserAccessResource(
const parsed = accessJson as unknown[]
if (!Array.isArray(parsed) || parsed.length === 0) return true
rules = parsed as AccessRule[]
} catch {
} catch (err) {
console.error('Failed to parse learning resource access rules JSON:', err)
return true
}

View File

@@ -739,8 +739,8 @@ export const liveVotingRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
} catch (err) {
console.error('[LiveVoting] Audit log failed:', err)
}
return session

View File

@@ -538,7 +538,8 @@ export const mentorRouter = router({
} else {
failed++
}
} catch {
} catch (err) {
console.error('Failed to send mentor assignment notifications:', err)
failed++
}
}
@@ -866,8 +867,8 @@ export const mentorRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
} catch (err) {
console.error('[Mentor] Audit log failed:', err)
}
return note
@@ -1081,8 +1082,8 @@ export const mentorRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
} catch (err) {
console.error('[Mentor] Audit log failed:', err)
}
return { completion, allRequiredDone }
@@ -1342,7 +1343,7 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => {
return workspaceSendMessage(
{
mentorAssignmentId: input.mentorAssignmentId,
workspaceId: input.mentorAssignmentId,
senderId: ctx.user.id,
message: input.message,
role: input.role,
@@ -1388,7 +1389,7 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => {
return workspaceUploadFile(
{
mentorAssignmentId: input.mentorAssignmentId,
workspaceId: input.mentorAssignmentId,
uploadedByUserId: ctx.user.id,
fileName: input.fileName,
mimeType: input.mimeType,

View File

@@ -145,7 +145,9 @@ export const messageRouter = router({
scheduled: isScheduled,
},
})
} catch {}
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return {
...message,
@@ -334,7 +336,9 @@ export const messageRouter = router({
entityId: template.id,
detailsJson: { name: input.name, category: input.category },
})
} catch {}
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
@@ -378,7 +382,9 @@ export const messageRouter = router({
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch {}
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
@@ -402,7 +408,9 @@ export const messageRouter = router({
entityType: 'MessageTemplate',
entityId: input.id,
})
} catch {}
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),

View File

@@ -505,7 +505,8 @@ export const projectRouter = router({
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
orderBy: { confidence: 'desc' },
})
} catch {
} catch (err) {
console.error('Failed to fetch project tags:', err)
// ProjectTag table may not exist yet
}
@@ -637,10 +638,6 @@ export const projectRouter = router({
})
if (input.roundId) {
await tx.project.update({
where: { id: created.id },
data: { roundId: input.roundId },
})
await tx.projectRoundState.create({
data: {
projectId: created.id,
@@ -746,12 +743,13 @@ export const projectRouter = router({
status: 'SENT',
},
})
} catch {
} catch (err) {
console.error('Failed to log invitation notification for project team member:', err)
// Never fail on notification logging
}
} catch {
} catch (err) {
// 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 inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
} catch {
} catch (err) {
// 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)
}
}

View File

@@ -1,4 +1,4 @@
import { router, adminProcedure } from '../trpc'
import { router, adminProcedure, withAIRateLimit } from '../trpc'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
@@ -69,6 +69,7 @@ export const rankingRouter = router({
* RANK-05, RANK-06, RANK-08.
*/
executeRanking: adminProcedure
.use(withAIRateLimit)
.input(
z.object({
roundId: z.string(),
@@ -260,6 +261,7 @@ export const rankingRouter = router({
* Reads ranking criteria from round configJson and executes quickRank.
*/
triggerAutoRank: adminProcedure
.use(withAIRateLimit)
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { roundId } = input

View File

@@ -120,7 +120,6 @@ export const roundRouter = router({
submissionWindow: {
include: { fileRequirements: true },
},
advancementRules: { orderBy: { sortOrder: 'asc' } },
visibleSubmissionWindows: {
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 expiryMs = expiryHours * 60 * 60 * 1000
let invited = 0
let skipped = 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) {
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
skipped++
continue
}
toInvite.push({ ...user, token: generateInviteToken() })
}
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
if (toInvite.length > 0) {
await ctx.prisma.$transaction(
toInvite.map((u) =>
ctx.prisma.user.update({
where: { id: u.id },
data: {
status: 'INVITED',
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}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
invited++
} catch (err) {
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
failed++
)
for (const result of results) {
if (result.status === 'fulfilled') {
invited++
} else {
console.error('[bulkInviteTeamMembers] Email send failed:', result.reason)
failed++
}
}
}

View File

@@ -322,14 +322,14 @@ export const settingsRouter = router({
* Test email connection
*/
testEmailConnection: superAdminProcedure
.input(z.object({ testEmail: z.string().email() }))
.mutation(async ({ ctx, input }) => {
.mutation(async () => {
try {
const { sendTestEmail } = await import('@/lib/email')
const success = await sendTestEmail(input.testEmail)
return { success, error: success ? null : 'Failed to send test email' }
const { verifyEmailConnection } = await import('@/lib/email')
const success = await verifyEmailConnection()
return { success, error: success ? null : 'SMTP connection verification failed' }
} 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,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for digest settings update:', err)
// Never throw on audit failure
}
@@ -701,7 +702,8 @@ export const settingsRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for analytics settings update:', err)
// Never throw on audit failure
}
@@ -760,7 +762,8 @@ export const settingsRouter = router({
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
} catch (err) {
console.error('Failed to write audit log for audit settings update:', err)
// Never throw on audit failure
}

View File

@@ -365,11 +365,13 @@ export const specialAwardRouter = router({
})
// Fire and forget - process in background
void processEligibilityJob(
processEligibilityJob(
input.awardId,
input.includeSubmitted ?? false,
ctx.user.id
)
).catch((err) => {
console.error('[SpecialAward] processEligibilityJob failed:', err)
})
return { started: true }
}),
@@ -913,12 +915,14 @@ export const specialAwardRouter = router({
})
// Fire and forget - process in background with round scoping
void processEligibilityJob(
processEligibilityJob(
input.awardId,
true, // include submitted
ctx.user.id,
input.roundId
)
).catch((err) => {
console.error('[SpecialAward] processEligibilityJob (round) failed:', err)
})
return { started: true }
}),

View File

@@ -106,7 +106,7 @@ export const userRouter = router({
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
expertiseTags: z.array(z.string()).max(15).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(),
})
)
@@ -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({
where: { id: ctx.user.id },
})
@@ -539,7 +542,7 @@ export const userRouter = router({
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(),
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(),
})
)
@@ -657,6 +660,10 @@ export const userRouter = router({
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({
where: { id: input.id },
})

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, superAdminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import {
@@ -80,7 +81,7 @@ export const webhookRouter = router({
name: z.string().min(1).max(200),
url: z.string().url(),
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),
})
)
@@ -108,7 +109,9 @@ export const webhookRouter = router({
entityId: webhook.id,
detailsJson: { name: input.name, url: input.url, events: input.events },
})
} catch {}
} catch (err) {
console.error('[Webhook] Audit log failed:', err)
}
return webhook
}),
@@ -123,7 +126,7 @@ export const webhookRouter = router({
name: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
events: z.array(z.string()).min(1).optional(),
headers: z.any().optional(),
headers: z.record(z.string()).optional(),
isActive: z.boolean().optional(),
maxRetries: z.number().int().min(0).max(10).optional(),
})
@@ -152,7 +155,9 @@ export const webhookRouter = router({
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch {}
} catch (err) {
console.error('[Webhook] Audit log failed:', err)
}
return webhook
}),
@@ -176,7 +181,9 @@ export const webhookRouter = router({
entityType: 'Webhook',
entityId: input.id,
})
} catch {}
} catch (err) {
console.error('[Webhook] Audit log failed:', err)
}
return { success: true }
}),
@@ -192,7 +199,7 @@ export const webhookRouter = router({
})
if (!webhook) {
throw new Error('Webhook not found')
throw new TRPCError({ code: 'NOT_FOUND', message: 'Webhook not found' })
}
const testPayload = {
@@ -231,7 +238,9 @@ export const webhookRouter = router({
entityId: input.id,
detailsJson: { deliveryStatus: result?.status },
})
} catch {}
} catch (err) {
console.error('[Webhook] Audit log failed:', err)
}
return result
}),
@@ -292,7 +301,9 @@ export const webhookRouter = router({
entityType: 'Webhook',
entityId: input.id,
})
} catch {}
} catch (err) {
console.error('[Webhook] Audit log failed:', err)
}
return webhook
}),

View File

@@ -14,7 +14,7 @@ import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/open
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, logAIError } from './ai-errors'
import { extractMultipleFileContents } from './file-content-extractor'
import type { PrismaClient } from '@prisma/client'
import type { PrismaClient, CompetitionCategory } from '@prisma/client'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -95,14 +95,14 @@ async function generateCategoryShortlist(
rubric?: string
aiParseFiles: boolean
},
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<{ recommendations: ShortlistRecommendation[]; tokensUsed: number; errors: string[] }> {
const { roundId, category, topN, rubric, aiParseFiles } = params
// Load projects with evaluations for this category
const projects = await prisma.project.findMany({
where: {
competitionCategory: category,
competitionCategory: category as CompetitionCategory,
assignments: { some: { roundId } },
},
include: {
@@ -320,7 +320,7 @@ export async function generateShortlist(
rubric?: string
aiParseFiles?: boolean
},
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<ShortlistResult> {
const {
roundId,

View File

@@ -284,9 +284,9 @@ export async function processEligibilityJob(
eligibilityJobError: errorMessage,
},
})
} catch {
} catch (updateErr) {
// 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)
}
}
}

View 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,
}
}

View File

@@ -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',
],
}
}
}

View File

@@ -19,13 +19,13 @@ type WorkspaceResult = { success: boolean; errors?: string[] }
* Activate a mentor workspace for a given assignment.
*/
export async function activateWorkspace(
mentorAssignmentId: string,
workspaceId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<WorkspaceResult> {
try {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: mentorAssignmentId },
where: { id: workspaceId },
})
if (!assignment) {
@@ -36,9 +36,9 @@ export async function activateWorkspace(
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({
where: { id: mentorAssignmentId },
where: { id: workspaceId },
data: {
workspaceEnabled: true,
workspaceOpenAt: new Date(),
@@ -49,7 +49,7 @@ export async function activateWorkspace(
data: {
eventType: 'mentor_workspace.activated',
entityType: 'MentorAssignment',
entityId: mentorAssignmentId,
entityId: workspaceId,
actorId,
detailsJson: {
projectId: assignment.projectId,
@@ -64,7 +64,7 @@ export async function activateWorkspace(
userId: actorId,
action: 'WORKSPACE_ACTIVATE',
entityType: 'MentorAssignment',
entityId: mentorAssignmentId,
entityId: workspaceId,
detailsJson: { projectId: assignment.projectId },
})
})
@@ -86,15 +86,15 @@ export async function activateWorkspace(
*/
export async function sendMessage(
params: {
mentorAssignmentId: string
workspaceId: string
senderId: string
message: string
role: 'MENTOR_ROLE' | 'APPLICANT_ROLE' | 'ADMIN_ROLE'
},
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: params.mentorAssignmentId },
where: { id: params.workspaceId },
})
if (!assignment) {
@@ -107,11 +107,11 @@ export async function sendMessage(
return prisma.mentorMessage.create({
data: {
mentorAssignmentId: params.mentorAssignmentId,
workspaceId: params.workspaceId,
projectId: assignment.projectId,
senderId: params.senderId,
message: params.message,
role: params.role,
senderRole: params.role,
},
include: {
sender: { select: { id: true, name: true, email: true } },
@@ -123,11 +123,11 @@ export async function sendMessage(
* Get messages for a workspace.
*/
export async function getMessages(
mentorAssignmentId: string,
prisma: PrismaClient | any,
workspaceId: string,
prisma: PrismaClient,
) {
return prisma.mentorMessage.findMany({
where: { mentorAssignmentId },
where: { workspaceId },
include: {
sender: { select: { id: true, name: true, email: true, role: true } },
},
@@ -140,7 +140,7 @@ export async function getMessages(
*/
export async function markRead(
messageId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<void> {
await prisma.mentorMessage.update({
where: { id: messageId },
@@ -155,7 +155,7 @@ export async function markRead(
*/
export async function uploadFile(
params: {
mentorAssignmentId: string
workspaceId: string
uploadedByUserId: string
fileName: string
mimeType: string
@@ -164,10 +164,10 @@ export async function uploadFile(
objectKey: string
description?: string
},
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: params.mentorAssignmentId },
where: { id: params.workspaceId },
})
if (!assignment) {
@@ -180,7 +180,7 @@ export async function uploadFile(
return prisma.mentorFile.create({
data: {
mentorAssignmentId: params.mentorAssignmentId,
mentorAssignmentId: params.workspaceId,
uploadedByUserId: params.uploadedByUserId,
fileName: params.fileName,
mimeType: params.mimeType,
@@ -205,7 +205,7 @@ export async function addFileComment(
content: string
parentCommentId?: string
},
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
return prisma.mentorFileComment.create({
data: {
@@ -233,7 +233,7 @@ export async function promoteFile(
slotKey: string
promotedById: string
},
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<{ success: boolean; errors?: string[] }> {
try {
const file = await prisma.mentorFile.findUnique({
@@ -251,7 +251,7 @@ export async function promoteFile(
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
await tx.mentorFile.update({
where: { id: params.mentorFileId },

View File

@@ -104,8 +104,8 @@ export async function sendNotification(
// Overall success if at least one channel succeeded
result.success =
(result.channels.email?.success ?? true) ||
(result.channels.whatsapp?.success ?? true)
(result.channels.email?.success ?? false) ||
(result.channels.whatsapp?.success ?? false)
return result
}

View File

@@ -46,7 +46,7 @@ export async function lockResults(
lockedById: string
resultSnapshot: unknown
},
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<LockResult> {
try {
// 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({
data: {
competitionId: params.competitionId,
@@ -109,7 +109,7 @@ export async function lockResults(
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'result-lock',
resultSnapshot: params.resultSnapshot,
resultSnapshot: params.resultSnapshot as Prisma.InputJsonValue,
},
},
})
@@ -155,7 +155,7 @@ export async function unlockResults(
unlockedById: string
reason: string
},
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<UnlockResult> {
try {
const lock = await prisma.resultLock.findUnique({
@@ -166,7 +166,7 @@ export async function unlockResults(
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({
data: {
resultLockId: params.resultLockId,
@@ -226,7 +226,7 @@ export async function isLocked(
competitionId: string,
roundId: string,
category: CompetitionCategory,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<LockStatus> {
const lock = await prisma.resultLock.findFirst({
where: { competitionId, roundId, category },
@@ -265,7 +265,7 @@ export async function isLocked(
*/
export async function getLockHistory(
competitionId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
return prisma.resultLock.findMany({
where: { competitionId },

View File

@@ -75,7 +75,7 @@ const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
export async function previewRoundAssignment(
roundId: string,
config?: { honorIntents?: boolean; requiredReviews?: number },
prisma?: PrismaClient | any,
prisma?: PrismaClient,
): Promise<AssignmentPreview> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const honorIntents = config?.honorIntents ?? true
@@ -390,7 +390,7 @@ export async function executeRoundAssignment(
roundId: string,
assignments: Array<{ userId: string; projectId: string }>,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<{ created: number; errors: string[] }> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
const errors: string[] = []
@@ -398,7 +398,7 @@ export async function executeRoundAssignment(
for (const assignment of assignments) {
try {
await db.$transaction(async (tx: any) => {
await db.$transaction(async (tx: Prisma.TransactionClient) => {
// Create assignment record
await tx.assignment.create({
data: {
@@ -483,7 +483,7 @@ export async function executeRoundAssignment(
export async function getRoundCoverageReport(
roundId: string,
requiredReviews: number = 3,
prisma?: PrismaClient | any,
prisma?: PrismaClient,
): Promise<CoverageReport> {
const db = prisma ?? (await import('@/lib/prisma')).prisma
@@ -558,7 +558,7 @@ export async function getRoundCoverageReport(
export async function getUnassignedQueue(
roundId: string,
requiredReviews: number = 3,
prisma?: PrismaClient | any,
prisma?: PrismaClient,
) {
const db = prisma ?? (await import('@/lib/prisma')).prisma

View File

@@ -74,7 +74,7 @@ const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
export async function activateRound(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<RoundTransitionResult> {
try {
const round = await prisma.round.findUnique({
@@ -127,7 +127,7 @@ export async function activateRound(
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({
where: { id: roundId },
data: { status: 'ROUND_ACTIVE', ...windowData },
@@ -234,7 +234,7 @@ export async function activateRound(
export async function closeRound(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<RoundTransitionResult> {
try {
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({
where: { id: roundId },
data: { status: 'ROUND_CLOSED' },
@@ -383,7 +383,7 @@ export async function closeRound(
export async function archiveRound(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<RoundTransitionResult> {
try {
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({
where: { id: roundId },
data: { status: 'ROUND_ARCHIVED' },
@@ -456,7 +456,7 @@ export async function archiveRound(
export async function reopenRound(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
try {
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
const subsequentActiveRounds = await tx.round.findMany({
where: {
@@ -601,7 +601,7 @@ export async function transitionProject(
roundId: string,
newState: ProjectRoundStateValue,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
options?: { adminOverride?: boolean },
): Promise<ProjectRoundTransitionResult> {
try {
@@ -624,7 +624,7 @@ export async function transitionProject(
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()
// Upsert ProjectRoundState
@@ -722,7 +722,7 @@ export async function batchTransitionProjects(
roundId: string,
newState: ProjectRoundStateValue,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
options?: { adminOverride?: boolean },
): Promise<BatchProjectTransitionResult> {
const succeeded: string[] = []
@@ -754,7 +754,7 @@ export async function batchTransitionProjects(
export async function getProjectRoundStates(
roundId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
const states = await prisma.projectRoundState.findMany({
where: { roundId },
@@ -803,7 +803,7 @@ export async function getProjectRoundStates(
export async function getProjectRoundState(
projectId: string,
roundId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
return prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
@@ -823,7 +823,7 @@ export async function checkRequirementsAndTransition(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<{ transitioned: boolean; newState?: string }> {
try {
// Get all required FileRequirements for this round (legacy model)
@@ -939,13 +939,97 @@ export async function batchCheckRequirementsAndTransition(
roundId: string,
projectIds: string[],
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): 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) {
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
if (result.transitioned) {
const currentState = stateByProject.get(projectId)
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)
}
}
@@ -967,7 +1051,7 @@ export async function triggerInProgressOnActivity(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<void> {
try {
const prs = await prisma.projectRoundState.findUnique({
@@ -994,7 +1078,7 @@ export async function checkEvaluationCompletionAndTransition(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<{ transitioned: boolean }> {
try {
const prs = await prisma.projectRoundState.findUnique({

View File

@@ -75,7 +75,7 @@ export type ConfirmFinalizationResult = {
export async function processRoundClose(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<{ processed: number }> {
const round = await prisma.round.findUnique({
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) {
// Skip already-terminal states
if (isTerminalState(prs.state)) {
// Set proposed outcome to match current state for display
if (!prs.proposedOutcome) {
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome: prs.state },
updates.push({
prsId: prs.id,
projectId: prs.projectId,
currentState: prs.state,
targetState: prs.state as ProjectRoundStateValue,
proposedOutcome: prs.state as ProjectRoundStateValue,
needsTransition: false,
})
}
processed++
@@ -190,7 +205,6 @@ export async function processRoundClose(
switch (round.roundType as RoundType) {
case 'INTAKE':
case 'SUBMISSION': {
// Projects with activity → COMPLETED, purely PENDING → REJECTED
if (prs.state === 'PENDING') {
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
@@ -202,7 +216,6 @@ export async function processRoundClose(
}
case 'EVALUATION': {
// Use ranking scores to determine pass/reject
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
@@ -218,7 +231,6 @@ export async function processRoundClose(
}
case 'FILTERING': {
// Use FilteringResult to determine outcome for each project
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
@@ -229,12 +241,10 @@ export async function processRoundClose(
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// PENDING projects in filtering: check FilteringResult
if (fr) {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else {
// No filtering result at all → reject
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
}
@@ -243,7 +253,6 @@ export async function processRoundClose(
}
case 'MENTORING': {
// Projects already PASSED (pass-through) stay PASSED
if (prs.state === 'PASSED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS') {
@@ -252,7 +261,6 @@ export async function processRoundClose(
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// Pending = never requested mentoring, pass through
proposedOutcome = 'PASSED' as ProjectRoundStateValue
targetState = 'COMPLETED' as ProjectRoundStateValue
}
@@ -260,7 +268,6 @@ export async function processRoundClose(
}
case 'LIVE_FINAL': {
// All presented projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
@@ -271,7 +278,6 @@ export async function processRoundClose(
}
case 'DELIBERATION': {
// All voted projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
@@ -282,28 +288,113 @@ export async function processRoundClose(
}
}
// Transition project if needed (admin override for non-standard paths)
if (targetState !== prs.state && !isTerminalState(prs.state)) {
// Need to handle multi-step transitions
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
} else {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
}
}
// Set proposed outcome
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome },
const needsTransition = targetState !== prs.state && !isTerminalState(prs.state)
updates.push({
prsId: prs.id,
projectId: prs.projectId,
currentState: prs.state,
targetState,
proposedOutcome,
needsTransition,
})
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 }
}
@@ -311,7 +402,7 @@ export async function processRoundClose(
export async function getFinalizationSummary(
roundId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<FinalizationSummary> {
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
@@ -477,7 +568,7 @@ export async function confirmFinalization(
rejectionMessage?: string
},
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<ConfirmFinalizationResult> {
// Validate: round is CLOSED, not already finalized, grace period expired
const round = await prisma.round.findUniqueOrThrow({
@@ -521,7 +612,7 @@ export async function confirmFinalization(
: 'Next Round'
// 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({
where: { roundId, proposedOutcome: { not: null } },
include: {
@@ -701,6 +792,8 @@ export async function confirmFinalization(
const inviteTokenMap = new Map<string, string>() // userId → token
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) {
if (prs.state !== 'PASSED') continue
const users = prs.project.teamMembers.length > 0
@@ -710,17 +803,26 @@ export async function confirmFinalization(
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
const token = generateInviteToken()
inviteTokenMap.set(user.id, token)
await prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
status: 'INVITED',
},
})
tokenUpdates.push({ userId: user.id, token })
}
}
}
// 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 rejectedUserIds = new Set<string>()
@@ -801,7 +903,7 @@ export async function confirmFinalization(
// Create in-app notifications
if (advancedUserIds.size > 0) {
void createBulkNotifications({
createBulkNotifications({
userIds: [...advancedUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
@@ -810,11 +912,13 @@ export async function confirmFinalization(
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
}).catch((err) => {
console.error('[Finalization] createBulkNotifications (advanced) failed:', err)
})
}
if (rejectedUserIds.size > 0) {
void createBulkNotifications({
createBulkNotifications({
userIds: [...rejectedUserIds],
type: 'project_rejected',
title: 'Competition Update',
@@ -823,6 +927,8 @@ export async function confirmFinalization(
linkLabel: 'View Dashboard',
icon: 'Info',
priority: 'normal',
}).catch((err) => {
console.error('[Finalization] createBulkNotifications (rejected) failed:', err)
})
}
} catch (emailError) {

View File

@@ -34,7 +34,7 @@ export type SubmissionValidationResult = {
export async function openWindow(
windowId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<WindowLifecycleResult> {
try {
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'] }
}
await prisma.$transaction(async (tx: any) => {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.submissionWindow.update({
where: { id: windowId },
data: {
@@ -93,7 +93,7 @@ export async function openWindow(
export async function closeWindow(
windowId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<WindowLifecycleResult> {
try {
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`] }
}
await prisma.$transaction(async (tx: any) => {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const data: Record<string, unknown> = {
windowCloseAt: new Date(),
}
@@ -155,7 +155,7 @@ export async function closeWindow(
export async function lockWindow(
windowId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<WindowLifecycleResult> {
try {
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'] }
}
await prisma.$transaction(async (tx: any) => {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.submissionWindow.update({
where: { id: windowId },
data: { isLocked: true },
@@ -212,7 +212,7 @@ export async function lockWindow(
*/
export async function checkDeadlinePolicy(
windowId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<DeadlineStatus> {
const window = await prisma.submissionWindow.findUnique({ where: { id: windowId } })
@@ -273,7 +273,7 @@ export async function validateSubmission(
projectId: string,
windowId: string,
files: Array<{ mimeType: string; size: number; requirementId?: string }>,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<SubmissionValidationResult> {
const errors: string[] = []
@@ -327,7 +327,7 @@ export async function validateSubmission(
*/
export async function isWindowReadOnly(
windowId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<boolean> {
const status = await checkDeadlinePolicy(windowId, prisma)
return status.status === 'LOCKED' || status.status === 'CLOSED'
@@ -340,7 +340,7 @@ export async function isWindowReadOnly(
*/
export async function getVisibleWindows(
roundId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
const visibility = await prisma.roundSubmissionVisibility.findMany({
where: { roundId, canView: true },

View File

@@ -2,6 +2,46 @@ import crypto from 'crypto'
import { Prisma } from '@prisma/client'
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.
*/

View File

@@ -4,6 +4,7 @@ import { ZodError } from 'zod'
import type { Prisma } from '@prisma/client'
import type { Context } from './context'
import type { UserRole } from '@prisma/client'
import { checkRateLimit } from '@/lib/rate-limit'
/**
* 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
// =============================================================================
/**
* 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
.use(isAuthenticated)
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -318,6 +365,7 @@ export const protectedProcedure = t.procedure
*/
export const adminProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -335,6 +383,7 @@ export const superAdminProcedure = t.procedure
*/
export const juryProcedure = t.procedure
.use(hasRole('JURY_MEMBER'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -344,6 +393,7 @@ export const juryProcedure = t.procedure
*/
export const mentorProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -353,6 +403,7 @@ export const mentorProcedure = t.procedure
*/
export const observerProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -362,6 +413,7 @@ export const observerProcedure = t.procedure
*/
export const awardMasterProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -371,5 +423,12 @@ export const awardMasterProcedure = t.procedure
*/
export const audienceProcedure = t.procedure
.use(isAuthenticated)
.use(withRateLimit)
.use(withErrorAudit)
.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 }

View File

@@ -5,7 +5,6 @@ import type {
JuryGroupMember,
SubmissionWindow,
SubmissionFileRequirement,
AdvancementRule,
RoundSubmissionVisibility,
ProjectRoundState,
DeliberationSession,
@@ -36,7 +35,6 @@ export type RoundSummary = Pick<
export type RoundWithRelations = Round & {
juryGroup: (JuryGroup & { members: JuryGroupMember[] }) | null
submissionWindow: (SubmissionWindow & { fileRequirements: SubmissionFileRequirement[] }) | null
advancementRules: AdvancementRule[]
visibleSubmissionWindows: (RoundSubmissionVisibility & { submissionWindow: SubmissionWindow })[]
_count?: { projectRoundStates: number; assignments: number }
}

View File

@@ -329,7 +329,6 @@ export async function cleanupTestData(programId: string, userIds: string[] = [])
// Delete in reverse dependency order — scoped by programId or userIds
if (userIds.length > 0) {
await prisma.decisionAuditLog.deleteMany({ where: { actorId: { in: userIds } } })
await prisma.overrideAction.deleteMany({ where: { actorId: { in: userIds } } })
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
}
// 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.evaluationDiscussion.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.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })