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 (
+
+ );
+}
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 (
+
+
+
+
+
+
+
+ | Name |
+ Format |
+ Mode |
+ Duration (min) |
+ Status |
+ Actions |
+
+
+
+ {presets.map((preset) => (
+
+ |
+ {preset.name}
+ {preset.description && {preset.description} }
+ |
+ {preset.format} |
+ {preset.mode} |
+ {Math.round((preset.durationSeconds || 0) / 60)} |
+
+
+ {preset.archived ? "Archived" : "Active"}
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ {modalOpen && (
+ <>
+
setModalOpen(false)} />
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/admin/contests/steps/Step1BasicInfo.tsx b/src/components/admin/contests/steps/Step1BasicInfo.tsx
new file mode 100644
index 0000000..016094c
--- /dev/null
+++ b/src/components/admin/contests/steps/Step1BasicInfo.tsx
@@ -0,0 +1,143 @@
+import styles from "../ContestWizard.module.scss";
+
+interface Step1Props {
+ name: string;
+ description: string;
+ mode: string;
+ teamSize: number;
+ updateFields: (fields: any) => void;
+ errors: Record
;
+}
+
+export default function Step1BasicInfo({
+ name,
+ description,
+ mode,
+ teamSize,
+ updateFields,
+ errors,
+}: Step1Props) {
+ return (
+
+
+ Step 1: Tournament Basic Info
+
+
+
+
+ updateFields({ name: e.target.value })}
+ placeholder="e.g. CCW Monsoon Bracket Clash"
+ style={{
+ padding: "0.5rem 0.75rem",
+ border: errors.name ? "1px solid var(--danger)" : "1px solid var(--border-input)",
+ borderRadius: "6px",
+ fontSize: "0.875rem",
+ background: "var(--surface)",
+ color: "var(--foreground)",
+ }}
+ required
+ />
+ {errors.name && {errors.name}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/contests/steps/Step2Registration.tsx b/src/components/admin/contests/steps/Step2Registration.tsx
new file mode 100644
index 0000000..cd631d1
--- /dev/null
+++ b/src/components/admin/contests/steps/Step2Registration.tsx
@@ -0,0 +1,92 @@
+import styles from "../ContestWizard.module.scss";
+
+interface Step2Props {
+ registrationType: string;
+ deadline: string;
+ maxParticipants: number;
+ updateFields: (fields: any) => void;
+ errors: Record;
+}
+
+export default function Step2Registration({
+ registrationType,
+ deadline,
+ maxParticipants,
+ updateFields,
+ errors,
+}: Step2Props) {
+ return (
+
+
+ Step 2: Registration settings
+
+
+
+
+
+
+ updateFields({ deadline: e.target.value })}
+ style={{
+ padding: "0.5rem 0.75rem",
+ border: errors.deadline ? "1px solid var(--danger)" : "1px solid var(--border-input)",
+ borderRadius: "6px",
+ fontSize: "0.875rem",
+ background: "var(--surface)",
+ color: "var(--foreground)",
+ }}
+ required
+ />
+ {errors.deadline && {errors.deadline}}
+
+
+
+
+ updateFields({ maxParticipants: Number(e.target.value) })}
+ min={2}
+ style={{
+ padding: "0.5rem 0.75rem",
+ border: errors.maxParticipants ? "1px solid var(--danger)" : "1px solid var(--border-input)",
+ borderRadius: "6px",
+ fontSize: "0.875rem",
+ background: "var(--surface)",
+ color: "var(--foreground)",
+ }}
+ required
+ />
+ {errors.maxParticipants && {errors.maxParticipants}}
+
+
+ );
+}
diff --git a/src/components/admin/contests/steps/Step3MatchPreset.tsx b/src/components/admin/contests/steps/Step3MatchPreset.tsx
new file mode 100644
index 0000000..825d207
--- /dev/null
+++ b/src/components/admin/contests/steps/Step3MatchPreset.tsx
@@ -0,0 +1,126 @@
+import styles from "../ContestWizard.module.scss";
+
+interface Step3Props {
+ presets: any[];
+ selectedPresetId: string;
+ updateFields: (fields: any) => void;
+ errors: Record;
+}
+
+export default function Step3MatchPreset({
+ presets,
+ selectedPresetId,
+ updateFields,
+ errors,
+}: Step3Props) {
+ const selectedPreset = presets.find((p) => p._id === selectedPresetId);
+
+ return (
+
+
+ Step 3: Select Match Preset
+
+
+ {errors.presetId && (
+
{errors.presetId}
+ )}
+
+
+ {presets.map((preset) => (
+
+ ))}
+
+
+ {selectedPreset && (
+
+
+ Preset Details: {selectedPreset.name}
+
+
+
+ Format:{" "}
+ {selectedPreset.format}
+
+
+ Mode:{" "}
+ {selectedPreset.mode}
+
+
+ Duration:{" "}
+
+ {Math.round((selectedPreset.durationSeconds || 0) / 60)} minutes
+
+
+
+ Selection:{" "}
+ {selectedPreset.problemSelectionMode}
+
+ {selectedPreset.problemSelectionMode === "bulk" ? (
+ <>
+
+ Platform:{" "}
+ {selectedPreset.bulkPlatform}
+
+
+ Rating Range:{" "}
+
+ {selectedPreset.bulkRatingMin} - {selectedPreset.bulkRatingMax}
+
+
+
+ Count:{" "}
+ {selectedPreset.bulkProblemCount}
+
+ >
+ ) : (
+
+ Problem Ratings:{" "}
+
+ {selectedPreset.problemSlots?.map((s: any) => s.rating).join(", ")}
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/admin/contests/steps/Step4BracketSettings.tsx b/src/components/admin/contests/steps/Step4BracketSettings.tsx
new file mode 100644
index 0000000..79e4b77
--- /dev/null
+++ b/src/components/admin/contests/steps/Step4BracketSettings.tsx
@@ -0,0 +1,77 @@
+import styles from "../ContestWizard.module.scss";
+
+interface Step4Props {
+ thirdPlacePlayoff: boolean;
+ seedingMethod: string;
+ updateFields: (fields: any) => void;
+ errors: Record;
+}
+
+export default function Step4BracketSettings({
+ thirdPlacePlayoff,
+ seedingMethod,
+ updateFields,
+ errors,
+}: Step4Props) {
+ return (
+
+
+ Step 4: Bracket & Seeding Settings
+
+
+
+
+
+ If checked, a bronze-medal match will be created for semifinal losers.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/contests/steps/Step5Preview.tsx b/src/components/admin/contests/steps/Step5Preview.tsx
new file mode 100644
index 0000000..9d70690
--- /dev/null
+++ b/src/components/admin/contests/steps/Step5Preview.tsx
@@ -0,0 +1,85 @@
+import styles from "../ContestWizard.module.scss";
+
+interface Step5Props {
+ formData: any;
+ presets: any[];
+}
+
+export default function Step5Preview({ formData, presets }: Step5Props) {
+ const selectedPreset = presets.find((p) => p._id === formData.presetId);
+
+ return (
+
+
+ Step 5: Review & Create Tournament
+
+
+
+
+ Tournament Name:
+ {formData.name}
+
+ {formData.description && (
+ <>
+ Description:
+ {formData.description}
+ >
+ )}
+
+ Format:
+ Bracket (Knockout)
+
+ Match Mode:
+ {formData.mode}
+
+ Team Size:
+ {formData.teamSize === 3 ? "Trio (3v3)" : "Solo (1v1)"}
+
+
+
+ Registration:
+ {formData.registrationType}
+
+ Deadline:
+
+ {formData.deadline ? new Date(formData.deadline).toLocaleString() : "Not Set"}
+
+
+ Max Participants:
+ {formData.maxParticipants}
+
+
+
+ Match Preset:
+ {selectedPreset?.name || "None"}
+
+ {selectedPreset && (
+ <>
+ Match Duration:
+
+ {Math.round((selectedPreset.durationSeconds || 0) / 60)} minutes
+
+
+ Problem Selection:
+
+ {selectedPreset.problemSelectionMode === "bulk"
+ ? `Bulk (${selectedPreset.bulkProblemCount} problems from ${selectedPreset.bulkPlatform}, rating ${selectedPreset.bulkRatingMin}-${selectedPreset.bulkRatingMax})`
+ : `Fine-tuned slots (${selectedPreset.problemSlots?.map((s: any) => s.rating).join(", ")})`}
+
+ >
+ )}
+
+
+
+ Bronze Playoff:
+ {formData.thirdPlacePlayoff ? "Enabled" : "Disabled"}
+
+ Seeding:
+
+ {formData.seedingMethod === "cf_rating" ? "Auto Codeforces Rating" : "Manual"}
+
+
+
+
+ );
+}
diff --git a/src/lib/actions/admin/contests.ts b/src/lib/actions/admin/contests.ts
new file mode 100644
index 0000000..5a1199e
--- /dev/null
+++ b/src/lib/actions/admin/contests.ts
@@ -0,0 +1,137 @@
+"use server";
+
+import { auth } from "@/lib/auth";
+import { isAdmin } from "@/lib/roles";
+import { headers } from "next/headers";
+import dbConnect from "@/lib/mongodb";
+import CustomContest from "@/models/CustomContest";
+import ContestPreset from "@/models/ContestPreset";
+import mongoose from "mongoose";
+
+export async function validateStep(step: number, data: Record) {
+ const errors: Record = {};
+
+ if (step === 1) {
+ if (!data.name || data.name.trim().length < 3) {
+ errors.name = "Name must be at least 3 characters";
+ } else if (data.name.trim().length > 100) {
+ errors.name = "Name must be at most 100 characters";
+ }
+ if (data.description && data.description.length > 500) {
+ errors.description = "Description must be at most 500 characters";
+ }
+ if (data.mode !== "blitz" && data.mode !== "arena") {
+ errors.mode = "Mode must be blitz or arena";
+ }
+ if (data.teamSize !== 1 && data.teamSize !== 3) {
+ errors.teamSize = "Team size must be 1 or 3";
+ }
+ }
+
+ if (step === 2) {
+ if (data.registrationType !== "open" && data.registrationType !== "closed") {
+ errors.registrationType = "Registration type must be open or closed";
+ }
+ if (!data.deadline) {
+ errors.deadline = "Registration deadline is required";
+ } else {
+ const deadlineDate = new Date(data.deadline);
+ if (isNaN(deadlineDate.getTime())) {
+ errors.deadline = "Invalid date format";
+ } else if (deadlineDate.getTime() <= Date.now()) {
+ errors.deadline = "Deadline must be in the future";
+ }
+ }
+ if (!data.maxParticipants || isNaN(Number(data.maxParticipants))) {
+ errors.maxParticipants = "Max participants is required and must be a number";
+ } else if (Number(data.maxParticipants) < 2) {
+ errors.maxParticipants = "Minimum 2 participants required";
+ }
+ }
+
+ if (step === 3) {
+ if (!data.presetId) {
+ errors.presetId = "Please select a match preset";
+ } else {
+ await dbConnect();
+ const preset = await ContestPreset.findById(data.presetId);
+ if (!preset) {
+ errors.presetId = "Selected preset does not exist";
+ } else if (preset.archived) {
+ errors.presetId = "Selected preset is archived";
+ }
+ }
+ }
+
+ if (step === 4) {
+ if (data.thirdPlacePlayoff === undefined) {
+ errors.thirdPlacePlayoff = "Third-place playoff setting is required";
+ }
+ if (data.seedingMethod !== "cf_rating" && data.seedingMethod !== "manual") {
+ errors.seedingMethod = "Seeding method must be cf_rating or manual";
+ }
+ }
+
+ return {
+ valid: Object.keys(errors).length === 0,
+ errors,
+ };
+}
+
+export async function createBracketContest(data: any) {
+ const reqHeaders = await headers();
+ const session = await auth.api.getSession({ headers: reqHeaders });
+ if (!session) return { error: "Unauthorized" };
+
+ const user = session.user as any;
+ if (!isAdmin(user.role)) return { error: "Forbidden" };
+
+ // Re-run validation server-side for safety
+ let step1 = await validateStep(1, data);
+ let step2 = await validateStep(2, data);
+ let step3 = await validateStep(3, data);
+ let step4 = await validateStep(4, data);
+
+ if (!step1.valid || !step2.valid || !step3.valid || !step4.valid) {
+ return { error: "Invalid form data submission" };
+ }
+
+ await dbConnect();
+
+ // Fetch preset to pull the problem selection mode and other options
+ const preset = await ContestPreset.findById(data.presetId);
+ if (!preset) return { error: "Selected preset does not exist" };
+
+ try {
+ const contest = await CustomContest.create({
+ name: data.name.trim(),
+ description: data.description?.trim(),
+ creatorId: new mongoose.Types.ObjectId(user.id),
+ format: "bracket",
+ mode: data.mode,
+ status: "draft",
+ teamSize: data.teamSize,
+ presetId: preset._id,
+ problemSelectionMode: preset.problemSelectionMode,
+ bulkPlatform: preset.bulkPlatform,
+ bulkRatingMin: preset.bulkRatingMin,
+ bulkRatingMax: preset.bulkRatingMax,
+ bulkProblemCount: preset.bulkProblemCount,
+ problemSlots: preset.problemSlots,
+ registrations: [],
+ registrationSettings: {
+ type: data.registrationType,
+ deadline: new Date(data.deadline),
+ maxParticipants: Number(data.maxParticipants),
+ },
+ bracketSettings: {
+ thirdPlacePlayoff: !!data.thirdPlacePlayoff,
+ seedingMethod: data.seedingMethod,
+ },
+ });
+
+ return { contestId: contest._id.toString() };
+ } catch (err: any) {
+ return { error: err.message || "Failed to create contest" };
+ }
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index c3a2ded..102d295 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -14,8 +14,8 @@ if (!db) {
}
export const auth = betterAuth({
- database: mongodbAdapter(db, {
- client,
+ database: mongodbAdapter(db as any, {
+ client: client as any,
}),
secret: process.env.AUTH_SECRET,
diff --git a/src/lib/bracket.ts b/src/lib/bracket.ts
new file mode 100644
index 0000000..5e8a09d
--- /dev/null
+++ b/src/lib/bracket.ts
@@ -0,0 +1,487 @@
+import mongoose, { type ObjectId } from "mongoose";
+import dbConnect from "./mongodb";
+import CustomContest from "../models/CustomContest";
+import ContestRound from "../models/ContestRound";
+import ContestRoom from "../models/ContestRoom";
+import ContestTeam from "../models/ContestTeam";
+import { getRedis } from "./redis";
+import { publishContest } from "./sse";
+import { logger } from "./utils";
+import CPUser from "../models/CPUser";
+import {
+ type BracketNode,
+ type BracketSnapshot,
+ getRoundName,
+ snakeSeed,
+ nextPowerOf2,
+} from "../types/bracket";
+
+function toStr(id: mongoose.Types.ObjectId | string): string {
+ return typeof id === "string" ? id : id.toString();
+}
+
+export async function generateBracket(contestId: string) {
+ await dbConnect();
+ const contest = await CustomContest.findById(contestId);
+ if (!contest) throw new Error("Contest not found");
+ if (contest.format !== "bracket") throw new Error("Contest is not a bracket format");
+ if (contest.status !== "active") throw new Error("Contest must be in 'active' status to generate bracket");
+ if (!contest.registrations || contest.registrations.length < 2)
+ throw new Error("Need at least 2 registrations to generate a bracket");
+
+ const existingRooms = await ContestRoom.countDocuments({ contestId });
+ if (existingRooms > 0) throw new Error("Bracket already generated for this contest");
+
+ const teamSize = contest.teamSize || 1;
+ const mode = contest.mode || "blitz";
+
+ const groupedTeams = groupRegistrationsIntoTeams(contest.registrations, teamSize);
+
+ const cpUsers = await CPUser.find({
+ userId: { $in: groupedTeams.flatMap((t) => t.memberIds) },
+ }).lean();
+ const ratingMap = new Map();
+ for (const u of cpUsers) {
+ ratingMap.set(toStr(u.userId), u.cfRating || 0);
+ }
+
+ const seededTeams = groupedTeams.map((team) => {
+ const avgRating =
+ team.memberIds.reduce((sum, id) => sum + (ratingMap.get(id) || 0), 0) / team.memberIds.length;
+ return { ...team, rating: avgRating };
+ });
+ seededTeams.sort((a, b) => b.rating - a.rating);
+
+ const bracketSize = nextPowerOf2(seededTeams.length);
+ const totalRounds = Math.log2(bracketSize);
+
+ const seededOrder = snakeSeed(
+ seededTeams.map((t, i) => ({ teamId: t.teamName, seed: i + 1 }))
+ );
+
+ const matchAssignments: (typeof seededTeams[0] | null)[] = [];
+ for (let i = 0; i < bracketSize; i++) {
+ if (i < seededOrder.length) {
+ const matchTeam = seededTeams.find((t) => t.teamName === seededOrder[i].teamId);
+ matchAssignments.push(matchTeam || null);
+ } else {
+ matchAssignments.push(null);
+ }
+ }
+
+ const rounds: typeof ContestRound.prototype[] = [];
+ for (let r = 0; r < totalRounds; r++) {
+ const roundNum = r + 1;
+ const round = await ContestRound.create({
+ contestId: contest._id,
+ roundNumber: roundNum,
+ name: getRoundName(roundNum, totalRounds),
+ status: r === 0 ? ("active" as const) : ("pending" as const),
+ rooms: [],
+ bracketLevel: r === 0 ? "round1" : `round${r + 1}`,
+ });
+ rounds.push(round);
+ }
+
+ const redis = await getRedis();
+ const allRoomIds: string[] = [];
+ let roundIndex = 0;
+
+ for (const round of rounds) {
+ const matchesInRound = Math.pow(2, totalRounds - roundIndex - 1);
+ const roundRooms: mongoose.Types.ObjectId[] = [];
+
+ for (let m = 0; m < matchesInRound; m++) {
+ const bracketPos = `${roundIndex}-${m}`;
+ const leftTeamId =
+ roundIndex === 0 ? getTeamByMatchIndex(matchAssignments, m * 2) : null;
+ const rightTeamId =
+ roundIndex === 0 ? getTeamByMatchIndex(matchAssignments, m * 2 + 1) : null;
+
+ const hasNoTeams = !leftTeamId && !rightTeamId;
+ const isBye = !hasNoTeams && (!leftTeamId || !rightTeamId);
+ const roomStatus = hasNoTeams ? ("pending" as const) : isBye ? ("ended" as const) : ("waiting" as const);
+
+ const room = await ContestRoom.create({
+ contestId: contest._id,
+ name: `${round.name} - Match ${m + 1}`,
+ status: roomStatus,
+ participants: [],
+ teams: [],
+ currentRoundId: round._id,
+ currentProblemIndex: 0,
+ firstSolvers: [],
+ bracketPosition: bracketPos,
+ });
+
+ const teamIds: (mongoose.Types.ObjectId | null)[] = [null, null];
+
+ if (leftTeamId) {
+ const team = await ContestTeam.create({
+ roomId: room._id,
+ name: leftTeamId.teamName,
+ members: leftTeamId.memberIds.map((id) => new mongoose.Types.ObjectId(id)),
+ teamSize,
+ score: 0,
+ contestId: contest._id,
+ roundId: round._id,
+ });
+ teamIds[0] = team._id;
+ }
+ if (rightTeamId) {
+ const team = await ContestTeam.create({
+ roomId: room._id,
+ name: rightTeamId.teamName,
+ members: rightTeamId.memberIds.map((id) => new mongoose.Types.ObjectId(id)),
+ teamSize,
+ score: 0,
+ contestId: contest._id,
+ roundId: round._id,
+ });
+ teamIds[1] = team._id;
+ }
+
+ room.teams = teamIds.filter(Boolean) as mongoose.Types.ObjectId[];
+ await room.save();
+
+ roundRooms.push(room._id);
+ allRoomIds.push(toStr(room._id));
+
+ if (isBye && !hasNoTeams) {
+ const winnerTeam = teamIds[0] || teamIds[1];
+ if (winnerTeam) {
+ await ContestTeam.findByIdAndUpdate(winnerTeam, { score: 1 });
+ const nextRoundIdx = roundIndex + 1;
+ if (nextRoundIdx < rounds.length) {
+ const matchIdx = Math.floor(m / 2);
+ await seedTeamToRound(rounds[nextRoundIdx]._id, winnerTeam, matchIdx, contest._id);
+ } else {
+ contest.winner = winnerTeam;
+ contest.status = "completed";
+ await contest.save();
+ }
+ }
+ }
+ }
+
+ round.rooms = roundRooms;
+ await round.save();
+ roundIndex++;
+ }
+
+ await redis.hSet(`contest:${contestId}:meta`, {
+ format: "knockout",
+ currentRound: "1",
+ status: "active",
+ });
+ if (allRoomIds.length > 0) {
+ await redis.sAdd(`contest:${contestId}:rooms`, allRoomIds);
+ }
+
+ const snapshot = await getBracketSnapshot(contestId);
+ await publishContest(contestId, { type: "contest.bracket_update", ...snapshot });
+
+ logger.info(`[Bracket] Generated bracket for contest ${contestId}: ${allRoomIds.length} rooms across ${totalRounds} rounds`);
+ return snapshot;
+}
+
+function groupRegistrationsIntoTeams(
+ registrations: { userId: mongoose.Types.ObjectId; cfHandle: string; teamName?: string; registeredAt: Date }[],
+ teamSize: number
+): { teamName: string; memberIds: string[] }[] {
+ if (teamSize === 1) {
+ return registrations.map((r) => ({
+ teamName: r.cfHandle || toStr(r.userId).slice(-6),
+ memberIds: [toStr(r.userId)],
+ }));
+ }
+
+ const groups = new Map();
+ for (const reg of registrations) {
+ const key = reg.teamName || `team-${toStr(reg.userId).slice(-6)}`;
+ if (!groups.has(key)) {
+ groups.set(key, { teamName: key, memberIds: [] });
+ }
+ groups.get(key)!.memberIds.push(toStr(reg.userId));
+ }
+
+ const valid: { teamName: string; memberIds: string[] }[] = [];
+ for (const [, group] of groups) {
+ if (group.memberIds.length === teamSize) {
+ valid.push(group);
+ } else {
+ logger.warn(`[Bracket] Team "${group.teamName}" has ${group.memberIds.length} members, expected ${teamSize}. Skipping.`);
+ }
+ }
+ return valid;
+}
+
+function getTeamByMatchIndex(
+ assignments: ({ teamName: string; memberIds: string[]; rating: number } | null)[],
+ index: number
+): { teamName: string; memberIds: string[]; rating: number } | null {
+ if (index < 0 || index >= assignments.length) return null;
+ return assignments[index];
+}
+
+async function seedTeamToRound(
+ roundId: mongoose.Types.ObjectId,
+ teamId: mongoose.Types.ObjectId,
+ matchIndex: number,
+ contestId: mongoose.Types.ObjectId
+) {
+ const round = await ContestRound.findById(roundId);
+ if (!round) return;
+
+ const rooms = await ContestRoom.find({ _id: { $in: round.rooms } }).sort({ createdAt: 1 });
+ const targetRoom = rooms[matchIndex];
+ if (!targetRoom) return;
+
+ await ContestRoom.findByIdAndUpdate(targetRoom._id, {
+ $addToSet: { teams: teamId },
+ });
+
+ await ContestTeam.findByIdAndUpdate(teamId, { roomId: targetRoom._id });
+}
+
+export async function advanceWinner(roomId: string, contestId: string, winnerTeamId: string | null) {
+ if (!winnerTeamId) {
+ logger.warn(`[Bracket] advanceWinner called for room ${roomId} with null winner`);
+ return;
+ }
+
+ await dbConnect();
+ const room = await ContestRoom.findById(roomId).populate("currentRoundId");
+ if (!room) {
+ logger.warn(`[Bracket] Room ${roomId} not found for advancement`);
+ return;
+ }
+
+ const contest = await CustomContest.findById(contestId);
+ if (!contest || contest.format !== "bracket") return;
+
+ const currentRound = room.currentRoundId as unknown as { _id: string; roundNumber: number };
+ if (!currentRound) return;
+
+ const bracketPos = room.bracketPosition;
+ if (!bracketPos) return;
+
+ const matchIndex = parseInt(bracketPos.split("-")[1], 10);
+
+ const nextRound = await ContestRound.findOne({
+ contestId,
+ roundNumber: currentRound.roundNumber + 1,
+ });
+ if (!nextRound) {
+ contest.winner = new mongoose.Types.ObjectId(winnerTeamId);
+ contest.status = "completed";
+ const winnerTeamDoc = await ContestTeam.findById(winnerTeamId);
+ contest.winnerName = winnerTeamDoc?.name || "";
+ await contest.save();
+ logger.info(`[Bracket] Contest ${contestId} completed. Winner: ${winnerTeamId}`);
+
+ const finalSnapshot = await getBracketSnapshot(contestId);
+ await publishContest(contestId, { type: "contest.bracket_update", ...finalSnapshot });
+ await publishContest(contestId, {
+ type: "contest.round_complete",
+ roundNumber: currentRound.roundNumber,
+ advancingTeams: [winnerTeamId],
+ });
+
+ const redis = await getRedis();
+ const keys = await redis.keys(`contest:${contestId}:*`);
+ if (keys.length > 0) await redis.del(keys);
+ return;
+ }
+
+ const nextMatchIndex = Math.floor(matchIndex / 2);
+ const nextRooms = await ContestRoom.find({ _id: { $in: nextRound.rooms } }).sort({ createdAt: 1 });
+ const nextRoom = nextRooms[nextMatchIndex];
+ if (!nextRoom) {
+ logger.warn(`[Bracket] No next room found for match ${nextMatchIndex} in round ${nextRound.roundNumber}`);
+ return;
+ }
+
+ await ContestRoom.findByIdAndUpdate(nextRoom._id, {
+ $addToSet: { teams: new mongoose.Types.ObjectId(winnerTeamId) },
+ });
+ await ContestTeam.findByIdAndUpdate(winnerTeamId, { roomId: nextRoom._id, roundId: nextRound._id });
+
+ const updatedRoom = await ContestRoom.findById(nextRoom._id);
+ if (updatedRoom && updatedRoom.teams.length === 2) {
+ updatedRoom.status = "waiting";
+ await updatedRoom.save();
+ logger.info(`[Bracket] Next room ${nextRoom._id} is now ready with 2 teams`);
+ }
+
+ await publishContest(contestId, {
+ type: "contest.standing_update",
+ teamId: winnerTeamId,
+ contestId,
+ });
+
+ const snapshot = await getBracketSnapshot(contestId);
+ await publishContest(contestId, { type: "contest.bracket_update", ...snapshot });
+
+ logger.info(`[Bracket] Advanced team ${winnerTeamId} to room ${nextRoom._id}`);
+}
+
+export async function checkRoundCompletion(contestId: string, roundNumber: number) {
+ await dbConnect();
+ const redis = await getRedis();
+ const lockKey = `contest:${contestId}:round:${roundNumber}:check_lock`;
+
+ const lockAcquired = await redis.set(lockKey, "1", { NX: true, EX: 5 });
+ if (!lockAcquired) {
+ logger.info(`[Bracket] Round ${roundNumber} check already in progress for contest ${contestId}`);
+ return;
+ }
+
+ try {
+ const contest = await CustomContest.findById(contestId);
+ if (!contest || contest.format !== "bracket") return;
+
+ const round = await ContestRound.findOne({ contestId, roundNumber });
+ if (!round) return;
+
+ const rooms = await ContestRoom.find({ _id: { $in: round.rooms } });
+ const allCompleted = rooms.every((r) => r.status === "ended");
+ if (!allCompleted) return;
+
+ const advancingTeams: string[] = [];
+ for (const room of rooms) {
+ if (room.teams.length === 2) {
+ const teamScores = await Promise.all(
+ room.teams.map((tId: ObjectId) => ContestTeam.findById(tId))
+ );
+ const winner = teamScores.reduce((best, t) =>
+ t && (!best || t.score > best.score) ? t : best,
+ null as typeof teamScores[0]
+ );
+ if (winner) advancingTeams.push(toStr(winner._id));
+ } else if (room.teams.length === 1) {
+ advancingTeams.push(toStr(room.teams[0]));
+ }
+ }
+
+ round.status = "completed";
+ await round.save();
+
+ await redis.hSet(`contest:${contestId}:meta`, { currentRound: String(roundNumber + 1) });
+
+ await publishContest(contestId, {
+ type: "contest.round_complete",
+ roundNumber,
+ advancingTeams,
+ });
+
+ const snapshot = await getBracketSnapshot(contestId);
+ await publishContest(contestId, { type: "contest.bracket_update", ...snapshot });
+
+ const nextRound = await ContestRound.findOne({ contestId, roundNumber: roundNumber + 1 });
+ if (nextRound) {
+ nextRound.status = "active";
+ await nextRound.save();
+ logger.info(`[Bracket] Round ${roundNumber} complete. Advancing to round ${roundNumber + 1}`);
+ } else {
+ logger.info(`[Bracket] Contest ${contestId} fully completed.`);
+ }
+ } finally {
+ await redis.del(lockKey);
+ }
+}
+
+export async function getBracketSnapshot(contestId: string): Promise {
+ await dbConnect();
+ const contest = await CustomContest.findById(contestId);
+ if (!contest) throw new Error("Contest not found");
+
+ const rounds = await ContestRound.find({ contestId }).sort({ roundNumber: 1 });
+ const totalRounds = rounds.length;
+ const currentRound = parseInt(
+ (await (await getRedis()).hGet(`contest:${contestId}:meta`, "currentRound")) || "1",
+ 10
+ );
+
+ const nodes: BracketNode[] = [];
+
+ for (const round of rounds) {
+ const rooms = await ContestRoom.find({ _id: { $in: round.rooms } }).sort({ createdAt: 1 });
+ for (const room of rooms) {
+ const teams = await Promise.all(
+ room.teams.map((tId: ObjectId) => ContestTeam.findById(tId))
+ );
+ const teamIds: [string | null, string | null] = [null, null];
+ const scores: [number, number] = [0, 0];
+
+ for (let i = 0; i < Math.min(teams.length, 2); i++) {
+ if (teams[i]) {
+ teamIds[i] = toStr(teams[i]!._id);
+ scores[i] = teams[i]!.score;
+ }
+ }
+
+ let winner: string | null = null;
+ if (room.status === "ended") {
+ if (scores[0] > scores[1]) winner = teamIds[0];
+ else if (scores[1] > scores[0]) winner = teamIds[1];
+ else if (teamIds[0] && !teamIds[1]) winner = teamIds[0];
+ }
+
+ let status: BracketNode["status"] = "pending";
+ if (room.status === "ended") status = "completed";
+ else if (room.status === "active") status = "active";
+ else if (teamIds[0] === null || teamIds[1] === null) status = "bye";
+
+ nodes.push({
+ roomId: toStr(room._id),
+ roundNumber: round.roundNumber,
+ matchIndex: rooms.indexOf(room),
+ teams: teamIds,
+ scores,
+ status,
+ winner,
+ bracketPosition: room.bracketPosition || "",
+ });
+ }
+ }
+
+ return { contestId, currentRound, totalRounds, nodes };
+}
+
+export async function processWalkover(
+ roomId: string,
+ winnerTeamId: string,
+ note: string,
+ adminUserId: string
+) {
+ await dbConnect();
+ const room = await ContestRoom.findById(roomId);
+ if (!room) throw new Error("Room not found");
+
+ const contest = await CustomContest.findById(room.contestId);
+ if (!contest || contest.format !== "bracket") throw new Error("Room is not part of a bracket contest");
+
+ room.status = "ended";
+ await room.save();
+
+ const winnerTeam = await ContestTeam.findById(winnerTeamId);
+ if (winnerTeam) {
+ winnerTeam.score = (winnerTeam.score || 0) + 1;
+ await winnerTeam.save();
+ }
+
+ logger.info(`[Bracket] Walkover in room ${roomId}: winner ${winnerTeamId}, note: "${note}", admin: ${adminUserId}`);
+
+ await advanceWinner(roomId, toStr(contest._id), winnerTeamId);
+
+ if (room.currentRoundId) {
+ const round = await ContestRound.findById(room.currentRoundId);
+ if (round) {
+ await checkRoundCompletion(toStr(contest._id), round.roundNumber);
+ }
+ }
+
+ const snapshot = await getBracketSnapshot(toStr(contest._id));
+ return snapshot;
+}
diff --git a/src/lib/bullmq.ts b/src/lib/bullmq.ts
new file mode 100644
index 0000000..def9897
--- /dev/null
+++ b/src/lib/bullmq.ts
@@ -0,0 +1,48 @@
+import { Queue, ConnectionOptions } from "bullmq";
+import { logger } from "./utils";
+
+const redisUrlString = process.env.REDIS_URL || "redis://localhost:6379";
+let redisUrl: URL;
+try {
+ redisUrl = new URL(redisUrlString);
+} catch (err) {
+ logger.error(`[BullMQ] Invalid REDIS_URL: ${redisUrlString}. Falling back to default localhost.`, err);
+ redisUrl = new URL("redis://localhost:6379");
+}
+
+export const connection: ConnectionOptions = {
+ host: redisUrl.hostname || "127.0.0.1",
+ port: redisUrl.port ? parseInt(redisUrl.port, 10) : 6379,
+ username: redisUrl.username || undefined,
+ password: redisUrl.password || undefined,
+ db: (redisUrl.pathname && redisUrl.pathname.slice(1)) ? parseInt(redisUrl.pathname.slice(1), 10) : undefined,
+ tls: redisUrl.protocol === "rediss:" ? {} : undefined,
+ maxRetriesPerRequest: null,
+};
+
+// Create cf_sync_queue: limiter: { max: 2, duration: 1000 }, defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
+// Note: limiter is configured on the worker
+export const cfSyncQueue = new Queue("cf_sync_queue", {
+ connection,
+ defaultJobOptions: {
+ attempts: 3,
+ backoff: {
+ type: "exponential",
+ delay: 5000,
+ },
+ },
+});
+
+// Create reconciliation_queue: defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 2000 } }
+export const reconciliationQueue = new Queue("reconciliation_queue", {
+ connection,
+ defaultJobOptions: {
+ attempts: 3,
+ backoff: {
+ type: "exponential",
+ delay: 2000,
+ },
+ },
+});
+
+logger.info("[BullMQ] Queues initialized successfully.");
diff --git a/src/lib/cf-api.ts b/src/lib/cf-api.ts
new file mode 100644
index 0000000..07169d2
--- /dev/null
+++ b/src/lib/cf-api.ts
@@ -0,0 +1,103 @@
+import axios from "axios";
+import { logger } from "./utils";
+import { getRedis } from "./redis";
+
+export interface CodeforcesSubmission {
+ id: number;
+ contestId?: number;
+ creationTimeSeconds: number;
+ relativeTimeSeconds: number;
+ problem: {
+ contestId?: number;
+ index: string;
+ name: string;
+ type: string;
+ rating?: number;
+ tags: string[];
+ };
+ author: {
+ contestId?: number;
+ members: { handle: string }[];
+ participantType: string;
+ ghost: boolean;
+ startTimeSeconds?: number;
+ };
+ programmingLanguage: string;
+ verdict?: string;
+ testset: string;
+ passedTestCount: number;
+ timeConsumedMillis: number;
+ memoryConsumedBytes: number;
+}
+
+/**
+ * Fetches user submissions from Codeforces.
+ * @param handle The Codeforces handle.
+ * @param count Number of recent submissions to fetch. If omitted, fetches all.
+ * @returns Array of CodeforcesSubmission.
+ */
+export async function fetchCodeforcesUserStatus(handle: string, count?: number): Promise {
+ try {
+ const url = count
+ ? `https://codeforces.com/api/user.status?handle=${handle}&from=1&count=${count}`
+ : `https://codeforces.com/api/user.status?handle=${handle}`;
+
+ const response = await axios.get(url, {
+ timeout: 10000, // 10 seconds timeout
+ });
+
+ if (response.data.status !== "OK") {
+ throw new Error(`Codeforces API returned non-OK status: ${response.data.comment || "Unknown error"}`);
+ }
+
+ return response.data.result;
+ } catch (error: any) {
+ logger.error(`[cf-api] Error fetching status for handle ${handle}:`, error.message || error);
+ throw error;
+ }
+}
+
+/**
+ * Service function to prefetch a user's Codeforces solved history and store it as a Redis SET.
+ * TTL: 6 hours (21600 seconds)
+ * Redis Key: solved:
+ * @param handle The Codeforces handle.
+ */
+export async function prefetchUserSolvedHistory(handle: string): Promise {
+ logger.info(`[cf-api] Prefetching solved history for handle: ${handle}`);
+ try {
+ // Fetch all submissions to build the solved history
+ const submissions = await fetchCodeforcesUserStatus(handle);
+
+ const solvedProblemIds = new Set();
+ for (const sub of submissions) {
+ if (sub.verdict === "OK" && sub.problem.contestId && sub.problem.index) {
+ solvedProblemIds.add(`${sub.problem.contestId}${sub.problem.index}`);
+ }
+ }
+
+ const redis = await getRedis();
+ const key = `solved:${handle.toLowerCase()}`;
+
+ if (solvedProblemIds.size > 0) {
+ // Use pipeline to add elements and set TTL atomically
+ const pipeline = redis.multi();
+ pipeline.del(key); // Clear existing
+ pipeline.sAdd(key, Array.from(solvedProblemIds));
+ pipeline.expire(key, 6 * 60 * 60); // 6 hours TTL
+ await pipeline.exec();
+
+ logger.info(`[cf-api] Cached ${solvedProblemIds.size} solved problems for ${handle}`);
+ } else {
+ logger.info(`[cf-api] Handle ${handle} has 0 solved problems.`);
+ const pipeline = redis.multi();
+ pipeline.del(key);
+ pipeline.sAdd(key, "__empty__");
+ pipeline.expire(key, 6 * 60 * 60);
+ await pipeline.exec();
+ }
+ } catch (error: any) {
+ logger.error(`[cf-api] Failed to prefetch solved history for ${handle}:`, error.message || error);
+ throw error;
+ }
+}
diff --git a/src/lib/jobs/cfProblemSync.ts b/src/lib/jobs/cfProblemSync.ts
new file mode 100644
index 0000000..3973ccd
--- /dev/null
+++ b/src/lib/jobs/cfProblemSync.ts
@@ -0,0 +1,90 @@
+import axios from "axios";
+import CFQuestion from "@/models/CFQuestion";
+import { logger } from "@/lib/utils";
+import dbConnect from "@/lib/mongodb";
+
+const CODEFORCES_PROBLEMS_URL = "https://codeforces.com/api/problemset.problems";
+const BATCH_SIZE = 1000;
+
+export async function syncCodeforcesProblems() {
+ logger.info("[CF-Problem-Sync] Starting Codeforces problem synchronization...");
+ await dbConnect();
+
+ try {
+ // fetch all existing problemIds from database to perform incremental sync
+ const existingQuestions = await CFQuestion.find({}, { problemId: 1 }).lean();
+ const existingProblemIds = new Set(existingQuestions.map((q) => q.problemId));
+ const isFirstRun = existingProblemIds.size === 0;
+
+ if (isFirstRun) {
+ logger.info("[CF-Problem-Sync] Database is empty. Performing full ingest...");
+ } else {
+ logger.info(`[CF-Problem-Sync] Found ${existingProblemIds.size} existing problems. Running incremental sync...`);
+ }
+
+
+ const response = await axios.get(CODEFORCES_PROBLEMS_URL);
+
+ if (response.data.status !== "OK") {
+ throw new Error(`Codeforces API error: ${response.data.comment || "Unknown"}`);
+ }
+
+ const { problems } = response.data.result;
+
+ if (!problems || !Array.isArray(problems)) {
+ throw new Error("Invalid problems list returned by Codeforces API.");
+ }
+
+
+ const newProblems = problems.filter((prob: any) => {
+ if (!prob.contestId || !prob.index) return false;
+ const problemId = `${prob.contestId}${prob.index}`;
+ return !existingProblemIds.has(problemId);
+ });
+
+ if (newProblems.length === 0) {
+ logger.info("[CF-Problem-Sync] No new problems found. Database is already up to date.");
+ return;
+ }
+
+ logger.info(`[CF-Problem-Sync] Found ${newProblems.length} new problems to sync.`);
+
+
+ const bulkOps = newProblems.map((prob: any) => {
+ const problemId = `${prob.contestId}${prob.index}`;
+ const rating = prob.rating;
+
+ return {
+ updateOne: {
+ filter: { problemId },
+ update: {
+ $set: {
+ contestId: prob.contestId,
+ index: prob.index,
+ name: prob.name,
+ rating,
+ tags: prob.tags || [],
+ },
+ },
+ upsert: true,
+ },
+ };
+ });
+
+
+ for (let i = 0; i < bulkOps.length; i += BATCH_SIZE) {
+ const batch = bulkOps.slice(i, i + BATCH_SIZE);
+ await CFQuestion.bulkWrite(batch);
+ logger.info(
+ `[CF-Problem-Sync] Successfully processed batch ${Math.floor(i / BATCH_SIZE) + 1} of ${Math.ceil(
+ bulkOps.length / BATCH_SIZE
+ )}`
+ );
+ }
+
+ logger.info(`[CF-Problem-Sync] Sync complete. Added ${newProblems.length} new problems.`);
+ } catch (error: any) {
+ logger.error("[CF-Problem-Sync] Fatal error during Codeforces problem sync:", error);
+ throw error; // Rethrow to let BullMQ handle attempts and delay
+ }
+}
diff --git a/src/lib/presenceListener.ts b/src/lib/presenceListener.ts
new file mode 100644
index 0000000..abe48ae
--- /dev/null
+++ b/src/lib/presenceListener.ts
@@ -0,0 +1,84 @@
+import { getRedis } from "@/lib/redis";
+import { logger } from "@/lib/utils";
+import { publishRoom } from "@/lib/sse";
+import { reconciliationQueue } from "@/lib/bullmq";
+
+export async function startPresenceKeyspaceListener() {
+ logger.info("[PresenceListener] Starting Redis keyspace notification listener...");
+
+ try {
+ const redis = await getRedis();
+ const subscriber = redis.duplicate();
+ await subscriber.connect();
+
+
+ await subscriber.pSubscribe("__keyevent@*__:expired", async (key, channel) => {
+ // Match pattern: room::presence:
+ const match = key.match(/^room:([^:]+):presence:([^:]+)$/);
+ if (!match) return;
+
+ const roomId = match[1];
+ const userId = match[2];
+
+
+ const lockKey = `room:${roomId}:presence:${userId}:expire_lock`;
+ const acquired = await redis.set(lockKey, "1", { NX: true, EX: 10 });
+ if (!acquired) {
+ return; // Expiration already processed by another worker
+ }
+
+ logger.info(`[PresenceListener] Presence expired for user ${userId} in room ${roomId}.`);
+
+ try {
+ // Trigger auto-forfeit
+ logger.info(`[PresenceListener] User ${userId} auto-forfeited in room ${roomId} due to inactivity.`);
+
+ const state = await redis.hGetAll(`room:${roomId}:state`);
+ if (state && state.status === "active") {
+ await redis.hSet(`room:${roomId}:state`, { forfeitFlag: "true" });
+
+ await reconciliationQueue.add(
+ "room_forfeit",
+ { roomId, contestId: state.contestId, trigger: "forfeit", forfeitedUserId: userId },
+ { jobId: `forfeit-${roomId}-${userId}` }
+ );
+
+ // Need teamId. Find it from the sets
+ const teams = await redis.sMembers(`room:${roomId}:teams`);
+ let userTeamId = null;
+ for (const tId of teams) {
+ const isMember = await redis.sIsMember(`team:${tId}:users`, userId);
+ if (isMember) {
+ userTeamId = tId;
+ break;
+ }
+ }
+
+ await publishRoom(roomId, {
+ type: "room.player_forfeited",
+ userId,
+ teamId: userTeamId
+ });
+ }
+
+ // Check if offline event has been sent
+ const offlineSentKey = `room:${roomId}:presence:${userId}:offline_sent`;
+ const offlineSentExists = await redis.exists(offlineSentKey);
+
+ if (!offlineSentExists) {
+ logger.info(`[PresenceListener] Offline event not yet published for user ${userId} in room ${roomId}. Publishing now.`);
+ await publishRoom(roomId, { type: "presence.offline", userId });
+ } else {
+ // Clean up the helper
+ await redis.del(offlineSentKey);
+ }
+ } catch (err) {
+ logger.error(`[PresenceListener] Error handling presence expiration for user ${userId} in room ${roomId}:`, err);
+ }
+ });
+
+ logger.info("[PresenceListener] Successfully subscribed to keyspace expired events.");
+ } catch (err) {
+ logger.error("[PresenceListener] Failed to start Redis keyspace notification listener:", err);
+ }
+}
diff --git a/src/lib/redis.ts b/src/lib/redis.ts
index a333e96..fc31e66 100644
--- a/src/lib/redis.ts
+++ b/src/lib/redis.ts
@@ -5,17 +5,55 @@ const redisClient = createClient({
url: process.env.REDIS_URL || "redis://localhost:6379",
});
+export async function claimProblem(redis: any, locksKey: string, problemId: string, teamId: string, cfTimestamp: number): Promise {
+ const script = `
+ local locksKey = KEYS[1]
+ local problemId = ARGV[1]
+ local teamId = ARGV[2]
+ local cfTimestamp = tonumber(ARGV[3])
+
+ local existing = redis.call("HGET", locksKey, problemId)
+ if existing then
+ local sepIndex = string.find(existing, "|")
+ if sepIndex then
+ local existingTeamId = string.sub(existing, 1, sepIndex - 1)
+ local existingTimestamp = tonumber(string.sub(existing, sepIndex + 1))
+ if cfTimestamp < existingTimestamp then
+ redis.call("HSET", locksKey, problemId, teamId .. "|" .. cfTimestamp)
+ return "reclaimed|" .. existingTeamId
+ else
+ return "lost"
+ end
+ end
+ end
+
+ redis.call("HSET", locksKey, problemId, teamId .. "|" .. cfTimestamp)
+ return "claimed"
+ `;
+
+ return await redis.eval(script, {
+ keys: [locksKey],
+ arguments: [problemId, teamId, cfTimestamp.toString()]
+ });
+}
+
redisClient.on("error", (err) => logger.error("Redis Client Error", err));
-let connectPromise: Promise | null = null;
+let connectPromise: Promise | null = null;
-export async function getRedis(): Promise {
- if (redisClient.isReady) return redisClient as RedisClientType;
+export async function getRedis(): Promise {
+ if (redisClient.isReady) return redisClient;
if (!connectPromise) {
connectPromise = redisClient
.connect()
- .then(() => {
- return redisClient as RedisClientType;
+ .then(async () => {
+ try {
+ await redisClient.configSet("maxmemory-policy", "noeviction");
+ await redisClient.configSet("notify-keyspace-events", "KEA");
+ } catch (configErr) {
+ logger.warn("Failed to set Redis configurations programmatically:", configErr);
+ }
+ return redisClient;
})
.catch((err) => {
connectPromise = null;
diff --git a/src/lib/sse.ts b/src/lib/sse.ts
new file mode 100644
index 0000000..c5cf43d
--- /dev/null
+++ b/src/lib/sse.ts
@@ -0,0 +1,16 @@
+import { getRedis } from "@/lib/redis";
+
+export async function publishRoom(roomId: string, event: any): Promise {
+ const redis = await getRedis();
+ return redis.publish(`events:room:${roomId}`, JSON.stringify(event));
+}
+
+export async function publishContest(contestId: string, event: any): Promise {
+ const redis = await getRedis();
+ return redis.publish(`events:contest:${contestId}`, JSON.stringify(event));
+}
+
+export async function publishUser(userId: string, event: any): Promise {
+ const redis = await getRedis();
+ return redis.publish(`events:user:${userId}`, JSON.stringify(event));
+}
diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts
new file mode 100644
index 0000000..d9ade2a
--- /dev/null
+++ b/src/lib/workers/cfSyncWorker.ts
@@ -0,0 +1,342 @@
+import { Worker, Job } from "bullmq";
+import { connection } from "../bullmq";
+import { logger } from "../utils";
+import { syncCodeforcesProblems } from "../jobs/cfProblemSync";
+import { fetchCodeforcesUserStatus } from "../cf-api";
+import { publishUser, publishRoom } from "../sse";
+import { getRedis, claimProblem } from "../redis";
+import { reconciliationQueue } from "../bullmq";
+import dbConnect from "../mongodb";
+import ContestRoom from "../../models/ContestRoom";
+import CustomContest from "../../models/CustomContest";
+import mongoose from "mongoose";
+
+// Optional: cache a pause timer to avoid repeated pausing when circuit breaker trips
+let isCircuitBreakerOpen = false;
+
+export const cfSyncWorker = new Worker(
+ "cf_sync_queue",
+ async (job: Job) => {
+ logger.info(`[cfSyncWorker] Processing job ${job.id} (name: ${job.name})`, job.data);
+
+ if (job.name === "nightly-cf-problem-sync") {
+ await syncCodeforcesProblems();
+ return;
+ }
+
+ if (job.name === "solved_prefetch") {
+ const { cfHandle } = job.data;
+ const { prefetchUserSolvedHistory } = require("../cf-api");
+ await prefetchUserSolvedHistory(cfHandle);
+ return;
+ }
+
+ if (job.name === "cf_sync") {
+ const { roomId, userId, teamId, cfHandle, problemId } = job.data;
+
+ try {
+ await dbConnect();
+
+ if (!mongoose.Types.ObjectId.isValid(roomId)) {
+ logger.warn(`[cfSyncWorker] Invalid roomId format: ${roomId}`);
+ await publishUser(userId, { verdict: "invalid", reason: "invalid_room_id" });
+ return;
+ }
+
+ // 1. Fetch Room and Contest to get timestamps
+ const room = await ContestRoom.findById(roomId).lean();
+ if (!room) {
+ logger.warn(`[cfSyncWorker] Room ${roomId} not found for sync.`);
+ await publishUser(userId, { verdict: "invalid", reason: "room_not_found" });
+ return;
+ }
+
+ if (!room.contestId || !mongoose.Types.ObjectId.isValid(room.contestId)) {
+ logger.warn(`[cfSyncWorker] Invalid or missing contestId in room ${roomId}.`);
+ await publishUser(userId, { verdict: "invalid", reason: "invalid_contest_id" });
+ return;
+ }
+
+ const contest = await CustomContest.findById(room.contestId).lean();
+ if (!contest) {
+ logger.warn(`[cfSyncWorker] Contest not found for room ${roomId}.`);
+ await publishUser(userId, { verdict: "invalid", reason: "contest_not_found" });
+ return;
+ }
+
+ // Verify userId is part of the team
+ const redis = await getRedis();
+ const isTeamMember = await redis.sIsMember(`team:${teamId}:users`, userId);
+ if (!isTeamMember) {
+ 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 lowerTimestamp = contest.startTime.getTime();
+ // Add a small grace period (e.g., 5 minutes) or just use endTime
+ const upperTimestamp = contest.endTime.getTime() + 5 * 60 * 1000;
+
+ // 2. Fetch CF Submissions (last 20)
+ let submissions = [];
+ try {
+ submissions = await fetchCodeforcesUserStatus(cfHandle, 20);
+ } catch (error: any) {
+ if (error.response?.status === 429) {
+ logger.warn(`[cfSyncWorker] CF API rate limited (429). Pausing queue for 30s.`);
+ if (!isCircuitBreakerOpen) {
+ isCircuitBreakerOpen = true;
+ // Pause the worker for 30s, this is a BullMQ feature
+ cfSyncWorker.pause();
+ setTimeout(() => {
+ cfSyncWorker.resume();
+ isCircuitBreakerOpen = false;
+ }, 30000);
+ }
+ throw error; // Let BullMQ retry
+ }
+ throw error;
+ }
+
+ // 3. Validation Matrix
+ let isValid = false;
+ let matchedSubmission = null;
+ let hasSubmissionForProblem = false;
+ let bestVerdict = "not_found";
+
+ for (const sub of submissions) {
+ const subProblemId = `${sub.problem.contestId || ""}${sub.problem.index}`;
+ const subTimestamp = sub.creationTimeSeconds * 1000;
+ const subVerdict = sub.verdict || "UNKNOWN";
+
+ // Check if it's the right problem
+ if (subProblemId.toUpperCase() === problemId.toUpperCase()) {
+ hasSubmissionForProblem = true;
+ if (subVerdict !== "OK") {
+ bestVerdict = subVerdict;
+ }
+
+ // Check handle match
+ const authorHandle = sub.author.members.some(
+ (m: any) => m.handle.toLowerCase() === cfHandle.toLowerCase()
+ );
+
+ if (
+ authorHandle &&
+ subVerdict === "OK" &&
+ subTimestamp >= lowerTimestamp &&
+ subTimestamp <= upperTimestamp
+ ) {
+ isValid = true;
+ matchedSubmission = sub;
+ break;
+ }
+ }
+ }
+
+ // 4. Result Handling
+ if (isValid && matchedSubmission) {
+ const eventPayload = {
+ type: "sync.detected",
+ roomId,
+ userId,
+ teamId,
+ problemId,
+ cfSubmissionId: matchedSubmission.id,
+ cfTimestamp: matchedSubmission.creationTimeSeconds * 1000,
+ verdict: "OK",
+ pointsAwarded: null, // Stage 3 fills this
+ };
+
+ const redis = await getRedis();
+ const state = await redis.hGetAll(`room:${roomId}:state`);
+ let isAdvanceTriggered = false;
+
+ if (state && state.status === "active") {
+ const problemsRaw = await redis.lRange(`room:${roomId}:problems`, 0, -1);
+ const problems = problemsRaw.map(p => JSON.parse(p));
+
+ if (state.type === "arena") {
+ const targetProblem = problems.find((p: any) => p.problemId === problemId);
+ if (targetProblem) {
+ const points = targetProblem.points || 100;
+ const cfTimestamp = matchedSubmission.creationTimeSeconds * 1000;
+ const startTime = parseInt(state.startTime || "0", 10);
+
+ const claimResult = await claimProblem(
+ redis,
+ `room:${roomId}:locks`,
+ problemId,
+ teamId,
+ cfTimestamp
+ );
+
+ if (claimResult === "claimed" || claimResult.startsWith("reclaimed|")) {
+ if (claimResult.startsWith("reclaimed|")) {
+ const oldTeamId = claimResult.split("|")[1];
+ await redis.zIncrBy(`room:${roomId}:scores`, -points, oldTeamId);
+ }
+
+ await redis.zIncrBy(`room:${roomId}:scores`, points, teamId);
+ const solveMs = cfTimestamp - startTime;
+ await redis.zAdd(`room:${roomId}:solve_times`, { score: solveMs, value: teamId });
+
+ const submissionObj = {
+ userId,
+ teamId,
+ problemId,
+ cfSubmissionId: matchedSubmission.id,
+ verdict: "OK",
+ points,
+ solveMs,
+ cfTimestamp
+ };
+ await redis.xAdd(`room:${roomId}:submissions`, "*", { data: JSON.stringify(submissionObj) });
+
+ eventPayload.pointsAwarded = points;
+
+ await publishRoom(roomId, {
+ type: "room.locked",
+ problemId,
+ claimedBy: teamId,
+ timestamp: cfTimestamp
+ });
+
+ const scores: Record = {};
+ const teams = await redis.sMembers(`room:${roomId}:teams`);
+ for (const tId of teams) {
+ const score = await redis.zScore(`room:${roomId}:scores`, tId);
+ scores[tId] = score || 0;
+ }
+ await publishRoom(roomId, { type: "room.score", scores });
+
+ const lockCount = await redis.hLen(`room:${roomId}:locks`);
+ if (lockCount === problems.length) {
+ await redis.hSet(`room:${roomId}:state`, { status: "completed" });
+ await publishRoom(roomId, {
+ type: "room.end",
+ finalScores: scores,
+ duration: Date.now() - startTime
+ });
+
+ await reconciliationQueue.add(
+ "room_completed",
+ { roomId, contestId: state.contestId, trigger: "completed" },
+ { jobId: `completed-${roomId}` }
+ );
+ }
+ }
+ }
+ } else {
+ const currentProblemIndex = parseInt(state.currentProblem || "0", 10);
+ const currentProblem = problems[currentProblemIndex];
+
+ if (currentProblem && currentProblem.problemId === problemId) {
+ const points = currentProblem.points || 100;
+ const cfTimestamp = matchedSubmission.creationTimeSeconds * 1000;
+ const startTime = parseInt(state.startTime || "0", 10);
+ const solveMs = cfTimestamp - startTime;
+
+ await redis.zIncrBy(`room:${roomId}:scores`, points, teamId);
+ await redis.zAdd(`room:${roomId}:solve_times`, { score: solveMs, value: teamId });
+
+ const submissionObj = {
+ userId,
+ teamId,
+ problemId,
+ cfSubmissionId: matchedSubmission.id,
+ verdict: "OK",
+ points,
+ solveMs,
+ cfTimestamp
+ };
+ await redis.xAdd(`room:${roomId}:submissions`, "*", { data: JSON.stringify(submissionObj) });
+
+ eventPayload.pointsAwarded = points;
+ isAdvanceTriggered = true;
+
+ const newProblemIndex = currentProblemIndex + 1;
+ await redis.hIncrBy(`room:${roomId}:state`, "currentProblem", 1);
+
+ if (newProblemIndex === problems.length) {
+ await redis.hSet(`room:${roomId}:state`, { status: "completed" });
+
+ const finalScores: Record = {};
+ const teams = await redis.sMembers(`room:${roomId}:teams`);
+ for (const tId of teams) {
+ const score = await redis.zScore(`room:${roomId}:scores`, tId);
+ finalScores[tId] = score || 0;
+ }
+
+ await publishRoom(roomId, {
+ type: "room.end",
+ finalScores,
+ duration: Date.now() - startTime
+ });
+
+ await reconciliationQueue.add(
+ "room_completed",
+ { roomId, contestId: state.contestId, trigger: "completed" },
+ { jobId: `completed-${roomId}` }
+ );
+ } else {
+ const nextProblem = problems[newProblemIndex];
+ nextProblem.revealedAt = Date.now();
+ await redis.lSet(`room:${roomId}:problems`, newProblemIndex, JSON.stringify(nextProblem));
+
+ await publishRoom(roomId, {
+ type: "room.advance",
+ solvedBy: { userId, teamId },
+ problemIndex: newProblemIndex,
+ nextProblem
+ });
+
+ const scores: Record = {};
+ const teams = await redis.sMembers(`room:${roomId}:teams`);
+ for (const tId of teams) {
+ const score = await redis.zScore(`room:${roomId}:scores`, tId);
+ scores[tId] = score || 0;
+ }
+ await publishRoom(roomId, { type: "room.score", scores });
+ }
+ }
+ }
+ }
+
+ await publishUser(userId, eventPayload);
+
+ logger.info(`[cfSyncWorker] Valid AC detected for ${cfHandle} on ${problemId}. emitted sync.detected.`);
+
+ } else {
+ const failVerdict = hasSubmissionForProblem ? bestVerdict : "not_found";
+ logger.info(`[cfSyncWorker] Validation failed for ${cfHandle} on ${problemId}. Verdict: ${failVerdict}`);
+ await publishUser(userId, { type: "sync.failed", verdict: failVerdict });
+ }
+ } catch (error: any) {
+ logger.error(`[cfSyncWorker] Error processing cf_sync for user ${userId}:`, error.message);
+ throw error; // Rethrow to trigger BullMQ retries
+ }
+ }
+ },
+ {
+ connection,
+ limiter: {
+ max: 2,
+ duration: 1000,
+ },
+ }
+);
+
+cfSyncWorker.on("completed", (job: Job) => {
+ logger.info(`[cfSyncWorker] Job ${job.id} completed successfully`);
+});
+
+cfSyncWorker.on("failed", async (job: Job | undefined, err: Error) => {
+ logger.error(`[cfSyncWorker] Job ${job?.id} failed with error: ${err.message}`, err);
+
+ if (job?.name === "cf_sync" && job.attemptsMade >= (job.opts.attempts || 3)) {
+ const { userId } = job.data;
+ logger.error(`[cfSyncWorker] Permanent failure for sync job ${job.id}. Publishing cf_unavailable to user ${userId}`);
+ await publishUser(userId, { type: "sync.failed", reason: "cf_unavailable" });
+ }
+});
diff --git a/src/lib/workers/reconciliationWorker.ts b/src/lib/workers/reconciliationWorker.ts
new file mode 100644
index 0000000..a80e35f
--- /dev/null
+++ b/src/lib/workers/reconciliationWorker.ts
@@ -0,0 +1,206 @@
+import { Worker, Job } from "bullmq";
+import { connection } from "../bullmq";
+import { logger } from "../utils";
+import { getRedis } from "../redis";
+import dbConnect from "../mongodb";
+import ContestRoom from "../../models/ContestRoom";
+import ContestRound from "../../models/ContestRound";
+import CustomContest from "../../models/CustomContest";
+import { publishRoom } from "../sse";
+import ContestProblemSet from "../../models/ContestProblemSet";
+import ContestTeam from "../../models/ContestTeam";
+import ContestSubmission from "../../models/ContestSubmission";
+
+export const reconciliationWorker = new Worker(
+ "reconciliation_queue",
+ async (job: Job) => {
+ logger.info(`[reconciliationWorker] Processing job ${job.id} (name: ${job.name})`, job.data);
+ const { roomId, contestId, trigger, forfeitedUserId, teamId } = job.data;
+ const redis = await getRedis();
+ await dbConnect();
+
+ // Handle team ready timeout
+ if (job.name === "team_ready_timeout") {
+ const state = await redis.hGetAll(`room:${roomId}:state`);
+
+ // Only process if room is still waiting
+ if (state && state.status === "waiting") {
+ const teamMembers = await redis.sMembers(`team:${teamId}:users`);
+ const readyMembers = [];
+ for (const memberId of teamMembers) {
+ const isReady = await redis.sIsMember(`room:${roomId}:ready_users`, memberId);
+ if (isReady) {
+ readyMembers.push(memberId);
+ }
+ }
+
+ const allReady = readyMembers.length === teamMembers.length;
+ if (!allReady) {
+ // Team is not ready within 60s, withdraw the entire team
+ logger.info(`[reconciliationWorker] Team ${teamId} not ready within 60s, withdrawing from room ${roomId}`);
+
+ // Remove team from room and mark participants as withdrawn
+ await redis.sRem(`room:${roomId}:teams`, teamId);
+ await redis.del(`team:${teamId}:users`);
+ await redis.del(`team:${teamId}:meta`);
+
+ // Remove team members from participants
+ for (const memberId of teamMembers) {
+ await redis.sRem(`room:${roomId}:ready_users`, memberId);
+ }
+
+ // Publish withdrawal event
+ await publishRoom(roomId, {
+ type: "team.withdrawn",
+ teamId,
+ reason: "ready_timeout"
+ });
+
+ // If no teams are left or only one team, end the room
+ const remainingTeams = await redis.sMembers(`room:${roomId}:teams`);
+ if (remainingTeams.length === 0 || remainingTeams.length === 1) {
+ await redis.hSet(`room:${roomId}:state`, { status: "completed" });
+ const teamScores: Record = {};
+ for (const tId of remainingTeams) {
+ const score = await redis.zScore(`room:${roomId}:scores`, tId);
+ teamScores[tId] = score || 0;
+ }
+ await publishRoom(roomId, {
+ type: "room.end",
+ finalScores: teamScores,
+ reason: "team_withdrawal"
+ });
+ }
+ }
+ }
+ return;
+ }
+
+ // Original reconciliation logic continues below
+ const teams = await redis.sMembers(`room:${roomId}:teams`);
+ let winnerId = null;
+ let maxScore = -1;
+ let minSolveTime = Infinity;
+
+ const teamScores: Record = {};
+
+ for (const tId of teams) {
+ const scoreStr = await redis.zScore(`room:${roomId}:scores`, tId);
+ const score = scoreStr || 0;
+ teamScores[tId] = score;
+
+ const timeStr = await redis.zScore(`room:${roomId}:solve_times`, tId);
+ const solveTime = timeStr || 0;
+
+ if (score > maxScore) {
+ maxScore = score;
+ minSolveTime = solveTime;
+ winnerId = tId;
+ } else if (score === maxScore && score > 0) {
+ if (solveTime < minSolveTime) {
+ minSolveTime = solveTime;
+ winnerId = tId;
+ }
+ }
+ }
+
+ // Handle forfeit winner if provided
+ if (trigger === "forfeit" && forfeitedUserId) {
+ // Find the team that the forfeited user does NOT belong to
+ for (const tId of teams) {
+ const isMember = await redis.sIsMember(`team:${tId}:users`, forfeitedUserId);
+ if (!isMember) {
+ winnerId = tId;
+ break;
+ }
+ }
+ }
+
+ // 2. Write to MongoDB
+ const room = await ContestRoom.findById(roomId);
+ if (room) {
+ room.status = "ended";
+ // We don't have an explicit winner field in IContestRoom schema according to Stage 1,
+ // but if we do, we could set it. The prompt says: "Write final ContestRoom (scores, winner, endTime, trigger)."
+ // Let's assume we update the team scores.
+ for (const tId of teams) {
+ await ContestTeam.findByIdAndUpdate(tId, { score: teamScores[tId] });
+ }
+ await room.save();
+ }
+
+ // 2.5 Bracket advancement hook for knockout contests
+ if (contestId && winnerId) {
+ try {
+ const contest = await CustomContest.findById(contestId).lean();
+ if (contest && contest.format === "bracket") {
+ const { advanceWinner, checkRoundCompletion } = await import("../bracket");
+ await advanceWinner(roomId, contestId, winnerId);
+
+ if (room && room.currentRoundId) {
+ const roundDoc = await ContestRound.findById(room.currentRoundId).lean();
+ if (roundDoc) {
+ await checkRoundCompletion(contestId, roundDoc.roundNumber);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error(`[reconciliationWorker] Bracket advancement error for room ${roomId}:`, err);
+ }
+ }
+
+ // 3. Write ContestSubmission records
+ const submissions = await redis.xRange(`room:${roomId}:submissions`, "-", "+");
+ for (const sub of submissions) {
+ const data = 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();
+ }
+
+ // 4. Finalise ContestProblemSet (e.g. tracking who solved what) - stubbed for now if schema doesn't fully support
+
+ // Publish room.end if triggered by timeout or forfeit (meaning it didn't end naturally in cfSyncWorker)
+ if (trigger === "timeout" || trigger === "forfeit") {
+ const stateObj = await redis.hGetAll(`room:${roomId}:state`);
+ const startTime = parseInt(stateObj.startTime || "0", 10);
+ await publishRoom(roomId, {
+ type: "room.end",
+ finalScores: teamScores,
+ duration: Date.now() - startTime
+ });
+ await redis.hSet(`room:${roomId}:state`, { status: "completed" });
+ }
+
+ // 5. Clean up Redis
+ const keys = await redis.keys(`room:${roomId}:*`);
+ if (keys.length > 0) {
+ await redis.del(keys);
+ }
+
+ logger.info(`[reconciliationWorker] Finished job ${job.id} for room ${roomId}`);
+ },
+ {
+ connection,
+ }
+);
+
+reconciliationWorker.on("completed", (job) => {
+ logger.info(`[reconciliationWorker] Job ${job.id} completed successfully`);
+});
+
+reconciliationWorker.on("failed", (job, err) => {
+ logger.error(`[reconciliationWorker] Job ${job?.id} failed with error: ${err.message}`, err);
+});
diff --git a/src/models/CFQuestion.ts b/src/models/CFQuestion.ts
new file mode 100644
index 0000000..59eabe9
--- /dev/null
+++ b/src/models/CFQuestion.ts
@@ -0,0 +1,56 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface ICFQuestion extends Document {
+ problemId: string; // e.g. "1234A" (unique across all questions)
+ contestId: number; // e.g. 1234
+ index: string; // e.g. "A"
+ name: string;
+ rating?: number;
+ tags: string[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const CFQuestionSchema = new Schema(
+ {
+ problemId: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true,
+ },
+ contestId: {
+ type: Number,
+ required: true,
+ index: true,
+ },
+ index: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ rating: {
+ type: Number,
+ index: true,
+ },
+ tags: [
+ {
+ type: String,
+ index: true,
+ },
+ ],
+ },
+ { timestamps: true }
+);
+
+// Compound index to ensure uniqueness of contestId + index combination
+CFQuestionSchema.index({ contestId: 1, index: 1 }, { unique: true });
+
+const CFQuestion =
+ mongoose.models.CFQuestion ||
+ mongoose.model("CFQuestion", CFQuestionSchema, "cf_questions");
+
+export default CFQuestion;
diff --git a/src/models/CPUser.ts b/src/models/CPUser.ts
index 511029f..1338f58 100644
--- a/src/models/CPUser.ts
+++ b/src/models/CPUser.ts
@@ -1,5 +1,23 @@
import mongoose from "mongoose";
+const SolvedProblemSchema = new mongoose.Schema(
+ {
+ problemId: {
+ type: String,
+ required: true,
+ },
+ rating: {
+ type: Number,
+ default: 0,
+ },
+ solvedAt: {
+ type: Date,
+ default: Date.now,
+ },
+ },
+ { _id: false }
+);
+
const CPUserSchema = new mongoose.Schema(
{
userId: {
@@ -106,6 +124,10 @@ const CPUserSchema = new mongoose.Schema(
type: Number,
default: 0,
},
+ solvedProblems: {
+ type: [SolvedProblemSchema],
+ default: [],
+ },
},
{ timestamps: true },
);
@@ -118,6 +140,9 @@ CPUserSchema.index(
{ acHandle: 1 },
{ unique: true, partialFilterExpression: { acHandle: { $gt: "" } } },
);
+CPUserSchema.index(
+ { "solvedProblems.problemId": 1 }
+);
export default mongoose.models.CPUser ||
mongoose.model("CPUser", CPUserSchema, "cpusers");
diff --git a/src/models/ContestPreset.ts b/src/models/ContestPreset.ts
new file mode 100644
index 0000000..51e8f4e
--- /dev/null
+++ b/src/models/ContestPreset.ts
@@ -0,0 +1,61 @@
+import mongoose, { Schema, type Document } from "mongoose";
+import { IProblemSlot } from "./CustomContest";
+
+export interface IContestPreset extends Document {
+ name: string;
+ description?: string;
+ format?: "1v1" | "solo-tournament" | "team-tournament" | "bracket";
+ mode?: "blitz" | "arena";
+ durationSeconds?: number;
+ problemSelectionMode?: "bulk" | "fine-tuned";
+ // Mode A (Bulk)
+ bulkPlatform?: string;
+ bulkRatingMin?: number;
+ bulkRatingMax?: number;
+ bulkProblemCount?: number;
+ // Mode B (Fine-tuned)
+ problemSlots?: IProblemSlot[];
+ archived?: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ProblemSlotSchema = new Schema({
+ platform: { type: String, required: true },
+ rating: { type: Number, required: true },
+});
+
+const ContestPresetSchema = new Schema(
+ {
+ name: { type: String, required: true, unique: true, index: true },
+ description: { type: String },
+ format: {
+ type: String,
+ enum: ["1v1", "solo-tournament", "team-tournament", "bracket"],
+ },
+ mode: {
+ type: String,
+ enum: ["blitz", "arena"],
+ },
+ durationSeconds: { type: Number },
+ problemSelectionMode: {
+ type: String,
+ enum: ["bulk", "fine-tuned"],
+ },
+ // Mode A
+ bulkPlatform: { type: String },
+ bulkRatingMin: { type: Number },
+ bulkRatingMax: { type: Number },
+ bulkProblemCount: { type: Number },
+ // Mode B
+ problemSlots: [ProblemSlotSchema],
+ archived: { type: Boolean, default: false },
+ },
+ { timestamps: true }
+);
+
+const ContestPreset =
+ mongoose.models.ContestPreset ||
+ mongoose.model("ContestPreset", ContestPresetSchema, "contest_presets");
+
+export default ContestPreset;
diff --git a/src/models/ContestProblemSet.ts b/src/models/ContestProblemSet.ts
new file mode 100644
index 0000000..cf1a697
--- /dev/null
+++ b/src/models/ContestProblemSet.ts
@@ -0,0 +1,52 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface ISelectedProblem {
+ platform: string;
+ problemId: string;
+ name: string;
+ rating?: number;
+ url?: string;
+ points: number;
+}
+
+export interface IContestProblemSet extends Document {
+ contestId: mongoose.Types.ObjectId;
+ roomId?: mongoose.Types.ObjectId;
+ problems: ISelectedProblem[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const SelectedProblemSchema = new Schema({
+ platform: { type: String, required: true },
+ problemId: { type: String, required: true },
+ name: { type: String, required: true },
+ rating: { type: Number },
+ url: { type: String },
+ points: { type: Number, required: true, default: 100 },
+});
+
+const ContestProblemSetSchema = new Schema(
+ {
+ contestId: {
+ type: Schema.Types.ObjectId,
+ ref: "CustomContest",
+ required: true,
+ index: true,
+ },
+ roomId: {
+ type: Schema.Types.ObjectId,
+ ref: "ContestRoom",
+ index: true,
+ sparse: true,
+ },
+ problems: [SelectedProblemSchema],
+ },
+ { timestamps: true }
+);
+
+const ContestProblemSet =
+ mongoose.models.ContestProblemSet ||
+ mongoose.model("ContestProblemSet", ContestProblemSetSchema, "contest_problem_sets");
+
+export default ContestProblemSet;
diff --git a/src/models/ContestRoom.ts b/src/models/ContestRoom.ts
new file mode 100644
index 0000000..d5e74ff
--- /dev/null
+++ b/src/models/ContestRoom.ts
@@ -0,0 +1,54 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface IFirstSolver {
+ problemId: string;
+ userId: mongoose.Types.ObjectId;
+ solvedAt: Date;
+}
+
+export interface IContestRoom extends Document {
+ contestId: mongoose.Types.ObjectId;
+ name: string;
+ status: "waiting" | "active" | "ended" | "pending";
+ participants: mongoose.Types.ObjectId[];
+ teams: mongoose.Types.ObjectId[];
+ currentRoundId?: mongoose.Types.ObjectId;
+ currentProblemIndex: number;
+ firstSolvers: IFirstSolver[];
+ bracketPosition?: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const FirstSolverSchema = new Schema({
+ problemId: { type: String, required: true },
+ userId: { type: Schema.Types.ObjectId, ref: "CPUser", required: true },
+ solvedAt: { type: Date, required: true },
+});
+
+const ContestRoomSchema = new Schema(
+ {
+ contestId: { type: Schema.Types.ObjectId, ref: "CustomContest", required: true, index: true },
+ name: { type: String, required: true },
+ status: {
+ type: String,
+ required: true,
+ enum: ["waiting", "active", "ended", "pending"],
+ default: "waiting",
+ index: true,
+ },
+ participants: [{ type: Schema.Types.ObjectId, ref: "CPUser", index: true }],
+ teams: [{ type: Schema.Types.ObjectId, ref: "ContestTeam" }],
+ currentRoundId: { type: Schema.Types.ObjectId, ref: "ContestRound" },
+ currentProblemIndex: { type: Number, required: true, default: 0 },
+ firstSolvers: { type: [FirstSolverSchema], default: [] },
+ bracketPosition: { type: String, default: null },
+ },
+ { timestamps: true }
+);
+
+const ContestRoom =
+ mongoose.models.ContestRoom ||
+ mongoose.model("ContestRoom", ContestRoomSchema, "contest_rooms");
+
+export default ContestRoom;
diff --git a/src/models/ContestRound.ts b/src/models/ContestRound.ts
new file mode 100644
index 0000000..c56aaf8
--- /dev/null
+++ b/src/models/ContestRound.ts
@@ -0,0 +1,37 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface IContestRound extends Document {
+ contestId: mongoose.Types.ObjectId;
+ roundNumber: number;
+ name: string;
+ status: "pending" | "active" | "completed";
+ rooms: mongoose.Types.ObjectId[];
+ bracketLevel?: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ContestRoundSchema = new Schema(
+ {
+ contestId: { type: Schema.Types.ObjectId, ref: "CustomContest", required: true, index: true },
+ roundNumber: { type: Number, required: true },
+ name: { type: String, required: true },
+ status: {
+ type: String,
+ required: true,
+ enum: ["pending", "active", "completed"],
+ default: "pending",
+ },
+ rooms: [{ type: Schema.Types.ObjectId, ref: "ContestRoom" }],
+ bracketLevel: { type: String },
+ },
+ { timestamps: true }
+);
+
+ContestRoundSchema.index({ contestId: 1, roundNumber: 1 });
+
+const ContestRound =
+ mongoose.models.ContestRound ||
+ mongoose.model("ContestRound", ContestRoundSchema, "contest_rounds");
+
+export default ContestRound;
diff --git a/src/models/ContestStanding.ts b/src/models/ContestStanding.ts
new file mode 100644
index 0000000..77a6bb5
--- /dev/null
+++ b/src/models/ContestStanding.ts
@@ -0,0 +1,46 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface IContestStanding extends Document {
+ roomId: mongoose.Types.ObjectId;
+ contestId: mongoose.Types.ObjectId;
+ teamId?: mongoose.Types.ObjectId;
+ userId: mongoose.Types.ObjectId;
+ score: number;
+ rank?: number;
+ problemsSolved: number;
+ solvedTimes: Map;
+ wins?: number;
+ losses?: number;
+ draws?: number;
+ eliminated?: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ContestStandingSchema = new Schema(
+ {
+ roomId: { type: Schema.Types.ObjectId, ref: "ContestRoom", required: true, index: true },
+ contestId: { type: Schema.Types.ObjectId, ref: "CustomContest", required: true },
+ teamId: { type: Schema.Types.ObjectId, ref: "ContestTeam" },
+ userId: { type: Schema.Types.ObjectId, ref: "CPUser", required: true },
+ score: { type: Number, required: true, default: 0 },
+ rank: { type: Number },
+ problemsSolved: { type: Number, required: true, default: 0 },
+ solvedTimes: {
+ type: Map,
+ of: Date,
+ default: new Map(),
+ },
+ wins: { type: Number, default: 0 },
+ losses: { type: Number, default: 0 },
+ draws: { type: Number, default: 0 },
+ eliminated: { type: Boolean, default: false },
+ },
+ { timestamps: true }
+);
+
+const ContestStanding =
+ mongoose.models.ContestStanding ||
+ mongoose.model("ContestStanding", ContestStandingSchema, "contest_standings");
+
+export default ContestStanding;
diff --git a/src/models/ContestSubmission.ts b/src/models/ContestSubmission.ts
new file mode 100644
index 0000000..8ab1976
--- /dev/null
+++ b/src/models/ContestSubmission.ts
@@ -0,0 +1,43 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface IContestSubmission extends Document {
+ contestId: mongoose.Types.ObjectId;
+ roomId: mongoose.Types.ObjectId;
+ userId: mongoose.Types.ObjectId;
+ teamId?: mongoose.Types.ObjectId; // For team contests
+ problemId: string;
+ platform: string;
+ submissionId: string;
+ verdict: string;
+ points?: number; // Points awarded
+ solveMs?: number; // Time to solve (in milliseconds)
+ submittedAt: Date;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ContestSubmissionSchema = new Schema(
+ {
+ contestId: { type: Schema.Types.ObjectId, ref: "CustomContest", required: true, index: true },
+ roomId: { type: Schema.Types.ObjectId, ref: "ContestRoom", required: true, index: true },
+ userId: { type: Schema.Types.ObjectId, ref: "CPUser", required: true },
+ teamId: { type: Schema.Types.ObjectId, ref: "ContestTeam" },
+ problemId: { type: String, required: true },
+ platform: { type: String, required: true },
+ submissionId: { type: String, required: true },
+ verdict: { type: String, required: true },
+ points: { type: Number },
+ solveMs: { type: Number },
+ submittedAt: { type: Date, required: true },
+ },
+ { timestamps: true }
+);
+
+ContestSubmissionSchema.index({ roomId: 1, userId: 1 });
+ContestSubmissionSchema.index({ contestId: 1, problemId: 1 });
+
+const ContestSubmission =
+ mongoose.models.ContestSubmission ||
+ mongoose.model("ContestSubmission", ContestSubmissionSchema, "contest_submissions");
+
+export default ContestSubmission;
diff --git a/src/models/ContestTeam.ts b/src/models/ContestTeam.ts
new file mode 100644
index 0000000..d765168
--- /dev/null
+++ b/src/models/ContestTeam.ts
@@ -0,0 +1,32 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface IContestTeam extends Document {
+ roomId: mongoose.Types.ObjectId;
+ name: string;
+ members: mongoose.Types.ObjectId[];
+ teamSize: number; // 1 or 3
+ score: number;
+ roundId?: mongoose.Types.ObjectId; // For tournament context
+ contestId?: mongoose.Types.ObjectId; // For tournament context
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ContestTeamSchema = new Schema(
+ {
+ roomId: { type: Schema.Types.ObjectId, ref: "ContestRoom", required: true, index: true },
+ name: { type: String, required: true },
+ members: [{ type: Schema.Types.ObjectId, ref: "CPUser", required: true, index: true }],
+ teamSize: { type: Number, required: true, enum: [1, 3] },
+ score: { type: Number, required: true, default: 0 },
+ roundId: { type: Schema.Types.ObjectId, ref: "ContestRound" },
+ contestId: { type: Schema.Types.ObjectId, ref: "CustomContest" },
+ },
+ { timestamps: true }
+);
+
+const ContestTeam =
+ mongoose.models.ContestTeam ||
+ mongoose.model("ContestTeam", ContestTeamSchema, "contest_teams");
+
+export default ContestTeam;
diff --git a/src/models/CustomContest.ts b/src/models/CustomContest.ts
new file mode 100644
index 0000000..5234fd6
--- /dev/null
+++ b/src/models/CustomContest.ts
@@ -0,0 +1,139 @@
+import mongoose, { Schema, type Document } from "mongoose";
+
+export interface IProblemSlot {
+ platform: string;
+ rating: number;
+}
+
+export interface IRegistration {
+ userId: mongoose.Types.ObjectId;
+ cfHandle: string;
+ teamName?: string;
+ registeredAt: Date;
+}
+
+export interface IRegistrationSettings {
+ type: "open" | "closed";
+ deadline: Date;
+ maxParticipants: number;
+}
+
+export interface IBracketSettings {
+ thirdPlacePlayoff: boolean;
+ seedingMethod: "cf_rating" | "manual";
+}
+
+export interface ICustomContest extends Document {
+ name: string;
+ description?: string;
+ creatorId: mongoose.Types.ObjectId;
+ startTime?: Date;
+ endTime?: Date;
+ durationSeconds?: number;
+ format: "1v1" | "solo-tournament" | "team-tournament" | "bracket";
+ mode: "blitz" | "arena";
+ status: "draft" | "registration" | "active" | "completed";
+ teamSize?: number;
+ presetId?: mongoose.Types.ObjectId;
+ problemSelectionMode: "bulk" | "fine-tuned";
+ // Mode A (Bulk)
+ bulkPlatform?: string;
+ bulkRatingMin?: number;
+ bulkRatingMax?: number;
+ bulkProblemCount?: number;
+ // Mode B (Fine-tuned)
+ problemSlots?: IProblemSlot[];
+ // Registration and Bracket fields
+ registrations?: IRegistration[];
+ registrationSettings?: IRegistrationSettings;
+ bracketSettings?: IBracketSettings;
+ winner?: mongoose.Types.ObjectId;
+ winnerName?: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ProblemSlotSchema = new Schema({
+ platform: { type: String, required: true },
+ rating: { type: Number, required: true },
+});
+
+const RegistrationSchema = new Schema({
+ userId: { type: Schema.Types.ObjectId, ref: "CPUser", required: true },
+ cfHandle: { type: String, required: true },
+ teamName: { type: String },
+ registeredAt: { type: Date, default: Date.now },
+});
+
+const RegistrationSettingsSchema = new Schema({
+ type: { type: String, enum: ["open", "closed"], required: true },
+ deadline: { type: Date, required: true },
+ maxParticipants: { type: Number, required: true, min: 2 },
+});
+
+const BracketSettingsSchema = new Schema({
+ thirdPlacePlayoff: { type: Boolean, default: false },
+ seedingMethod: { type: String, enum: ["cf_rating", "manual"], required: true },
+});
+
+const CustomContestSchema = new Schema(
+ {
+ name: { type: String, required: true },
+ description: { type: String, maxlength: 500 },
+ creatorId: { type: Schema.Types.ObjectId, ref: "CPUser", required: true, index: true },
+ startTime: { type: Date },
+ endTime: { type: Date },
+ durationSeconds: { type: Number },
+ format: {
+ type: String,
+ required: true,
+ enum: ["1v1", "solo-tournament", "team-tournament", "bracket"],
+ },
+ mode: {
+ type: String,
+ required: true,
+ enum: ["blitz", "arena"],
+ },
+ status: {
+ type: String,
+ required: true,
+ enum: ["draft", "registration", "active", "completed"],
+ default: "draft",
+ index: true,
+ },
+ teamSize: {
+ type: Number,
+ enum: [1, 3],
+ },
+ presetId: { type: Schema.Types.ObjectId, ref: "ContestPreset" },
+ problemSelectionMode: {
+ type: String,
+ required: true,
+ enum: ["bulk", "fine-tuned"],
+ },
+ // Mode A
+ bulkPlatform: { type: String },
+ bulkRatingMin: { type: Number },
+ bulkRatingMax: { type: Number },
+ bulkProblemCount: { type: Number },
+ // Mode B
+ problemSlots: [ProblemSlotSchema],
+ // Registration and Bracket
+ registrations: [RegistrationSchema],
+ registrationSettings: RegistrationSettingsSchema,
+ bracketSettings: BracketSettingsSchema,
+ winner: { type: Schema.Types.ObjectId, ref: "ContestTeam" },
+ winnerName: { type: String },
+ },
+ { timestamps: true }
+);
+
+CustomContestSchema.index({ status: 1, startTime: 1 });
+CustomContestSchema.index({ format: 1, status: 1 });
+
+const CustomContest =
+ mongoose.models.CustomContest ||
+ mongoose.model("CustomContest", CustomContestSchema, "custom_contests");
+
+export default CustomContest;
+
diff --git a/src/types/bracket.ts b/src/types/bracket.ts
new file mode 100644
index 0000000..80f8889
--- /dev/null
+++ b/src/types/bracket.ts
@@ -0,0 +1,62 @@
+export type BracketPosition = string;
+
+export type BracketNode = {
+ roomId: string;
+ roundNumber: number;
+ matchIndex: number;
+ teams: [string | null, string | null];
+ scores: [number, number];
+ status: "pending" | "active" | "completed" | "bye";
+ winner: string | null;
+ bracketPosition: BracketPosition;
+};
+
+export type BracketSnapshot = {
+ contestId: string;
+ currentRound: number;
+ totalRounds: number;
+ nodes: BracketNode[];
+};
+
+export const ROUND_NAMES: Record = {
+ 1: "Final",
+ 2: "Semi-Finals",
+ 3: "Quarter-Finals",
+ 4: "Round of 16",
+ 5: "Round of 32",
+ 6: "Round of 64",
+ 7: "Round of 128",
+};
+
+export function getRoundName(roundNumber: number, totalRounds: number): string {
+ if (roundNumber === totalRounds) return "Final";
+ if (roundNumber === totalRounds - 1) return "Semi-Finals";
+ if (roundNumber === totalRounds - 2) return "Quarter-Finals";
+ const participants = Math.pow(2, roundNumber + 1);
+ return `Round of ${participants}`;
+}
+
+export function snakeSeed(teams: { teamId: string; seed: number }[]): { teamId: string; seed: number }[] {
+ const sorted = [...teams].sort((a, b) => a.seed - b.seed);
+ const n = sorted.length;
+ const result: { teamId: string; seed: number }[] = [];
+ let left = 0;
+ let right = n - 1;
+ let fromLeft = true;
+ while (left <= right) {
+ if (fromLeft) {
+ result.push(sorted[left]);
+ left++;
+ } else {
+ result.push(sorted[right]);
+ right--;
+ }
+ fromLeft = !fromLeft;
+ }
+ return result;
+}
+
+export function nextPowerOf2(n: number): number {
+ if (n <= 1) return 2;
+ return Math.pow(2, Math.ceil(Math.log2(n)));
+}
diff --git a/src/worker.ts b/src/worker.ts
index 0aab923..12137c6 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -9,13 +9,41 @@ import { sendHackathonDeadlineReminders } from "./lib/jobs/hackathonReminder";
import { sendPOTDReminders } from "./lib/jobs/potdReminder";
import { logger } from "./lib/utils";
import dbConnect from "./lib/mongodb";
+import { cfSyncWorker } from "./lib/workers/cfSyncWorker";
+import { reconciliationWorker } from "./lib/workers/reconciliationWorker";
+import { cfSyncQueue } from "./lib/bullmq";
+import CFQuestion from "./models/CFQuestion";
+import { startPresenceKeyspaceListener } from "./lib/presenceListener";
async function run() {
- logger.info("[Worker] Starting standalone background worker...");
+ logger.info("[Worker] Starting standalone background worker (Agenda + BullMQ)...");
// Ensure DB is connected
await dbConnect();
+ // Start Redis keyspace notifications listener for presence tracking
+ await startPresenceKeyspaceListener();
+
+ // BullMq sync runs at 2
+ await cfSyncQueue.add(
+ "nightly-cf-problem-sync",
+ {},
+ {
+ repeat: {
+ pattern: "0 2 * * *",
+ },
+ jobId: "nightly-cf-problem-sync",
+ }
+ );
+ logger.info("[Worker] Scheduled nightly Codeforces problem sync repeatable job.");
+
+
+ const cfQuestionCount = await CFQuestion.countDocuments();
+ if (cfQuestionCount === 0) {
+ logger.info("[Worker] CFQuestion database is empty. Triggering immediate full ingest...");
+ await cfSyncQueue.add("nightly-cf-problem-sync", { isFirstRun: true });
+ }
+
// Define jobs
agenda.define("sync-cf-ratings", async () => {
await syncCodeforcesRatings();
@@ -73,8 +101,17 @@ async function run() {
// Graceful shutdown
async function graceful() {
- logger.info("[Worker] Stopping agenda...");
- await agenda.stop();
+ logger.info("[Worker] Stopping agenda and BullMQ workers...");
+ try {
+ await Promise.all([
+ agenda.stop(),
+ cfSyncWorker.close(),
+ reconciliationWorker.close(),
+ ]);
+ logger.info("[Worker] All services stopped successfully.");
+ } catch (err) {
+ logger.error("[Worker] Error during graceful shutdown:", err);
+ }
process.exit(0);
}