Skip to content

Commit

Permalink
Create endpoint for course summary (#75)
Browse files Browse the repository at this point in the history
* Create summary endpoint
* Improve tests
  • Loading branch information
laurogripa authored Jan 22, 2025
1 parent 991941f commit 8b05e69
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 25 deletions.
115 changes: 90 additions & 25 deletions src/controllers/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { UserModel } from "@/models/User";
import { Types } from "mongoose";
import { MongoError } from "mongodb";
import { Module } from "@/models/Module";
import { calculateExperience, calculateLevel, calculateXpToNextLevel, Difficulty } from "@/helpers/progress";

export const submitAnswer = async (req: Request, res: Response) => {
const { courseId, lessonId, choice } = req.body;
Expand Down Expand Up @@ -81,6 +82,90 @@ export const getLessonProgress = async (req: Request, res: Response) => {
}
};

export const getCourseSummary = async (req: Request, res: Response) => {
const { courseId } = req.params;
const userId = res.locals?.populatedUser;

if (!userId || !courseId) {
return res.status(400).send({ error: { message: "Missing params" } });
}

try {
const progress = await ProgressModel.aggregate([
{
$match: { userId: new Types.ObjectId(userId as string), courseId: new Types.ObjectId(courseId) },
},
{
$group: {
_id: {
lessonId: "$lessonId",
difficulty: "$difficulty",
},
count: { $sum: 1 },
correctCount: { $sum: { $cond: ["$isCorrect", 1, 0] } },
},
},
{
$project: {
lessonId: "$_id.lessonId",
difficulty: "$_id.difficulty",
correctAtFirstTry: { $cond: [{ $eq: ["$count", 1] }, { $eq: ["$correctCount", 1] }, false] },
isCorrect: { $gt: ["$correctCount", 0] },
_id: 0,
},
},
]);

const course = (await CourseModel.findOne({ _id: courseId }).populate({
path: "modules",
populate: {
path: "lessons",
model: "Lesson",
},
})) as Course;

if (!course) {
return res.status(400).send({ error: { message: "Course not found" } });
}

const progressMap = new Map(progress.map((p) => [String(p.lessonId), p]));

const courseSummary = {
title: course.title,
modules: course.modules.map((module) => {
const populatedModule = module as Module & { lessons: Lesson[] };

const lessons = populatedModule.lessons.map((lesson) => {
const populatedLesson = lesson as Lesson;

const progressRecord = progressMap.get(String(populatedLesson._id));
const isCompleted = progressRecord?.isCorrect || false;
const correctAtFirstTry = progressRecord?.correctAtFirstTry || false;

return {
title: populatedLesson.title,
difficulty: populatedLesson.difficulty,
expEarned: isCompleted
? calculateExperience(populatedLesson.difficulty as Difficulty, correctAtFirstTry)
: 0,
};
});

return {
title: populatedModule.title,
isCompleted: lessons.every((lesson) => lesson.expEarned > 0),
lessons,
};
}),
};

return res.status(200).send({ courseSummary });
} catch (e) {
console.error(`[ERROR][getCourseSummary] ${e}`);
return res.status(500).send({ error: { message: "Internal server error" } });
}
};

export const getCourseProgress = async (req: Request, res: Response) => {
const { courseId } = req.params;

Expand All @@ -104,9 +189,10 @@ export const getCourseProgress = async (req: Request, res: Response) => {
return res.status(400).send({ error: { message: "Course not found" } });
}

// 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 totalLessons = course.modules.reduce((sum, module) => {
const populatedModule = module as Module & { lessons: Lesson[] };
return sum + populatedModule.lessons.length;
}, 0);
const completedLessonsSet = new Set(progress.map((p) => p.lessonId.toString()));
const completedLessons = completedLessonsSet.size;
const progressPercentage = (completedLessons / totalLessons) * 100;
Expand Down Expand Up @@ -135,27 +221,6 @@ export const getCourseProgress = async (req: Request, res: Response) => {
}
};

const EXP_POINTS = {
hard: { perfect: 200, withMistakes: 100 },
medium: { perfect: 100, withMistakes: 50 },
easy: { perfect: 50, withMistakes: 25 },
};

type Difficulty = keyof typeof EXP_POINTS;

const calculateLevel = (exp: number): number => {
let level = 0;
while (10 * Math.pow(level, 2) + 100 * level + 150 < exp) {
level++;
}
return level;
};

const calculateXpToNextLevel = (exp: number, currentLevel: number): number => {
const nextLevelExp = 10 * Math.pow(currentLevel, 2) + 100 * currentLevel + 150;
return nextLevelExp - exp;
};

export const getUserXPAndLevel = async (_req: Request, res: Response) => {
const userId = res.locals?.populatedUser?._id;

Expand Down Expand Up @@ -201,7 +266,7 @@ export const getUserXPAndLevel = async (_req: Request, res: Response) => {
for (const p of progress) {
if (p.isCorrect) {
const difficulty = p.difficulty as Difficulty;
const points = p.correctAtFirstTry ? EXP_POINTS[difficulty].perfect : EXP_POINTS[difficulty].withMistakes;
const points = calculateExperience(difficulty, p.correctAtFirstTry);
exp += points;
}
}
Expand Down
24 changes: 24 additions & 0 deletions src/helpers/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const EXP_POINTS = {
hard: { perfect: 200, withMistakes: 100 },
medium: { perfect: 100, withMistakes: 50 },
easy: { perfect: 50, withMistakes: 25 },
};

export type Difficulty = keyof typeof EXP_POINTS;

export const calculateExperience = (difficulty: Difficulty, correctAtFirstTry: boolean) => {
return correctAtFirstTry ? EXP_POINTS[difficulty].perfect : EXP_POINTS[difficulty].withMistakes;
};

export const calculateLevel = (exp: number): number => {
let level = 0;
while (10 * Math.pow(level, 2) + 100 * level + 150 < exp) {
level++;
}
return level;
};

export const calculateXpToNextLevel = (exp: number, currentLevel: number): number => {
const nextLevelExp = 10 * Math.pow(currentLevel, 2) + 100 * currentLevel + 150;
return nextLevelExp - exp;
};
2 changes: 2 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import teamMiddleware from "./middlewares/team";
import {
getCompletedCourses,
getCourseProgress,
getCourseSummary,
getLessonProgress,
getUserXPAndLevel,
submitAnswer,
Expand Down Expand Up @@ -97,6 +98,7 @@ const router = (app: Express) => {
app.post("/progress", [authMiddleware], submitAnswer);
app.get("/progress/lesson/:courseId/:lessonId", [authMiddleware], getLessonProgress);
app.get("/progress/course/:courseId", [authMiddleware], getCourseProgress);
app.get("/progress/course/summary/:courseId", [authMiddleware], getCourseSummary);
app.get("/progress/level", [authMiddleware], getUserXPAndLevel);
app.get("/progress/courses", [authMiddleware], getCompletedCourses);

Expand Down
78 changes: 78 additions & 0 deletions tests/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,84 @@ describe("Setting API Server up...", () => {
});
});

it("should return a course summary", async () => {
await ProgressModel.create({
courseId: course._id,
lessonId: lesson1._id,
userId: user?.id,
choice: 0,
isCorrect: true,
difficulty: lesson1.difficulty,
});

await ProgressModel.create({
courseId: course._id,
lessonId: lesson2._id,
userId: user?.id,
choice: 2,
isCorrect: true,
difficulty: lesson2.difficulty,
});

await ProgressModel.create({
courseId: course._id,
lessonId: lesson3._id,
userId: user?.id,
choice: 2,
isCorrect: true,
difficulty: lesson3.difficulty,
});

await ProgressModel.create({
courseId: course._id,
lessonId: lesson4._id,
userId: user?.id,
choice: 1,
isCorrect: false,
difficulty: lesson4.difficulty,
});

await ProgressModel.create({
courseId: course._id,
lessonId: lesson4._id,
userId: user?.id,
choice: 0,
isCorrect: true,
difficulty: lesson4.difficulty,
});

await ProgressModel.create({
courseId: course._id,
lessonId: lesson5._id,
userId: user?.id,
choice: 1,
isCorrect: false,
difficulty: lesson5.difficulty,
});

await axios
.get(`${API_URL}/progress/course/summary/${course._id}`, { headers })
.then((r) => {
expect(r.status).toEqual(200);
expect(r.data.courseSummary.title).toEqual(course.title);

// 1st module should be completed
expect(r.data.courseSummary.modules[0].title).toEqual(module1.title);
expect(r.data.courseSummary.modules[0].isCompleted).toEqual(true);

// 2nd module should be completed
expect(r.data.courseSummary.modules[1].title).toEqual(module2.title);
expect(r.data.courseSummary.modules[1].isCompleted).toEqual(true);

// 3rd module should NOT be completed
expect(r.data.courseSummary.modules[2].title).toEqual(module3.title);
expect(r.data.courseSummary.modules[2].isCompleted).toEqual(false);
})
.catch((e) => {
expect(e).toBeUndefined();
});
});

it("should return an array of completed courses for a valid user", async () => {
await ProgressModel.create({
courseId: course._id,
Expand Down

0 comments on commit 8b05e69

Please sign in to comment.