From 5cfe9dbb475f75bb3e9f7d29b1ba50e79ec226b1 Mon Sep 17 00:00:00 2001 From: Priyangshu-Mandal Date: Sat, 5 Sep 2026 22:04:52 +0530 Subject: [PATCH 1/3] feat(contests): complete problem content, code runner, and submission integrity --- docker-compose.yml | 12 + pnpm-lock.yaml | 230 ++++++++++++++++++ pnpm-workspace.yaml | 1 + src/app/api/contests/rooms/route.ts | 33 ++- src/app/api/contests/sync/route.ts | 54 +++- src/components/contests/ArenaRoomClient.tsx | 13 +- src/components/contests/BlitzRoomClient.tsx | 11 +- src/components/contests/ContestCodeRunner.tsx | 84 +++++++ .../ContestProblemContent.module.scss | 33 +++ .../contests/ContestProblemContent.tsx | 53 ++++ .../contests/roomPresentation.test.ts | 15 ++ src/components/contests/roomPresentation.ts | 7 + .../shared/code-runner/CodeRunner.module.scss | 23 ++ src/lib/api/schemas/contestRoute.ts | 1 - src/lib/contests/dtos.ts | 8 + src/lib/contests/problemContent.test.ts | 50 ++++ src/lib/contests/problemContent.ts | 53 ++++ src/lib/contests/runtime.ts | 12 + src/lib/workers/cfSyncWorker.ts | 25 ++ src/lib/workers/reconciliationWorker.ts | 83 ++++--- src/models/ContestProblemSet.ts | 21 ++ src/models/ContestSubmission.ts | 3 + 22 files changed, 775 insertions(+), 50 deletions(-) create mode 100644 src/components/contests/ContestCodeRunner.tsx create mode 100644 src/components/contests/ContestProblemContent.module.scss create mode 100644 src/components/contests/ContestProblemContent.tsx create mode 100644 src/components/contests/roomPresentation.test.ts create mode 100644 src/lib/contests/problemContent.test.ts create mode 100644 src/lib/contests/problemContent.ts diff --git a/docker-compose.yml b/docker-compose.yml index cfab746c..a92fc716 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,4 +15,16 @@ services: command: ["--replSet", "rs0", "--bind_ip_all"] ports: - "27017:27017" + environment: + MONGO_INITDB_DATABASE: ccw + healthcheck: + test: echo "db.runCommand('ping')" | mongosh localhost/test --quiet + interval: 5s + timeout: 5s + retries: 30 restart: unless-stopped + volumes: + - mongodb_data:/data/db + +volumes: + mongodb_data: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 060501ca..0534ff18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1595,6 +1595,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1606,6 +1610,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1904,6 +1912,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1915,6 +1930,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2113,6 +2131,9 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2333,6 +2354,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2349,6 +2375,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2366,6 +2395,17 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-package-json@1.2.0: + resolution: {integrity: sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2434,10 +2474,18 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -2564,6 +2612,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-interval@2.0.1: resolution: {integrity: sha512-r4Aotzf+OtKIGQCB3odUowy4GfUDTy3aTWTfLd7ZF2gBCy3XW3v/dJLRefZnOFFnjqs5B1TypvS8WarpBkYUNQ==} @@ -2664,6 +2716,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -3141,9 +3197,21 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} @@ -3309,10 +3377,18 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -3412,6 +3488,9 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3456,6 +3535,11 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + redis-memory-server@0.16.1: + resolution: {integrity: sha512-+FfNDWAxuWx8RopTu09eBoiGPr6jLgVf1RVXWVMAc27/RT+vOqpjia3M6Q/Ex5dLu/PZfoWZotlM2iHiAgDdsg==} + engines: {node: '>=18'} + hasBin: true + redis-parser@3.0.0: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} @@ -3657,6 +3741,14 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -3687,6 +3779,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -4054,6 +4150,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -4084,6 +4188,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -4092,6 +4200,9 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5351,6 +5462,8 @@ snapshots: agent-base@7.1.4: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5360,6 +5473,10 @@ snapshots: ansi-regex@6.3.0: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -5656,6 +5773,12 @@ snapshots: color-name@1.1.4: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -5664,6 +5787,8 @@ snapshots: commander@8.3.0: {} + commondir@1.0.1: {} + concat-map@0.0.1: {} concurrently@10.0.4: @@ -5874,6 +5999,8 @@ snapshots: emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} encoding-sniffer@0.2.1: @@ -6259,6 +6386,16 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -6289,6 +6426,19 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-package-json@1.2.0: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6360,11 +6510,17 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-port@5.1.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -6658,6 +6814,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -7347,6 +7505,12 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + monaco-editor@0.55.1: dependencies: dompurify: 3.2.7 @@ -7526,10 +7690,18 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -7626,6 +7798,11 @@ snapshots: proxy-from-env@2.1.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pyodide@314.0.3: @@ -7674,6 +7851,24 @@ snapshots: redis-errors@1.2.0: {} + redis-memory-server@0.16.1: + dependencies: + camelcase: 6.3.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + extract-zip: 2.0.1 + find-cache-dir: 3.3.2 + find-package-json: 1.2.0 + get-port: 5.1.1 + https-proxy-agent: 7.0.6 + lockfile: 1.0.4 + rimraf: 5.0.10 + semver: 7.8.1 + tar: 7.5.16 + tmp: 0.2.7 + transitivePeerDependencies: + - supports-color + redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 @@ -7986,6 +8181,18 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -8048,6 +8255,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.3.0 @@ -8458,6 +8669,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -8476,6 +8699,8 @@ snapshots: yallist@3.1.1: {} + yallist@5.0.0: {} + yargs-parser@22.0.0: {} yargs@18.0.0: @@ -8487,6 +8712,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.4.3): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8c32b4cb..c6e0418d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ allowBuilds: "@parcel/watcher": true esbuild: true msgpackr-extract: true + redis-memory-server: true sharp: true unrs-resolver: true minimumReleaseAgeExclude: diff --git a/src/app/api/contests/rooms/route.ts b/src/app/api/contests/rooms/route.ts index 6fe594e6..ab43e054 100644 --- a/src/app/api/contests/rooms/route.ts +++ b/src/app/api/contests/rooms/route.ts @@ -14,6 +14,7 @@ import { auth } from "@/lib/auth"; import { errorToLogMetadata, logger } from "@/lib/utils"; import { parseJson } from "@/lib/api/result"; import { createContestRoomSchema } from "@/lib/api/schemas/contestRoute"; +import { fetchContestProblemContent } from "@/lib/contests/problemContent"; export async function POST(req: NextRequest) { try { @@ -81,6 +82,16 @@ export async function POST(req: NextRequest) { return jsonError("VALIDATION_ERROR", "insufficient_problems"); } + const problemsWithContent = await Promise.all( + availableProblems.map(async (problem) => ({ + problem, + content: await fetchContestProblemContent({ + platform: "codeforces", + problemId: problem.problemId, + }), + })), + ); + // Write stub ContestRoom to MongoDB const room = new ContestRoom({ contestId: contest._id, @@ -95,12 +106,13 @@ export async function POST(req: NextRequest) { const problemSet = new ContestProblemSet({ contestId: contest._id, roomId: room._id, - problems: availableProblems.map((p) => ({ + problems: problemsWithContent.map(({ problem, content }) => ({ platform: "codeforces", - problemId: p.problemId, - name: p.name, - rating: p.rating, - points: Math.floor((p.rating || 1000) / 10), + problemId: problem.problemId, + name: content?.title || problem.name, + rating: problem.rating, + points: Math.floor((problem.rating || 1000) / 10), + ...content, })), }); @@ -128,13 +140,14 @@ export async function POST(req: NextRequest) { const redis = await getRedis(); // Write ordered problem array to room::problems - const redisProblems = availableProblems.map((p) => + const redisProblems = problemsWithContent.map(({ problem, content }) => JSON.stringify({ - problemId: p.problemId, - name: p.name, - rating: p.rating, - points: Math.floor((p.rating || 1000) / 10), + problemId: problem.problemId, + name: content?.title || problem.name, + rating: problem.rating, + points: Math.floor((problem.rating || 1000) / 10), revealedAt: null, + ...content, }), ); await redis.del(`room:${roomId}:problems`); diff --git a/src/app/api/contests/sync/route.ts b/src/app/api/contests/sync/route.ts index 8b4d5e0b..b61146a2 100644 --- a/src/app/api/contests/sync/route.ts +++ b/src/app/api/contests/sync/route.ts @@ -12,6 +12,14 @@ import { releaseUserRateLimit, } from "@/lib/userRateLimit"; import { webEnv } from "@/lib/env/web"; +import { + contestRoomStateSchema, + parseContestRoomProblems, +} from "@/lib/contests/runtime"; +import dbConnect from "@/lib/mongodb"; +import CPUser from "@/models/CPUser"; +import ContestRoom from "@/models/ContestRoom"; +import ContestTeam from "@/models/ContestTeam"; export async function POST(request: NextRequest) { let consumedForUser: string | undefined; @@ -24,7 +32,18 @@ export async function POST(request: NextRequest) { const body = await parseJson(request, contestSyncSchema); if (!body.ok) return jsonResult(body); - const { roomId, teamId, cfHandle, problemId } = body.data; + const { roomId, teamId, problemId } = body.data; + + await dbConnect(); + const cpUser = await CPUser.findOne({ userId }).lean(); + if (!cpUser?.cfHandle || !cpUser.cfVerified) { + return jsonError("FORBIDDEN", "A verified Codeforces handle is required"); + } + + const room = await ContestRoom.findById(roomId).lean(); + if (!room || room.status !== "active") { + return jsonError("CONFLICT", "This contest room is not active"); + } const redis = await getRedis(); @@ -47,6 +66,37 @@ export async function POST(request: NextRequest) { } } + const team = await ContestTeam.findOne({ + _id: resolvedTeamId, + roomId: room._id, + members: userId, + }).lean(); + if (!team) { + return jsonError("FORBIDDEN", "You are not a member of this room's team"); + } + + const state = contestRoomStateSchema.parse( + await redis.hGetAll(`room:${roomId}:state`), + ); + if (state.status !== "active") { + return jsonError("CONFLICT", "This contest room is not active"); + } + const parsedProblems = parseContestRoomProblems( + await redis.lRange(`room:${roomId}:problems`, 0, -1), + ); + const problemIndex = parsedProblems.findIndex( + (problem) => problem.problemId === problemId, + ); + if (problemIndex === -1) { + return jsonError("VALIDATION_ERROR", "Problem is not assigned to this room"); + } + if ( + state.type !== "arena" && + problemIndex > Number.parseInt(state.currentProblem || "0", 10) + ) { + return jsonError("FORBIDDEN", "This problem has not been revealed yet"); + } + // 2. Check rate limit const rateLimit = await consumeUserRateLimit( "contest-sync", @@ -66,7 +116,7 @@ export async function POST(request: NextRequest) { roomId, userId, teamId: resolvedTeamId, - cfHandle, + cfHandle: cpUser.cfHandle, problemId, }; const job = await cfSyncQueue.add("cf_sync", jobData); diff --git a/src/components/contests/ArenaRoomClient.tsx b/src/components/contests/ArenaRoomClient.tsx index 40b29833..91451ccf 100644 --- a/src/components/contests/ArenaRoomClient.tsx +++ b/src/components/contests/ArenaRoomClient.tsx @@ -26,6 +26,7 @@ import { getDisplayName } from "@/lib/utils"; import { getContestRoomResultsPath, + getCodeforcesProblemUrl, getDisplayTeamName, } from "@/components/contests/roomPresentation"; import RoomActivityFeed from "@/components/contests/RoomActivityFeed"; @@ -35,6 +36,8 @@ import { useRoomCountdown } from "@/components/contests/useRoomCountdown"; import { useRoomEventSource } from "@/components/contests/useRoomEventSource"; import UserAvatar from "@/components/shared/UserAvatar"; import BackLink from "@/components/shared/BackLink"; +import ContestCodeRunner from "@/components/contests/ContestCodeRunner"; +import ContestProblemContent from "@/components/contests/ContestProblemContent"; import styles from "./ArenaRoomClient.module.scss"; @@ -110,6 +113,8 @@ export default function ArenaRoomClient({ initialTimeLimit, ); const timeLeft = useRoomCountdown(matchState, startTime, timeLimit); + const runnerProblem = + problems.find((problem) => !locks[problem.problemId]) || problems[0]; const isSoloFormat = ["1v1", "solo-tournament"].includes(contest?.format); const displayTeamName = (team?: ContestRoomTeamDto) => @@ -364,7 +369,6 @@ export default function ArenaRoomClient({ body: JSON.stringify({ roomId, teamId, - cfHandle: cfHandle || "dummy0", // Use real handle if available, otherwise fallback problemId: problemId, }), }); @@ -639,7 +643,7 @@ export default function ArenaRoomClient({
+ + )}
diff --git a/src/components/contests/BlitzRoomClient.tsx b/src/components/contests/BlitzRoomClient.tsx index aa5ca17d..cc2e9682 100644 --- a/src/components/contests/BlitzRoomClient.tsx +++ b/src/components/contests/BlitzRoomClient.tsx @@ -29,6 +29,7 @@ import { getDisplayName } from "@/lib/utils"; import { getContestRoomResultsPath, + getCodeforcesProblemUrl, getDisplayTeamName, } from "@/components/contests/roomPresentation"; import RoomActivityFeed from "@/components/contests/RoomActivityFeed"; @@ -38,6 +39,8 @@ import { useRoomCountdown } from "@/components/contests/useRoomCountdown"; import { useRoomEventSource } from "@/components/contests/useRoomEventSource"; import UserAvatar from "@/components/shared/UserAvatar"; import BackLink from "@/components/shared/BackLink"; +import ContestCodeRunner from "@/components/contests/ContestCodeRunner"; +import ContestProblemContent from "@/components/contests/ContestProblemContent"; import styles from "./BlitzRoomClient.module.scss"; @@ -366,7 +369,6 @@ export default function BlitzRoomClient({ body: JSON.stringify({ roomId, teamId, - cfHandle: cfHandle || "dummy0", // Use real handle if available, otherwise fallback problemId: activeProblem.problemId, }), }); @@ -625,7 +627,7 @@ export default function BlitzRoomClient({
+ + )} diff --git a/src/components/contests/ContestCodeRunner.tsx b/src/components/contests/ContestCodeRunner.tsx new file mode 100644 index 00000000..4f42b270 --- /dev/null +++ b/src/components/contests/ContestCodeRunner.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + CODE_RUNNER_DEFAULT_CODE, + type CodeRunnerLanguage, + type TestCase, +} from "@/lib/constants"; +import { + CodeEditor, + LanguageSelector, + TestCasePanel, +} from "@/components/shared/code-runner"; + +import styles from "@/components/shared/code-runner/CodeRunner.module.scss"; + +type Props = { + problemId?: string; + samples?: Array<{ input: string; output: string }>; +}; + +/** A local runner: code never leaves the participant's browser. */ +export default function ContestCodeRunner({ problemId, samples }: Props) { + const [language, setLanguage] = useState("cpp"); + const [codeByLanguage, setCodeByLanguage] = useState< + Record + >({ ...CODE_RUNNER_DEFAULT_CODE }); + const [testCases, setTestCases] = useState(() => + samples?.length + ? samples.map((sample, index) => ({ + id: `sample-${index + 1}`, + input: sample.input, + expectedOutput: sample.output, + })) + : [{ id: "sample-1", input: "", expectedOutput: "" }], + ); + const [activeTestCaseId, setActiveTestCaseId] = useState( + testCases[0]?.id || "sample-1", + ); + + useEffect(() => { + const nextTestCases: TestCase[] = samples?.length + ? samples.map((sample, index) => ({ + id: `${problemId || "sample"}-${index + 1}`, + input: sample.input, + expectedOutput: sample.output, + })) + : [{ id: `${problemId || "sample"}-1`, input: "", expectedOutput: "" }]; + setTestCases(nextTestCases); + setActiveTestCaseId(nextTestCases[0].id); + }, [problemId, samples]); + + return ( +
+ Open local code runner +

+ Runs locally in your browser for testing only. Submit solutions on + Codeforces for scoring. +

+ +
+ + setCodeByLanguage((previous) => ({ + ...previous, + [language]: value, + })) + } + /> +
+ +
+ ); +} diff --git a/src/components/contests/ContestProblemContent.module.scss b/src/components/contests/ContestProblemContent.module.scss new file mode 100644 index 00000000..734efa31 --- /dev/null +++ b/src/components/contests/ContestProblemContent.module.scss @@ -0,0 +1,33 @@ +.content { + margin-top: 1rem; + border-top: 1px solid var(--border-subtle, #d7dce2); + padding-top: 0.9rem; + color: var(--text-primary, #1f2933); +} + +.content summary { + cursor: pointer; + font-weight: 700; +} + +.section { + margin-top: 0.9rem; + overflow-wrap: anywhere; + line-height: 1.6; +} + +.section h3 { + margin: 0 0 0.35rem; + font-size: 0.95rem; +} + +.section :global(pre) { + overflow-x: auto; + padding: 0.75rem; + background: var(--surface-muted, #f3f5f7); +} + +.section :global(img) { + max-width: 100%; + height: auto; +} diff --git a/src/components/contests/ContestProblemContent.tsx b/src/components/contests/ContestProblemContent.tsx new file mode 100644 index 00000000..9b47e038 --- /dev/null +++ b/src/components/contests/ContestProblemContent.tsx @@ -0,0 +1,53 @@ +import type { ContestRoomProblemDto } from "@/lib/contests/dtos"; + +import styles from "./ContestProblemContent.module.scss"; + +type Props = { + problem?: ContestRoomProblemDto; +}; + +export default function ContestProblemContent({ problem }: Props) { + if (!problem?.statementHtml) return null; + + return ( +
+ Problem statement +
+ {problem.inputSpecificationHtml && ( +
+

Input

+
+
+ )} + {problem.outputSpecificationHtml && ( +
+

Output

+
+
+ )} + {problem.constraintsHtml && ( +
+

Constraints

+
+
+ )} + {problem.notesHtml && ( +
+

Notes

+
+
+ )} +
+ ); +} diff --git a/src/components/contests/roomPresentation.test.ts b/src/components/contests/roomPresentation.test.ts new file mode 100644 index 00000000..60aa9451 --- /dev/null +++ b/src/components/contests/roomPresentation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { getCodeforcesProblemUrl } from "./roomPresentation"; + +describe("getCodeforcesProblemUrl", () => { + it("keeps numeric suffixes in a Codeforces problem index", () => { + expect(getCodeforcesProblemUrl("1678B1")).toBe( + "https://codeforces.com/contest/1678/problem/B1", + ); + }); + + it("rejects malformed problem identifiers", () => { + expect(getCodeforcesProblemUrl("1678")).toBeNull(); + }); +}); diff --git a/src/components/contests/roomPresentation.ts b/src/components/contests/roomPresentation.ts index 4bddaba4..ed03b56e 100644 --- a/src/components/contests/roomPresentation.ts +++ b/src/components/contests/roomPresentation.ts @@ -51,3 +51,10 @@ export function getContestRoomResultsPath( fromBracket ? "?from=bracket" : "" }`; } + +/** Preserve numeric suffixes in Codeforces indexes (for example, B1). */ +export function getCodeforcesProblemUrl(problemId: string): string | null { + const match = problemId.trim().match(/^(\d+)([A-Za-z][A-Za-z0-9]*)$/); + if (!match) return null; + return `https://codeforces.com/contest/${match[1]}/problem/${match[2]}`; +} diff --git a/src/components/shared/code-runner/CodeRunner.module.scss b/src/components/shared/code-runner/CodeRunner.module.scss index 94d9a38b..35f6e1fd 100644 --- a/src/components/shared/code-runner/CodeRunner.module.scss +++ b/src/components/shared/code-runner/CodeRunner.module.scss @@ -206,3 +206,26 @@ color: var(--muted); font-size: 0.875rem; } + +.contestRunner { + display: flex; + flex-direction: column; + gap: 0.875rem; + margin-top: 1rem; + + summary { + cursor: pointer; + color: var(--primary); + font-weight: 600; + } +} + +.runnerNotice { + margin: 0; + color: var(--muted); + font-size: 0.875rem; +} + +.contestEditor { + height: 22rem; +} diff --git a/src/lib/api/schemas/contestRoute.ts b/src/lib/api/schemas/contestRoute.ts index c80e8a32..aac8e41b 100644 --- a/src/lib/api/schemas/contestRoute.ts +++ b/src/lib/api/schemas/contestRoute.ts @@ -30,7 +30,6 @@ export const teamRegistrationSchema = z.object({ export const contestSyncSchema = z.object({ roomId: objectIdStringSchema, teamId: objectIdStringSchema.optional(), - cfHandle: z.string().trim().min(1).max(100), problemId: z.string().trim().min(1).max(100), }); diff --git a/src/lib/contests/dtos.ts b/src/lib/contests/dtos.ts index 1e9f9976..358869b3 100644 --- a/src/lib/contests/dtos.ts +++ b/src/lib/contests/dtos.ts @@ -32,6 +32,14 @@ export type ContestRoomProblemDto = { rating?: number; points?: number; revealedAt?: number | null; + statementHtml?: string; + inputSpecificationHtml?: string; + outputSpecificationHtml?: string; + constraintsHtml?: string; + notesHtml?: string; + samples?: Array<{ input: string; output: string }>; + timeLimitMs?: number; + memoryLimitMb?: number; [key: string]: unknown; }; diff --git a/src/lib/contests/problemContent.test.ts b/src/lib/contests/problemContent.test.ts new file mode 100644 index 00000000..c7e87c17 --- /dev/null +++ b/src/lib/contests/problemContent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +const fetchProblemContentForScheduling = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/platforms/problemContent", () => ({ + fetchProblemContentForScheduling, +})); + +import { fetchContestProblemContent } from "@/lib/contests/problemContent"; + +describe("fetchContestProblemContent", () => { + it("loads Codeforces content from a compound problem id", async () => { + fetchProblemContentForScheduling.mockResolvedValueOnce({ + title: "Next Round", + statementHtml: "

Statement

", + inputSpecificationHtml: "

Input

", + outputSpecificationHtml: "

Output

", + samples: [{ input: "1", output: "2" }], + sourceUrl: "https://codeforces.com/contest/158/problem/A", + }); + + await expect( + fetchContestProblemContent({ problemId: "158A" }), + ).resolves.toMatchObject({ title: "Next Round" }); + expect(fetchProblemContentForScheduling).toHaveBeenCalledWith( + "codeforces", + "158", + "A", + ); + }); + + it("returns no content for malformed or unsupported problems", async () => { + await expect( + fetchContestProblemContent({ problemId: "not-a-problem" }), + ).resolves.toBeNull(); + await expect( + fetchContestProblemContent({ platform: "atcoder", problemId: "abc_a" }), + ).resolves.toBeNull(); + }); + + it("keeps room provisioning resilient when content fetch fails", async () => { + fetchProblemContentForScheduling.mockRejectedValueOnce( + new Error("Codeforces unavailable"), + ); + + await expect( + fetchContestProblemContent({ problemId: "158A" }), + ).resolves.toBeNull(); + }); +}); diff --git a/src/lib/contests/problemContent.ts b/src/lib/contests/problemContent.ts new file mode 100644 index 00000000..c50cf004 --- /dev/null +++ b/src/lib/contests/problemContent.ts @@ -0,0 +1,53 @@ +import type { Platform } from "@/lib/constants"; +import { + fetchProblemContentForScheduling, + type ProblemContentSnapshot, +} from "@/lib/platforms/problemContent"; + +export type ContestProblemContent = Pick< + ProblemContentSnapshot, + | "title" + | "statementHtml" + | "inputSpecificationHtml" + | "outputSpecificationHtml" + | "constraintsHtml" + | "notesHtml" + | "samples" + | "timeLimitMs" + | "memoryLimitMb" + | "sourceUrl" +>; + +export type ContestProblemSelection = { + platform?: string; + problemId: string; +}; + +function splitCodeforcesProblemId(problemId: string): { + contestId: string; + problemIndex: string; +} | null { + const match = problemId.trim().match(/^(\d+)([A-Za-z][A-Za-z0-9]*)$/); + if (!match) return null; + return { contestId: match[1], problemIndex: match[2] }; +} + +export async function fetchContestProblemContent( + problem: ContestProblemSelection, +): Promise { + const platform = (problem.platform || "codeforces") as Platform; + if (platform !== "codeforces") return null; + + const parts = splitCodeforcesProblemId(problem.problemId); + if (!parts) return null; + + try { + return await fetchProblemContentForScheduling( + platform, + parts.contestId, + parts.problemIndex, + ); + } catch { + return null; + } +} diff --git a/src/lib/contests/runtime.ts b/src/lib/contests/runtime.ts index 027c28f6..3f5be9e7 100644 --- a/src/lib/contests/runtime.ts +++ b/src/lib/contests/runtime.ts @@ -64,8 +64,20 @@ export type CfSyncQueueData = CfSyncJobData | NightlyProblemSyncJobData; export const contestRoomProblemSchema = z .object({ problemId: z.string().min(1), + name: z.string().optional(), + rating: z.number().optional(), points: z.number().optional(), revealedAt: z.number().nullable().optional(), + statementHtml: z.string().optional(), + inputSpecificationHtml: z.string().optional(), + outputSpecificationHtml: z.string().optional(), + constraintsHtml: z.string().optional(), + notesHtml: z.string().optional(), + samples: z + .array(z.object({ input: z.string(), output: z.string() })) + .optional(), + timeLimitMs: z.number().optional(), + memoryLimitMb: z.number().optional(), }) .passthrough(); diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index bbe0cce8..2272587c 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -23,6 +23,7 @@ import { claimProblem, getRedis } from "@/lib/redis"; import { logger } from "@/lib/utils"; import ContestMatch from "@/models/ContestMatch"; import ContestRoom from "@/models/ContestRoom"; +import ContestTeam from "@/models/ContestTeam"; // Circuit breaker removed, relying on BullMQ job-level retries @@ -93,6 +94,22 @@ export const cfSyncWorker = new Worker( // Verify userId is part of the team const redis = await getRedis(); + const team = await ContestTeam.findOne({ + _id: teamId, + roomId: room._id, + members: userId, + }).lean(); + if (!team) { + logger.warn( + `[cfSyncWorker] User ${userId} is not a member of team ${teamId} in room ${roomId}.`, + ); + await publishUser(userId, { + verdict: "invalid", + reason: "not_team_member", + }); + return; + } + const isTeamMember = await redis.sIsMember( `team:${teamId}:users`, userId, @@ -111,6 +128,14 @@ export const cfSyncWorker = new Worker( const state = contestRoomStateSchema.parse( await redis.hGetAll(`room:${roomId}:state`), ); + if (state.status !== "active") { + await publishUser(userId, { + type: "sync.failed", + reason: "room_not_active", + problemId, + }); + return; + } const problemsRaw = await redis.lRange( `room:${roomId}:problems`, 0, diff --git a/src/lib/workers/reconciliationWorker.ts b/src/lib/workers/reconciliationWorker.ts index 64448e08..a1337ca0 100644 --- a/src/lib/workers/reconciliationWorker.ts +++ b/src/lib/workers/reconciliationWorker.ts @@ -17,6 +17,7 @@ import { notify } from "@/lib/notify"; import { bullMqConnection } from "@/lib/bullmq"; import { getRedis } from "@/lib/redis"; import { logger } from "@/lib/utils"; +import { fetchContestProblemContent } from "@/lib/contests/problemContent"; import CPUser from "@/models/CPUser"; import ContestMatch from "@/models/ContestMatch"; import ContestProblemSet from "@/models/ContestProblemSet"; @@ -618,6 +619,13 @@ export const reconciliationWorker = new Worker< ); } + const problemsWithContent = await Promise.all( + availableProblems.map(async (problem) => ({ + problem, + content: await fetchContestProblemContent(problem), + })), + ); + const room = new ContestRoom({ contestId: contest._id, name: `Room for ${contest.name}`, @@ -630,12 +638,13 @@ export const reconciliationWorker = new Worker< const problemSet = new ContestProblemSet({ contestId: contest._id, roomId: room._id, - problems: availableProblems.map((problem) => ({ + problems: problemsWithContent.map(({ problem, content }) => ({ platform: "codeforces", problemId: problem.problemId, - name: problem.name, + name: content?.title || problem.name, rating: problem.rating, points: Math.floor((problem.rating || 1000) / 10), + ...content, })), }); @@ -659,13 +668,14 @@ export const reconciliationWorker = new Worker< const newRoomId = room._id.toString(); - const redisProblems = availableProblems.map((problem) => + const redisProblems = problemsWithContent.map(({ problem, content }) => JSON.stringify({ problemId: problem.problemId, - name: problem.name, + name: content?.title || problem.name, rating: problem.rating, points: Math.floor((problem.rating || 1000) / 10), revealedAt: null, + ...content, }), ); await redis.del(`room:${newRoomId}:problems`); @@ -952,20 +962,24 @@ export const reconciliationWorker = new Worker< ); for (const sub of completedSubs) { const data = JSON.parse(sub.message.data); - const submission = new ContestSubmission({ - roomId, - contestId, - userId: data.userId, - teamId: data.teamId, - problemId: data.problemId, - platform: "codeforces", - submissionId: data.cfSubmissionId, - verdict: data.verdict, - points: data.points, - solveMs: data.solveMs, - submittedAt: new Date(data.cfTimestamp || Date.now()), - }); - await submission.save(); + await ContestSubmission.updateOne( + { roomId, submissionId: String(data.cfSubmissionId) }, + { + $setOnInsert: { + contestId, + submissionId: String(data.cfSubmissionId), + userId: data.userId, + teamId: data.teamId, + problemId: data.problemId, + platform: "codeforces", + verdict: data.verdict, + points: data.points, + solveMs: data.solveMs, + submittedAt: new Date(data.cfTimestamp || Date.now()), + }, + }, + { upsert: true }, + ); } // Finally, update the room status to "ended" @@ -1187,21 +1201,24 @@ export const reconciliationWorker = new Worker< const data = contestSubmissionEventSchema.parse( JSON.parse(sub.message.data), ); - // Construct and save ContestSubmission - const submission = new ContestSubmission({ - roomId, - contestId, - userId: data.userId, - teamId: data.teamId, - problemId: data.problemId, - platform: "codeforces", - submissionId: data.cfSubmissionId, - verdict: data.verdict, - points: data.points, - solveMs: data.solveMs, - submittedAt: new Date(data.cfTimestamp || Date.now()), - }); - await submission.save(); + await ContestSubmission.updateOne( + { roomId, submissionId: String(data.cfSubmissionId) }, + { + $setOnInsert: { + contestId, + submissionId: String(data.cfSubmissionId), + userId: data.userId, + teamId: data.teamId, + problemId: data.problemId, + platform: "codeforces", + verdict: data.verdict, + points: data.points, + solveMs: data.solveMs, + submittedAt: new Date(data.cfTimestamp || Date.now()), + }, + }, + { upsert: true }, + ); } // 4. Finalise ContestProblemSet diff --git a/src/models/ContestProblemSet.ts b/src/models/ContestProblemSet.ts index b02b32ea..2ba4b0c7 100644 --- a/src/models/ContestProblemSet.ts +++ b/src/models/ContestProblemSet.ts @@ -7,6 +7,14 @@ export interface ISelectedProblem { rating?: number; url?: string; points: number; + statementHtml?: string; + inputSpecificationHtml?: string; + outputSpecificationHtml?: string; + constraintsHtml?: string; + notesHtml?: string; + samples?: Array<{ input: string; output: string }>; + timeLimitMs?: number; + memoryLimitMb?: number; } export interface IContestProblemSet extends Document { @@ -24,6 +32,19 @@ const SelectedProblemSchema = new Schema({ rating: { type: Number }, url: { type: String }, points: { type: Number, required: true, default: 100 }, + statementHtml: { type: String }, + inputSpecificationHtml: { type: String }, + outputSpecificationHtml: { type: String }, + constraintsHtml: { type: String }, + notesHtml: { type: String }, + samples: [ + { + input: { type: String, required: true }, + output: { type: String, required: true }, + }, + ], + timeLimitMs: { type: Number }, + memoryLimitMb: { type: Number }, }); const ContestProblemSetSchema = new Schema( diff --git a/src/models/ContestSubmission.ts b/src/models/ContestSubmission.ts index 0b2b1ade..2bc2b97c 100644 --- a/src/models/ContestSubmission.ts +++ b/src/models/ContestSubmission.ts @@ -45,6 +45,9 @@ const ContestSubmissionSchema = new Schema( ContestSubmissionSchema.index({ roomId: 1, userId: 1 }); ContestSubmissionSchema.index({ contestId: 1, problemId: 1 }); +// Reconciliation jobs can retry; the upstream Codeforces submission is the +// stable idempotency key for a room. +ContestSubmissionSchema.index({ roomId: 1, submissionId: 1 }, { unique: true }); const ContestSubmission = mongoose.models.ContestSubmission || From 8f30e97bfd9d3b11b8355d20d2138ab2cedd3e44 Mon Sep 17 00:00:00 2001 From: Priyangshu-Mandal Date: Sat, 5 Sep 2026 22:58:57 +0530 Subject: [PATCH 2/3] integration tests passed --- .gitignore | 4 ++++ docker-compose.yml | 17 +++++++++++++++++ tests/integration/contest-presets.test.ts | 3 +-- tests/run-ci.ts | 7 +++++-- tests/setup/environment.ts | 6 ++++-- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 975a6aec..a74fc59f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ # Dependencies node_modules/ +.pnpm-store/ +.pnpm-install*.log # Debug .log* @@ -14,6 +16,8 @@ backup out/ build/ coverage/ +test-results/ +playwright-report/ # Typescript *.tsbuildinfo diff --git a/docker-compose.yml b/docker-compose.yml index a92fc716..bf1e3317 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,5 +26,22 @@ services: volumes: - mongodb_data:/data/db + mongodb-init: + image: mongo:latest + depends_on: + mongodb: + condition: service_healthy + entrypoint: ["mongosh", "mongodb://mongodb:27017/admin", "--quiet", "--eval"] + command: + - >- + try { + rs.status(); + print('MongoDB replica set already initialized'); + } catch (error) { + rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27017'}]}); + print('MongoDB replica set initialized'); + } + restart: "no" + volumes: mongodb_data: diff --git a/tests/integration/contest-presets.test.ts b/tests/integration/contest-presets.test.ts index 81ba30af..928f7935 100644 --- a/tests/integration/contest-presets.test.ts +++ b/tests/integration/contest-presets.test.ts @@ -106,7 +106,7 @@ describe("contest preset routes", () => { new NextRequest("http://localhost/api/contests/sync", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ roomId: "bad", cfHandle: "", problemId: "" }), + body: JSON.stringify({ roomId: "bad", problemId: "" }), }), ); const error = await responseError(response); @@ -115,7 +115,6 @@ describe("contest preset routes", () => { expect(error.code).toBe("VALIDATION_ERROR"); expect(error.fields).toMatchObject({ roomId: [expect.any(String)], - cfHandle: [expect.any(String)], problemId: [expect.any(String)], }); }); diff --git a/tests/run-ci.ts b/tests/run-ci.ts index 96ae298e..72dc1b62 100644 --- a/tests/run-ci.ts +++ b/tests/run-ci.ts @@ -9,8 +9,11 @@ const testEnvironment = { AZURE_CLIENT_ID: "test-client-id", AZURE_CLIENT_SECRET: "test-client-secret", AZURE_TENANT_ID: "test-tenant-id", - MONGODB_URI: "mongodb://127.0.0.1:27017/ccw-ci", - MONGODB_TEST_URI: process.env.MONGODB_TEST_URI ?? "mongodb://127.0.0.1:27017", + MONGODB_URI: + "mongodb://localhost:27017/ccw-ci?replicaSet=rs0&retryWrites=false", + MONGODB_TEST_URI: + process.env.MONGODB_TEST_URI ?? + "mongodb://localhost:27017/?replicaSet=rs0&retryWrites=false", REDIS_URL: "redis://127.0.0.1:6379/15", }; diff --git a/tests/setup/environment.ts b/tests/setup/environment.ts index bdec916d..78a63d49 100644 --- a/tests/setup/environment.ts +++ b/tests/setup/environment.ts @@ -8,5 +8,7 @@ Object.assign(process.env, { AZURE_TENANT_ID: "test-tenant-id", }); process.env.REDIS_URL ??= "redis://127.0.0.1:6379/15"; -process.env.MONGODB_TEST_URI ??= "mongodb://127.0.0.1:27017"; -process.env.MONGODB_URI ??= "mongodb://127.0.0.1:27017/ccw-test"; +process.env.MONGODB_TEST_URI ??= + "mongodb://localhost:27017/?replicaSet=rs0&retryWrites=false"; +process.env.MONGODB_URI ??= + "mongodb://localhost:27017/ccw-test?replicaSet=rs0&retryWrites=false"; From a740c99d5a004731af08271be3ed0f804289f619 Mon Sep 17 00:00:00 2001 From: Priyangshu-Mandal Date: Sat, 5 Sep 2026 23:06:11 +0530 Subject: [PATCH 3/3] fix: repair duplicate pnpm lockfile entries --- pnpm-lock.yaml | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0534ff18..5c97dfcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1595,10 +1595,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1610,10 +1606,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1912,13 +1904,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2612,10 +2597,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - human-interval@2.0.1: resolution: {integrity: sha512-r4Aotzf+OtKIGQCB3odUowy4GfUDTy3aTWTfLd7ZF2gBCy3XW3v/dJLRefZnOFFnjqs5B1TypvS8WarpBkYUNQ==} @@ -5462,8 +5443,6 @@ snapshots: agent-base@7.1.4: {} - agent-base@7.1.4: {} - ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5473,10 +5452,6 @@ snapshots: ansi-regex@6.3.0: {} - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -5773,12 +5748,6 @@ snapshots: color-name@1.1.4: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0