added new requirements for lessons and units

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-15 16:26:09 +08:00
parent b7d62b3b18
commit 9c82b0de09
25 changed files with 1569 additions and 233 deletions
@@ -0,0 +1,159 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirements.controller.js (admin)
* Type of Program: Controller
* Description: Admin CRUD for CompletionRequirement rows — what counts as "complete" for a
* given course/unit/lesson. One shared implementation per entity_type, mounted at
* nested routes (routes/admin/courses.routes.js) AND standalone-library routes
* (routes/admin/units.routes.js, routes/admin/lessons.routes.js), matching the
* existing dual-mount convention already used for quiz routes.
*
* getXRequirements — GET, list configured rows for one entity.
* syncXRequirements — PUT, soft-delete-and-recreate the full set for one entity (same pattern
* as controllers/admin/task.controller.js#updateTask, proactively applying
* its documented fix: strip requirement_id/timestamps before bulkCreate so
* the fresh rows never collide with the just-soft-deleted PKs). Validates
* each row's `type` against the registry's validEntityTypes for this
* entity_type, and rejects duplicate types on one entity — validation the
* Task requirements system doesn't have.
*
* When a pass_quiz requirement is added/removed on a unit/course, the corresponding
* UnitQuiz.is_required / CourseAssessment.is_required is flipped in lockstep, so the
* existing sequential quiz-lock (which reads is_required) and the new completion
* requirement never disagree with each other.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const sequelize = require('../../config/db.config');
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
const { Course, Unit, Lesson, UnitQuiz, CourseAssessment } = require('../../models/courses/courses.associations');
const { VALID_ENTITY_TYPES } = require('../../utils/courses/completion_requirements.registry');
const notDeleted = { deletedAt: null };
const ENTITY_MODELS = {
course: { model: Course, pk: 'course_id', label: 'Course' },
unit: { model: Unit, pk: 'unit_id', label: 'Unit' },
lesson: { model: Lesson, pk: 'lesson_id', label: 'Lesson' },
};
async function fetchRequirements(entityType, entityId, t) {
return CompletionRequirement.findAll({
where: { entity_type: entityType, entity_id: entityId },
order: [['order', 'ASC']],
transaction: t,
});
}
// ─── GET ──────────────────────────────────────────────────────────────────────
async function getRequirements(entityType, entityId, req, res) {
try {
const { model, pk, label } = ENTITY_MODELS[entityType];
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted } });
if (!entity) return R.error(res, `${label} not found.`, 404);
const rows = await fetchRequirements(entityType, entityId);
return R.success(res, 'Completion requirements retrieved.', rows);
} catch (err) {
console.error('[ADMIN][COMPLETION REQUIREMENTS][GET]', err);
return R.error(res, 'Could not retrieve completion requirements.', 500);
}
}
// ─── PUT (soft-delete-and-recreate) ──────────────────────────────────────────
async function syncRequirements(entityType, entityId, req, res) {
const t = await sequelize.transaction();
try {
const { model, pk, label } = ENTITY_MODELS[entityType];
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted }, transaction: t });
if (!entity) {
await t.rollback();
return R.error(res, `${label} not found.`, 404);
}
const requirements = Array.isArray(req.body.requirements) ? req.body.requirements : [];
// Server-side validation the Task requirements system doesn't have: type must be a
// known type, valid for this entity_type, and configured at most once per entity.
const seenTypes = new Set();
for (const r of requirements) {
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
if (!allowedEntityTypes) {
await t.rollback();
return R.error(res, `Unknown requirement type "${r.type}".`, 400);
}
if (!allowedEntityTypes.includes(entityType)) {
await t.rollback();
return R.error(res, `"${r.type}" cannot be configured on a ${entityType}.`, 400);
}
if (seenTypes.has(r.type)) {
await t.rollback();
return R.error(res, `Duplicate "${r.type}" requirement — only one per entity is allowed.`, 400);
}
seenTypes.add(r.type);
}
// Soft-delete existing rows, then bulkCreate the replacement set. Requirement_id and
// timestamps are stripped from each incoming row (server-owned) so the fresh insert
// never collides with the just-soft-deleted row still occupying that requirement_id PK —
// see controllers/admin/task.controller.js#updateTask for the bug this proactively avoids.
await CompletionRequirement.destroy({
where: { entity_type: entityType, entity_id: entityId },
force: false,
transaction: t,
});
if (requirements.length) {
const rows = requirements.map((r, i) => {
const { requirement_id, createdAt, updatedAt, deletedAt, entity_type, entity_id, ...rest } = r;
return {
...rest,
entity_type: entityType,
entity_id: entityId,
order: rest.order ?? i,
min_percent: rest.type === 'watch_percent' ? (rest.min_percent ?? 100) : null,
button_label: rest.type === 'manual_complete' ? (rest.button_label || null) : null,
is_required: rest.is_required ?? true,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
};
});
await CompletionRequirement.bulkCreate(rows, { transaction: t });
}
// Reconcile the pass_quiz requirement with the pre-existing UnitQuiz/CourseAssessment
// is_required flag, so ModifyQuiz.jsx's "required to proceed" toggle and this new
// completion-requirements editor never disagree about the same underlying boolean.
const hasPassQuiz = requirements.some((r) => r.type === 'pass_quiz');
if (entityType === 'unit') {
await UnitQuiz.update({ is_required: hasPassQuiz }, { where: { unit_id: entityId }, transaction: t });
} else if (entityType === 'course') {
await CourseAssessment.update({ is_required: hasPassQuiz }, { where: { course_id: entityId }, transaction: t });
}
await t.commit();
const rows = await fetchRequirements(entityType, entityId);
return R.success(res, 'Completion requirements updated.', rows);
} catch (err) {
await t.rollback();
console.error('[ADMIN][COMPLETION REQUIREMENTS][SYNC]', err);
return R.error(res, 'Could not update completion requirements.', 500);
}
}
// ─── Route-bound exports ──────────────────────────────────────────────────────
exports.getCourseRequirements = (req, res) => getRequirements('course', req.params.courseId, req, res);
exports.syncCourseRequirements = (req, res) => syncRequirements('course', req.params.courseId, req, res);
exports.getUnitRequirements = (req, res) => getRequirements('unit', req.params.unitId, req, res);
exports.syncUnitRequirements = (req, res) => syncRequirements('unit', req.params.unitId, req, res);
exports.getLessonRequirements = (req, res) => getRequirements('lesson', req.params.lessonId, req, res);
exports.syncLessonRequirements = (req, res) => syncRequirements('lesson', req.params.lessonId, req, res);
+28
View File
@@ -37,6 +37,8 @@ const {
UnitQuiz, QuizQuestion, QuizOption,
UnitReadingProgress, LessonReadingProgress,
} = require("../../models/courses/courses.associations");
const CompletionRequirement = require("../../models/courses/completion_requirement.mdl");
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
const mdl_Users = require("../../models/users/users.mdl");
@@ -248,6 +250,17 @@ exports.createUnitFull = async (req, res) => {
if (lessonInput.blocks?.length > CREATE_UNIT_FULL_LIMITS.maxBlocks) {
return R.error(res, `A lesson page can have at most ${CREATE_UNIT_FULL_LIMITS.maxBlocks} blocks.`, 400);
}
if (lessonInput.requirements !== undefined) {
if (!Array.isArray(lessonInput.requirements)) return R.error(res, "Lesson requirements must be a list.", 400);
const seenTypes = new Set();
for (const r of lessonInput.requirements) {
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
if (!allowedEntityTypes) return R.error(res, `Unknown requirement type "${r.type}".`, 400);
if (!allowedEntityTypes.includes("lesson")) return R.error(res, `"${r.type}" cannot be configured on a lesson.`, 400);
if (seenTypes.has(r.type)) return R.error(res, `Duplicate "${r.type}" requirement — only one per lesson is allowed.`, 400);
seenTypes.add(r.type);
}
}
}
const by = createdBy ?? req.user?.user_id ?? null;
@@ -278,6 +291,21 @@ exports.createUnitFull = async (req, res) => {
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
if (lessonInput.requirements?.length) {
const rows = lessonInput.requirements.map((r, ri) => ({
entity_type: "lesson",
entity_id: lesson.lesson_id,
type: r.type,
order: r.order ?? ri,
min_percent: r.type === "watch_percent" ? (r.min_percent ?? 100) : null,
button_label: r.type === "manual_complete" ? (r.button_label || null) : null,
is_required: r.is_required ?? true,
createdBy: by,
updatedBy: by,
}));
await CompletionRequirement.bulkCreate(rows, { transaction: t });
}
await UnitLesson.create({
unit_id: unit.unit_id,
lesson_id: lesson.lesson_id,
+8 -2
View File
@@ -66,7 +66,10 @@ const setRefreshCookie = (res, refreshToken) => {
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
// 'none' (not 'strict') in production — frontend (Vercel) and this API
// (Render) are cross-site, so 'strict'/'lax' silently drop the cookie on
// every fetch/XHR refresh call. 'none' requires secure:true (set above).
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
};
@@ -83,7 +86,10 @@ const setGoogleResultCookie = (res, payload) => {
res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
// Set right after a top-level redirect (survives 'lax'), but read back via
// a cross-site fetch from GET /auth/google/result — 'lax' drops it there
// in production since frontend (Vercel) and this API (Render) are cross-site.
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land
signed: true,
});
@@ -29,8 +29,7 @@
const { Op } = require('sequelize');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service');
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const {
@@ -63,100 +62,12 @@ async function getAccessibleTaskListIds(userId) {
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
}
// ─── Internal helper: sync task_progress after a lesson read ─────────────────
// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID
// (only for tasks the user is assigned to) and marks them completed in task_progress.
// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements
// are now satisfied — these are eligible for display as "auto turned-in" on the frontend.
async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) {
if (lessonStatus !== 'completed') return [];
const { taskListIds } = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) return [];
// Collect UUIDs to match based on what became completed
const matchUuids = [lessonUuid];
if (unitStatus === 'completed') matchUuids.push(unitUuid);
if (courseStatus === 'completed') matchUuids.push(courseUuid);
const requirements = await TaskRequirement.findAll({
where: {
reference_id: { [Op.in]: matchUuids },
type: { [Op.in]: ['read_lesson', 'read_unit', 'read_course'] },
deletedAt: null,
},
include: [{
model: Task,
as: 'task',
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
required: true,
attributes: ['task_id', 'name', 'task_list_id'],
}],
attributes: ['requirement_id', 'task_id', 'type', 'reference_id'],
});
if (!requirements.length) return [];
// Filter to (type, reference_id) pairs that actually became completed this call
const toComplete = requirements.filter((req) => {
if (req.type === 'read_lesson' && req.reference_id === lessonUuid) return true;
if (req.type === 'read_unit' && req.reference_id === unitUuid && unitStatus === 'completed') return true;
if (req.type === 'read_course' && req.reference_id === courseUuid && courseStatus === 'completed') return true;
return false;
});
if (!toComplete.length) return [];
const now = new Date();
// Upsert TaskProgress as completed for each matching requirement
await Promise.all(toComplete.map((req) =>
TaskProgress.upsert(
{
task_id: req.task_id,
requirement_id: req.requirement_id,
user_id: userId,
reference_id: req.reference_id,
type: req.type,
completed: true,
completed_at: now,
createdBy: userId,
updatedBy: userId,
},
{ conflictFields: ['requirement_id', 'user_id', 'reference_id'] }
)
));
// Check if any impacted task now has ALL its read requirements done
// (only auto-turn-in pure read tasks — tasks with upload_file/visit_link need manual submission)
const taskIds = [...new Set(toComplete.map((r) => r.task_id))];
const completedTasks = [];
for (const taskId of taskIds) {
const allReqs = await TaskRequirement.findAll({
where: { task_id: taskId, deletedAt: null },
attributes: ['requirement_id', 'type', 'reference_id'],
});
const hasNonReadReqs = allReqs.some((r) => !['read_course', 'read_unit', 'read_lesson'].includes(r.type));
if (hasNonReadReqs) continue; // let the user manually submit
const readReqs = allReqs; // all are read-type at this point
const doneProgress = await TaskProgress.findAll({
where: { task_id: taskId, user_id: userId, completed: true },
attributes: ['requirement_id', 'reference_id'],
});
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
const allDone = readReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`));
if (allDone) {
const taskName = toComplete.find((r) => r.task_id === taskId)?.task?.name ?? '';
completedTasks.push({ task_id: taskId, task_name: taskName });
}
}
return completedTasks;
}
// Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in
// services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called
// directly from completion_requirements.service.js's cascade/recompute functions — covers every
// completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just
// this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still
// needs its richer { taskListIds, taskListToGroup } shape.
// =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
@@ -453,10 +364,11 @@ exports.getCourseTaskContext = async (req, res) => {
//
// Flow:
// 1. Resolve course / unit / lesson to get their UUIDs
// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx
// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables)
// 4. Side-effect B: sync task_progress for matching task requirements
// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
// 2. Delegate to recomputeCascade (completion_requirements service) — lesson + unit + course
// evaluated against any configured CompletionRequirement rows (or the default implicit rule),
// all in one transaction
// 3. Side-effect: sync task_progress for matching task requirements
// 4. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
exports.upsertLessonProgress = async (req, res) => {
try {
@@ -485,43 +397,108 @@ exports.upsertLessonProgress = async (req, res) => {
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
// ── 1. Primary write: course_reading_progress ─────────────────────────
const result = await upsertLessonRead(userId, {
// ── 1. Consolidated evaluation + persistence: lesson → unit → course ──
const result = await recomputeCascade(userId, {
courseId: course.course_id,
courseUuid: course.uuid,
unitId: unit.unit_id,
unitUuid: unit.uuid,
lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
// ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ──
upsertReadingProgress(userId, {
courseId: course.course_id,
unitId: unit.unit_id,
lessonId: lesson.lesson_id,
lessonStatus: status,
}).catch((e) => console.error('[READING PROGRESS] piggyback write failed:', e));
// ── 3. Side-effect B: sync task_progress ──────────────────────────────
const completedTasks = await syncTaskProgress(userId, {
lessonUuid: lesson.uuid,
unitUuid: unit.uuid,
courseUuid: course.uuid,
lessonStatus: status,
unitStatus: result.unit.status,
courseStatus: result.course.status,
});
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran
// inside recomputeCascade — result.completed_tasks reflects it directly.
logActivity(userId, 'lesson_read', {
entityType: 'lesson',
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status },
});
return R.success(res, 'Progress updated.', { ...result, completed_tasks: completedTasks }, 200);
return R.success(res, 'Progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
// =============================================================================
// ── WATCH PROGRESS (watch_percent completion requirement) ────────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/watch-progress
// Body: { percent, block_id?, block_type? } — running max % of video/audio watched, 0-100.
// block_id/block_type identify which block on the lesson's page sent this update — needed
// to drive watch_video/listen_audio (every block of that type must individually reach 100);
// omit them and only the aggregate watch_percent requirement (if configured) is touched.
// No-ops (still 200s) if the lesson has neither requirement type configured.
exports.upsertWatchProgress = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const percent = Number(req.body.percent);
if (!Number.isFinite(percent)) return R.error(res, 'percent must be a number.', 400);
const blockId = req.body.block_id ?? null;
const blockType = req.body.block_type ?? null;
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
const result = await recordWatchProgress(userId, {
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
unitId: unit.unit_id, unitUuid: unit.uuid,
courseId: course.course_id, courseUuid: course.uuid,
percent, blockId, blockType,
});
return R.success(res, 'Watch progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][WATCH PROGRESS]', err);
return R.error(res, 'Could not update watch progress.', 500);
}
};
// =============================================================================
// ── MARK COMPLETE (manual_complete completion requirement) ───────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/mark-complete
// No-ops (still 200s) if the lesson has no configured manual_complete requirement.
exports.markLessonComplete = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
const result = await recordManualComplete(userId, {
entityType: 'lesson', entityId: lesson.lesson_id,
lessonUuid: lesson.uuid,
unitId: unit.unit_id, unitUuid: unit.uuid,
courseId: course.course_id, courseUuid: course.uuid,
});
return R.success(res, 'Lesson marked complete.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][MARK COMPLETE]', err);
return R.error(res, 'Could not mark lesson complete.', 500);
}
};
+90 -17
View File
@@ -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,
+85 -4
View File
@@ -43,7 +43,7 @@ const {
const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { upsertLessonRead } = require("../../services/reading_progress.service");
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service");
const notDeleted = { deletedAt: null };
@@ -343,8 +343,11 @@ exports.saveUnitQuizDraft = async (req, res) => {
// ─── STANDALONE LESSON PROGRESS ───────────────────────────────────────────────
// POST /client/lessons/:uuid/progress Body: { status, unit_uuid? }
// Writes lesson_reading_progress with course_id NULL. When unit_uuid is given
// (unit context, still no course) the parent unit row is derived + upserted too.
// Evaluated + written via completion_requirements.service#recomputeCascade with
// courseId null, which persists to lesson_reading_progress/unit_reading_progress
// (the tables that tolerate a null course_id) instead of course_reading_progress.
// When unit_uuid is given (unit context, still no course) the parent unit is
// re-evaluated + upserted too, against any configured CompletionRequirement rows.
exports.upsertStandaloneLessonProgress = async (req, res) => {
try {
@@ -369,10 +372,12 @@ exports.upsertStandaloneLessonProgress = async (req, res) => {
unitId = unit.unit_id;
}
const result = await upsertLessonRead(userId, {
const result = await recomputeCascade(userId, {
courseId: null,
unitId,
unitUuid,
lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
@@ -389,6 +394,82 @@ exports.upsertStandaloneLessonProgress = async (req, res) => {
}
};
// ─── STANDALONE WATCH PROGRESS ─────────────────────────────────────────────────
// POST /client/lessons/:uuid/watch-progress Body: { percent, unit_uuid?, block_id?, block_type? }
// block_id/block_type identify which block on the lesson's page sent this update — needed
// to drive watch_video/listen_audio; omit them and only watch_percent (if configured) is touched.
exports.upsertStandaloneWatchProgress = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const percent = Number(req.body.percent);
if (!Number.isFinite(percent)) return R.error(res, "percent must be a number.", 400);
const unitUuid = req.body.unit_uuid ?? null;
const blockId = req.body.block_id ?? null;
const blockType = req.body.block_type ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
unitId = unit.unit_id;
}
const result = await recordWatchProgress(userId, {
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
unitId, unitUuid,
courseId: null, courseUuid: null,
percent, blockId, blockType,
});
return R.success(res, "Watch progress updated.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE WATCH PROGRESS]", err);
return R.error(res, "Could not update watch progress.", 500);
}
};
// ─── STANDALONE MARK COMPLETE ──────────────────────────────────────────────────
// POST /client/lessons/:uuid/mark-complete Body: { unit_uuid? }
exports.markStandaloneLessonComplete = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const unitUuid = req.body.unit_uuid ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
unitId = unit.unit_id;
}
const result = await recordManualComplete(userId, {
entityType: "lesson", entityId: lesson.lesson_id,
lessonUuid: lesson.uuid,
unitId, unitUuid,
courseId: null, courseUuid: null,
});
return R.success(res, "Lesson marked complete.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE MARK COMPLETE]", err);
return R.error(res, "Could not mark lesson complete.", 500);
}
};
// ─── Shared UUID handlers re-exported for the standalone routes ───────────────
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;