From e8e22a2c18896575c773de0b8701565dce3a0bf8 Mon Sep 17 00:00:00 2001 From: rohit-h11 Date: Thu, 18 Jun 2026 14:58:02 +0530 Subject: [PATCH 01/18] added new datamodels for contests feature and updated cf user to store problem ids of solved questions --- src/models/CPUser.ts | 4 ++ src/models/ContestPreset.ts | 59 ++++++++++++++++++++++++ src/models/ContestProblemSet.ts | 46 ++++++++++++++++++ src/models/ContestRoom.ts | 52 +++++++++++++++++++++ src/models/ContestRound.ts | 35 ++++++++++++++ src/models/ContestStanding.ts | 38 +++++++++++++++ src/models/ContestSubmission.ts | 37 +++++++++++++++ src/models/ContestTeam.ts | 26 +++++++++++ src/models/CustomContest.ts | 82 +++++++++++++++++++++++++++++++++ 9 files changed, 379 insertions(+) create mode 100644 src/models/ContestPreset.ts create mode 100644 src/models/ContestProblemSet.ts create mode 100644 src/models/ContestRoom.ts create mode 100644 src/models/ContestRound.ts create mode 100644 src/models/ContestStanding.ts create mode 100644 src/models/ContestSubmission.ts create mode 100644 src/models/ContestTeam.ts create mode 100644 src/models/CustomContest.ts diff --git a/src/models/CPUser.ts b/src/models/CPUser.ts index 511029f..11d455e 100644 --- a/src/models/CPUser.ts +++ b/src/models/CPUser.ts @@ -106,6 +106,10 @@ const CPUserSchema = new mongoose.Schema( type: Number, default: 0, }, + solvedProblems: { + type: [String], + default: [], + }, }, { timestamps: true }, ); diff --git a/src/models/ContestPreset.ts b/src/models/ContestPreset.ts new file mode 100644 index 0000000..8d7bf62 --- /dev/null +++ b/src/models/ContestPreset.ts @@ -0,0 +1,59 @@ +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[]; + 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], + }, + { 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..11d3fa1 --- /dev/null +++ b/src/models/ContestProblemSet.ts @@ -0,0 +1,46 @@ +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; + 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, + unique: true, + index: 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..2d85330 --- /dev/null +++ b/src/models/ContestRoom.ts @@ -0,0 +1,52 @@ +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"; + participants: mongoose.Types.ObjectId[]; + teams: mongoose.Types.ObjectId[]; + currentRoundId?: mongoose.Types.ObjectId; + currentProblemIndex: number; + firstSolvers: IFirstSolver[]; + 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"], + 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: [] }, + }, + { 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..49f2aa3 --- /dev/null +++ b/src/models/ContestRound.ts @@ -0,0 +1,35 @@ +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[]; + 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" }], + }, + { 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..b33ab52 --- /dev/null +++ b/src/models/ContestStanding.ts @@ -0,0 +1,38 @@ +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; + 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(), + }, + }, + { 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..589c3e2 --- /dev/null +++ b/src/models/ContestSubmission.ts @@ -0,0 +1,37 @@ +import mongoose, { Schema, type Document } from "mongoose"; + +export interface IContestSubmission extends Document { + contestId: mongoose.Types.ObjectId; + roomId: mongoose.Types.ObjectId; + userId: mongoose.Types.ObjectId; + problemId: string; + platform: string; + submissionId: string; + verdict: string; + 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 }, + problemId: { type: String, required: true }, + platform: { type: String, required: true }, + submissionId: { type: String, required: true }, + verdict: { type: String, required: true }, + 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..04d21ca --- /dev/null +++ b/src/models/ContestTeam.ts @@ -0,0 +1,26 @@ +import mongoose, { Schema, type Document } from "mongoose"; + +export interface IContestTeam extends Document { + roomId: mongoose.Types.ObjectId; + name: string; + members: mongoose.Types.ObjectId[]; + score: number; + 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 }], + score: { type: Number, required: true, default: 0 }, + }, + { 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..5cc68f3 --- /dev/null +++ b/src/models/CustomContest.ts @@ -0,0 +1,82 @@ +import mongoose, { Schema, type Document } from "mongoose"; + +export interface IProblemSlot { + platform: string; + rating: number; +} + +export interface ICustomContest extends Document { + name: string; + creatorId: mongoose.Types.ObjectId; + startTime: Date; + endTime: Date; + durationSeconds: number; + format: "1v1" | "solo-tournament" | "team-tournament" | "bracket"; + mode: "blitz" | "arena"; + status: "draft" | "scheduled" | "active" | "ended"; + 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[]; + createdAt: Date; + updatedAt: Date; +} + +const ProblemSlotSchema = new Schema({ + platform: { type: String, required: true }, + rating: { type: Number, required: true }, +}); + +const CustomContestSchema = new Schema( + { + name: { type: String, required: true }, + creatorId: { type: Schema.Types.ObjectId, ref: "CPUser", required: true, index: true }, + startTime: { type: Date, required: true }, + endTime: { type: Date, required: true }, + durationSeconds: { type: Number, required: true }, + 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", "scheduled", "active", "ended"], + default: "draft", + index: true, + }, + 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], + }, + { timestamps: true } +); + +CustomContestSchema.index({ status: 1, startTime: 1 }); + +const CustomContest = + mongoose.models.CustomContest || + mongoose.model("CustomContest", CustomContestSchema, "custom_contests"); + +export default CustomContest; From c654170b526ccde0c2c4623c1a8d7a0375f8b87f Mon Sep 17 00:00:00 2001 From: rohit-h11 Date: Thu, 18 Jun 2026 16:10:41 +0530 Subject: [PATCH 02/18] SSE setup --- ecosystem.local.config.js | 23 +++++++ scripts/seed.ts | 2 +- src/app/api/events/route.ts | 117 ++++++++++++++++++++++++++++++++++++ src/lib/redis.ts | 8 ++- src/lib/sse.ts | 16 +++++ src/models/CFQuestion.ts | 60 ++++++++++++++++++ src/models/CPUser.ts | 23 ++++++- 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 ecosystem.local.config.js create mode 100644 src/app/api/events/route.ts create mode 100644 src/lib/sse.ts create mode 100644 src/models/CFQuestion.ts 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/scripts/seed.ts b/scripts/seed.ts index a1c04b0..605adc0 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -20,7 +20,7 @@ async function seed() { await mongoose.connect(MONGODB_URI!); const devUser = { name: "Coding Club IITG", - email: "codingclub@iitg.ac.in", + email: "h.rohit@iitg.ac.in", role: "Secretary", moduleRoles: [], emailVerified: true, diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..bc0945e --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1,117 @@ +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"; + +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); + } + + 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); + } + } 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/lib/redis.ts b/src/lib/redis.ts index a333e96..10fe490 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -14,7 +14,13 @@ export async function getRedis(): Promise { if (!connectPromise) { connectPromise = redisClient .connect() - .then(() => { + .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 as RedisClientType; }) .catch((err) => { 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/models/CFQuestion.ts b/src/models/CFQuestion.ts new file mode 100644 index 0000000..f5e3c2f --- /dev/null +++ b/src/models/CFQuestion.ts @@ -0,0 +1,60 @@ +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[]; + points?: number; + 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, + }, + ], + points: { + type: Number, + }, + }, + { 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 11d455e..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: { @@ -107,7 +125,7 @@ const CPUserSchema = new mongoose.Schema( default: 0, }, solvedProblems: { - type: [String], + type: [SolvedProblemSchema], default: [], }, }, @@ -122,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"); From 9fb4c940ce41afaf175c67314f12a9311e0a0c14 Mon Sep 17 00:00:00 2001 From: rohit-h11 Date: Fri, 19 Jun 2026 10:36:29 +0530 Subject: [PATCH 03/18] CFsync ques implemented --- package.json | 2 + pnpm-lock.yaml | 176 +++++++++++++++++++++++- pnpm-workspace.yaml | 1 + src/lib/bullmq.ts | 48 +++++++ src/lib/jobs/cfProblemSync.ts | 90 ++++++++++++ src/lib/workers/cfSyncWorker.ts | 30 ++++ src/lib/workers/reconciliationWorker.ts | 22 +++ src/models/CFQuestion.ts | 4 - src/worker.ts | 39 +++++- 9 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 src/lib/bullmq.ts create mode 100644 src/lib/jobs/cfProblemSync.ts create mode 100644 src/lib/workers/cfSyncWorker.ts create mode 100644 src/lib/workers/reconciliationWorker.ts diff --git a/package.json b/package.json index 40c3cd5..abc95df 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", 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..c47aa71 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ allowBuilds: "@parcel/watcher": true esbuild: true + msgpackr-extract: set this to true or false sharp: true unrs-resolver: true diff --git a/src/lib/bullmq.ts b/src/lib/bullmq.ts new file mode 100644 index 0000000..7a1d303 --- /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/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/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts new file mode 100644 index 0000000..6301005 --- /dev/null +++ b/src/lib/workers/cfSyncWorker.ts @@ -0,0 +1,30 @@ +import { Worker, Job } from "bullmq"; +import { connection } from "../bullmq"; +import { logger } from "../utils"; +import { syncCodeforcesProblems } from "../jobs/cfProblemSync"; + +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(); + } + }, + { + connection, + limiter: { + max: 2, + duration: 1000, + }, + } +); + +cfSyncWorker.on("completed", (job) => { + logger.info(`[cfSyncWorker] Job ${job.id} completed successfully`); +}); + +cfSyncWorker.on("failed", (job, err) => { + logger.error(`[cfSyncWorker] Job ${job?.id} failed with error: ${err.message}`, err); +}); diff --git a/src/lib/workers/reconciliationWorker.ts b/src/lib/workers/reconciliationWorker.ts new file mode 100644 index 0000000..e7b8348 --- /dev/null +++ b/src/lib/workers/reconciliationWorker.ts @@ -0,0 +1,22 @@ +import { Worker, Job } from "bullmq"; +import { connection } from "../bullmq"; +import { logger } from "../utils"; + +export const reconciliationWorker = new Worker( + "reconciliation_queue", + async (job: Job) => { + logger.info(`[reconciliationWorker] Processing job ${job.id} (name: ${job.name})`, job.data); + // Full logic in later stages + }, + { + 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 index f5e3c2f..59eabe9 100644 --- a/src/models/CFQuestion.ts +++ b/src/models/CFQuestion.ts @@ -7,7 +7,6 @@ export interface ICFQuestion extends Document { name: string; rating?: number; tags: string[]; - points?: number; createdAt: Date; updatedAt: Date; } @@ -43,9 +42,6 @@ const CFQuestionSchema = new Schema( index: true, }, ], - points: { - type: Number, - }, }, { timestamps: true } ); diff --git a/src/worker.ts b/src/worker.ts index 0aab923..6734e10 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -9,13 +9,37 @@ 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"; 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(); + // Schedule BullMQ repeatable Codeforces problem sync (Runs nightly at 2:00 AM) + 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."); + + // If database is empty, trigger immediate full ingest + 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 +97,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); } From 63b1709c165333875d06a4e65e6c1a9257b41216 Mon Sep 17 00:00:00 2001 From: rohit-h11 Date: Fri, 19 Jun 2026 11:41:03 +0530 Subject: [PATCH 04/18] issue 1 done --- pnpm-workspace.yaml | 2 +- src/app/api/events/route.ts | 21 ++++++++++++-- src/lib/bullmq.ts | 2 +- src/lib/presenceListener.ts | 55 +++++++++++++++++++++++++++++++++++++ src/worker.ts | 8 ++++-- 5 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 src/lib/presenceListener.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c47aa71..85e15d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ allowBuilds: "@parcel/watcher": true esbuild: true - msgpackr-extract: set this to true or false + msgpackr-extract: true sharp: true unrs-resolver: true diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index bc0945e..b72e225 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -4,6 +4,7 @@ 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"; @@ -25,9 +26,9 @@ export async function GET(request: NextRequest) { const isValidObjectId = /^[0-9a-fA-F]{24}$/.test(userId); const activeRooms = isValidObjectId ? await ContestRoom.find({ - participants: userId, - status: "active", - }).lean() + participants: userId, + status: "active", + }).lean() : []; const redis = await getRedis(); @@ -37,6 +38,13 @@ export async function GET(request: NextRequest) { 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}`]; @@ -68,6 +76,13 @@ export async function GET(request: NextRequest) { 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); diff --git a/src/lib/bullmq.ts b/src/lib/bullmq.ts index 7a1d303..def9897 100644 --- a/src/lib/bullmq.ts +++ b/src/lib/bullmq.ts @@ -21,7 +21,7 @@ export const connection: ConnectionOptions = { }; // Create cf_sync_queue: limiter: { max: 2, duration: 1000 }, defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 5000 } } -// Note: limiter is configured on the Worker +// Note: limiter is configured on the worker export const cfSyncQueue = new Queue("cf_sync_queue", { connection, defaultJobOptions: { diff --git a/src/lib/presenceListener.ts b/src/lib/presenceListener.ts new file mode 100644 index 0000000..a93e1b1 --- /dev/null +++ b/src/lib/presenceListener.ts @@ -0,0 +1,55 @@ +import { getRedis } from "@/lib/redis"; +import { logger } from "@/lib/utils"; +import { publishRoom } from "@/lib/sse"; + +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 stub (log only for now) + logger.info(`[PresenceListener] AUTO-FORFEIT STUB: User ${userId} auto-forfeited in room ${roomId} due to inactivity.`); + + // 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/worker.ts b/src/worker.ts index 6734e10..12137c6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -13,6 +13,7 @@ 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 (Agenda + BullMQ)..."); @@ -20,7 +21,10 @@ async function run() { // Ensure DB is connected await dbConnect(); - // Schedule BullMQ repeatable Codeforces problem sync (Runs nightly at 2:00 AM) + // Start Redis keyspace notifications listener for presence tracking + await startPresenceKeyspaceListener(); + + // BullMq sync runs at 2 await cfSyncQueue.add( "nightly-cf-problem-sync", {}, @@ -33,7 +37,7 @@ async function run() { ); logger.info("[Worker] Scheduled nightly Codeforces problem sync repeatable job."); - // If database is empty, trigger immediate full ingest + const cfQuestionCount = await CFQuestion.countDocuments(); if (cfQuestionCount === 0) { logger.info("[Worker] CFQuestion database is empty. Triggering immediate full ingest..."); From 0b2a318001b8a2283420394a0ea35c844d7c05b0 Mon Sep 17 00:00:00 2001 From: rohit-h11 Date: Sun, 21 Jun 2026 10:50:25 +0530 Subject: [PATCH 05/18] stage 1 done --- STAGE1_DONE.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 STAGE1_DONE.md diff --git a/STAGE1_DONE.md b/STAGE1_DONE.md new file mode 100644 index 0000000..dc7018b --- /dev/null +++ b/STAGE1_DONE.md @@ -0,0 +1,174 @@ +# Stage 1 Documentation: Infrastructure & Event Flow + +This document details the real-time presence infrastructure, background queuing systems, caching patterns, and server-sent event (SSE) specifications implemented in Stage 1. + +--- + +## 1. Redis Key Conventions + +All keys stored in Redis follow strict namespacing rules to prevent collisions and support multiple workers cleanly. + +### Cache-Aside Keys +All cache keys use the global prefix `ccw`. Parameterized cache keys are built deterministically by sorting parameters alphabetically and ignoring `null`/`undefined` values. + +* **Format (Parameterized):** `ccw::=&=...` +* **Format (Simple):** `ccw:` + +#### Default Cache TTLs (`CACHE_TTLS`) +| Key Prefix | Description | TTL (Seconds) | Human Readable | +| :--- | :--- | :---: | :---: | +| `ccw:team` | Team-specific data | 21,600 | 6 hours | +| `ccw:contests` | Contest listings | 10,800 | 3 hours | +| `ccw:cf_problemset` | Codeforces problem cache | 21,600 | 6 hours | +| `ccw:cf_user_info` | Codeforces user info/rating cache | 3,600 | 1 hour | +| `ccw:events` | General events data | 300 | 5 minutes | +| `ccw:projects` | Project listings | 300 | 5 minutes | +| `ccw:leaderboards` | Platform leaderboards | 300 | 5 minutes | +| `ccw:blog` | Blog posts / metadata | 120 | 2 minutes | +| `ccw:files` | Uploaded assets cache | 120 | 2 minutes | +| `ccw:users` | User profile data | 120 | 2 minutes | +| `ccw:potd` | Problem of the Day cache | 120 | 2 minutes | +| `ccw:hackathons` | Hackathon info | 300 | 5 minutes | +| `ccw:hackathon_requests` | Hackathon join requests / invites | 60 | 1 minute | + +--- + +### Real-Time Presence Keys +Presence tracking utilizes the Redis keyspace events listener to auto-forfeit inactive contest participants. + +* **Online State Key:** `room::presence:` + * **Value:** `"online"` + * **TTL:** Persistent during active SSE connection. Changes to a **90-second TTL** on SSE client disconnection. +* **Expiration Lock Key:** `room::presence::expire_lock` + * **Value:** `"1"` + * **TTL:** 10 seconds. + * **Purpose:** Prevents duplicate auto-forfeit processing across multiple concurrent worker instances when the presence key expires. +* **Offline Sent Key:** `room::presence::offline_sent` + * **Value:** `"1"` + * **TTL:** 120 seconds. + * **Purpose:** Built as a helper flag to prevent publishing duplicate offline notifications when a user drops off-stream (published once immediately on SSE disconnect, and checked before repeating during a keyspace expire event). + +--- + +### Programmatic Redis Policies +During initialization, the client enforces the following configurations: +1. `maxmemory-policy` is set to `noeviction` to ensure background queues and locks are never discarded due to memory constraints. +2. `notify-keyspace-events` is set to `KEA` (Keyspace, Keyevent, All) to enable the expired key channel (`__keyevent@*__:expired`) subscription. + +--- + +## 2. BullMQ Queue Names + +Background tasks are managed via BullMQ queues connected to Redis: + +### `cf_sync_queue` +* **Purpose:** Orchestrates Codeforces problem ingestion and ingestion synchronization. +* **Job Name:** `nightly-cf-problem-sync` (runs nightly at 2:00 AM, or triggers instantly on startup if the database is empty). +* **Worker Limiter:** Restricts execution to a maximum of 2 jobs per second (`limiter: { max: 2, duration: 1000 }`). +* **Default Job Options:** + ```javascript + { + attempts: 3, + backoff: { + type: "exponential", + delay: 5000 + } + } + ``` + +### `reconciliation_queue` +* **Purpose:** Reconciles active contest and room state logic. +* **Default Job Options:** + ```javascript + { + attempts: 3, + backoff: { + type: "exponential", + delay: 2000 + } + } + ``` + +--- + +## 3. SSE Channel Conventions + +The Server-Sent Events (SSE) gateway dynamically subscribes connected clients to Redis Pub/Sub channels based on active room memberships: + +1. **User Channel:** `events:user:${userId}` + * Private user-targeted events (e.g. notifications, private status). +2. **Room Channel:** `events:room:${roomId}` + * Shared room-scoped events (e.g. participant presence, round starting). +3. **Contest Channel:** `events:contest:${contestId}` + * Contest-wide updates (e.g. real-time leaderboard or standings updates). + +--- + +## 4. Event Name Catalogue + +### Gateway Events (SSE Format) +When client-side listeners establish a `/api/events` connection, the following event streams are handled: + +* **Event Type:** `connected` + * Sent immediately on connection. + * **Payload:** + ```json + { + "userId": "65ab...", + "subscribedChannels": [ + "events:user:65ab...", + "events:room:76bc...", + "events:contest:87cd..." + ] + } + ``` +* **Event Type:** `message` + * Forwarded dynamically from active channel subscriptions. + * **Payload:** + ```json + { + "channel": "events:room:76bc...", + "payload": { + "type": "presence.online", + "userId": "65ab..." + } + } + ``` + +--- + +### Presence Broadcast Events +Published on `events:room:${roomId}` to broadcast participant availability: + +* `presence.online`: Sent when the user starts an SSE stream or joins. + ```json + { "type": "presence.online", "userId": "string" } + ``` +* `presence.offline`: Sent immediately upon clean SSE disconnect, or triggered via keyspace listener upon the 90-second presence key expiration. + ```json + { "type": "presence.offline", "userId": "string" } + ``` + +--- + +### Background / Cron Job Catalog +Scheduled and tracked in the standalone worker (Agenda / BullMQ): + +| Job Name | Engine | Schedule | Target/Function | +| :--- | :---: | :--- | :--- | +| `nightly-cf-problem-sync` | BullMQ | `0 2 * * *` (Daily 2am) | `syncCodeforcesProblems()` | +| `sync-cf-ratings` | Agenda | Every 6 hours | `syncCodeforcesRatings()` | +| `sync-ac-ratings` | Agenda | Every 6 hours | `syncAtCoderRatings()` | +| `sync-potd-submissions` | Agenda | Daily at 2:05 AM IST | `syncPOTDSubmissions()` | +| `sync-contests` | Agenda | Every 3 hours | `syncContests()` | +| `cleanup-images` | Agenda | Weekly, Sun 3:00 AM IST | `cleanupOrphanedImages()` | +| `hackathon-deadline-reminders` | Agenda | Every 1 hour | `sendHackathonDeadlineReminders()` | +| `potd-reminders` | Agenda | Every 1 hour | `sendPOTDReminders()` | + +--- + +## 5. Future Roadmap Notes + +> [!NOTE] +> User profiles under the `CPUser` model and their corresponding `solvedProblems` lists are planned to be updated during the user registration process, which will be implemented later in **Stage 6A**. + From 8f2e187dbb616a3d4d2237ca5f32226003aa18a2 Mon Sep 17 00:00:00 2001 From: rohit-h11 Date: Sun, 21 Jun 2026 12:52:33 +0530 Subject: [PATCH 06/18] seed email changed --- scripts/seed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/seed.ts b/scripts/seed.ts index 605adc0..a1c04b0 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -20,7 +20,7 @@ async function seed() { await mongoose.connect(MONGODB_URI!); const devUser = { name: "Coding Club IITG", - email: "h.rohit@iitg.ac.in", + email: "codingclub@iitg.ac.in", role: "Secretary", moduleRoles: [], emailVerified: true, From 4bb25850e4fb60d9946c0e6f7b61971108430090 Mon Sep 17 00:00:00 2001 From: Rishabh Thakur Date: Sun, 21 Jun 2026 12:51:50 +0530 Subject: [PATCH 07/18] feat(cf-sync): implement Codeforces API adapter and solved history prefetch (Issue #4) --- src/lib/cf-api.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/lib/cf-api.ts 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; + } +} From dc6dce7123657559c8d7bc84f46e3c9301f8b193 Mon Sep 17 00:00:00 2001 From: Rishabh Thakur Date: Sun, 21 Jun 2026 12:53:01 +0530 Subject: [PATCH 08/18] feat(cf-sync): implement Stage 2 CF Sync Engine and Validation Matrix (Issue #5) --- STAGE1_DONE.md | 25 ++++++ src/app/api/contests/sync/route.ts | 71 +++++++++++++++++ src/lib/workers/cfSyncWorker.ts | 124 ++++++++++++++++++++++++++++- 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/app/api/contests/sync/route.ts diff --git a/STAGE1_DONE.md b/STAGE1_DONE.md index dc7018b..320e480 100644 --- a/STAGE1_DONE.md +++ b/STAGE1_DONE.md @@ -172,3 +172,28 @@ Scheduled and tracked in the standalone worker (Agenda / BullMQ): > [!NOTE] > User profiles under the `CPUser` model and their corresponding `solvedProblems` lists are planned to be updated during the user registration process, which will be implemented later in **Stage 6A**. +## 6. Cooldown UI Contract + +The synchronization endpoint `POST /api/contests/sync` enforces a strict 60-second cooldown per user via the Redis key `ratelimit:sync:`. +The server is always authoritative for this rate limit. The frontend client may mirror this 60s countdown in the UI (e.g., disabling the Sync button and showing a timer), but it must gracefully handle HTTP 429 responses if the server-side limit is still active. + +## 7. Internal Event Shape (`sync.detected`) + +When the CF Sync Engine successfully validates an Accepted (AC) submission matching the validation matrix, it emits the internal `sync.detected` event. + +* **Payload Shape:** + ```json + { + "type": "sync.detected", + "roomId": "string", + "userId": "string", + "teamId": "string", + "problemId": "string", + "cfSubmissionId": 123456789, + "cfTimestamp": 1690000000000, + "verdict": "OK", + "pointsAwarded": null + } + ``` +* **Note:** `pointsAwarded` is initially `null`. The Room Engine (Stage 3) consumes this event, assigns the correct score based on time and penalties, and then broadcasts the finalized points to the contest streams. + diff --git a/src/app/api/contests/sync/route.ts b/src/app/api/contests/sync/route.ts new file mode 100644 index 0000000..dfb4240 --- /dev/null +++ b/src/app/api/contests/sync/route.ts @@ -0,0 +1,71 @@ +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(); + 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, 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/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index 6301005..d11be44 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -2,6 +2,14 @@ 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 } from "../sse"; +import dbConnect from "../mongodb"; +import ContestRoom from "../../models/ContestRoom"; +import CustomContest from "../../models/CustomContest"; + +// Optional: cache a pause timer to avoid repeated pausing when circuit breaker trips +let isCircuitBreakerOpen = false; export const cfSyncWorker = new Worker( "cf_sync_queue", @@ -10,6 +18,114 @@ export const cfSyncWorker = new Worker( if (job.name === "nightly-cf-problem-sync") { await syncCodeforcesProblems(); + return; + } + + if (job.name === "cf_sync") { + const { roomId, userId, teamId, cfHandle, problemId } = job.data; + + try { + await dbConnect(); + + // 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; + } + + 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; + } + + 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; + + for (const sub of submissions) { + const subProblemId = `${sub.problem.contestId}${sub.problem.index}`; + const subTimestamp = sub.creationTimeSeconds * 1000; + const subVerdict = sub.verdict; + + // Check if it's the right problem + if (subProblemId.toUpperCase() === problemId.toUpperCase()) { + // 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 + }; + + // Wait, emit internal sync.detected event (consumed by Room engine in Stage 3) + // For now, publish to the user stream as required. + await publishUser(userId, eventPayload); + + // To consume internally, we could publish to a local event emitter or Redis queue. + // The issue says: "emit the internal sync.detected event... Also publish sync.detected to events:user:" + // Assuming Stage 3 will listen on some internal bus or BullMQ. For now, we'll log it. + logger.info(`[cfSyncWorker] Valid AC detected for ${cfHandle} on ${problemId}. emitted sync.detected.`); + } else { + logger.info(`[cfSyncWorker] Validation failed or WA for ${cfHandle} on ${problemId}.`); + await publishUser(userId, { type: "sync.failed", verdict: "WA" }); + } + } catch (error: any) { + logger.error(`[cfSyncWorker] Error processing cf_sync for user ${userId}:`, error.message); + throw error; // Rethrow to trigger BullMQ retries + } } }, { @@ -25,6 +141,12 @@ cfSyncWorker.on("completed", (job) => { logger.info(`[cfSyncWorker] Job ${job.id} completed successfully`); }); -cfSyncWorker.on("failed", (job, err) => { +cfSyncWorker.on("failed", async (job, err) => { 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" }); + } }); From 1312bcb32245b0bcaa2386dd9d2623aea287db9c Mon Sep 17 00:00:00 2001 From: Rishabh Thakur Date: Sun, 21 Jun 2026 12:56:32 +0530 Subject: [PATCH 09/18] fix(cf-sync): add mongoose ObjectId validation for roomId and contestId --- src/lib/workers/cfSyncWorker.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index d11be44..20fb882 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -7,6 +7,7 @@ import { publishUser } from "../sse"; 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; @@ -27,6 +28,12 @@ export const cfSyncWorker = new Worker( 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) { @@ -35,6 +42,12 @@ export const cfSyncWorker = new Worker( 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}.`); From 7cc6d461cd6391f4108bcdc08116c31b8aab04f0 Mon Sep 17 00:00:00 2001 From: Rishabh Thakur Date: Sun, 21 Jun 2026 12:57:17 +0530 Subject: [PATCH 10/18] fix(cf-sync): resolve TypeScript implicit any errors in worker event listeners --- src/lib/workers/cfSyncWorker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index 20fb882..7b3f8a6 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -150,11 +150,11 @@ export const cfSyncWorker = new Worker( } ); -cfSyncWorker.on("completed", (job) => { +cfSyncWorker.on("completed", (job: Job) => { logger.info(`[cfSyncWorker] Job ${job.id} completed successfully`); }); -cfSyncWorker.on("failed", async (job, err) => { +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)) { From cb0eee62f3675e54871df92b7e09b55730f8e9a9 Mon Sep 17 00:00:00 2001 From: Rishabh Thakur Date: Sun, 21 Jun 2026 13:00:33 +0530 Subject: [PATCH 11/18] fix(cf-sync): improve validation matrix edge case handling for missing contestId and false WA verdicts --- src/lib/workers/cfSyncWorker.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index 7b3f8a6..f9f8a52 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -83,14 +83,21 @@ export const cfSyncWorker = new Worker( // 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 subProblemId = `${sub.problem.contestId || ""}${sub.problem.index}`; const subTimestamp = sub.creationTimeSeconds * 1000; - const subVerdict = sub.verdict; + 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() @@ -123,17 +130,14 @@ export const cfSyncWorker = new Worker( pointsAwarded: null, // Stage 3 fills this }; - // Wait, emit internal sync.detected event (consumed by Room engine in Stage 3) - // For now, publish to the user stream as required. await publishUser(userId, eventPayload); - // To consume internally, we could publish to a local event emitter or Redis queue. - // The issue says: "emit the internal sync.detected event... Also publish sync.detected to events:user:" // Assuming Stage 3 will listen on some internal bus or BullMQ. For now, we'll log it. logger.info(`[cfSyncWorker] Valid AC detected for ${cfHandle} on ${problemId}. emitted sync.detected.`); } else { - logger.info(`[cfSyncWorker] Validation failed or WA for ${cfHandle} on ${problemId}.`); - await publishUser(userId, { type: "sync.failed", verdict: "WA" }); + 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); From 70191f752308537cae6ac68f860b35c5299268ef Mon Sep 17 00:00:00 2001 From: Rishabh Thakur Date: Sun, 21 Jun 2026 13:58:16 +0530 Subject: [PATCH 12/18] remove irrelevant doc --- STAGE1_DONE.md | 199 ------------------------------------------------- 1 file changed, 199 deletions(-) delete mode 100644 STAGE1_DONE.md diff --git a/STAGE1_DONE.md b/STAGE1_DONE.md deleted file mode 100644 index 320e480..0000000 --- a/STAGE1_DONE.md +++ /dev/null @@ -1,199 +0,0 @@ -# Stage 1 Documentation: Infrastructure & Event Flow - -This document details the real-time presence infrastructure, background queuing systems, caching patterns, and server-sent event (SSE) specifications implemented in Stage 1. - ---- - -## 1. Redis Key Conventions - -All keys stored in Redis follow strict namespacing rules to prevent collisions and support multiple workers cleanly. - -### Cache-Aside Keys -All cache keys use the global prefix `ccw`. Parameterized cache keys are built deterministically by sorting parameters alphabetically and ignoring `null`/`undefined` values. - -* **Format (Parameterized):** `ccw::=&=...` -* **Format (Simple):** `ccw:` - -#### Default Cache TTLs (`CACHE_TTLS`) -| Key Prefix | Description | TTL (Seconds) | Human Readable | -| :--- | :--- | :---: | :---: | -| `ccw:team` | Team-specific data | 21,600 | 6 hours | -| `ccw:contests` | Contest listings | 10,800 | 3 hours | -| `ccw:cf_problemset` | Codeforces problem cache | 21,600 | 6 hours | -| `ccw:cf_user_info` | Codeforces user info/rating cache | 3,600 | 1 hour | -| `ccw:events` | General events data | 300 | 5 minutes | -| `ccw:projects` | Project listings | 300 | 5 minutes | -| `ccw:leaderboards` | Platform leaderboards | 300 | 5 minutes | -| `ccw:blog` | Blog posts / metadata | 120 | 2 minutes | -| `ccw:files` | Uploaded assets cache | 120 | 2 minutes | -| `ccw:users` | User profile data | 120 | 2 minutes | -| `ccw:potd` | Problem of the Day cache | 120 | 2 minutes | -| `ccw:hackathons` | Hackathon info | 300 | 5 minutes | -| `ccw:hackathon_requests` | Hackathon join requests / invites | 60 | 1 minute | - ---- - -### Real-Time Presence Keys -Presence tracking utilizes the Redis keyspace events listener to auto-forfeit inactive contest participants. - -* **Online State Key:** `room::presence:` - * **Value:** `"online"` - * **TTL:** Persistent during active SSE connection. Changes to a **90-second TTL** on SSE client disconnection. -* **Expiration Lock Key:** `room::presence::expire_lock` - * **Value:** `"1"` - * **TTL:** 10 seconds. - * **Purpose:** Prevents duplicate auto-forfeit processing across multiple concurrent worker instances when the presence key expires. -* **Offline Sent Key:** `room::presence::offline_sent` - * **Value:** `"1"` - * **TTL:** 120 seconds. - * **Purpose:** Built as a helper flag to prevent publishing duplicate offline notifications when a user drops off-stream (published once immediately on SSE disconnect, and checked before repeating during a keyspace expire event). - ---- - -### Programmatic Redis Policies -During initialization, the client enforces the following configurations: -1. `maxmemory-policy` is set to `noeviction` to ensure background queues and locks are never discarded due to memory constraints. -2. `notify-keyspace-events` is set to `KEA` (Keyspace, Keyevent, All) to enable the expired key channel (`__keyevent@*__:expired`) subscription. - ---- - -## 2. BullMQ Queue Names - -Background tasks are managed via BullMQ queues connected to Redis: - -### `cf_sync_queue` -* **Purpose:** Orchestrates Codeforces problem ingestion and ingestion synchronization. -* **Job Name:** `nightly-cf-problem-sync` (runs nightly at 2:00 AM, or triggers instantly on startup if the database is empty). -* **Worker Limiter:** Restricts execution to a maximum of 2 jobs per second (`limiter: { max: 2, duration: 1000 }`). -* **Default Job Options:** - ```javascript - { - attempts: 3, - backoff: { - type: "exponential", - delay: 5000 - } - } - ``` - -### `reconciliation_queue` -* **Purpose:** Reconciles active contest and room state logic. -* **Default Job Options:** - ```javascript - { - attempts: 3, - backoff: { - type: "exponential", - delay: 2000 - } - } - ``` - ---- - -## 3. SSE Channel Conventions - -The Server-Sent Events (SSE) gateway dynamically subscribes connected clients to Redis Pub/Sub channels based on active room memberships: - -1. **User Channel:** `events:user:${userId}` - * Private user-targeted events (e.g. notifications, private status). -2. **Room Channel:** `events:room:${roomId}` - * Shared room-scoped events (e.g. participant presence, round starting). -3. **Contest Channel:** `events:contest:${contestId}` - * Contest-wide updates (e.g. real-time leaderboard or standings updates). - ---- - -## 4. Event Name Catalogue - -### Gateway Events (SSE Format) -When client-side listeners establish a `/api/events` connection, the following event streams are handled: - -* **Event Type:** `connected` - * Sent immediately on connection. - * **Payload:** - ```json - { - "userId": "65ab...", - "subscribedChannels": [ - "events:user:65ab...", - "events:room:76bc...", - "events:contest:87cd..." - ] - } - ``` -* **Event Type:** `message` - * Forwarded dynamically from active channel subscriptions. - * **Payload:** - ```json - { - "channel": "events:room:76bc...", - "payload": { - "type": "presence.online", - "userId": "65ab..." - } - } - ``` - ---- - -### Presence Broadcast Events -Published on `events:room:${roomId}` to broadcast participant availability: - -* `presence.online`: Sent when the user starts an SSE stream or joins. - ```json - { "type": "presence.online", "userId": "string" } - ``` -* `presence.offline`: Sent immediately upon clean SSE disconnect, or triggered via keyspace listener upon the 90-second presence key expiration. - ```json - { "type": "presence.offline", "userId": "string" } - ``` - ---- - -### Background / Cron Job Catalog -Scheduled and tracked in the standalone worker (Agenda / BullMQ): - -| Job Name | Engine | Schedule | Target/Function | -| :--- | :---: | :--- | :--- | -| `nightly-cf-problem-sync` | BullMQ | `0 2 * * *` (Daily 2am) | `syncCodeforcesProblems()` | -| `sync-cf-ratings` | Agenda | Every 6 hours | `syncCodeforcesRatings()` | -| `sync-ac-ratings` | Agenda | Every 6 hours | `syncAtCoderRatings()` | -| `sync-potd-submissions` | Agenda | Daily at 2:05 AM IST | `syncPOTDSubmissions()` | -| `sync-contests` | Agenda | Every 3 hours | `syncContests()` | -| `cleanup-images` | Agenda | Weekly, Sun 3:00 AM IST | `cleanupOrphanedImages()` | -| `hackathon-deadline-reminders` | Agenda | Every 1 hour | `sendHackathonDeadlineReminders()` | -| `potd-reminders` | Agenda | Every 1 hour | `sendPOTDReminders()` | - ---- - -## 5. Future Roadmap Notes - -> [!NOTE] -> User profiles under the `CPUser` model and their corresponding `solvedProblems` lists are planned to be updated during the user registration process, which will be implemented later in **Stage 6A**. - -## 6. Cooldown UI Contract - -The synchronization endpoint `POST /api/contests/sync` enforces a strict 60-second cooldown per user via the Redis key `ratelimit:sync:`. -The server is always authoritative for this rate limit. The frontend client may mirror this 60s countdown in the UI (e.g., disabling the Sync button and showing a timer), but it must gracefully handle HTTP 429 responses if the server-side limit is still active. - -## 7. Internal Event Shape (`sync.detected`) - -When the CF Sync Engine successfully validates an Accepted (AC) submission matching the validation matrix, it emits the internal `sync.detected` event. - -* **Payload Shape:** - ```json - { - "type": "sync.detected", - "roomId": "string", - "userId": "string", - "teamId": "string", - "problemId": "string", - "cfSubmissionId": 123456789, - "cfTimestamp": 1690000000000, - "verdict": "OK", - "pointsAwarded": null - } - ``` -* **Note:** `pointsAwarded` is initially `null`. The Room Engine (Stage 3) consumes this event, assigns the correct score based on time and penalties, and then broadcasts the finalized points to the contest streams. - From 2dc9a81b01c7c7ded49b03b2834fd7b1e238dfdc Mon Sep 17 00:00:00 2001 From: ronits2407 Date: Sun, 21 Jun 2026 16:43:49 +0530 Subject: [PATCH 13/18] feat(Blitz Room Engine): implimented Blitz Room Engine --- scripts/seed.ts | 4 +- .../api/contests/rooms/[id]/ready/route.ts | 108 +++++++++++++ src/app/api/contests/rooms/route.ts | 147 ++++++++++++++++++ src/lib/presenceListener.ts | 33 +++- src/lib/workers/cfSyncWorker.ts | 86 +++++++++- src/lib/workers/reconciliationWorker.ts | 92 ++++++++++- 6 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 src/app/api/contests/rooms/[id]/ready/route.ts create mode 100644 src/app/api/contests/rooms/route.ts diff --git a/scripts/seed.ts b/scripts/seed.ts index a1c04b0..ac831cd 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -19,8 +19,8 @@ const User = mongoose.models.User || mongoose.model("User", UserSchema); async function seed() { await mongoose.connect(MONGODB_URI!); const devUser = { - name: "Coding Club IITG", - email: "codingclub@iitg.ac.in", + name: "Ronit Sonawane", + email: "k.sonawane@iitg.ac.in", role: "Secretary", moduleRoles: [], emailVerified: true, 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..c8305f1 --- /dev/null +++ b/src/app/api/contests/rooms/[id]/ready/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { getRedis } from "@/lib/redis"; +import ContestRoom from "@/models/ContestRoom"; +import dbConnect from "@/lib/mongodb"; +import { publishRoom } from "@/lib/sse"; +import { reconciliationQueue } from "@/lib/bullmq"; + +export async function POST(req: NextRequest, { params }: { params: { 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 }); + } + + // Use a Redis set to track unique users who are ready + const readyAdded = await redis.sAdd(`room:${roomId}:ready_users`, userId); + + if (readyAdded) { + const readyCount = await redis.sCard(`room:${roomId}:ready_users`); + await redis.hSet(`room:${roomId}:state`, { readyCount }); + + // Assuming 1v1 for now (2 participants total) + // For teams, we might check room.participants.length + const totalParticipants = room.participants.length; + + if (readyCount === totalParticipants) { + // Room start (Task 4) + const now = Date.now(); + await redis.hSet(`room:${roomId}:state`, { + status: "active", + startTime: now.toString() + }); + + // Reveal problem[0] + const problemsRaw = await redis.lRange(`room:${roomId}:problems`, 0, -1); + 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 teams = await redis.sMembers(`room:${roomId}:teams`); + 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}` } + ); + } + } + + 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/route.ts b/src/app/api/contests/rooms/route.ts new file mode 100644 index 0000000..17c5524 --- /dev/null +++ b/src/app/api/contests/rooms/route.ts @@ -0,0 +1,147 @@ +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 }); + } + + 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, + problems: availableProblems.map(p => ({ + platform: "codeforces", + problemId: p.problemId, + name: p.name, + rating: p.rating, + points: 100 + })) + }); + + // Create teams in MongoDB + const createdTeams = []; + for (const t of teams) { + const team = new ContestTeam({ + roomId: room._id, + name: t.name, + members: t.members, + 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 + await redis.hSet(`room:${roomId}:state`, { + status: "waiting", + type: "blitz", + currentProblem: 0, + startTime: "", + timeLimit: contest.durationSeconds, + contestId: contestId.toString(), + readyCount: 0 + }); + + // Write room::teams Set + await redis.sAdd(`room:${roomId}:teams`, [createdTeams[0]._id.toString(), createdTeams[1]._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/lib/presenceListener.ts b/src/lib/presenceListener.ts index a93e1b1..8fafe86 100644 --- a/src/lib/presenceListener.ts +++ b/src/lib/presenceListener.ts @@ -1,6 +1,7 @@ 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..."); @@ -29,8 +30,36 @@ export async function startPresenceKeyspaceListener() { logger.info(`[PresenceListener] Presence expired for user ${userId} in room ${roomId}.`); try { - // Trigger auto-forfeit stub (log only for now) - logger.info(`[PresenceListener] AUTO-FORFEIT STUB: User ${userId} auto-forfeited in room ${roomId} due to inactivity.`); + // 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`; diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index f9f8a52..e67a3e0 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -3,7 +3,9 @@ import { connection } from "../bullmq"; import { logger } from "../utils"; import { syncCodeforcesProblems } from "../jobs/cfProblemSync"; import { fetchCodeforcesUserStatus } from "../cf-api"; -import { publishUser } from "../sse"; +import { publishUser, publishRoom } from "../sse"; +import { getRedis } from "../redis"; +import { reconciliationQueue } from "../bullmq"; import dbConnect from "../mongodb"; import ContestRoom from "../../models/ContestRoom"; import CustomContest from "../../models/CustomContest"; @@ -130,10 +132,90 @@ export const cfSyncWorker = new Worker( 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 currentProblemIndex = parseInt(state.currentProblem || "0", 10); + const problemsRaw = await redis.lRange(`room:${roomId}:problems`, 0, -1); + const problems = problemsRaw.map(p => JSON.parse(p)); + 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 + }; + 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); - // Assuming Stage 3 will listen on some internal bus or BullMQ. For now, we'll log it. 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}`); diff --git a/src/lib/workers/reconciliationWorker.ts b/src/lib/workers/reconciliationWorker.ts index e7b8348..2b7ebab 100644 --- a/src/lib/workers/reconciliationWorker.ts +++ b/src/lib/workers/reconciliationWorker.ts @@ -1,12 +1,102 @@ 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 ContestProblemSet from "../../models/ContestProblemSet"; +import ContestTeam from "../../models/ContestTeam"; +import ContestSubmission from "../../models/ContestSubmission"; // Make sure to create this model if not exists export const reconciliationWorker = new Worker( "reconciliation_queue", async (job: Job) => { logger.info(`[reconciliationWorker] Processing job ${job.id} (name: ${job.name})`, job.data); - // Full logic in later stages + const { roomId, contestId, trigger, forfeitedUserId } = job.data; + const redis = await getRedis(); + await dbConnect(); + + // 1. Determine winner + 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(); + } + + // 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, + cfSubmissionId: data.cfSubmissionId, + verdict: data.verdict, + points: data.points, + solveMs: data.solveMs + }); + await submission.save(); + } + + // 4. Finalise ContestProblemSet (e.g. tracking who solved what) - stubbed for now if schema doesn't fully support + + // 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, From eaae74518fee56135903d2565794bf5946dc5d8c Mon Sep 17 00:00:00 2001 From: ronits2407 Date: Sun, 21 Jun 2026 16:51:59 +0530 Subject: [PATCH 14/18] fix(Blitz Room): update seeded user email --- scripts/seed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/seed.ts b/scripts/seed.ts index ac831cd..a1c04b0 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -19,8 +19,8 @@ const User = mongoose.models.User || mongoose.model("User", UserSchema); async function seed() { await mongoose.connect(MONGODB_URI!); const devUser = { - name: "Ronit Sonawane", - email: "k.sonawane@iitg.ac.in", + name: "Coding Club IITG", + email: "codingclub@iitg.ac.in", role: "Secretary", moduleRoles: [], emailVerified: true, From a942abf0de5eaa2e4bb2074065f67d35fef77875 Mon Sep 17 00:00:00 2001 From: Aditya Kumar <125749598+UniveronAditya@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:47:35 +0530 Subject: [PATCH 15/18] feat: Arena Room mechanics --- package.json | 1 + .../api/contests/rooms/[id]/ready/route.ts | 20 +- src/app/api/contests/rooms/route.ts | 13 +- src/lib/auth.ts | 4 +- src/lib/redis.ts | 40 +++- src/lib/workers/cfSyncWorker.ts | 207 ++++++++++++------ src/lib/workers/reconciliationWorker.ts | 13 ++ 7 files changed, 214 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index abc95df..07aee94 100644 --- a/package.json +++ b/package.json @@ -50,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/src/app/api/contests/rooms/[id]/ready/route.ts b/src/app/api/contests/rooms/[id]/ready/route.ts index c8305f1..97e284d 100644 --- a/src/app/api/contests/rooms/[id]/ready/route.ts +++ b/src/app/api/contests/rooms/[id]/ready/route.ts @@ -6,7 +6,7 @@ import dbConnect from "@/lib/mongodb"; import { publishRoom } from "@/lib/sse"; import { reconciliationQueue } from "@/lib/bullmq"; -export async function POST(req: NextRequest, { params }: { params: { id: string } }) { +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { let userId = ""; @@ -59,12 +59,20 @@ export async function POST(req: NextRequest, { params }: { params: { id: string startTime: now.toString() }); - // Reveal problem[0] + // Reveal problem(s) based on mode const problemsRaw = await redis.lRange(`room:${roomId}:problems`, 0, -1); - if (problemsRaw.length > 0) { - const firstProblem = JSON.parse(problemsRaw[0]); - firstProblem.revealedAt = now; - await redis.lSet(`room:${roomId}:problems`, 0, JSON.stringify(firstProblem)); + 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"; diff --git a/src/app/api/contests/rooms/route.ts b/src/app/api/contests/rooms/route.ts index 17c5524..b019f82 100644 --- a/src/app/api/contests/rooms/route.ts +++ b/src/app/api/contests/rooms/route.ts @@ -116,15 +116,18 @@ export async function POST(req: NextRequest) { } // Set room::state Hash - await redis.hSet(`room:${roomId}:state`, { + const stateObj: any = { status: "waiting", - type: "blitz", - currentProblem: 0, + type: contest.mode || "blitz", startTime: "", - timeLimit: contest.durationSeconds, + 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[0]._id.toString(), createdTeams[1]._id.toString()]); 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/redis.ts b/src/lib/redis.ts index 10fe490..fc31e66 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -5,12 +5,44 @@ 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() @@ -21,7 +53,7 @@ export async function getRedis(): Promise { } catch (configErr) { logger.warn("Failed to set Redis configurations programmatically:", configErr); } - return redisClient as RedisClientType; + return redisClient; }) .catch((err) => { connectPromise = null; diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index e67a3e0..563dbb2 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -4,7 +4,7 @@ import { logger } from "../utils"; import { syncCodeforcesProblems } from "../jobs/cfProblemSync"; import { fetchCodeforcesUserStatus } from "../cf-api"; import { publishUser, publishRoom } from "../sse"; -import { getRedis } from "../redis"; +import { getRedis, claimProblem } from "../redis"; import { reconciliationQueue } from "../bullmq"; import dbConnect from "../mongodb"; import ContestRoom from "../../models/ContestRoom"; @@ -137,77 +137,150 @@ export const cfSyncWorker = new Worker( let isAdvanceTriggered = false; if (state && state.status === "active") { - const currentProblemIndex = parseInt(state.currentProblem || "0", 10); const problemsRaw = await redis.lRange(`room:${roomId}:problems`, 0, -1); const problems = problemsRaw.map(p => JSON.parse(p)); - 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 - }; - 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; + + 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 + }; + 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 }); - 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; + const submissionObj = { + userId, + teamId, + problemId, + cfSubmissionId: matchedSubmission.id, + verdict: "OK", + points, + solveMs + }; + 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 publishRoom(roomId, { type: "room.score", scores }); } } } diff --git a/src/lib/workers/reconciliationWorker.ts b/src/lib/workers/reconciliationWorker.ts index 2b7ebab..c9720b3 100644 --- a/src/lib/workers/reconciliationWorker.ts +++ b/src/lib/workers/reconciliationWorker.ts @@ -4,6 +4,7 @@ import { logger } from "../utils"; import { getRedis } from "../redis"; import dbConnect from "../mongodb"; import ContestRoom from "../../models/ContestRoom"; +import { publishRoom } from "../sse"; import ContestProblemSet from "../../models/ContestProblemSet"; import ContestTeam from "../../models/ContestTeam"; import ContestSubmission from "../../models/ContestSubmission"; // Make sure to create this model if not exists @@ -90,6 +91,18 @@ export const reconciliationWorker = new Worker( // 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) { From fd81a7dace442c4c3b6998d892aa290e39637994 Mon Sep 17 00:00:00 2001 From: Sani Date: Tue, 23 Jun 2026 18:29:44 +0530 Subject: [PATCH 16/18] feat: Implement team multi-user logic for 3v3 contest matches --- scripts/seed.ts | 142 ++++++++++++++++-- .../api/contests/rooms/[id]/ready/route.ts | 62 ++++++-- src/app/api/contests/rooms/route.ts | 22 ++- src/app/api/contests/sync/route.ts | 19 ++- src/lib/workers/cfSyncWorker.ts | 15 +- src/lib/workers/reconciliationWorker.ts | 67 ++++++++- src/models/ContestProblemSet.ts | 8 +- src/models/ContestSubmission.ts | 6 + src/models/ContestTeam.ts | 6 + 9 files changed, 314 insertions(+), 33 deletions(-) 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/api/contests/rooms/[id]/ready/route.ts b/src/app/api/contests/rooms/[id]/ready/route.ts index 97e284d..48cf977 100644 --- a/src/app/api/contests/rooms/[id]/ready/route.ts +++ b/src/app/api/contests/rooms/[id]/ready/route.ts @@ -2,9 +2,11 @@ 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 { @@ -40,19 +42,47 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Room is not waiting" }, { status: 400 }); } - // Use a Redis set to track unique users who are ready + // 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) { - const readyCount = await redis.sCard(`room:${roomId}:ready_users`); - await redis.hSet(`room:${roomId}:state`, { readyCount }); + // 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); + } + } - // Assuming 1v1 for now (2 participants total) - // For teams, we might check room.participants.length - const totalParticipants = room.participants.length; + 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); + } - if (readyCount === totalParticipants) { - // Room start (Task 4) + // 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", @@ -82,7 +112,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const updatedProblems = await redis.lRange(`room:${roomId}:problems`, 0, -1); // Fetch scores - const teams = await redis.sMembers(`room:${roomId}:teams`); const scores: Record = {}; for (const tId of teams) { const score = await redis.zScore(`room:${roomId}:scores`, tId); @@ -103,8 +132,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: await reconciliationQueue.add( "room_timeout", { roomId, contestId: state.contestId, trigger: "timeout" }, - { delay: timeLimitSecs * 1000, jobId: `timeout:${roomId}` } + { 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}` } + ); + } } } diff --git a/src/app/api/contests/rooms/route.ts b/src/app/api/contests/rooms/route.ts index b019f82..bfffe4e 100644 --- a/src/app/api/contests/rooms/route.ts +++ b/src/app/api/contests/rooms/route.ts @@ -14,10 +14,25 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { contestId, teams } = body; - if (!contestId || !teams || !Array.isArray(teams) || teams.length !== 2) { + 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) { @@ -73,6 +88,7 @@ export async function POST(req: NextRequest) { // Write stub ContestProblemSet const problemSet = new ContestProblemSet({ contestId: contest._id, + roomId: room._id, problems: availableProblems.map(p => ({ platform: "codeforces", problemId: p.problemId, @@ -83,12 +99,14 @@ export async function POST(req: NextRequest) { }); // 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(); @@ -130,7 +148,7 @@ export async function POST(req: NextRequest) { await redis.hSet(`room:${roomId}:state`, stateObj); // Write room::teams Set - await redis.sAdd(`room:${roomId}:teams`, [createdTeams[0]._id.toString(), createdTeams[1]._id.toString()]); + await redis.sAdd(`room:${roomId}:teams`, createdTeams.map(t => t._id.toString())); // Write team::meta and team::users for (const t of createdTeams) { diff --git a/src/app/api/contests/sync/route.ts b/src/app/api/contests/sync/route.ts index dfb4240..ba1325c 100644 --- a/src/app/api/contests/sync/route.ts +++ b/src/app/api/contests/sync/route.ts @@ -27,6 +27,23 @@ export async function POST(request: NextRequest) { } 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 @@ -39,7 +56,7 @@ export async function POST(request: NextRequest) { await redis.set(rateLimitKey, "1", { EX: 60 }); // 3. Enqueue job - const jobData = { roomId, userId, teamId, cfHandle, problemId }; + const jobData = { roomId, userId, teamId: resolvedTeamId, cfHandle, problemId }; const job = await cfSyncQueue.add("cf_sync", jobData); // Approximate position diff --git a/src/lib/workers/cfSyncWorker.ts b/src/lib/workers/cfSyncWorker.ts index 563dbb2..96a83eb 100644 --- a/src/lib/workers/cfSyncWorker.ts +++ b/src/lib/workers/cfSyncWorker.ts @@ -57,6 +57,15 @@ export const cfSyncWorker = new Worker( 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; @@ -172,7 +181,8 @@ export const cfSyncWorker = new Worker( cfSubmissionId: matchedSubmission.id, verdict: "OK", points, - solveMs + solveMs, + cfTimestamp }; await redis.xAdd(`room:${roomId}:submissions`, "*", { data: JSON.stringify(submissionObj) }); @@ -230,7 +240,8 @@ export const cfSyncWorker = new Worker( cfSubmissionId: matchedSubmission.id, verdict: "OK", points, - solveMs + solveMs, + cfTimestamp }; await redis.xAdd(`room:${roomId}:submissions`, "*", { data: JSON.stringify(submissionObj) }); diff --git a/src/lib/workers/reconciliationWorker.ts b/src/lib/workers/reconciliationWorker.ts index c9720b3..b09addd 100644 --- a/src/lib/workers/reconciliationWorker.ts +++ b/src/lib/workers/reconciliationWorker.ts @@ -13,11 +13,68 @@ 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 } = job.data; + const { roomId, contestId, trigger, forfeitedUserId, teamId } = job.data; const redis = await getRedis(); await dbConnect(); - // 1. Determine winner + // 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; @@ -81,10 +138,12 @@ export const reconciliationWorker = new Worker( userId: data.userId, teamId: data.teamId, problemId: data.problemId, - cfSubmissionId: data.cfSubmissionId, + platform: "codeforces", + submissionId: data.cfSubmissionId, verdict: data.verdict, points: data.points, - solveMs: data.solveMs + solveMs: data.solveMs, + submittedAt: new Date(data.cfTimestamp || Date.now()) }); await submission.save(); } diff --git a/src/models/ContestProblemSet.ts b/src/models/ContestProblemSet.ts index 11d3fa1..cf1a697 100644 --- a/src/models/ContestProblemSet.ts +++ b/src/models/ContestProblemSet.ts @@ -11,6 +11,7 @@ export interface ISelectedProblem { export interface IContestProblemSet extends Document { contestId: mongoose.Types.ObjectId; + roomId?: mongoose.Types.ObjectId; problems: ISelectedProblem[]; createdAt: Date; updatedAt: Date; @@ -31,9 +32,14 @@ const ContestProblemSetSchema = new Schema( type: Schema.Types.ObjectId, ref: "CustomContest", required: true, - unique: true, index: true, }, + roomId: { + type: Schema.Types.ObjectId, + ref: "ContestRoom", + index: true, + sparse: true, + }, problems: [SelectedProblemSchema], }, { timestamps: true } diff --git a/src/models/ContestSubmission.ts b/src/models/ContestSubmission.ts index 589c3e2..8ab1976 100644 --- a/src/models/ContestSubmission.ts +++ b/src/models/ContestSubmission.ts @@ -4,10 +4,13 @@ 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; @@ -18,10 +21,13 @@ 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 } diff --git a/src/models/ContestTeam.ts b/src/models/ContestTeam.ts index 04d21ca..d765168 100644 --- a/src/models/ContestTeam.ts +++ b/src/models/ContestTeam.ts @@ -4,7 +4,10 @@ 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; } @@ -14,7 +17,10 @@ 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 } ); From d16d82af4cec3ea6fa309731edd92cf4897a5156 Mon Sep 17 00:00:00 2001 From: Ayan-Bain Date: Wed, 24 Jun 2026 17:32:58 +0530 Subject: [PATCH 17/18] feat(contests): implement knockout tournament data layer, admin creation wizard (Stage 6A) - Update Mongoose models (CustomContest, ContestPreset, ContestRoom, ContestRound, ContestStanding) to support bracket formats, description, team size constraints, and status states (draft, registration, active, completed). - Create administrative APIs for contest presets (/api/contests/presets) and specific preset interactions including retrieval, updates, and archival. - Build premium dark-themed preset management view (/admin/contests/presets) with functional modals for CRUD and archiving. - Implement multi-step Tournament Creation Wizard (/admin/contests/new) with step validation server actions and a live progress tracker. - Design Solo/Team registration API endpoint (/api/contests/[id]/register) that performs validation checks and triggers background solved history prefetching. - Create status transition API endpoint (/api/contests/[id]/status) to manage the tournament state chain and emit updates via SSE. - Setup a prefetch worker job handler in cfSyncWorker.ts to resolve Codeforces solved lists in the background. - Integrate preset management and tournament wizard cards directly onto the Admin dashboard home page (/admin). - Add automated verification test script (scripts/test-stage6a.ts) covering the test plan gates. - Resolve Next.js 15+ dynamic route signature compatibility and parameter types. --- .../(protected)/admin/contests/new/page.tsx | 24 ++ .../admin/contests/presets/page.tsx | 25 ++ .../contests/presets/presets.module.scss | 10 + src/app/(protected)/admin/page.tsx | 10 + src/app/api/contests/[id]/register/route.ts | 154 ++++++++ src/app/api/contests/[id]/status/route.ts | 70 ++++ src/app/api/contests/presets/[id]/route.ts | 102 +++++ src/app/api/contests/presets/route.ts | 61 +++ .../admin/contests/ContestWizard.module.scss | 127 ++++++ .../admin/contests/ContestWizard.tsx | 178 +++++++++ .../admin/contests/PresetManager.module.scss | 295 ++++++++++++++ .../admin/contests/PresetManager.tsx | 372 ++++++++++++++++++ .../admin/contests/steps/Step1BasicInfo.tsx | 143 +++++++ .../contests/steps/Step2Registration.tsx | 92 +++++ .../admin/contests/steps/Step3MatchPreset.tsx | 126 ++++++ .../contests/steps/Step4BracketSettings.tsx | 77 ++++ .../admin/contests/steps/Step5Preview.tsx | 85 ++++ src/lib/actions/admin/contests.ts | 137 +++++++ src/lib/workers/cfSyncWorker.ts | 7 + src/models/ContestPreset.ts | 2 + src/models/ContestRoom.ts | 2 + src/models/ContestRound.ts | 2 + src/models/ContestStanding.ts | 8 + src/models/CustomContest.ts | 67 +++- 24 files changed, 2168 insertions(+), 8 deletions(-) create mode 100644 src/app/(protected)/admin/contests/new/page.tsx create mode 100644 src/app/(protected)/admin/contests/presets/page.tsx create mode 100644 src/app/(protected)/admin/contests/presets/presets.module.scss create mode 100644 src/app/api/contests/[id]/register/route.ts create mode 100644 src/app/api/contests/[id]/status/route.ts create mode 100644 src/app/api/contests/presets/[id]/route.ts create mode 100644 src/app/api/contests/presets/route.ts create mode 100644 src/components/admin/contests/ContestWizard.module.scss create mode 100644 src/components/admin/contests/ContestWizard.tsx create mode 100644 src/components/admin/contests/PresetManager.module.scss create mode 100644 src/components/admin/contests/PresetManager.tsx create mode 100644 src/components/admin/contests/steps/Step1BasicInfo.tsx create mode 100644 src/components/admin/contests/steps/Step2Registration.tsx create mode 100644 src/components/admin/contests/steps/Step3MatchPreset.tsx create mode 100644 src/components/admin/contests/steps/Step4BracketSettings.tsx create mode 100644 src/components/admin/contests/steps/Step5Preview.tsx create mode 100644 src/lib/actions/admin/contests.ts diff --git a/src/app/(protected)/admin/contests/new/page.tsx b/src/app/(protected)/admin/contests/new/page.tsx new file mode 100644 index 0000000..2db0f83 --- /dev/null +++ b/src/app/(protected)/admin/contests/new/page.tsx @@ -0,0 +1,24 @@ +import dbConnect from "@/lib/mongodb"; +import ContestPreset from "@/models/ContestPreset"; +import ContestWizard from "@/components/admin/contests/ContestWizard"; + +export const metadata = { + title: "CCW Admin - New Tournament", + description: "Create a new knockout tournament", +}; + +export default async function NewContestPage() { + await dbConnect(); + // Fetch active (non-archived) presets + const presetsJson = await ContestPreset.find({ archived: { $ne: true } }) + .sort({ name: 1 }) + .lean(); + + const presets = JSON.parse(JSON.stringify(presetsJson)); + + return ( +
+ +
+ ); +} diff --git a/src/app/(protected)/admin/contests/presets/page.tsx b/src/app/(protected)/admin/contests/presets/page.tsx new file mode 100644 index 0000000..794c390 --- /dev/null +++ b/src/app/(protected)/admin/contests/presets/page.tsx @@ -0,0 +1,25 @@ +import dbConnect from "@/lib/mongodb"; +import ContestPreset from "@/models/ContestPreset"; +import PresetManager from "@/components/admin/contests/PresetManager"; +import styles from "./presets.module.scss"; + +export const metadata = { + title: "CCW Admin - Contest Presets", + description: "Manage contest presets", +}; + +export default async function PresetsPage() { + await dbConnect(); + // Fetch initial presets server-side + const presetsJson = await ContestPreset.find().sort({ name: 1 }).lean(); + + // Serialize Mongo _id and Dates + const presets = JSON.parse(JSON.stringify(presetsJson)); + + return ( +
+

Contest Presets

+ +
+ ); +} diff --git a/src/app/(protected)/admin/contests/presets/presets.module.scss b/src/app/(protected)/admin/contests/presets/presets.module.scss new file mode 100644 index 0000000..7f245b2 --- /dev/null +++ b/src/app/(protected)/admin/contests/presets/presets.module.scss @@ -0,0 +1,10 @@ +.pageContainer { + padding: 1.5rem; +} + +.title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.5rem; + color: var(--foreground-strong); +} diff --git a/src/app/(protected)/admin/page.tsx b/src/app/(protected)/admin/page.tsx index cd5656e..2e15507 100644 --- a/src/app/(protected)/admin/page.tsx +++ b/src/app/(protected)/admin/page.tsx @@ -40,6 +40,16 @@ export default async function AdminPage() { title="Send Notifications" description="Broadcast announcements to all members or specific modules." /> + + ); diff --git a/src/app/api/contests/[id]/register/route.ts b/src/app/api/contests/[id]/register/route.ts new file mode 100644 index 0000000..772efea --- /dev/null +++ b/src/app/api/contests/[id]/register/route.ts @@ -0,0 +1,154 @@ +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 + const updatedRegs = [...registrations]; + for (const u of cpUsers) { + updatedRegs.push({ + userId: u.userId, + cfHandle: u.cfHandle, + 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/components/admin/contests/ContestWizard.module.scss b/src/components/admin/contests/ContestWizard.module.scss new file mode 100644 index 0000000..90200b4 --- /dev/null +++ b/src/components/admin/contests/ContestWizard.module.scss @@ -0,0 +1,127 @@ +@use "@/styles/mixins" as *; + +.wizardContainer { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +.wizardTitle { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 2rem; + color: var(--foreground-strong); + text-align: center; +} + +.progressTracker { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 3rem; + padding: 0 1rem; +} + +.step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + position: relative; + z-index: 1; + + .circle { + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--surface-secondary); + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: 600; + color: var(--muted); + transition: all 0.3s ease; + } + + .label { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + transition: all 0.3s ease; + white-space: nowrap; + position: absolute; + top: 2.5rem; + } + + &.active { + .circle { + background: var(--primary); + border-color: var(--primary); + color: var(--fg-on-emphasis); + box-shadow: 0 0 0 4px var(--focus-ring); + } + .label { + color: var(--foreground-strong); + } + } + + &.completed { + .circle { + background: #2ecc71; + border-color: #2ecc71; + color: var(--fg-on-emphasis); + } + .label { + color: #2ecc71; + } + } +} + +.line { + flex: 1; + height: 2px; + background: var(--border); + margin: 0 -0.5rem; // pull the line in so it connects to the circles + // transform: translateY(-0rem); // 1rem is exactly half of the 2rem circle height, aligning the line directly in the center + transition: all 0.3s ease; + + &.completedLine { + background: #2ecc71; + } +} + +.stepContent { + background: var(--surface-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + min-height: 350px; + margin-bottom: 2rem; +} + +.wizardControls { + display: flex; + gap: 1rem; +} + +.backButton { + @include btn-secondary; + padding: 0.625rem 1.25rem; + font-weight: 600; +} + +.nextButton, +.createButton { + @include btn-primary; + padding: 0.625rem 1.25rem; + font-weight: 600; +} + +.createButton { + background: #2e7d32; + &:hover { + background: #1b5e20; + } +} diff --git a/src/components/admin/contests/ContestWizard.tsx b/src/components/admin/contests/ContestWizard.tsx new file mode 100644 index 0000000..fad5f09 --- /dev/null +++ b/src/components/admin/contests/ContestWizard.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { validateStep, createBracketContest } from "@/lib/actions/admin/contests"; +import Step1BasicInfo from "./steps/Step1BasicInfo"; +import Step2Registration from "./steps/Step2Registration"; +import Step3MatchPreset from "./steps/Step3MatchPreset"; +import Step4BracketSettings from "./steps/Step4BracketSettings"; +import Step5Preview from "./steps/Step5Preview"; +import styles from "./ContestWizard.module.scss"; + +interface ContestWizardProps { + presets: any[]; +} + +export default function ContestWizard({ presets }: ContestWizardProps) { + const router = useRouter(); + const [currentStep, setCurrentStep] = useState(1); + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [formData, setFormData] = useState({ + name: "", + description: "", + mode: "blitz", + teamSize: 1, + registrationType: "open", + deadline: "", + maxParticipants: 8, + presetId: "", + thirdPlacePlayoff: false, + seedingMethod: "cf_rating", + }); + + const steps = [ + { number: 1, title: "Basic Info" }, + { number: 2, title: "Registration" }, + { number: 3, title: "Match Preset" }, + { number: 4, title: "Bracket Settings" }, + { number: 5, title: "Preview" }, + ]; + + function updateFields(fields: Partial) { + setFormData((prev) => ({ ...prev, ...fields })); + // Clear errors for fields as they are edited + const updatedErrors = { ...errors }; + Object.keys(fields).forEach((key) => { + delete updatedErrors[key]; + }); + setErrors(updatedErrors); + } + + async function handleNext() { + setIsSubmitting(true); + try { + const result = await validateStep(currentStep, formData); + if (!result.valid) { + setErrors(result.errors); + } else { + setErrors({}); + setCurrentStep((prev) => prev + 1); + } + } catch (err: any) { + alert(err.message || "Validation failed"); + } finally { + setIsSubmitting(false); + } + } + + function handleBack() { + if (currentStep > 1) { + setCurrentStep((prev) => prev - 1); + setErrors({}); + } + } + + async function handleCreate() { + setIsSubmitting(true); + try { + const result = await createBracketContest(formData); + if ("error" in result) { + alert(result.error); + } else { + alert("Contest created successfully!"); + router.push(`/admin`); + } + } catch (err: any) { + alert(err.message || "Failed to create contest"); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+

Create Bracket Tournament

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

{preset.description}

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

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

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