From 0934804969c0d1fc61571a3bfe5715c466027894 Mon Sep 17 00:00:00 2001 From: Ewerton Onga Date: Sat, 18 Jan 2025 14:52:37 -0300 Subject: [PATCH 1/3] adding modules progress for app navigation --- src/controllers/progress.ts | 16 ++++++++++++++- tests/progress.test.ts | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/controllers/progress.ts b/src/controllers/progress.ts index 4fea70a..7148fb4 100644 --- a/src/controllers/progress.ts +++ b/src/controllers/progress.ts @@ -107,13 +107,27 @@ export const getCourseProgress = async (req: Request, res: Response) => { // not ideal, but it avoids complex type casting // eslint-disable-next-line @typescript-eslint/no-explicit-any const totalLessons = (course.modules as any[]).reduce((sum, module) => sum + (module.lessons as any[]).length, 0); - const completedLessons = new Set(progress.map((p) => p.lessonId.toString())).size; + const completedLessonsSet = new Set(progress.map((p) => p.lessonId.toString())); + const completedLessons = completedLessonsSet.size; const progressPercentage = (completedLessons / totalLessons) * 100; + const modulesProgress: Record> = {}; + (course.modules as Module[]).forEach((module) => { + const moduleId = module?._id?.toString(); + if (!moduleId) return; + modulesProgress[moduleId] = {}; + (module.lessons as Lesson[]).forEach((lesson) => { + const lessonId = lesson?._id?.toString(); + if (!lessonId) return; + modulesProgress[moduleId][lessonId] = completedLessonsSet.has(lessonId); + }); + }); + return res.status(200).send({ totalLessons, completedLessons, progressPercentage, + modulesProgress, }); } catch (e) { console.error(`[ERROR][getCourseProgress] ${e}`); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 276d515..72fce65 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -337,12 +337,21 @@ describe("Setting API Server up...", () => { }); it("Get course progress with no completed lessons (GET /progress/course/:courseId)", async () => { + const expectedModulesProgress: Record> = {}; + [module1, module2, module3].forEach((module) => { + const lessonsProgress: Record = {}; + module.lessons.forEach((lessonId) => { + lessonsProgress[lessonId.toString()] = false; + }); + expectedModulesProgress[module?._id?.toString() || ""] = lessonsProgress; + }); await axios .get(`${API_URL}/progress/course/${course._id}`, { headers }) .then((r) => { expect(r.data.totalLessons).toEqual(5); expect(r.data.completedLessons).toEqual(0); expect(r.data.progressPercentage).toEqual(0); + expect(r.data.modulesProgress).toEqual(expectedModulesProgress); }) .catch((e) => { expect(e).toBeUndefined(); @@ -359,12 +368,22 @@ describe("Setting API Server up...", () => { difficulty: lesson1.difficulty, }); + const expectedModulesProgress: Record> = {}; + [module1, module2, module3].forEach((module) => { + const lessonsProgress: Record = {}; + module.lessons.forEach((lessonId) => { + lessonsProgress[lessonId.toString()] = lessonId === lesson1._id ? true : false; + }); + expectedModulesProgress[module?._id?.toString() || ""] = lessonsProgress; + }); + await axios .get(`${API_URL}/progress/course/${course._id}`, { headers }) .then((r) => { expect(r.data.totalLessons).toEqual(5); expect(r.data.completedLessons).toEqual(1); expect(r.data.progressPercentage).toEqual(20); + expect(r.data.modulesProgress).toEqual(expectedModulesProgress); }) .catch((e) => { expect(e).toBeUndefined(); @@ -389,12 +408,22 @@ describe("Setting API Server up...", () => { difficulty: lesson2.difficulty, }); + const expectedModulesProgress: Record> = {}; + [module1, module2, module3].forEach((module) => { + const lessonsProgress: Record = {}; + module.lessons.forEach((lessonId) => { + lessonsProgress[lessonId.toString()] = lessonId === lesson1._id ? true : false; + }); + expectedModulesProgress[module?._id?.toString() || ""] = lessonsProgress; + }); + await axios .get(`${API_URL}/progress/course/${course._id}`, { headers }) .then((r) => { expect(r.data.totalLessons).toEqual(5); expect(r.data.completedLessons).toEqual(1); expect(r.data.progressPercentage).toEqual(20); + expect(r.data.modulesProgress).toEqual(expectedModulesProgress); }) .catch((e) => { expect(e).toBeUndefined(); @@ -447,6 +476,15 @@ describe("Setting API Server up...", () => { difficulty: lesson5.difficulty, }); + const expectedModulesProgress: Record> = {}; + [module1, module2, module3].forEach((module) => { + const lessonsProgress: Record = {}; + module.lessons.forEach((lessonId) => { + lessonsProgress[lessonId.toString()] = true; + }); + expectedModulesProgress[module?._id?.toString() || ""] = lessonsProgress; + }); + await axios .get(`${API_URL}/progress/course/${course._id}`, { headers }) .then((r) => { @@ -454,6 +492,7 @@ describe("Setting API Server up...", () => { expect(r.data.totalLessons).toEqual(5); expect(r.data.completedLessons).toEqual(5); expect(r.data.progressPercentage).toEqual(100); + expect(r.data.modulesProgress).toEqual(expectedModulesProgress); }) .catch((e) => { expect(e).toBeUndefined(); From 730d034272630651befcf8cc035e36b1ca1c8b85 Mon Sep 17 00:00:00 2001 From: Ewerton Onga Date: Sat, 18 Jan 2025 16:25:08 -0300 Subject: [PATCH 2/3] ranking feature --- package.json | 3 +- src/controllers/ranking.ts | 33 ++++++++++++++++ src/index.ts | 4 ++ src/models/Ranking.ts | 21 ++++++++++ src/routes.ts | 4 ++ src/scripts/runRankingUpdate.ts | 10 +++++ src/services/ranking.ts | 61 ++++++++++++++++++++++++++++++ src/tasks/scheduleRankingUpdate.ts | 15 ++++++++ 8 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/controllers/ranking.ts create mode 100644 src/models/Ranking.ts create mode 100644 src/scripts/runRankingUpdate.ts create mode 100644 src/services/ranking.ts create mode 100644 src/tasks/scheduleRankingUpdate.ts diff --git a/package.json b/package.json index ae67147..2d3d610 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "express": "^4.19.2", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", - "mongoose": "^8.6.4" + "mongoose": "^8.6.4", + "node-cron": "^3.0.3" }, "module": "src/index.js", "devDependencies": { diff --git a/src/controllers/ranking.ts b/src/controllers/ranking.ts new file mode 100644 index 0000000..f9b934f --- /dev/null +++ b/src/controllers/ranking.ts @@ -0,0 +1,33 @@ +import { Request, Response } from "express"; +import { RankingModel } from "@/models/Ranking"; +import { calculateRanking } from "@/services/ranking"; + +export const getRanking = async (req: Request, res: Response) => { + const type = req.query.type as "weekly" | "general"; + + if (!type || !["weekly", "general"].includes(type)) { + return res.status(400).send({ error: { message: "Invalid ranking type. Valid types are 'weekly' or 'general'." } }); + } + + try { + let ranking = await RankingModel.findOne({ type }).select("ranking lastUpdated").lean(); + + if (!ranking) { + await calculateRanking(type); + ranking = await RankingModel.findOne({ type }).select("ranking lastUpdated").lean(); + + if (!ranking) { + return res.status(500).send({ error: { message: "Failed to calculate ranking. Please try again." } }); + } + } + + return res.status(200).send({ + type, + lastUpdated: ranking.lastUpdated, + ranking: ranking.ranking, + }); + } catch (error) { + console.error(`[ERROR][getRanking] Failed to fetch ranking for type '${type}':`, error); + return res.status(500).send({ error: { message: "Internal server error. Please try again later." } }); + } +}; diff --git a/src/index.ts b/src/index.ts index 27ca0b4..f5fd421 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import router from "./routes"; import { setupMongoDB } from "./database"; import { env } from "./environment"; import corsConfig from "./middlewares/cors.config"; +import { scheduleRankingUpdate } from "./tasks/scheduleRankingUpdate"; const app = express(); @@ -22,4 +23,7 @@ app.listen(env.SERVER_PORT, env.SERVER_HOST, async () => { await setupMongoDB(); // eslint-disable-next-line no-console console.info(`> Listening at http://${env.SERVER_HOST}:${env.SERVER_PORT}`); + scheduleRankingUpdate(); + // eslint-disable-next-line no-console + console.info("Ranking Schedule initiated"); }); diff --git a/src/models/Ranking.ts b/src/models/Ranking.ts new file mode 100644 index 0000000..ec927be --- /dev/null +++ b/src/models/Ranking.ts @@ -0,0 +1,21 @@ +import { getModelForClass, prop } from "@typegoose/typegoose"; +import BaseModel from "./BaseModel"; + +class Ranking extends BaseModel { + @prop({ required: true, enum: ["weekly", "general"], type: String }) + public type: string; + + @prop({ required: true, type: Date }) + public lastUpdated: Date; + + @prop({ required: true, type: () => [Object] }) + public ranking!: Array<{ + userId: string; + name: string; + picture: string; + xp: number; + }>; +} + +const RankingModel = getModelForClass(Ranking); +export { Ranking, RankingModel }; diff --git a/src/routes.ts b/src/routes.ts index ed044cf..d7e1210 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -44,6 +44,7 @@ import { } from "./controllers/progress"; import adminMiddleware from "./middlewares/admin"; import { generateCertificate, getCertificate, getCertificates, mintCertificate } from "./controllers/certificates"; +import { getRanking } from "./controllers/ranking"; const router = (app: Express) => { // Users @@ -104,6 +105,9 @@ const router = (app: Express) => { app.get("/certificates/:certificateId", getCertificate); app.get("/certificates", [authMiddleware], getCertificates); app.post("/certificates/mint", [authMiddleware], mintCertificate); + + // Ranking + app.get("/ranking", getRanking); }; export default router; diff --git a/src/scripts/runRankingUpdate.ts b/src/scripts/runRankingUpdate.ts new file mode 100644 index 0000000..46c70eb --- /dev/null +++ b/src/scripts/runRankingUpdate.ts @@ -0,0 +1,10 @@ +import { calculateRanking } from "@/services/ranking"; + +(async () => { + try { + await calculateRanking("weekly"); + await calculateRanking("general"); + } catch (error) { + console.error("Error updating ranking:", error); + } +})(); diff --git a/src/services/ranking.ts b/src/services/ranking.ts new file mode 100644 index 0000000..025dd73 --- /dev/null +++ b/src/services/ranking.ts @@ -0,0 +1,61 @@ +import { ProgressModel } from "@/models/Progress"; +import { UserModel } from "@/models/User"; +import { RankingModel } from "@/models/Ranking"; + +export const calculateRanking = async (type: "weekly" | "general") => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - today.getDay()); + + let matchCondition = {}; + if (type === "weekly") { + matchCondition = { createdAt: { $gte: startOfWeek } }; + } + + const xpByDifficulty = { + easy: 50, + medium: 100, + hard: 200, + }; + + const aggregationPipeline = [ + { $match: { ...matchCondition, isCorrect: true } }, + { + $group: { + _id: "$userId", + totalXP: { + $sum: { + $switch: { + branches: [ + { case: { $eq: ["$difficulty", "easy"] }, then: xpByDifficulty.easy }, + { case: { $eq: ["$difficulty", "medium"] }, then: xpByDifficulty.medium }, + { case: { $eq: ["$difficulty", "hard"] }, then: xpByDifficulty.hard }, + ], + default: 0, + }, + }, + }, + }, + }, + ]; + + const results = await ProgressModel.aggregate(aggregationPipeline); + + const ranking = await Promise.all( + results.map(async (result) => { + const user = await UserModel.findById(result._id).select("name picture"); + return { + userId: result._id.toString(), + name: user?.name || "Unknown", + picture: user?.picture || "", + xp: result.totalXP, + }; + }), + ); + + ranking.sort((a, b) => b.xp - a.xp); + + await RankingModel.updateOne({ type }, { type, lastUpdated: new Date(), ranking }, { upsert: true }); +}; diff --git a/src/tasks/scheduleRankingUpdate.ts b/src/tasks/scheduleRankingUpdate.ts new file mode 100644 index 0000000..1971bd7 --- /dev/null +++ b/src/tasks/scheduleRankingUpdate.ts @@ -0,0 +1,15 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const cron = require("node-cron"); + +import { calculateRanking } from "@/services/ranking"; + +export const scheduleRankingUpdate = () => { + cron.schedule("0 0 * * *", async () => { + try { + await calculateRanking("weekly"); + await calculateRanking("general"); + } catch (error) { + console.error("[UpdateRankError]:", error); + } + }); +}; From 4d1fd5379a9a4d6dbe02eddd85d2dd2b5e6a8114 Mon Sep 17 00:00:00 2001 From: Ewerton Onga Date: Sat, 18 Jan 2025 16:49:17 -0300 Subject: [PATCH 3/3] adding auth to get ranking endpoint --- src/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes.ts b/src/routes.ts index d7e1210..5121c65 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -107,7 +107,7 @@ const router = (app: Express) => { app.post("/certificates/mint", [authMiddleware], mintCertificate); // Ranking - app.get("/ranking", getRanking); + app.get("/ranking", [authMiddleware], getRanking); }; export default router;