Skip to content

Commit

Permalink
Merge pull request #73 from PolkadotEducation/feature/course-progress…
Browse files Browse the repository at this point in the history
…-and-ranking

course progress for course navigation and ranking feature
  • Loading branch information
keijionga authored Jan 20, 2025
2 parents 6e6e6d1 + 4d1fd53 commit 991941f
Show file tree
Hide file tree
Showing 10 changed files with 204 additions and 2 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
16 changes: 15 additions & 1 deletion src/controllers/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, boolean>> = {};
(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}`);
Expand Down
33 changes: 33 additions & 0 deletions src/controllers/ranking.ts
Original file line number Diff line number Diff line change
@@ -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." } });
}
};
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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");
});
21 changes: 21 additions & 0 deletions src/models/Ranking.ts
Original file line number Diff line number Diff line change
@@ -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 };
4 changes: 4 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", [authMiddleware], getRanking);
};

export default router;
10 changes: 10 additions & 0 deletions src/scripts/runRankingUpdate.ts
Original file line number Diff line number Diff line change
@@ -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);
}
})();
61 changes: 61 additions & 0 deletions src/services/ranking.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
15 changes: 15 additions & 0 deletions src/tasks/scheduleRankingUpdate.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
};
39 changes: 39 additions & 0 deletions tests/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, boolean>> = {};
[module1, module2, module3].forEach((module) => {
const lessonsProgress: Record<string, boolean> = {};
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();
Expand All @@ -359,12 +368,22 @@ describe("Setting API Server up...", () => {
difficulty: lesson1.difficulty,
});

const expectedModulesProgress: Record<string, Record<string, boolean>> = {};
[module1, module2, module3].forEach((module) => {
const lessonsProgress: Record<string, boolean> = {};
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();
Expand All @@ -389,12 +408,22 @@ describe("Setting API Server up...", () => {
difficulty: lesson2.difficulty,
});

const expectedModulesProgress: Record<string, Record<string, boolean>> = {};
[module1, module2, module3].forEach((module) => {
const lessonsProgress: Record<string, boolean> = {};
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();
Expand Down Expand Up @@ -447,13 +476,23 @@ describe("Setting API Server up...", () => {
difficulty: lesson5.difficulty,
});

const expectedModulesProgress: Record<string, Record<string, boolean>> = {};
[module1, module2, module3].forEach((module) => {
const lessonsProgress: Record<string, boolean> = {};
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) => {
expect(r.status).toEqual(200);
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();
Expand Down

0 comments on commit 991941f

Please sign in to comment.