mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added new requirements for lessons and units
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -37,6 +37,12 @@ const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl
|
||||
const { gradeSubmission } = require("../../utils/courses/grading.util");
|
||||
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
|
||||
const { onCourseCompleted } = require('../../services/achievements.service');
|
||||
const {
|
||||
evaluateEntity,
|
||||
recomputeUnitAfterQuiz,
|
||||
recomputeCourseAfterAssessment,
|
||||
} = require('../../services/completion_requirements.service');
|
||||
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
|
||||
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
|
||||
const Certificate = require('../../models/courses/certificate.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
@@ -403,21 +409,53 @@ exports.getCourse = async (req, res) => {
|
||||
}));
|
||||
}
|
||||
|
||||
let is_completed = false;
|
||||
// Attach each lesson's resolved completion trigger (read_all_content [default] /
|
||||
// watch_percent / manual_complete) so the reader can dispatch the right UI without a
|
||||
// second round trip. A lesson may have zero configured rows (→ default scroll trigger)
|
||||
// or one row of one of these types (pass_quiz isn't valid on a lesson).
|
||||
const allLessonIds = plain.units?.flatMap((u) => (u.lessons ?? []).map((l) => l.lesson_id)) ?? [];
|
||||
if (allLessonIds.length) {
|
||||
const lessonRequirements = await CompletionRequirement.findAll({
|
||||
where: { entity_type: 'lesson', entity_id: allLessonIds },
|
||||
attributes: ['entity_id', 'type', 'min_percent', 'button_label'],
|
||||
});
|
||||
const byLessonId = new Map();
|
||||
lessonRequirements.forEach((r) => { if (!byLessonId.has(String(r.entity_id))) byLessonId.set(String(r.entity_id), r); });
|
||||
|
||||
plain.units = plain.units.map((u) => ({
|
||||
...u,
|
||||
lessons: (u.lessons ?? []).map((l) => {
|
||||
const row = byLessonId.get(String(l.lesson_id));
|
||||
return {
|
||||
...l,
|
||||
completion: row
|
||||
? { type: row.type, min_percent: row.min_percent, button_label: row.button_label }
|
||||
: { type: 'read_all_content', min_percent: null, button_label: null },
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// has_passed reflects the assessment attempt alone; is_completed is the consolidated
|
||||
// evaluator's result (default rule: all units read AND assessment passed, if one exists —
|
||||
// was previously hardcoded to assessment-pass alone here too, same bug fixed in
|
||||
// submitCourseAssessment/getLessonsByUnitUuid — this is the 5th call site of that bug).
|
||||
if (plain.assessment) {
|
||||
const passedAttempt = await QuizAttempt.findOne({
|
||||
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
|
||||
});
|
||||
is_completed = !!passedAttempt;
|
||||
const questionCount = plain.assessment.questions?.length ?? 0;
|
||||
plain.assessment = {
|
||||
...plain.assessment,
|
||||
has_passed: is_completed,
|
||||
has_passed: !!passedAttempt,
|
||||
question_count: questionCount,
|
||||
questions: undefined,
|
||||
};
|
||||
}
|
||||
plain.is_completed = is_completed;
|
||||
const courseEvaluation = await evaluateEntity({
|
||||
entityType: 'course', entityId: course.course_id, userId: req.user.user_id, courseId: course.course_id,
|
||||
});
|
||||
plain.is_completed = courseEvaluation.status === 'completed';
|
||||
|
||||
const plan_tier = plain.subscription ?? null;
|
||||
|
||||
@@ -920,6 +958,16 @@ exports.submitUnitQuiz = async (req, res) => {
|
||||
{ where: { quiz_id: quiz.quiz_id, user_id, status: 'in_progress' } }
|
||||
);
|
||||
|
||||
// Passing a unit quiz can satisfy a pass_quiz completion requirement on the unit (and
|
||||
// cascade to the course) — previously this endpoint never touched reading progress at all.
|
||||
// Also syncs any read_unit/read_course task requirements the unit/course completion now
|
||||
// satisfies, even though no lesson was read (the gap task-sync used to miss).
|
||||
let completedTasks = [];
|
||||
if (passed) {
|
||||
const evaluation = await recomputeUnitAfterQuiz(user_id, { unitId, courseId });
|
||||
completedTasks = evaluation?.completed_tasks ?? [];
|
||||
}
|
||||
|
||||
return R.success(res, "Quiz submitted.", {
|
||||
attempt_id: attempt.attempt_id,
|
||||
attempt_number: attempt.attempt_number,
|
||||
@@ -928,6 +976,7 @@ exports.submitUnitQuiz = async (req, res) => {
|
||||
passing_score: attempt.passing_score,
|
||||
total_points: totalPoints,
|
||||
earned_points: earnedPoints,
|
||||
completed_tasks: completedTasks,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][QUIZ][SUBMIT]", err);
|
||||
@@ -1039,9 +1088,19 @@ exports.submitCourseAssessment = async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
// BUG FIX: this used to be `course_completed = passed` — certification/achievements fired
|
||||
// on assessment-pass alone, never checking whether the learner had actually read the course.
|
||||
// Now gated on the consolidated evaluator (default rule: all units read AND assessment passed;
|
||||
// or whatever the admin has explicitly configured via CompletionRequirement rows).
|
||||
let course_completed = false;
|
||||
let completedTasks = [];
|
||||
if (passed) {
|
||||
course_completed = true;
|
||||
const evaluation = await recomputeCourseAfterAssessment(user_id, courseId);
|
||||
course_completed = evaluation?.status === 'completed';
|
||||
completedTasks = evaluation?.completed_tasks ?? [];
|
||||
}
|
||||
|
||||
if (course_completed) {
|
||||
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid', 'title'] });
|
||||
const totalCompleted = await QuizAttempt.count({
|
||||
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
|
||||
@@ -1081,6 +1140,7 @@ exports.submitCourseAssessment = async (req, res) => {
|
||||
total_points: totalPoints,
|
||||
earned_points: earnedPoints,
|
||||
course_completed,
|
||||
completed_tasks: completedTasks,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ASSESSMENT][SUBMIT]", err);
|
||||
@@ -1240,20 +1300,29 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
}
|
||||
|
||||
const plain = unit.toJSON();
|
||||
const userId = req.user.user_id;
|
||||
|
||||
// Per-lesson completion for the requesting user. NOTE: lesson_reading_progress
|
||||
// upserts on (user_id, lesson_id) only — a lesson's completion is a property
|
||||
// of the lesson itself, not scoped to whichever unit it was read under.
|
||||
// Per-lesson completion for the requesting user, evaluated against each lesson's own
|
||||
// configured CompletionRequirement rows (or the default implicit rule when none are
|
||||
// configured) via the consolidated evaluator. courseId is null here — this route is
|
||||
// reached both course-scoped and standalone with no course context, and a lesson's
|
||||
// completion is a property of the lesson itself, not scoped to whichever unit/course
|
||||
// it was read under (see completion_requirements.registry.js's courseId-null dispatch,
|
||||
// which reads lesson_reading_progress rather than course_reading_progress).
|
||||
const flatLessons = flattenLessons(plain.lessons);
|
||||
const progressRows = flatLessons.length
|
||||
const lessonCompletedAtRows = flatLessons.length
|
||||
? await LessonReadingProgress.findAll({
|
||||
where: { user_id: req.user.user_id, lesson_id: flatLessons.map((l) => l.lesson_id) },
|
||||
attributes: ["lesson_id", "status", "completed_at"],
|
||||
where: { user_id: userId, lesson_id: flatLessons.map((l) => l.lesson_id) },
|
||||
attributes: ["lesson_id", "completed_at"],
|
||||
})
|
||||
: [];
|
||||
const progressMap = new Map(progressRows.map((p) => [String(p.lesson_id), p]));
|
||||
const completedAtMap = new Map(lessonCompletedAtRows.map((p) => [String(p.lesson_id), p.completed_at]));
|
||||
|
||||
const lessons = flatLessons.map((l) => ({
|
||||
const lessonEvaluations = await Promise.all(
|
||||
flatLessons.map((l) => evaluateEntity({ entityType: "lesson", entityId: l.lesson_id, userId, courseId: null }))
|
||||
);
|
||||
|
||||
const lessons = flatLessons.map((l, i) => ({
|
||||
lesson_id: l.lesson_id,
|
||||
uuid: l.uuid,
|
||||
title: l.title,
|
||||
@@ -1262,20 +1331,24 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
duration_seconds: l.duration_seconds ?? 0,
|
||||
blocks: l.page?.blocks ?? [],
|
||||
objectives: (l.objectives ?? []).slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
|
||||
status: progressMap.get(String(l.lesson_id))?.status ?? "not_started",
|
||||
completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null,
|
||||
status: lessonEvaluations[i].status === "completed" ? "completed" : (completedAtMap.has(String(l.lesson_id)) ? "in_progress" : "not_started"),
|
||||
completed_at: lessonEvaluations[i].status === "completed" ? (completedAtMap.get(String(l.lesson_id)) ?? null) : null,
|
||||
}));
|
||||
|
||||
// Attach has_passed to the quiz stub — same pattern as getCourse's unit list.
|
||||
let quiz = null;
|
||||
if (plain.quiz) {
|
||||
const passedAttempt = await QuizAttempt.findOne({
|
||||
where: { quiz_id: plain.quiz.quiz_id, user_id: req.user.user_id, passed: true },
|
||||
where: { quiz_id: plain.quiz.quiz_id, user_id: userId, passed: true },
|
||||
});
|
||||
quiz = { ...plain.quiz, has_passed: !!passedAttempt };
|
||||
}
|
||||
|
||||
const is_completed = lessons.length > 0 && lessons.every((l) => l.status === "completed");
|
||||
// Consolidated evaluator — replaces the old inline `lessons.every(status === "completed")`
|
||||
// re-derivation, which drifted from the POST-progress path's own unit derivation. Now both
|
||||
// read and write paths go through the same evaluateEntity() call.
|
||||
const unitEvaluation = await evaluateEntity({ entityType: "unit", entityId: unit.unit_id, userId, courseId: null });
|
||||
const is_completed = unitEvaluation.status === "completed";
|
||||
|
||||
return R.success(res, "Unit lessons retrieved.", {
|
||||
unit_id: unit.unit_id,
|
||||
|
||||
Reference in New Issue
Block a user