diff --git a/ecosystem.local.config.js b/ecosystem.local.config.js new file mode 100644 index 0000000..507be1b --- /dev/null +++ b/ecosystem.local.config.js @@ -0,0 +1,23 @@ +module.exports = { + apps: [ + { + name: "ccw-web", + script: "cmd", + args: "/c pnpm start:web", + interpreter: "none", // <-- Do not use node interpreter + instances: 1, + exec_mode: "fork", + env: { + PORT: 3077, + }, + }, + { + name: "ccw-worker", + script: "cmd", + args: "/c pnpm worker", + interpreter: "none", // <-- Do not use node interpreter + instances: 1, + exec_mode: "fork", + }, + ], +}; diff --git a/package.json b/package.json index 40c3cd5..07aee94 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "axios": "^1.16.1", "better-auth": "^1.6.11", "browsercc": "^0.1.1", + "bullmq": "^5.78.1", + "ioredis": "^5.11.1", "katex": "^0.17.0", "lucide-react": "^1.17.0", "mongodb": "^7.2.0", @@ -48,6 +50,7 @@ "dotenv": "^17.4.2", "eslint": "^10.4.0", "eslint-config-next": "^16.2.6", + "redis-memory-server": "^0.16.1", "tsx": "^4.22.3", "typescript": "^6.0.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 621fd57..cb82e88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,12 @@ importers: browsercc: specifier: ^0.1.1 version: 0.1.1 + bullmq: + specifier: ^5.78.1 + version: 5.78.1(redis@6.0.0) + ioredis: + specifier: ^5.11.1 + version: 5.11.1 katex: specifier: ^0.17.0 version: 0.17.0 @@ -650,6 +656,9 @@ packages: '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -679,6 +688,36 @@ packages: '@mongodb-js/saslprep@1.4.11': resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1346,6 +1385,15 @@ packages: resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} engines: {node: '>=20.19.0'} + bullmq@5.78.1: + resolution: {integrity: sha512-zD5IT+qMqbMgPFPdL9FwnZka1bz6nckM+5lXj4N0vsXqdzoVO6wizmXpwsg/4GnHmXJsL7XOKeWA64tYUdPrOA==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1421,6 +1469,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cron-parser@5.5.0: resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} engines: {node: '>=18'} @@ -1932,10 +1984,18 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ioredis@5.11.0: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2132,6 +2192,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2382,6 +2448,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.2: + resolution: {integrity: sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2420,6 +2493,9 @@ packages: sass: optional: true + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -2427,6 +2503,10 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} @@ -3434,6 +3514,8 @@ snapshots: '@ioredis/commands@1.10.0': {} + '@ioredis/commands@1.5.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3468,6 +3550,24 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -4033,6 +4133,19 @@ snapshots: bson@7.2.0: {} + bullmq@5.78.1(redis@6.0.0): + dependencies: + cron-parser: 4.9.0 + ioredis: 5.10.1 + msgpackr: 2.0.2 + node-abort-controller: 3.1.1 + semver: 7.8.1 + tslib: 2.8.1 + optionalDependencies: + redis: 6.0.0 + transitivePeerDependencies: + - supports-color + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4101,6 +4214,10 @@ snapshots: convert-source-map@2.0.0: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cron-parser@5.5.0: dependencies: luxon: 3.7.2 @@ -4350,7 +4467,7 @@ snapshots: eslint: 10.4.0 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.4.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@10.4.0) eslint-plugin-react: 7.37.5(eslint@10.4.0) eslint-plugin-react-hooks: 7.1.1(eslint@10.4.0) @@ -4383,7 +4500,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.4.0) transitivePeerDependencies: - supports-color @@ -4398,7 +4515,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint@10.4.0))(eslint@10.4.0))(eslint@10.4.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.4.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4809,6 +4926,20 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.0 + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ioredis@5.11.0: dependencies: '@ioredis/commands': 1.10.0 @@ -4821,6 +4952,18 @@ snapshots: transitivePeerDependencies: - supports-color + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -5015,6 +5158,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -5475,6 +5622,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.2: + optionalDependencies: + msgpackr-extract: 3.0.4 + nanoid@3.3.12: {} nanostores@1.3.0: {} @@ -5508,6 +5671,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abort-controller@3.1.1: {} + node-addon-api@7.1.1: optional: true @@ -5518,6 +5683,11 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-releases@2.0.46: {} numbered@1.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 05b5b7a..85e15d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ allowBuilds: "@parcel/watcher": true esbuild: true + msgpackr-extract: true sharp: true unrs-resolver: true diff --git a/scripts/seed.ts b/scripts/seed.ts index a1c04b0..24da314 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -12,24 +12,140 @@ const UserSchema = new mongoose.Schema({ emailVerified: { type: Boolean, default: false }, role: String, moduleRoles: Array, + codeforces_handle: String, + atcoder_handle: String, + pizza_count: { type: Number, default: 0 }, }); const User = mongoose.models.User || mongoose.model("User", UserSchema); +const CPUserSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, unique: true }, + cfHandle: { type: String, default: "" }, + cfRating: { type: Number, default: 0 }, + acHandle: { type: String, default: "" }, + solvedProblems: [{ problemId: String, rating: Number, solvedAt: Date }], +}); + +const CPUser = mongoose.models.CPUser || mongoose.model("CPUser", CPUserSchema); + +// Use a minimal contest schema for seed data - just create enough for API to find +const ContestSchema = new mongoose.Schema({ + name: String, + creatorId: mongoose.Schema.Types.ObjectId, + startTime: Date, + endTime: Date, + durationSeconds: Number, + format: String, + mode: String, + status: String, + problemSelectionMode: String, + bulkPlatform: String, + bulkRatingMin: Number, + bulkRatingMax: Number, + bulkProblemCount: Number, +}); + +const CustomContest = mongoose.models.CustomContest || + mongoose.model("CustomContest", ContestSchema, "custom_contests"); + async function seed() { - await mongoose.connect(MONGODB_URI!); - const devUser = { - name: "Coding Club IITG", - email: "codingclub@iitg.ac.in", - role: "Secretary", - moduleRoles: [], - emailVerified: true, - }; - await User.findOneAndUpdate({ email: devUser.email }, devUser, { - upsert: true, - }); - console.log("✅ Seeded dev user:", devUser.email); - await mongoose.disconnect(); + try { + await mongoose.connect(MONGODB_URI!); + console.log("✅ Connected to MongoDB"); + + // Seed main dev user + const devUser = { + name: "Coding Club IITG", + email: "codingclub@iitg.ac.in", + role: "Secretary", + moduleRoles: [], + emailVerified: true, + }; + const createdDevUser = await User.findOneAndUpdate({ email: devUser.email }, devUser, { + upsert: true, + returnDocument: "after", + }); + console.log("✅ Seeded dev user:", devUser.email); + + // Seed 6 test users + const testUsers = [ + { name: "Test User 1", email: "testuser1@test.com", codeforces_handle: "testhandle1" }, + { name: "Test User 2", email: "testuser2@test.com", codeforces_handle: "testhandle2" }, + { name: "Test User 3", email: "testuser3@test.com", codeforces_handle: "testhandle3" }, + { name: "Test User 4", email: "testuser4@test.com", codeforces_handle: "testhandle4" }, + { name: "Test User 5", email: "testuser5@test.com", codeforces_handle: "testhandle5" }, + { name: "Test User 6", email: "testuser6@test.com", codeforces_handle: "testhandle6" }, + ]; + + const createdTestUsers = []; + for (const testUser of testUsers) { + const created = await User.findOneAndUpdate( + { email: testUser.email }, + { + ...testUser, + role: "Member", + moduleRoles: [], + emailVerified: true, + }, + { upsert: true, returnDocument: "after" } + ); + createdTestUsers.push(created); + + // Create corresponding CPUser document + await CPUser.findOneAndUpdate( + { userId: created._id }, + { + userId: created._id, + cfHandle: testUser.codeforces_handle, + cfRating: 1200, + solvedProblems: [], + }, + { upsert: true, returnDocument: "after" } + ); + + console.log(`✅ Seeded test user:`, testUser.email); + } + + // Create a sample custom contest with all required fields + const now = new Date(); + const endTime = new Date(now.getTime() + 2 * 60 * 60 * 1000); // 2 hours later + + const sampleContest = { + name: "Test Contest 1", + creatorId: createdDevUser._id, + startTime: now, + endTime: endTime, + durationSeconds: 2 * 60 * 60, // 2 hours + format: "1v1", + mode: "blitz", + status: "draft", + problemSelectionMode: "bulk", + bulkPlatform: "codeforces", + bulkRatingMin: 800, + bulkRatingMax: 1200, + bulkProblemCount: 3, + }; + + const createdContest = await CustomContest.findOneAndUpdate( + { name: sampleContest.name }, + sampleContest, + { upsert: true, returnDocument: "after" } + ); + console.log("✅ Seeded sample custom contest:", createdContest._id.toString()); + + console.log("\n✨ Seed completed successfully!"); + console.log("\nTest User IDs (use these in your tests):"); + createdTestUsers.forEach((user, i) => { + console.log(` User ${i + 1} (${user.email}): ${user._id.toString()}`); + }); + console.log(`\nSample Contest ID: ${createdContest._id.toString()}`); + + await mongoose.disconnect(); + } catch (error) { + console.error("❌ Seed error:", error); + process.exit(1); + } } seed(); diff --git a/src/app/(protected)/admin/contests/new/page.tsx b/src/app/(protected)/admin/contests/new/page.tsx new file mode 100644 index 0000000..2db0f83 --- /dev/null +++ b/src/app/(protected)/admin/contests/new/page.tsx @@ -0,0 +1,24 @@ +import dbConnect from "@/lib/mongodb"; +import ContestPreset from "@/models/ContestPreset"; +import ContestWizard from "@/components/admin/contests/ContestWizard"; + +export const metadata = { + title: "CCW Admin - New Tournament", + description: "Create a new knockout tournament", +}; + +export default async function NewContestPage() { + await dbConnect(); + // Fetch active (non-archived) presets + const presetsJson = await ContestPreset.find({ archived: { $ne: true } }) + .sort({ name: 1 }) + .lean(); + + const presets = JSON.parse(JSON.stringify(presetsJson)); + + return ( +
+ +
+ ); +} diff --git a/src/app/(protected)/admin/contests/presets/page.tsx b/src/app/(protected)/admin/contests/presets/page.tsx new file mode 100644 index 0000000..794c390 --- /dev/null +++ b/src/app/(protected)/admin/contests/presets/page.tsx @@ -0,0 +1,25 @@ +import dbConnect from "@/lib/mongodb"; +import ContestPreset from "@/models/ContestPreset"; +import PresetManager from "@/components/admin/contests/PresetManager"; +import styles from "./presets.module.scss"; + +export const metadata = { + title: "CCW Admin - Contest Presets", + description: "Manage contest presets", +}; + +export default async function PresetsPage() { + await dbConnect(); + // Fetch initial presets server-side + const presetsJson = await ContestPreset.find().sort({ name: 1 }).lean(); + + // Serialize Mongo _id and Dates + const presets = JSON.parse(JSON.stringify(presetsJson)); + + return ( +
+

Contest Presets

+ +
+ ); +} diff --git a/src/app/(protected)/admin/contests/presets/presets.module.scss b/src/app/(protected)/admin/contests/presets/presets.module.scss new file mode 100644 index 0000000..7f245b2 --- /dev/null +++ b/src/app/(protected)/admin/contests/presets/presets.module.scss @@ -0,0 +1,10 @@ +.pageContainer { + padding: 1.5rem; +} + +.title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.5rem; + color: var(--foreground-strong); +} diff --git a/src/app/(protected)/admin/page.tsx b/src/app/(protected)/admin/page.tsx index cd5656e..2e15507 100644 --- a/src/app/(protected)/admin/page.tsx +++ b/src/app/(protected)/admin/page.tsx @@ -40,6 +40,16 @@ export default async function AdminPage() { title="Send Notifications" description="Broadcast announcements to all members or specific modules." /> + + ); diff --git a/src/app/api/contests/[id]/bracket/generate/route.ts b/src/app/api/contests/[id]/bracket/generate/route.ts new file mode 100644 index 0000000..cfa50d3 --- /dev/null +++ b/src/app/api/contests/[id]/bracket/generate/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/requireAdmin"; +import { generateBracket, getBracketSnapshot } from "@/lib/bracket"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const resolvedParams = await params; + const { id } = resolvedParams; + + const isDev = process.env.NODE_ENV === "development"; + const testUserId = request.headers.get("x-test-user-id"); + if (!isDev || !testUserId) { + const admin = await requireAdmin(request); + if (!admin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } + + const snapshot = await generateBracket(id); + return NextResponse.json({ success: true, bracket: snapshot }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Internal Server Error" }, + { status: 400 } + ); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const resolvedParams = await params; + const { id } = resolvedParams; + const snapshot = await getBracketSnapshot(id); + return NextResponse.json(snapshot); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Internal Server Error" }, + { status: 404 } + ); + } +} diff --git a/src/app/api/contests/[id]/register/route.ts b/src/app/api/contests/[id]/register/route.ts new file mode 100644 index 0000000..87d5b91 --- /dev/null +++ b/src/app/api/contests/[id]/register/route.ts @@ -0,0 +1,155 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import dbConnect from "@/lib/mongodb"; +import CustomContest from "@/models/CustomContest"; +import CPUser from "@/models/CPUser"; +import mongoose from "mongoose"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const resolvedParams = await params; + const { id } = resolvedParams; + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + + await dbConnect(); + const contest = await CustomContest.findById(id); + if (!contest) { + return NextResponse.json({ error: "Contest not found" }, { status: 404 }); + } + + if (contest.format !== "bracket") { + return NextResponse.json({ error: "Registration only available for knockout contests" }, { status: 400 }); + } + + if (contest.status !== "registration") { + return NextResponse.json({ error: "Contest not accepting registrations" }, { status: 400 }); + } + + const regSettings = contest.registrationSettings; + if (!regSettings) { + return NextResponse.json({ error: "Registration settings not found" }, { status: 400 }); + } + + if (new Date() > new Date(regSettings.deadline)) { + return NextResponse.json({ error: "Registration deadline passed" }, { status: 400 }); + } + + const registrations = contest.registrations || []; + + if (contest.teamSize === 1) { + // Solo Registration + if (registrations.length >= regSettings.maxParticipants) { + return NextResponse.json({ error: "Contest is full" }, { status: 400 }); + } + + // Check duplicate + const alreadyRegistered = registrations.some((reg: any) => reg.userId.toString() === userId); + if (alreadyRegistered) { + return NextResponse.json({ error: "Already registered" }, { status: 409 }); + } + + // Look up verified handle + const cpUser = await CPUser.findOne({ userId }); + if (!cpUser || !cpUser.cfHandle) { + return NextResponse.json({ error: "User must have a Codeforces handle" }, { status: 400 }); + } + + // Push registration + contest.registrations = [ + ...registrations, + { + userId: new mongoose.Types.ObjectId(userId), + cfHandle: cpUser.cfHandle, + registeredAt: new Date(), + }, + ]; + + await contest.save(); + + // Trigger solved prefetch job in background + try { + const { cfSyncQueue } = require("@/lib/bullmq"); + await cfSyncQueue.add("solved_prefetch", { cfHandle: cpUser.cfHandle }); + } catch (queueErr) { + // Log error but don't fail registration + console.error("Failed to enqueue solved_prefetch job:", queueErr); + } + + return NextResponse.json({ registered: true }); + } else if (contest.teamSize === 3) { + // Team Registration + const body = await request.json(); + const { teamName, memberIds } = body; + + if (!teamName || !memberIds || !Array.isArray(memberIds) || memberIds.length !== 3) { + return NextResponse.json({ error: "teamName and memberIds array of size 3 are required" }, { status: 400 }); + } + + if (!memberIds.includes(userId)) { + return NextResponse.json({ error: "Registrant must be part of the team members" }, { status: 400 }); + } + + // Check max limit + if (registrations.length >= regSettings.maxParticipants) { + return NextResponse.json({ error: "Contest is full" }, { status: 400 }); + } + + // Validate all members exist, have verified handles, and are not already registered + const cpUsers = await CPUser.find({ userId: { $in: memberIds.map((id) => new mongoose.Types.ObjectId(id)) } }); + if (cpUsers.length !== 3) { + return NextResponse.json({ error: "All 3 member users must exist" }, { status: 400 }); + } + + const allHaveHandles = cpUsers.every((u) => !!u.cfHandle); + if (!allHaveHandles) { + return NextResponse.json({ error: "All members must have a verified Codeforces handle" }, { status: 400 }); + } + + // Check registrations for duplicates + const registeredUserIds = new Set(registrations.map((reg: any) => reg.userId.toString())); + for (const memberId of memberIds) { + if (registeredUserIds.has(memberId)) { + return NextResponse.json({ error: "Member already registered" }, { status: 409 }); + } + } + + // Push all members to registration list with team grouping + const updatedRegs = [...registrations]; + for (const u of cpUsers) { + updatedRegs.push({ + userId: u.userId, + cfHandle: u.cfHandle, + teamName, + registeredAt: new Date(), + }); + } + contest.registrations = updatedRegs; + + await contest.save(); + + // Trigger prefetch jobs for all 3 members + try { + const { cfSyncQueue } = require("@/lib/bullmq"); + for (const u of cpUsers) { + await cfSyncQueue.add("solved_prefetch", { cfHandle: u.cfHandle }); + } + } catch (queueErr) { + console.error("Failed to enqueue solved_prefetch jobs:", queueErr); + } + + return NextResponse.json({ registered: true }); + } + + return NextResponse.json({ error: "Unsupported teamSize format" }, { status: 400 }); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Internal Server Error" }, { status: 500 }); + } +} diff --git a/src/app/api/contests/[id]/status/route.ts b/src/app/api/contests/[id]/status/route.ts new file mode 100644 index 0000000..edebf98 --- /dev/null +++ b/src/app/api/contests/[id]/status/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/requireAdmin"; +import dbConnect from "@/lib/mongodb"; +import CustomContest from "@/models/CustomContest"; +import { publishContest } from "@/lib/sse"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const resolvedParams = await params; + const { id } = resolvedParams; + const admin = await requireAdmin(request); + if (!admin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json(); + const { action } = body; + + if (!action) { + return NextResponse.json({ error: "Action is required" }, { status: 400 }); + } + + await dbConnect(); + const contest = await CustomContest.findById(id); + if (!contest) { + return NextResponse.json({ error: "Contest not found" }, { status: 404 }); + } + + let newStatus: "draft" | "registration" | "active" | "completed"; + + if (action === "publish") { + if (contest.status !== "draft") { + return NextResponse.json({ error: "Invalid status transition" }, { status: 400 }); + } + newStatus = "registration"; + } else if (action === "start") { + if (contest.status !== "registration") { + return NextResponse.json({ error: "Invalid status transition" }, { status: 400 }); + } + newStatus = "active"; + } else if (action === "complete") { + if (contest.status !== "active") { + return NextResponse.json({ error: "Invalid status transition" }, { status: 400 }); + } + newStatus = "completed"; + } else { + return NextResponse.json({ error: "Invalid status transition" }, { status: 400 }); + } + + contest.status = newStatus; + await contest.save(); + + // Publish SSE update + try { + await publishContest(contest._id.toString(), { + type: "contest.status_change", + status: newStatus, + }); + } catch (sseError) { + console.error("Failed to publish SSE status change:", sseError); + } + + return NextResponse.json(contest); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Internal Server Error" }, { status: 500 }); + } +} diff --git a/src/app/api/contests/presets/[id]/route.ts b/src/app/api/contests/presets/[id]/route.ts new file mode 100644 index 0000000..f615206 --- /dev/null +++ b/src/app/api/contests/presets/[id]/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "@/lib/mongodb"; +import ContestPreset from "@/models/ContestPreset"; +import { requireAdmin } from "@/lib/requireAdmin"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + await dbConnect(); + const preset = await ContestPreset.findById(id); + if (!preset) { + return NextResponse.json({ error: "Preset not found" }, { status: 404 }); + } + return NextResponse.json(preset); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Internal Server Error" }, { status: 500 }); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const admin = await requireAdmin(request); + if (!admin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await dbConnect(); + const body = await request.json(); + const { name, description, format, mode, durationSeconds, problemSelectionMode, bulkPlatform, bulkRatingMin, bulkRatingMax, bulkProblemCount, problemSlots } = body; + + const preset = await ContestPreset.findById(id); + if (!preset) { + return NextResponse.json({ error: "Preset not found" }, { status: 404 }); + } + + if (name && name.trim() !== preset.name) { + const existing = await ContestPreset.findOne({ name: name.trim() }); + if (existing) { + return NextResponse.json({ error: "Preset name already exists" }, { status: 409 }); + } + preset.name = name.trim(); + } + + if (description !== undefined) preset.description = description; + if (format !== undefined) preset.format = format; + if (mode !== undefined) preset.mode = mode; + if (durationSeconds !== undefined) preset.durationSeconds = durationSeconds; + if (problemSelectionMode !== undefined) preset.problemSelectionMode = problemSelectionMode; + if (bulkPlatform !== undefined) preset.bulkPlatform = bulkPlatform; + if (bulkRatingMin !== undefined) preset.bulkRatingMin = bulkRatingMin; + if (bulkRatingMax !== undefined) preset.bulkRatingMax = bulkRatingMax; + if (bulkProblemCount !== undefined) preset.bulkProblemCount = bulkProblemCount; + if (problemSlots !== undefined) preset.problemSlots = problemSlots; + + await preset.save(); + return NextResponse.json(preset); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Internal Server Error" }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const admin = await requireAdmin(request); + if (!admin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await dbConnect(); + const body = await request.json(); + const { archived } = body; + + if (archived === undefined) { + return NextResponse.json({ error: "Missing archived status" }, { status: 400 }); + } + + const preset = await ContestPreset.findByIdAndUpdate( + id, + { archived }, + { new: true } + ); + + if (!preset) { + return NextResponse.json({ error: "Preset not found" }, { status: 404 }); + } + + return NextResponse.json(preset); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Internal Server Error" }, { status: 500 }); + } +} diff --git a/src/app/api/contests/presets/route.ts b/src/app/api/contests/presets/route.ts new file mode 100644 index 0000000..9680fb0 --- /dev/null +++ b/src/app/api/contests/presets/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "@/lib/mongodb"; +import ContestPreset from "@/models/ContestPreset"; +import { requireAdmin } from "@/lib/requireAdmin"; + +export async function GET(request: NextRequest) { + try { + await dbConnect(); + const { searchParams } = new URL(request.url); + const includeArchived = searchParams.get("includeArchived") === "true"; + + const query = includeArchived ? {} : { archived: { $ne: true } }; + const presets = await ContestPreset.find(query).sort({ name: 1 }); + + return NextResponse.json(presets); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Internal Server Error" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const admin = await requireAdmin(request); + if (!admin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await dbConnect(); + const body = await request.json(); + const { name, description, format, mode, durationSeconds, problemSelectionMode, bulkPlatform, bulkRatingMin, bulkRatingMax, bulkProblemCount, problemSlots } = body; + + if (!name || name.trim().length < 3) { + return NextResponse.json({ error: "Name must be at least 3 characters long" }, { status: 400 }); + } + + // Check unique name + const existing = await ContestPreset.findOne({ name: name.trim() }); + if (existing) { + return NextResponse.json({ error: "Preset name already exists" }, { status: 409 }); + } + + const preset = await ContestPreset.create({ + name: name.trim(), + description, + format, + mode, + durationSeconds, + problemSelectionMode, + bulkPlatform, + bulkRatingMin, + bulkRatingMax, + bulkProblemCount, + problemSlots, + archived: false, + }); + + return NextResponse.json(preset, { status: 201 }); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Internal Server Error" }, { status: 500 }); + } +} diff --git a/src/app/api/contests/rooms/[id]/ready/route.ts b/src/app/api/contests/rooms/[id]/ready/route.ts new file mode 100644 index 0000000..48cf977 --- /dev/null +++ b/src/app/api/contests/rooms/[id]/ready/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { getRedis } from "@/lib/redis"; +import ContestRoom from "@/models/ContestRoom"; +import ContestTeam from "@/models/ContestTeam"; +import dbConnect from "@/lib/mongodb"; +import { publishRoom } from "@/lib/sse"; +import { reconciliationQueue } from "@/lib/bullmq"; +import { logger } from "@/lib/utils"; + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + let userId = ""; + + if (process.env.NODE_ENV === "development" && req.headers.get("x-test-user-id")) { + userId = req.headers.get("x-test-user-id")!; + } else { + const session = await auth.api.getSession({ headers: req.headers }); + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + userId = session.user.id; + } + + // Await params for Next.js 15+ + const { id: roomId } = await params; + + await dbConnect(); + const room = await ContestRoom.findById(roomId); + if (!room) { + return NextResponse.json({ error: "Room not found" }, { status: 404 }); + } + + if (!room.participants.includes(userId as any)) { + return NextResponse.json({ error: "Not a participant" }, { status: 403 }); + } + + const redis = await getRedis(); + const state = await redis.hGetAll(`room:${roomId}:state`); + + if (!state || state.status !== "waiting") { + return NextResponse.json({ error: "Room is not waiting" }, { status: 400 }); + } + + // Determine which team this user belongs to + const teams = await redis.sMembers(`room:${roomId}:teams`); + let userTeamId: string | null = null; + for (const tId of teams) { + const isMember = await redis.sIsMember(`team:${tId}:users`, userId); + if (isMember) { + userTeamId = tId; + break; + } + } + + if (!userTeamId) { + return NextResponse.json({ error: "User is not part of any team" }, { status: 403 }); + } + + // Mark user as ready + const readyAdded = await redis.sAdd(`room:${roomId}:ready_users`, userId); + + if (readyAdded) { + // Check if this user's entire team is ready + const teamMembers = await redis.sMembers(`team:${userTeamId}:users`); + const readyMembers = []; + for (const memberId of teamMembers) { + const isReady = await redis.sIsMember(`room:${roomId}:ready_users`, memberId); + if (isReady) { + readyMembers.push(memberId); + } + } + + const teamReady = readyMembers.length === teamMembers.length; + if (teamReady) { + logger.info(`[Ready] Team ${userTeamId} is fully ready in room ${roomId}`); + await redis.sAdd(`room:${roomId}:teams_ready`, userTeamId); + } + + // Check if all teams are ready + const teamsReady = await redis.sMembers(`room:${roomId}:teams_ready`); + const allTeamsReady = teamsReady.length === teams.length; + + if (allTeamsReady) { + // Room start + const now = Date.now(); + await redis.hSet(`room:${roomId}:state`, { + status: "active", + startTime: now.toString() + }); + + // Reveal problem(s) based on mode + const problemsRaw = await redis.lRange(`room:${roomId}:problems`, 0, -1); + if (state.type === "arena") { + for (let i = 0; i < problemsRaw.length; i++) { + const p = JSON.parse(problemsRaw[i]); + p.revealedAt = now; + await redis.lSet(`room:${roomId}:problems`, i, JSON.stringify(p)); + } + } else { + if (problemsRaw.length > 0) { + const firstProblem = JSON.parse(problemsRaw[0]); + firstProblem.revealedAt = now; + await redis.lSet(`room:${roomId}:problems`, 0, JSON.stringify(firstProblem)); + } + } + + room.status = "active"; + await room.save(); + + const updatedState = await redis.hGetAll(`room:${roomId}:state`); + const updatedProblems = await redis.lRange(`room:${roomId}:problems`, 0, -1); + + // Fetch scores + const scores: Record = {}; + for (const tId of teams) { + const score = await redis.zScore(`room:${roomId}:scores`, tId); + scores[tId] = score || 0; + } + + // Publish state sync + await publishRoom(roomId, { + type: "room.state_sync", + roomId, + state: updatedState, + problems: updatedProblems.map(p => JSON.parse(p)), + scores + }); + + // Enqueue time limit job + const timeLimitSecs = parseInt(state.timeLimit || "3600", 10); + await reconciliationQueue.add( + "room_timeout", + { roomId, contestId: state.contestId, trigger: "timeout" }, + { delay: timeLimitSecs * 1000, jobId: `timeout-${roomId}` } + ); + } else if (!teamReady) { + // Set a timeout for this team to become ready (60s) + const readyTimeoutKey = `ready_timeout:${roomId}:${userTeamId}`; + const timeoutSet = await redis.set(readyTimeoutKey, "1", { EX: 60, NX: true }); + + if (timeoutSet) { + // Timeout was just set, schedule a job to check if team became ready + await reconciliationQueue.add( + "team_ready_timeout", + { roomId, teamId: userTeamId }, + { delay: 60000, jobId: `ready-timeout-${roomId}-${userTeamId}` } + ); + } + } + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("Ready check error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contests/rooms/[id]/walkover/route.ts b/src/app/api/contests/rooms/[id]/walkover/route.ts new file mode 100644 index 0000000..096d99c --- /dev/null +++ b/src/app/api/contests/rooms/[id]/walkover/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/requireAdmin"; +import { processWalkover } from "@/lib/bracket"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const resolvedParams = await params; + const { id: roomId } = resolvedParams; + + const isDev = process.env.NODE_ENV === "development"; + const testUserId = request.headers.get("x-test-user-id"); + let admin: any = null; + if (!isDev || !testUserId) { + admin = await requireAdmin(request); + if (!admin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } + + const body = await request.json(); + const { winnerTeamId, note } = body; + + if (!winnerTeamId || !note) { + return NextResponse.json( + { error: "winnerTeamId and note are required" }, + { status: 400 } + ); + } + + const adminUserId = admin?._id?.toString() || "dev-bypass"; + const snapshot = await processWalkover(roomId, winnerTeamId, note, adminUserId); + return NextResponse.json({ success: true, bracket: snapshot }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Internal Server Error" }, + { status: 400 } + ); + } +} diff --git a/src/app/api/contests/rooms/route.ts b/src/app/api/contests/rooms/route.ts new file mode 100644 index 0000000..bfffe4e --- /dev/null +++ b/src/app/api/contests/rooms/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "@/lib/mongodb"; +import CustomContest from "@/models/CustomContest"; +import ContestRoom from "@/models/ContestRoom"; +import ContestProblemSet from "@/models/ContestProblemSet"; +import ContestTeam from "@/models/ContestTeam"; +import CPUser from "@/models/CPUser"; +import CFQuestion from "@/models/CFQuestion"; +import { getRedis } from "@/lib/redis"; +import mongoose from "mongoose"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { contestId, teams } = body; + + if (!contestId || !teams || !Array.isArray(teams) || teams.length < 2) { + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + // Validate team sizes: each team must have 1 or 3 members + const teamSizes = teams.map((t: any) => t.members.length); + const validSizes = teamSizes.every((size: number) => size === 1 || size === 3); + const consistentSizes = teamSizes.every((size: number) => size === teamSizes[0]); + + if (!validSizes || !consistentSizes) { + return NextResponse.json( + { + error: "Invalid team sizes", + details: "Each team must have 1 or 3 members, and all teams must have the same size" + }, + { status: 400 } + ); + } + + await dbConnect(); + const contest = await CustomContest.findById(contestId); + if (!contest) { + return NextResponse.json({ error: "Contest not found" }, { status: 404 }); + } + + const problemCount = contest.bulkProblemCount || 3; + const minRating = contest.bulkRatingMin || 800; + const maxRating = contest.bulkRatingMax || 1200; + + // Collect all user IDs and fetch them to get solved problems + const allUserIds = teams.flatMap(t => t.members); + const users = await CPUser.find({ userId: { $in: allUserIds } }); + + // Collect all solved problem IDs + const solvedProblemIds = new Set(); + for (const user of users) { + if (user.solvedProblems) { + for (const sp of user.solvedProblems) { + solvedProblemIds.add(sp.problemId); + } + } + } + + // Query MongoDB problem pool + const availableProblems = await CFQuestion.aggregate([ + { + $match: { + rating: { $gte: minRating, $lte: maxRating }, + problemId: { $nin: Array.from(solvedProblemIds) } + } + }, + { $sample: { size: problemCount } } + ]); + + if (availableProblems.length < problemCount) { + return NextResponse.json({ + error: 'insufficient_problems', + minimumRatingRange: [minRating, maxRating] + }, { status: 400 }); + } + + // Write stub ContestRoom to MongoDB + const room = new ContestRoom({ + contestId: contest._id, + name: `Room for ${contest.name}`, + status: "waiting", + participants: allUserIds, + currentProblemIndex: 0, + firstSolvers: [] + }); + + // Write stub ContestProblemSet + const problemSet = new ContestProblemSet({ + contestId: contest._id, + roomId: room._id, + problems: availableProblems.map(p => ({ + platform: "codeforces", + problemId: p.problemId, + name: p.name, + rating: p.rating, + points: 100 + })) + }); + + // Create teams in MongoDB + const teamSize = teamSizes[0]; // Already validated that all sizes are equal + const createdTeams = []; + for (const t of teams) { + const team = new ContestTeam({ + roomId: room._id, + name: t.name, + members: t.members, + teamSize, + score: 0 + }); + await team.save(); + createdTeams.push(team); + } + room.teams = createdTeams.map(t => t._id); + + await room.save(); + await problemSet.save(); + + const roomId = room._id.toString(); + + const redis = await getRedis(); + + // Write ordered problem array to room::problems + const redisProblems = availableProblems.map(p => JSON.stringify({ + problemId: p.problemId, + name: p.name, + rating: p.rating, + revealedAt: null + })); + await redis.del(`room:${roomId}:problems`); + if (redisProblems.length > 0) { + await redis.rPush(`room:${roomId}:problems`, redisProblems); + } + + // Set room::state Hash + const stateObj: any = { + status: "waiting", + type: contest.mode || "blitz", + startTime: "", + timeLimit: contest.durationSeconds.toString(), + contestId: contestId.toString(), + readyCount: 0 + }; + if (contest.mode !== "arena") { + stateObj.currentProblem = 0; + } + await redis.hSet(`room:${roomId}:state`, stateObj); + + // Write room::teams Set + await redis.sAdd(`room:${roomId}:teams`, createdTeams.map(t => t._id.toString())); + + // Write team::meta and team::users + for (const t of createdTeams) { + const tId = t._id.toString(); + await redis.hSet(`team:${tId}:meta`, { name: t.name, score: 0 }); + await redis.sAdd(`team:${tId}:users`, t.members.map((m: any) => m.toString())); + } + + // Add roomId to contest::rooms Set + await redis.sAdd(`contest:${contestId}:rooms`, roomId); + + return NextResponse.json({ roomId }); + } catch (error) { + console.error("Room creation error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contests/sync/route.ts b/src/app/api/contests/sync/route.ts new file mode 100644 index 0000000..ba1325c --- /dev/null +++ b/src/app/api/contests/sync/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { getRedis } from "@/lib/redis"; +import { cfSyncQueue } from "@/lib/bullmq"; +import { publishUser } from "@/lib/sse"; +import { logger } from "@/lib/utils"; + +export async function POST(request: NextRequest) { + try { + let userId = ""; + + if (process.env.NODE_ENV === "development" && request.headers.get("x-test-user-id")) { + userId = request.headers.get("x-test-user-id")!; + } else { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + userId = session.user.id; + } + + const body = await request.json(); + const { roomId, teamId, cfHandle, problemId } = body; + + if (!roomId || !cfHandle || !problemId) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + const redis = await getRedis(); + + // Resolve teamId if not provided: check which team contains this userId + let resolvedTeamId = teamId; + if (!resolvedTeamId) { + const teams = await redis.sMembers(`room:${roomId}:teams`); + for (const tId of teams) { + const isMember = await redis.sIsMember(`team:${tId}:users`, userId); + if (isMember) { + resolvedTeamId = tId; + break; + } + } + if (!resolvedTeamId) { + return NextResponse.json({ error: "User is not part of any team in this room" }, { status: 403 }); + } + } + + const rateLimitKey = `ratelimit:sync:${userId}`; + + // 1. Check ratelimit + const isRateLimited = await redis.exists(rateLimitKey); + if (isRateLimited) { + return NextResponse.json({ error: "Rate limit exceeded. Please wait 60 seconds." }, { status: 429 }); + } + + // 2. Set ratelimit (60s TTL) + await redis.set(rateLimitKey, "1", { EX: 60 }); + + // 3. Enqueue job + const jobData = { roomId, userId, teamId: resolvedTeamId, cfHandle, problemId }; + const job = await cfSyncQueue.add("cf_sync", jobData); + + // Approximate position + const waitingCount = await cfSyncQueue.getWaitingCount(); + const position = waitingCount + 1; + const createdAt = Date.now(); + + // 4. Set sync Hash state + const syncStateKey = `sync:${roomId}:${userId}`; + await redis.hSet(syncStateKey, { + status: "queued", + position: position.toString(), + createdAt: createdAt.toString(), + jobId: job.id || "", + }); + // Set a TTL so it doesn't leak indefinitely (e.g., 1 hour) + await redis.expire(syncStateKey, 3600); + + // 5. Publish event to user + await publishUser(userId, { type: "sync.queued", position }); + + // 6. Return 202 + return NextResponse.json({ queued: true }, { status: 202 }); + + } catch (error: any) { + logger.error("[/api/contests/sync] Error enqueuing sync job:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..b72e225 --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { getRedis } from "@/lib/redis"; +import dbConnect from "@/lib/mongodb"; +import ContestRoom from "@/models/ContestRoom"; +import { logger } from "@/lib/utils"; +import { publishRoom } from "@/lib/sse"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + let userId = ""; + + if (process.env.NODE_ENV === "development" && request.headers.get("x-test-user-id")) { + userId = request.headers.get("x-test-user-id")!; + } else { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + userId = session.user.id; + } + + await dbConnect(); + + const isValidObjectId = /^[0-9a-fA-F]{24}$/.test(userId); + const activeRooms = isValidObjectId + ? await ContestRoom.find({ + participants: userId, + status: "active", + }).lean() + : []; + + const redis = await getRedis(); + + for (const room of activeRooms) { + const roomId = room._id.toString(); + const presenceKey = `room:${roomId}:presence:${userId}`; + await redis.set(presenceKey, "online"); + await redis.persist(presenceKey); + + // Remove the offline tracker key since the user is now online + const offlineSentKey = `room:${roomId}:presence:${userId}:offline_sent`; + await redis.del(offlineSentKey); + + // Publish online status + await publishRoom(roomId, { type: "presence.online", userId }); + } + + const channels = [`events:user:${userId}`]; + for (const room of activeRooms) { + const roomId = room._id.toString(); + const contestId = room.contestId.toString(); + channels.push(`events:room:${roomId}`); + channels.push(`events:contest:${contestId}`); + } + + const subscriber = redis.duplicate(); + await subscriber.connect(); + + let isClosed = false; + + const cleanup = async () => { + if (isClosed) return; + isClosed = true; + + try { + await subscriber.unsubscribe(); + await subscriber.disconnect(); + } catch (err) { + logger.error("[SSE] Error disconnecting subscriber client:", err); + } + + try { + for (const room of activeRooms) { + const roomId = room._id.toString(); + const presenceKey = `room:${roomId}:presence:${userId}`; + await redis.expire(presenceKey, 90); + + // Track that we sent the offline event immediately on disconnect + const offlineSentKey = `room:${roomId}:presence:${userId}:offline_sent`; + await redis.set(offlineSentKey, "1", { EX: 120 }); + + // Publish offline status + await publishRoom(roomId, { type: "presence.offline", userId }); + } + } catch (err) { + logger.error("[SSE] Error setting presence expiration:", err); + } + }; + + const stream = new ReadableStream({ + async start(controller) { + const sendEvent = (event: string, data: any) => { + if (isClosed) return; + const formatted = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(new TextEncoder().encode(formatted)); + }; + + sendEvent("connected", { + userId, + subscribedChannels: channels, + }); + + try { + await subscriber.subscribe(channels, (message, channel) => { + let parsed = message; + try { + parsed = JSON.parse(message); + } catch (e) { + } + sendEvent("message", { channel, payload: parsed }); + }); + } catch (err) { + logger.error("[SSE] Failed to subscribe to Redis channels:", err); + controller.error(err); + await cleanup(); + } + }, + async cancel() { + await cleanup(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + }, + }); +} diff --git a/src/components/admin/contests/ContestWizard.module.scss b/src/components/admin/contests/ContestWizard.module.scss new file mode 100644 index 0000000..90200b4 --- /dev/null +++ b/src/components/admin/contests/ContestWizard.module.scss @@ -0,0 +1,127 @@ +@use "@/styles/mixins" as *; + +.wizardContainer { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +.wizardTitle { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 2rem; + color: var(--foreground-strong); + text-align: center; +} + +.progressTracker { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 3rem; + padding: 0 1rem; +} + +.step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + position: relative; + z-index: 1; + + .circle { + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--surface-secondary); + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: 600; + color: var(--muted); + transition: all 0.3s ease; + } + + .label { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + transition: all 0.3s ease; + white-space: nowrap; + position: absolute; + top: 2.5rem; + } + + &.active { + .circle { + background: var(--primary); + border-color: var(--primary); + color: var(--fg-on-emphasis); + box-shadow: 0 0 0 4px var(--focus-ring); + } + .label { + color: var(--foreground-strong); + } + } + + &.completed { + .circle { + background: #2ecc71; + border-color: #2ecc71; + color: var(--fg-on-emphasis); + } + .label { + color: #2ecc71; + } + } +} + +.line { + flex: 1; + height: 2px; + background: var(--border); + margin: 0 -0.5rem; // pull the line in so it connects to the circles + // transform: translateY(-0rem); // 1rem is exactly half of the 2rem circle height, aligning the line directly in the center + transition: all 0.3s ease; + + &.completedLine { + background: #2ecc71; + } +} + +.stepContent { + background: var(--surface-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + min-height: 350px; + margin-bottom: 2rem; +} + +.wizardControls { + display: flex; + gap: 1rem; +} + +.backButton { + @include btn-secondary; + padding: 0.625rem 1.25rem; + font-weight: 600; +} + +.nextButton, +.createButton { + @include btn-primary; + padding: 0.625rem 1.25rem; + font-weight: 600; +} + +.createButton { + background: #2e7d32; + &:hover { + background: #1b5e20; + } +} diff --git a/src/components/admin/contests/ContestWizard.tsx b/src/components/admin/contests/ContestWizard.tsx new file mode 100644 index 0000000..fad5f09 --- /dev/null +++ b/src/components/admin/contests/ContestWizard.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { validateStep, createBracketContest } from "@/lib/actions/admin/contests"; +import Step1BasicInfo from "./steps/Step1BasicInfo"; +import Step2Registration from "./steps/Step2Registration"; +import Step3MatchPreset from "./steps/Step3MatchPreset"; +import Step4BracketSettings from "./steps/Step4BracketSettings"; +import Step5Preview from "./steps/Step5Preview"; +import styles from "./ContestWizard.module.scss"; + +interface ContestWizardProps { + presets: any[]; +} + +export default function ContestWizard({ presets }: ContestWizardProps) { + const router = useRouter(); + const [currentStep, setCurrentStep] = useState(1); + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [formData, setFormData] = useState({ + name: "", + description: "", + mode: "blitz", + teamSize: 1, + registrationType: "open", + deadline: "", + maxParticipants: 8, + presetId: "", + thirdPlacePlayoff: false, + seedingMethod: "cf_rating", + }); + + const steps = [ + { number: 1, title: "Basic Info" }, + { number: 2, title: "Registration" }, + { number: 3, title: "Match Preset" }, + { number: 4, title: "Bracket Settings" }, + { number: 5, title: "Preview" }, + ]; + + function updateFields(fields: Partial) { + setFormData((prev) => ({ ...prev, ...fields })); + // Clear errors for fields as they are edited + const updatedErrors = { ...errors }; + Object.keys(fields).forEach((key) => { + delete updatedErrors[key]; + }); + setErrors(updatedErrors); + } + + async function handleNext() { + setIsSubmitting(true); + try { + const result = await validateStep(currentStep, formData); + if (!result.valid) { + setErrors(result.errors); + } else { + setErrors({}); + setCurrentStep((prev) => prev + 1); + } + } catch (err: any) { + alert(err.message || "Validation failed"); + } finally { + setIsSubmitting(false); + } + } + + function handleBack() { + if (currentStep > 1) { + setCurrentStep((prev) => prev - 1); + setErrors({}); + } + } + + async function handleCreate() { + setIsSubmitting(true); + try { + const result = await createBracketContest(formData); + if ("error" in result) { + alert(result.error); + } else { + alert("Contest created successfully!"); + router.push(`/admin`); + } + } catch (err: any) { + alert(err.message || "Failed to create contest"); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+

Create Bracket Tournament

+ + {/* Progress Tracker */} +
+ {steps.map((step, index) => ( + +
step.number ? styles.completed : "" + }`} + > +
{step.number}
+
{step.title}
+
+ {index < steps.length - 1 && ( +
step.number ? styles.completedLine : ""}`} /> + )} + + ))} +
+ + {/* Step Content */} +
+ {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} + {currentStep === 3 && ( + + )} + {currentStep === 4 && ( + + )} + {currentStep === 5 && } +
+ + {/* Controls */} +
+ {currentStep > 1 && ( + + )} +
+ {currentStep < 5 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/admin/contests/PresetManager.module.scss b/src/components/admin/contests/PresetManager.module.scss new file mode 100644 index 0000000..0297b83 --- /dev/null +++ b/src/components/admin/contests/PresetManager.module.scss @@ -0,0 +1,295 @@ +@use "@/styles/mixins" as *; + +.container { + padding: 0.5rem 0; +} + +.header { + display: flex; + justify-content: flex-end; + margin-bottom: 1.5rem; +} + +.addButton { + @include btn-primary; + padding: 0.5rem 1rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.tableContainer { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-secondary); +} + +.table { + @include table-base; + + th, + td { + padding: 0.75rem 1rem; + font-size: 0.875rem; + } + + tbody tr { + transition: background 0.15s ease; + &:hover { + background: var(--surface-hover); + } + } +} + +.archivedRow { + opacity: 0.6; + background: rgba(0, 0, 0, 0.05); +} + +.description { + margin: 0.25rem 0 0 0; + font-size: 0.75rem; + color: var(--muted); +} + +.badge { + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 4px; +} + +.badgeActive { + background: rgba(46, 204, 113, 0.15); + color: #2ecc71; +} + +.badgeArchived { + background: rgba(149, 165, 166, 0.15); + color: #95a5a6; +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.actionButton { + background: var(--surface); + border: 1px solid var(--border-input); + color: var(--foreground); + padding: 0.375rem; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + + &:hover { + background: var(--surface-hover); + } +} + +.archiveBtn:hover { + border-color: var(--danger); + color: var(--danger); +} + +.unarchiveBtn:hover { + border-color: #2ecc71; + color: #2ecc71; +} + +.overlay { + @include modal-overlay; + z-index: 999; +} + +.modal { + @include modal-dialog(500px); + z-index: 1000; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-height: 90vh; + overflow-y: auto; + + h2 { + margin-top: 0; + margin-bottom: 1.5rem; + font-size: 1.25rem; + font-weight: 700; + color: var(--foreground-strong); + } +} + +.field { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-bottom: 1.25rem; + + label { + font-weight: 600; + font-size: 0.8125rem; + color: var(--foreground); + } + + input, + textarea, + select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-input); + border-radius: 6px; + font-size: 0.875rem; + background: var(--surface); + color: var(--foreground); + + &:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); + } + } + + textarea { + min-height: 80px; + resize: vertical; + } +} + +.row { + display: flex; + gap: 1rem; + + .field { + flex: 1; + } +} + +.bulkSection { + background: var(--surface); + border: 1px solid var(--border); + padding: 1rem; + border-radius: 6px; + margin-bottom: 1.25rem; +} + +.fineTunedSection { + background: var(--surface); + border: 1px solid var(--border); + padding: 1rem; + border-radius: 6px; + margin-bottom: 1.25rem; + + label { + font-weight: 600; + font-size: 0.8125rem; + margin-bottom: 0.5rem; + display: block; + } +} + +.slotRow { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; + align-items: center; + + select, + input { + padding: 0.375rem 0.5rem; + border: 1px solid var(--border-input); + border-radius: 6px; + font-size: 0.8125rem; + background: var(--surface); + color: var(--foreground); + } + + select { + flex: 1; + } + + input { + width: 100px; + } +} + +.removeSlotBtn { + background: none; + border: 1px solid var(--border-input); + color: var(--danger); + padding: 0.375rem 0.625rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + + &:hover:not(:disabled) { + background: rgba(231, 76, 60, 0.1); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.addSlotBtn { + background: none; + border: 1px dashed var(--border-input); + color: var(--primary); + padding: 0.375rem 0.75rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + width: 100%; + margin-top: 0.5rem; + + &:hover { + background: var(--surface-hover); + } +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; + border-top: 1px solid var(--border); + padding-top: 1.25rem; +} + +.cancelButton { + @include btn-secondary; + padding: 0.5rem 1rem; + font-weight: 600; +} + +.saveButton { + @include btn-primary; + padding: 0.5rem 1rem; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/components/admin/contests/PresetManager.tsx b/src/components/admin/contests/PresetManager.tsx new file mode 100644 index 0000000..2f0a0e9 --- /dev/null +++ b/src/components/admin/contests/PresetManager.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Edit2, Archive, Loader2 } from "lucide-react"; +import styles from "./PresetManager.module.scss"; + +interface PresetManagerProps { + initialPresets: any[]; +} + +export default function PresetManager({ initialPresets }: PresetManagerProps) { + const [presets, setPresets] = useState(initialPresets); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editingPreset, setEditingPreset] = useState(null); + + // Form states + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [format, setFormat] = useState("bracket"); + const [mode, setMode] = useState("blitz"); + const [durationSeconds, setDurationSeconds] = useState(300); + const [problemSelectionMode, setProblemSelectionMode] = useState("bulk"); + + // Mode A Bulk Settings + const [bulkPlatform, setBulkPlatform] = useState("codeforces"); + const [bulkRatingMin, setBulkRatingMin] = useState(800); + const [bulkRatingMax, setBulkRatingMax] = useState(1200); + const [bulkProblemCount, setBulkProblemCount] = useState(3); + + // Mode B Fine-Tuned Slots + const [problemSlots, setProblemSlots] = useState>([ + { platform: "codeforces", rating: 800 }, + ]); + + function resetForm() { + setName(""); + setDescription(""); + setFormat("bracket"); + setMode("blitz"); + setDurationSeconds(300); + setProblemSelectionMode("bulk"); + setBulkPlatform("codeforces"); + setBulkRatingMin(800); + setBulkRatingMax(1200); + setBulkProblemCount(3); + setProblemSlots([{ platform: "codeforces", rating: 800 }]); + setEditingPreset(null); + } + + function openCreate() { + resetForm(); + setModalOpen(true); + } + + function openEdit(preset: any) { + setEditingPreset(preset); + setName(preset.name || ""); + setDescription(preset.description || ""); + setFormat(preset.format || "bracket"); + setMode(preset.mode || "blitz"); + setDurationSeconds(preset.durationSeconds || 300); + setProblemSelectionMode(preset.problemSelectionMode || "bulk"); + setBulkPlatform(preset.bulkPlatform || "codeforces"); + setBulkRatingMin(preset.bulkRatingMin || 800); + setBulkRatingMax(preset.bulkRatingMax || 1200); + setBulkProblemCount(preset.bulkProblemCount || 3); + setProblemSlots(preset.problemSlots || [{ platform: "codeforces", rating: 800 }]); + setModalOpen(true); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const payload = { + name, + description, + format, + mode, + durationSeconds, + problemSelectionMode, + ...(problemSelectionMode === "bulk" + ? { bulkPlatform, bulkRatingMin, bulkRatingMax, bulkProblemCount } + : { problemSlots }), + }; + + const url = editingPreset ? `/api/contests/presets/${editingPreset._id}` : `/api/contests/presets`; + const method = editingPreset ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to save preset"); + } + + const savedPreset = await res.json(); + + if (editingPreset) { + setPresets(presets.map((p) => (p._id === savedPreset._id ? savedPreset : p))); + } else { + setPresets([...presets, savedPreset].sort((a, b) => a.name.localeCompare(b.name))); + } + + setModalOpen(false); + resetForm(); + } catch (err: any) { + alert(err.message); + } finally { + setLoading(false); + } + } + + async function toggleArchive(preset: any) { + const action = preset.archived ? "unarchive" : "archive"; + if (!confirm(`Are you sure you want to ${action} this preset?`)) return; + + setLoading(true); + try { + const res = await fetch(`/api/contests/presets/${preset._id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archived: !preset.archived }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to update archive status"); + } + + const updated = await res.json(); + setPresets(presets.map((p) => (p._id === updated._id ? updated : p))); + } catch (err: any) { + alert(err.message); + } finally { + setLoading(false); + } + } + + function addSlot() { + setProblemSlots([...problemSlots, { platform: "codeforces", rating: 800 }]); + } + + function updateSlot(index: number, field: "platform" | "rating", value: string | number) { + const updated = [...problemSlots]; + updated[index] = { ...updated[index], [field]: value }; + setProblemSlots(updated); + } + + function removeSlot(index: number) { + if (problemSlots.length <= 1) return; + setProblemSlots(problemSlots.filter((_, i) => i !== index)); + } + + return ( +
+
+ +
+ +
+ + + + + + + + + + + + + {presets.map((preset) => ( + + + + + + + + + ))} + +
NameFormatModeDuration (min)StatusActions
+ {preset.name} + {preset.description &&

{preset.description}

} +
{preset.format}{preset.mode}{Math.round((preset.durationSeconds || 0) / 60)} + + {preset.archived ? "Archived" : "Active"} + + +
+ + +
+
+
+ + {modalOpen && ( + <> +
setModalOpen(false)} /> +
+
+

{editingPreset ? "Edit Preset" : "New Preset"}

+ +
+ + setName(e.target.value)} + placeholder="e.g. Blitz 5min Easy" + required + /> +
+ +
+ +