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, UnitQuiz, QuizQuestion, QuizOption,
UnitReadingProgress, LessonReadingProgress, UnitReadingProgress, LessonReadingProgress,
} = require("../../models/courses/courses.associations"); } = 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"); 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) { 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); 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; 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); 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({ await UnitLesson.create({
unit_id: unit.unit_id, unit_id: unit.unit_id,
lesson_id: lesson.lesson_id, lesson_id: lesson.lesson_id,
+8 -2
View File
@@ -66,7 +66,10 @@ const setRefreshCookie = (res, refreshToken) => {
res.cookie('refreshToken', refreshToken, { res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production', 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 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
}); });
}; };
@@ -83,7 +86,10 @@ const setGoogleResultCookie = (res, payload) => {
res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), { res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', 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 maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land
signed: true, signed: true,
}); });
@@ -29,8 +29,7 @@
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service'); const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl');
const { const {
@@ -63,100 +62,12 @@ async function getAccessibleTaskListIds(userId) {
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup }; return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
} }
// ─── Internal helper: sync task_progress after a lesson read ───────────────── // Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in
// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID // services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called
// (only for tasks the user is assigned to) and marks them completed in task_progress. // directly from completion_requirements.service.js's cascade/recompute functions — covers every
// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements // completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just
// are now satisfied — these are eligible for display as "auto turned-in" on the frontend. // this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still
async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) { // needs its richer { taskListIds, taskListToGroup } shape.
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;
}
// ============================================================================= // =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ────────────────────── // ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
@@ -453,10 +364,11 @@ exports.getCourseTaskContext = async (req, res) => {
// //
// Flow: // Flow:
// 1. Resolve course / unit / lesson to get their UUIDs // 1. Resolve course / unit / lesson to get their UUIDs
// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx // 2. Delegate to recomputeCascade (completion_requirements service) — lesson + unit + course
// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables) // evaluated against any configured CompletionRequirement rows (or the default implicit rule),
// 4. Side-effect B: sync task_progress for matching task requirements // all in one transaction
// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied) // 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) => { exports.upsertLessonProgress = async (req, res) => {
try { try {
@@ -485,43 +397,108 @@ exports.upsertLessonProgress = async (req, res) => {
if (!unit || !courseLink) return R.error(res, 'Unit 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); if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
// ── 1. Primary write: course_reading_progress ───────────────────────── // ── 1. Consolidated evaluation + persistence: lesson → unit → course ──
const result = await upsertLessonRead(userId, { const result = await recomputeCascade(userId, {
courseId: course.course_id, courseId: course.course_id,
courseUuid: course.uuid, courseUuid: course.uuid,
unitId: unit.unit_id, unitId: unit.unit_id,
unitUuid: unit.uuid, unitUuid: unit.uuid,
lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid, lessonUuid: lesson.uuid,
lessonStatus: status, lessonStatus: status,
}); });
// ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ── // Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran
upsertReadingProgress(userId, { // inside recomputeCascade — result.completed_tasks reflects it directly.
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,
});
logActivity(userId, 'lesson_read', { logActivity(userId, 'lesson_read', {
entityType: 'lesson', entityType: 'lesson',
entityId: lesson.lesson_id, entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status }, 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) { } catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err); console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500); 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 { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service'); 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 PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl');
const UserNotification = require('../../models/notifications/user_notification.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) { if (plain.assessment) {
const passedAttempt = await QuizAttempt.findOne({ const passedAttempt = await QuizAttempt.findOne({
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true }, 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; const questionCount = plain.assessment.questions?.length ?? 0;
plain.assessment = { plain.assessment = {
...plain.assessment, ...plain.assessment,
has_passed: is_completed, has_passed: !!passedAttempt,
question_count: questionCount, question_count: questionCount,
questions: undefined, 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; 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' } } { 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.", { return R.success(res, "Quiz submitted.", {
attempt_id: attempt.attempt_id, attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number, attempt_number: attempt.attempt_number,
@@ -928,6 +976,7 @@ exports.submitUnitQuiz = async (req, res) => {
passing_score: attempt.passing_score, passing_score: attempt.passing_score,
total_points: totalPoints, total_points: totalPoints,
earned_points: earnedPoints, earned_points: earnedPoints,
completed_tasks: completedTasks,
}); });
} catch (err) { } catch (err) {
console.error("[CLIENT][QUIZ][SUBMIT]", 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 course_completed = false;
let completedTasks = [];
if (passed) { 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 course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid', 'title'] });
const totalCompleted = await QuizAttempt.count({ const totalCompleted = await QuizAttempt.count({
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } }, where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
@@ -1081,6 +1140,7 @@ exports.submitCourseAssessment = async (req, res) => {
total_points: totalPoints, total_points: totalPoints,
earned_points: earnedPoints, earned_points: earnedPoints,
course_completed, course_completed,
completed_tasks: completedTasks,
}); });
} catch (err) { } catch (err) {
console.error("[CLIENT][ASSESSMENT][SUBMIT]", err); console.error("[CLIENT][ASSESSMENT][SUBMIT]", err);
@@ -1240,20 +1300,29 @@ exports.getLessonsByUnitUuid = async (req, res) => {
} }
const plain = unit.toJSON(); const plain = unit.toJSON();
const userId = req.user.user_id;
// Per-lesson completion for the requesting user. NOTE: lesson_reading_progress // Per-lesson completion for the requesting user, evaluated against each lesson's own
// upserts on (user_id, lesson_id) only — a lesson's completion is a property // configured CompletionRequirement rows (or the default implicit rule when none are
// of the lesson itself, not scoped to whichever unit it was read under. // 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 flatLessons = flattenLessons(plain.lessons);
const progressRows = flatLessons.length const lessonCompletedAtRows = flatLessons.length
? await LessonReadingProgress.findAll({ ? await LessonReadingProgress.findAll({
where: { user_id: req.user.user_id, lesson_id: flatLessons.map((l) => l.lesson_id) }, where: { user_id: userId, lesson_id: flatLessons.map((l) => l.lesson_id) },
attributes: ["lesson_id", "status", "completed_at"], 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, lesson_id: l.lesson_id,
uuid: l.uuid, uuid: l.uuid,
title: l.title, title: l.title,
@@ -1262,20 +1331,24 @@ exports.getLessonsByUnitUuid = async (req, res) => {
duration_seconds: l.duration_seconds ?? 0, duration_seconds: l.duration_seconds ?? 0,
blocks: l.page?.blocks ?? [], blocks: l.page?.blocks ?? [],
objectives: (l.objectives ?? []).slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)), 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", status: lessonEvaluations[i].status === "completed" ? "completed" : (completedAtMap.has(String(l.lesson_id)) ? "in_progress" : "not_started"),
completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null, 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. // Attach has_passed to the quiz stub — same pattern as getCourse's unit list.
let quiz = null; let quiz = null;
if (plain.quiz) { if (plain.quiz) {
const passedAttempt = await QuizAttempt.findOne({ 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 }; 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.", { return R.success(res, "Unit lessons retrieved.", {
unit_id: unit.unit_id, 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 coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers
const { gradeSubmission } = require("../../utils/courses/grading.util"); const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.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 }; const notDeleted = { deletedAt: null };
@@ -343,8 +343,11 @@ exports.saveUnitQuizDraft = async (req, res) => {
// ─── STANDALONE LESSON PROGRESS ─────────────────────────────────────────────── // ─── STANDALONE LESSON PROGRESS ───────────────────────────────────────────────
// POST /client/lessons/:uuid/progress Body: { status, unit_uuid? } // POST /client/lessons/:uuid/progress Body: { status, unit_uuid? }
// Writes lesson_reading_progress with course_id NULL. When unit_uuid is given // Evaluated + written via completion_requirements.service#recomputeCascade with
// (unit context, still no course) the parent unit row is derived + upserted too. // 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) => { exports.upsertStandaloneLessonProgress = async (req, res) => {
try { try {
@@ -369,10 +372,12 @@ exports.upsertStandaloneLessonProgress = async (req, res) => {
unitId = unit.unit_id; unitId = unit.unit_id;
} }
const result = await upsertLessonRead(userId, { const result = await recomputeCascade(userId, {
courseId: null, courseId: null,
unitId, unitId,
unitUuid,
lessonId: lesson.lesson_id, lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid,
lessonStatus: status, 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 ─────────────── // ─── Shared UUID handlers re-exported for the standalone routes ───────────────
exports.getUnitByUuid = coursesCtrl.getUnitByUuid; exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
-79
View File
@@ -1,79 +0,0 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- JUNCTION REVAMP — Units and Lessons run independently (Jul. 7, 2026)
--
-- courses ⇄ course_units ⇄ units ⇄ unit_lessons ⇄ lessons
--
-- Run order matters: create → backfill → relax progress FKs → drop old columns.
-- Postgres / CockroachDB compatible. Take a backup before running.
-- ═══════════════════════════════════════════════════════════════════════════
BEGIN;
-- ── 1. Junction tables ───────────────────────────────────────────────────────
CREATE TABLE course_units (
course_unit_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses (course_id) ON DELETE CASCADE,
unit_id BIGINT NOT NULL REFERENCES units (unit_id) ON DELETE CASCADE,
order_index INTEGER NOT NULL DEFAULT 0,
"createdBy" BIGINT NULL,
"updatedBy" BIGINT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_course_units_course_unit UNIQUE (course_id, unit_id)
);
CREATE INDEX idx_course_units_course_id ON course_units (course_id);
CREATE INDEX idx_course_units_unit_id ON course_units (unit_id);
CREATE INDEX idx_course_units_order ON course_units (order_index);
CREATE TABLE unit_lessons (
unit_lesson_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unit_id BIGINT NOT NULL REFERENCES units (unit_id) ON DELETE CASCADE,
lesson_id BIGINT NOT NULL REFERENCES lessons (lesson_id) ON DELETE CASCADE,
order_index INTEGER NOT NULL DEFAULT 0,
"createdBy" BIGINT NULL,
"updatedBy" BIGINT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_unit_lessons_unit_lesson UNIQUE (unit_id, lesson_id)
);
CREATE INDEX idx_unit_lessons_unit_id ON unit_lessons (unit_id);
CREATE INDEX idx_unit_lessons_lesson_id ON unit_lessons (lesson_id);
CREATE INDEX idx_unit_lessons_order ON unit_lessons (order_index);
-- ── 2. Backfill from the old direct FKs (preserves ordering) ─────────────────
INSERT INTO course_units (course_id, unit_id, order_index, "createdBy", "createdAt", "updatedAt")
SELECT u.course_id, u.unit_id, COALESCE(u.order_index, 0), u."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM units u
WHERE u.course_id IS NOT NULL;
INSERT INTO unit_lessons (unit_id, lesson_id, order_index, "createdBy", "createdAt", "updatedAt")
SELECT l.unit_id, l.lesson_id, COALESCE(l.order_index, 0), l."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM lessons l
WHERE l.unit_id IS NOT NULL;
-- ── 3. Progress tables — standalone reads carry NULL course/unit context ─────
ALTER TABLE unit_reading_progress ALTER COLUMN course_id DROP NOT NULL;
ALTER TABLE lesson_reading_progress ALTER COLUMN course_id DROP NOT NULL;
ALTER TABLE lesson_reading_progress ALTER COLUMN unit_id DROP NOT NULL;
-- ── 4. Drop the old FK + order columns ───────────────────────────────────────
-- (dependent indexes/constraints on these columns are dropped automatically)
ALTER TABLE units DROP COLUMN course_id;
ALTER TABLE units DROP COLUMN order_index;
ALTER TABLE lessons DROP COLUMN unit_id;
ALTER TABLE lessons DROP COLUMN order_index;
COMMIT;
-- ── Sanity checks (run after commit) ─────────────────────────────────────────
-- SELECT COUNT(*) FROM course_units; -- should equal old COUNT(*) FROM units WHERE course_id IS NOT NULL
-- SELECT COUNT(*) FROM unit_lessons; -- should equal old COUNT(*) FROM lessons WHERE unit_id IS NOT NULL
-- SELECT c.title, u.title, cu.order_index FROM course_units cu
-- JOIN courses c USING (course_id) JOIN units u USING (unit_id)
-- ORDER BY c.title, cu.order_index LIMIT 20;
@@ -0,0 +1,45 @@
'use strict';
// Sequelize's createTable() wraps ENUM creation in a `DO $$...$$` block for
// idempotency, which CockroachDB rejects ("CREATE TYPE usage inside a function
// definition is not supported"). Built as raw SQL with STRING + CHECK
// constraints instead, matching the pattern established in
// 20260710000001-add-status-to-courses.js. Sequelize's DataTypes.ENUM at the
// model layer works fine against a STRING+CHECK column — no model-side change
// needed.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE TABLE completion_requirements (
requirement_id UUID PRIMARY KEY,
entity_type STRING NOT NULL,
entity_id BIGINT NOT NULL,
type STRING NOT NULL,
min_percent INTEGER NULL,
button_label STRING NULL,
is_required BOOLEAN NOT NULL DEFAULT true,
"order" INTEGER NOT NULL DEFAULT 0,
"createdBy" BIGINT NULL,
"updatedBy" BIGINT NULL,
"deletedBy" BIGINT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMPTZ NULL,
CONSTRAINT check_cr_entity_type CHECK (entity_type IN ('course', 'unit', 'lesson')),
CONSTRAINT check_cr_type CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete'))
);
`);
await queryInterface.sequelize.query(
`CREATE INDEX idx_cr_entity_type_entity_id ON completion_requirements (entity_type, entity_id);`
);
await queryInterface.sequelize.query(
`CREATE INDEX idx_cr_type ON completion_requirements (type);`
);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS completion_requirements;`);
},
};
@@ -0,0 +1,38 @@
'use strict';
// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as
// 20260714000001-create-completion-requirements.js.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE TABLE completion_requirement_progress (
progress_id UUID PRIMARY KEY,
requirement_id UUID NOT NULL REFERENCES completion_requirements (requirement_id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
entity_type STRING NOT NULL,
entity_id BIGINT NOT NULL,
progress_percent INTEGER NULL,
completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ NULL,
"createdBy" BIGINT NULL,
"updatedBy" BIGINT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT check_crp2_entity_type CHECK (entity_type IN ('course', 'unit', 'lesson')),
CONSTRAINT uq_crp2_requirement_user UNIQUE (requirement_id, user_id)
);
`);
await queryInterface.sequelize.query(
`CREATE INDEX idx_crp2_user_id ON completion_requirement_progress (user_id);`
);
await queryInterface.sequelize.query(
`CREATE INDEX idx_crp2_entity_type_entity_id ON completion_requirement_progress (entity_type, entity_id);`
);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS completion_requirement_progress;`);
},
};
@@ -0,0 +1,31 @@
'use strict';
// task_requirements.type IS a real DB enum (task_requirement_type), despite
// 20260709000001-add-requires-review-and-prompt-to-task-requirements.js's comment
// claiming otherwise ("Sequelize enforces the ENUM only at the application layer
// for this table, so no constraint migration is needed") — that assumption was
// wrong. submit_text and pass_quiz were added to the Sequelize model (task.mdl.js)
// and the admin RequirementBuilder.jsx UI at the same time, but never to the actual
// DB enum, so creating/querying either type has been failing with
// "invalid input value for enum task_requirement_type" ever since (found while
// verifying the new completion_requirements feature's task auto-complete path,
// which happened to be the first thing to actually query a pass_quiz-typed
// TaskRequirement against this DB).
//
// ALTER TYPE ... ADD VALUE (not CREATE TYPE) is a plain top-level statement, not
// wrapped in Sequelize's problematic DO $$...$$ block, so this runs fine as a
// normal migration — no raw-SQL-outside-migrations workaround needed here.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(
`ALTER TYPE task_requirement_type ADD VALUE IF NOT EXISTS 'submit_text'`
);
await queryInterface.sequelize.query(
`ALTER TYPE task_requirement_type ADD VALUE IF NOT EXISTS 'pass_quiz'`
);
},
// Postgres/CockroachDB cannot remove a value from an enum type — down is a no-op.
async down() {},
};
@@ -0,0 +1,45 @@
'use strict';
// Adds two new completion-requirement types — watch_video and listen_audio —
// alongside the existing watch_percent (adjustable-percent) type. Unlike
// watch_percent, which tracks one aggregate percent across whichever
// video/audio block the learner happens to be playing, these two require
// EVERY block of the matching type on the lesson to individually reach 100%,
// so per-block progress needs its own column (block_progress) rather than
// reusing the single progress_percent figure.
//
// Same CockroachDB constraint as 20260714000001 — DROP+ADD CONSTRAINT instead
// of an ENUM ALTER, since CockroachDB rejects CREATE TYPE inside the DO $$...$$
// block Sequelize wraps enum changes in.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE completion_requirements DROP CONSTRAINT check_cr_type;
`);
await queryInterface.sequelize.query(`
ALTER TABLE completion_requirements
ADD CONSTRAINT check_cr_type
CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete', 'watch_video', 'listen_audio'));
`);
await queryInterface.sequelize.query(`
ALTER TABLE completion_requirement_progress
ADD COLUMN block_progress JSONB NULL;
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE completion_requirement_progress DROP COLUMN IF EXISTS block_progress;
`);
await queryInterface.sequelize.query(`
ALTER TABLE completion_requirements DROP CONSTRAINT check_cr_type;
`);
await queryInterface.sequelize.query(`
ALTER TABLE completion_requirements
ADD CONSTRAINT check_cr_type
CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete'));
`);
},
};
+13 -2
View File
@@ -28,8 +28,19 @@
const csurf = require('csurf'); const csurf = require('csurf');
const R = require('../utils/response.util'); const R = require('../utils/response.util');
/** csurf instance — stores token in a signed cookie */ /**
const csrfProtection = csurf({ cookie: { httpOnly: true, sameSite: 'strict' } }); * csurf instance — stores token in a signed cookie.
* sameSite:'none' (not 'strict') in production — frontend (Vercel) and this
* API (Render) are cross-site, so 'strict' drops the cookie on every
* fetch/XHR call. 'none' requires secure:true, set alongside it below.
*/
const csrfProtection = csurf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
},
});
/** /**
* GET /api/csrf-token * GET /api/csrf-token
@@ -0,0 +1,60 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirement.mdl.js
* Type of Program: Model
* Description: Admin-configured rule describing what "complete" means for a course, unit, or lesson.
*
* CompletionRequirement — polymorphic via (entity_type, entity_id); entity_id is the BIGINT PK of the
* owning courses/units/lessons row (not a UUID reference like TaskRequirement —
* this row is OWNED by the entity, not pointing at an arbitrary target).
* type dispatches through utils/courses/completion_requirements.registry.js.
* Multiple rows on one entity are AND'd together by services/courses/completion.service.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const CompletionRequirement = sequelize.define('CompletionRequirement', {
requirement_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
entity_type: {
type: DataTypes.ENUM('course', 'unit', 'lesson'),
allowNull: false,
filterable: true,
},
entity_id: {
type: DataTypes.BIGINT,
allowNull: false,
comment: 'course_id | unit_id | lesson_id depending on entity_type. No DB-level FK (polymorphic across 3 tables).',
filterable: true,
},
type: {
type: DataTypes.ENUM('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete'),
allowNull: false,
filterable: true,
},
// ── watch_percent ───────────────────────────────────────────────────────
min_percent: { type: DataTypes.INTEGER, allowNull: true, comment: 'watch_percent only — minimum % of media watched.', filterable: false },
// ── manual_complete ─────────────────────────────────────────────────────
button_label: { type: DataTypes.STRING, allowNull: true, comment: 'manual_complete only — optional custom CTA text.', filterable: false },
is_required: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, filterable: true },
order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true },
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'completion_requirements',
paranoid: true,
timestamps: true,
});
module.exports = CompletionRequirement;
@@ -0,0 +1,84 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirement_progress.mdl.js
* Type of Program: Model
* Description: Per-user tracking state for a CompletionRequirement, for the requirement types that need
* genuinely new storage (watch_percent's running progress_percent, watch_video/listen_audio's
* per-block progress_percent map, and manual_complete's completed flag — pass_quiz reads
* QuizAttempt.passed directly, and read_all_content reads CourseReadingProgress directly;
* neither needs a row here).
*
* CompletionRequirementProgress — UPSERT key: (requirement_id, user_id).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const CompletionRequirementProgress = sequelize.define('CompletionRequirementProgress', {
progress_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
requirement_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'completion_requirements', key: 'requirement_id' },
onDelete: 'CASCADE',
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
entity_type: {
type: DataTypes.ENUM('course', 'unit', 'lesson'),
allowNull: false,
comment: 'Denormalized from the requirement row to avoid a join on every read.',
},
entity_id: {
type: DataTypes.BIGINT,
allowNull: false,
comment: 'Denormalized from the requirement row.',
},
progress_percent: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'watch_percent only — running max % watched.',
},
block_progress: {
type: DataTypes.JSONB,
allowNull: true,
comment: 'watch_video/listen_audio only — { [block_id]: percent } running max per matching block; completed once every current block of that type is at 100.',
},
completed: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'completion_requirement_progress',
timestamps: true,
paranoid: false, // progress rows are never soft-deleted, matches CourseReadingProgress
indexes: [
{
unique: true,
fields: ['requirement_id', 'user_id'],
name: 'uq_crp2_requirement_user',
},
{ fields: ['user_id'], name: 'idx_crp2_user_id' },
{ fields: ['entity_type', 'entity_id'], name: 'idx_crp2_entity_type_entity_id' },
],
});
module.exports = CompletionRequirementProgress;
+11
View File
@@ -24,6 +24,8 @@ const CourseReadingProgress = require("./course_reading_progress.mdl");
const UnitReadingProgress = require("./unit_reading_progress.mdl"); const UnitReadingProgress = require("./unit_reading_progress.mdl");
const LessonReadingProgress = require("./lesson_reading_progress.mdl"); const LessonReadingProgress = require("./lesson_reading_progress.mdl");
const CourseAchievement = require("./course_achievement.mdl"); const CourseAchievement = require("./course_achievement.mdl");
const CompletionRequirement = require("./completion_requirement.mdl");
const CompletionRequirementProgress = require("./completion_requirement_progress.mdl");
// ── CourseReadingProgress ───────────────────────────────────────────────────── // ── CourseReadingProgress ─────────────────────────────────────────────────────
CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
@@ -46,6 +48,14 @@ LessonReadingProgress.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesso
mdl_Users.hasMany(LessonReadingProgress, { foreignKey: 'user_id', as: 'lessonReadingProgress' }); mdl_Users.hasMany(LessonReadingProgress, { foreignKey: 'user_id', as: 'lessonReadingProgress' });
Lesson.hasMany(LessonReadingProgress, { foreignKey: 'lesson_id', as: 'readingProgress' }); Lesson.hasMany(LessonReadingProgress, { foreignKey: 'lesson_id', as: 'readingProgress' });
// ── CompletionRequirement ─────────────────────────────────────────────────────
// Polymorphic across course/unit/lesson via (entity_type, entity_id) — no belongsTo
// to Course/Unit/Lesson here (can't express a 3-way FK); entity resolution happens
// in services/courses/completion.service.js.
CompletionRequirement.hasMany(CompletionRequirementProgress, { foreignKey: 'requirement_id', as: 'progress' });
CompletionRequirementProgress.belongsTo(CompletionRequirement, { foreignKey: 'requirement_id', as: 'requirement' });
CompletionRequirementProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
// ── Course ⇄ Unit / Unit ⇄ Lesson (junctions) ───────────────────────────────── // ── Course ⇄ Unit / Unit ⇄ Lesson (junctions) ─────────────────────────────────
// Units and Lessons are standalone entities. Membership + ordering live on the // Units and Lessons are standalone entities. Membership + ordering live on the
// course_units / unit_lessons junction rows (order_index). // course_units / unit_lessons junction rows (order_index).
@@ -138,4 +148,5 @@ module.exports = {
mdl_Category, Certificate, CourseInstructor, mdl_Category, Certificate, CourseInstructor,
CourseReadingProgress, UnitReadingProgress, LessonReadingProgress, CourseReadingProgress, UnitReadingProgress, LessonReadingProgress,
CourseAchievement, CourseAchievement,
CompletionRequirement, CompletionRequirementProgress,
}; };
+18
View File
@@ -1,6 +1,7 @@
const router = require("express").Router(); const router = require("express").Router();
const ctrl = require("../../controllers/admin/courses.controller"); const ctrl = require("../../controllers/admin/courses.controller");
const progressCtrl = require("../../controllers/admin/course_reading_progress.controller"); const progressCtrl = require("../../controllers/admin/course_reading_progress.controller");
const requirementsCtrl = require("../../controllers/admin/completion_requirements.controller");
const { authenticate } = require("../../middleware/auth.middleware"); const { authenticate } = require("../../middleware/auth.middleware");
const { requireAdmin } = require("../../middleware/rbac.middleware"); const { requireAdmin } = require("../../middleware/rbac.middleware");
@@ -59,6 +60,13 @@ router.put("/:courseId/achievements", ctrl.syncCourseAchievements);
router.get("/:courseId/prerequisites", ctrl.getPrerequisites); router.get("/:courseId/prerequisites", ctrl.getPrerequisites);
router.put("/:courseId/prerequisites", ctrl.syncPrerequisites); router.put("/:courseId/prerequisites", ctrl.syncPrerequisites);
// ══════════════════════════════════════════════════════════════════════════════
// COMPLETION REQUIREMENTS
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/requirements", requirementsCtrl.getCourseRequirements);
router.put("/:courseId/requirements", requirementsCtrl.syncCourseRequirements);
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// COURSE ASSESSMENT // COURSE ASSESSMENT
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
@@ -117,6 +125,9 @@ router.put("/:courseId/units/:unitId", ctrl.updateUnit);
router.delete("/:courseId/units/:unitId", ctrl.archiveUnit); router.delete("/:courseId/units/:unitId", ctrl.archiveUnit);
router.delete("/:courseId/units/:unitId/permanent", ctrl.permanentlyDeleteUnit); router.delete("/:courseId/units/:unitId/permanent", ctrl.permanentlyDeleteUnit);
router.get("/:courseId/units/:unitId/requirements", requirementsCtrl.getUnitRequirements);
router.put("/:courseId/units/:unitId/requirements", requirementsCtrl.syncUnitRequirements);
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// UNIT QUIZ // UNIT QUIZ
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
@@ -177,6 +188,13 @@ router.delete("/:courseId/units/:unitId/lessons/:lessonId/permanent", ctrl.perma
router.get("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.getLessonPage); router.get("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.getLessonPage);
router.put("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.upsertLessonPage); router.put("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.upsertLessonPage);
// ══════════════════════════════════════════════════════════════════════════════
// LESSON COMPLETION REQUIREMENTS
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/units/:unitId/lessons/:lessonId/requirements", requirementsCtrl.getLessonRequirements);
router.put("/:courseId/units/:unitId/lessons/:lessonId/requirements", requirementsCtrl.syncLessonRequirements);
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// COURSE READING PROGRESS // COURSE READING PROGRESS
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
+5
View File
@@ -10,6 +10,7 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const router = require("express").Router(); const router = require("express").Router();
const ctrl = require("../../controllers/admin/lessons.controller"); const ctrl = require("../../controllers/admin/lessons.controller");
const requirementsCtrl = require("../../controllers/admin/completion_requirements.controller");
// Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter) // Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter)
@@ -36,4 +37,8 @@ router.delete("/:lessonId/permanent", ctrl.permanentlyDeleteLesson);
router.get("/:lessonId/page", ctrl.getLessonPage); router.get("/:lessonId/page", ctrl.getLessonPage);
router.put("/:lessonId/page", ctrl.upsertLessonPage); router.put("/:lessonId/page", ctrl.upsertLessonPage);
// ── Completion requirements (shared with the nested courses.routes.js mount) ──
router.get("/:lessonId/requirements", requirementsCtrl.getLessonRequirements);
router.put("/:lessonId/requirements", requirementsCtrl.syncLessonRequirements);
module.exports = router; module.exports = router;
+5
View File
@@ -11,6 +11,7 @@
const router = require("express").Router(); const router = require("express").Router();
const ctrl = require("../../controllers/admin/units.controller"); const ctrl = require("../../controllers/admin/units.controller");
const questionCtrl = require("../../controllers/admin/courses.controller"); // shared quiz-question handlers (keyed by quizId) const questionCtrl = require("../../controllers/admin/courses.controller"); // shared quiz-question handlers (keyed by quizId)
const requirementsCtrl = require("../../controllers/admin/completion_requirements.controller");
// Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter) // Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter)
@@ -35,6 +36,10 @@ router.put("/:unitId", ctrl.updateUnit);
router.delete("/:unitId", ctrl.archiveUnit); router.delete("/:unitId", ctrl.archiveUnit);
router.delete("/:unitId/permanent", ctrl.permanentlyDeleteUnit); router.delete("/:unitId/permanent", ctrl.permanentlyDeleteUnit);
// ── Completion requirements (shared with the nested courses.routes.js mount) ──
router.get("/:unitId/requirements", requirementsCtrl.getUnitRequirements);
router.put("/:unitId/requirements", requirementsCtrl.syncUnitRequirements);
// ── Lesson membership (attach / detach / reorder) ───────────────────────────── // ── Lesson membership (attach / detach / reorder) ─────────────────────────────
router.post("/:unitId/lessons", ctrl.attachLessons); router.post("/:unitId/lessons", ctrl.attachLessons);
router.put("/:unitId/lessons/order", ctrl.reorderLessons); router.put("/:unitId/lessons/order", ctrl.reorderLessons);
+2
View File
@@ -44,5 +44,7 @@ router.get( '/:courseId/progress/summary', prog
router.get( '/:courseId/progress', progressCtrl.getCourseProgress); router.get( '/:courseId/progress', progressCtrl.getCourseProgress);
router.get( '/:courseId/task-context', progressCtrl.getCourseTaskContext); router.get( '/:courseId/task-context', progressCtrl.getCourseTaskContext);
router.post('/:courseId/units/:unitId/lessons/:lessonId/progress', progressCtrl.upsertLessonProgress); router.post('/:courseId/units/:unitId/lessons/:lessonId/progress', progressCtrl.upsertLessonProgress);
router.post('/:courseId/units/:unitId/lessons/:lessonId/watch-progress', progressCtrl.upsertWatchProgress);
router.post('/:courseId/units/:unitId/lessons/:lessonId/mark-complete', progressCtrl.markLessonComplete);
module.exports = router; module.exports = router;
+2
View File
@@ -18,5 +18,7 @@ const ctrl = require('../../controllers/client/units.controller');
router.get('/', ctrl.getLessons); router.get('/', ctrl.getLessons);
router.get('/:uuid', ctrl.getLessonByUuid); router.get('/:uuid', ctrl.getLessonByUuid);
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress); router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress);
router.post('/:uuid/mark-complete', ctrl.markStandaloneLessonComplete);
module.exports = router; module.exports = router;
+8 -1
View File
@@ -105,7 +105,14 @@ app.use(session({
secret: process.env.SESSION_SECRET || 'change-me', secret: process.env.SESSION_SECRET || 'change-me',
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' }, // sameSite:'none' (not 'strict') in production — frontend (Vercel) and this
// API (Render) are cross-site, so 'strict' drops the cookie on every
// fetch/XHR call. 'none' requires secure:true, already set above.
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'strict',
},
})); }));
// Global rate limiter (1000 req / 15 min) // Global rate limiter (1000 req / 15 min)
+385
View File
@@ -0,0 +1,385 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirements.service.js
* Type of Program: Service
* Description: Consolidated completion evaluation + persistence. Replaces the duplicated
* derivation bodies previously spread across reading_progress.service.js
* (deriveUnitStatus), course_reading_progress.service.js (deriveUnitStatus /
* deriveCourseStatus), and an inline re-derivation in
* controllers/client/courses.controller.js#getLessonsByUnitUuid — all three now
* delegate to evaluateEntity() from utils/courses/completion_requirements.registry.js.
*
* evaluateEntity — re-exported from the registry (read-only, no persistence).
* recomputeAndPersist — evaluate one entity and upsert the result into the entity's
* system-of-record progress table (course-scoped → CourseReadingProgress;
* standalone/library → Unit/LessonReadingProgress, the only tables that
* tolerate a null course_id).
* recomputeCascade — the lesson-progress entry point: writes the raw client-asserted
* lesson fact, then re-evaluates+persists lesson → unit → course in one
* transaction. Replaces upsertLessonRead in both legacy services.
* recomputeUnitAfterQuiz / recomputeCourseAfterAssessment
* — thin wrappers called after a quiz/assessment submit, so passing a
* quiz/assessment immediately re-triggers parent evaluation instead of
* requiring a subsequent lesson read to notice (closes the gap where
* submitUnitQuiz/submitCourseAssessment never touched reading progress).
* recordWatchProgress / recordManualComplete
* — entry points backing the watch_percent / watch_video / listen_audio /
* manual_complete requirement types, writing CompletionRequirementProgress
* then cascading.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sequelize = require('../config/db.config');
const { evaluateEntity } = require('../utils/courses/completion_requirements.registry');
const CompletionRequirement = require('../models/courses/completion_requirement.mdl');
const CompletionRequirementProgress = require('../models/courses/completion_requirement_progress.mdl');
const Course = require('../models/courses/courses.mdl').Course;
const Unit = require('../models/courses/units.mdl');
const Lesson = require('../models/courses/lessons.mdl');
const LessonPage = require('../models/courses/lesson_page.mdl');
const { upsertProgress } = require('./course_reading_progress.service');
const {
upsertLessonProgress, upsertUnitProgress,
upsertLessonRead: mirrorLessonRead,
} = require('./reading_progress.service');
const { syncCompletedEntitiesToTaskProgress } = require('./task_reading_progress_sync.service');
// ─── Persist one entity's evaluated status into its system-of-record table ──────
async function persistStatus({ entityType, entityId, userId, courseId, referenceId, status }, t) {
if (courseId) {
// Course-scoped: CourseReadingProgress, UUID-keyed, requires a non-null course_id.
await upsertProgress({ userId, courseId, type: entityType, referenceId, status }, t);
return;
}
// Standalone/library: Unit/LessonReadingProgress, the tables that tolerate null course_id.
if (entityType === 'lesson') {
await upsertLessonProgress({ userId, courseId: null, unitId: null, lessonId: entityId, status }, t);
} else if (entityType === 'unit') {
await upsertUnitProgress({ userId, courseId: null, unitId: entityId, status }, t);
}
// No standalone system-of-record for 'course' — courses always have a courseId by definition.
}
/**
* Evaluate one entity and persist the result. Read-then-write — callers inside a cascade
* pass the same transaction so the write is visible to the next level's evaluation.
*/
async function recomputeAndPersist({ entityType, entityId, userId, courseId = null, referenceId }, t) {
const result = await evaluateEntity({ entityType, entityId, userId, courseId, transaction: t });
await persistStatus({ entityType, entityId, userId, courseId, referenceId, status: result.status }, t);
return result;
}
// ─── Main entry point: lesson read → unit → course cascade ──────────────────────
/**
* Called when a user reads (or finishes reading) a lesson via the legacy scroll-trigger
* endpoint. Writes the raw asserted lesson fact, then re-evaluates+persists lesson → unit →
* course in one transaction (a lesson configured with watch_percent/manual_complete/pass_quiz
* will simply ignore the raw fact during its own re-evaluation — see the registry's per-type
* dispatch — so this stays safe to call regardless of what's configured on the lesson).
*
* @param {number} userId
* @param {Object} payload
* @param {number|null} payload.courseId — course BIGINT PK, or null for standalone reads
* @param {string|null} payload.courseUuid
* @param {number|null} payload.unitId — unit BIGINT PK, or null for standalone lesson-only reads
* @param {string|null} payload.unitUuid
* @param {number} payload.lessonId
* @param {string} payload.lessonUuid
* @param {string} payload.lessonStatus — 'in_progress' | 'completed', the client's raw assertion
* @param {import('sequelize').Transaction} [externalTransaction] — reuse an already-open
* transaction (e.g. from recordWatchProgress/recordManualComplete) instead of opening
* and committing a new one, so the requirement-progress write and the cascade stay atomic.
* @returns {{ lesson, unit, course }}
*/
async function recomputeCascade(userId, {
courseId = null, courseUuid = null,
unitId = null, unitUuid = null,
lessonId, lessonUuid, lessonStatus = 'in_progress',
}, externalTransaction = null) {
const t = externalTransaction ?? await sequelize.transaction();
try {
// 1. Raw asserted fact — the data source for read_all_content / default lesson evaluation.
await persistStatus({ entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid, status: lessonStatus }, t);
// 2. Re-evaluate the lesson (respects whatever type is actually configured on it).
const lessonResult = await recomputeAndPersist(
{ entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid }, t
);
// 3. Unit — derived from all sibling lessons (skipped for lesson-only standalone reads).
let unitResult = null;
if (unitId) {
unitResult = await recomputeAndPersist(
{ entityType: 'unit', entityId: unitId, userId, courseId, referenceId: unitUuid }, t
);
}
// 4. Course — derived from all units (skipped for standalone reads, which have no courseId).
let courseResult = null;
if (courseId) {
courseResult = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: courseUuid }, t
);
}
if (!externalTransaction) await t.commit();
// Best-effort mirror into lesson_reading_progress/unit_reading_progress for course-scoped
// reads — standalone reads already write these tables directly as their system of record
// (see persistStatus above). Several admin dashboards (controllers/admin/units.controller.js,
// controllers/admin/courses.controller.js) still read these tables for completion stats;
// this keeps them populated without making them a decision source for the evaluator itself.
// Fired only after our own transaction is durable, and never awaited/allowed to fail the request.
if (courseId && !externalTransaction) {
mirrorLessonRead(userId, { courseId, unitId, lessonId, lessonStatus })
.catch((e) => console.error('[COMPLETION REQUIREMENTS] reading-progress mirror write failed:', e));
}
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) only runs once
// this transaction is durable. When called with an externalTransaction (from
// recordWatchProgress/recordManualComplete), that caller commits and syncs itself instead —
// running it here would read pre-commit state.
let completedTasks = [];
if (!externalTransaction && courseId) {
completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
}
return {
lesson: { lesson_id: lessonId, reference_id: lessonUuid, status: lessonResult.status },
unit: unitId ? { unit_id: unitId, reference_id: unitUuid, status: unitResult.status } : null,
course: courseId ? { course_id: courseId, reference_id: courseUuid, status: courseResult.status } : null,
completed_tasks: completedTasks,
};
} catch (err) {
if (!externalTransaction) await t.rollback();
throw err;
}
}
// ─── Quiz / assessment submit hooks ──────────────────────────────────────────────
// submitUnitQuiz / submitCourseAssessment never touched reading progress before this feature —
// these close that gap so a pass_quiz-configured unit/course reflects completion immediately.
async function recomputeUnitAfterQuiz(userId, { unitId, courseId }, t) {
const unit = await Unit.findOne({ where: { unit_id: unitId }, attributes: ['unit_id', 'uuid'], transaction: t });
if (!unit) return null;
const unitResult = await recomputeAndPersist(
{ entityType: 'unit', entityId: unitId, userId, courseId: courseId ?? null, referenceId: unit.uuid }, t
);
let courseResult = null;
let courseUuid = null;
if (courseId) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t });
if (course) {
courseUuid = course.uuid;
courseResult = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t
);
}
}
// Passing a quiz can complete a read_unit/read_course task requirement even though no
// lesson was ever read — the gap this whole function exists to close, so the task-sync
// needs to run here too, not just from the lesson-progress cascade.
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { unitUuid: unit.uuid, courseUuid });
return { unit: unitResult, course: courseResult, completed_tasks: completedTasks };
}
async function recomputeCourseAfterAssessment(userId, courseId, t) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t });
if (!course) return null;
const result = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t
);
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { courseUuid: course.uuid });
return { ...result, completed_tasks: completedTasks };
}
// ─── watch_percent / manual_complete entry points ────────────────────────────────
/**
* Upsert a lesson's watch progress, then cascade lesson → unit → course. Handles two
* independent requirement types in one write since both can be configured on the same
* lesson simultaneously:
* - watch_percent — one aggregate percent across whichever block is playing, monotonic,
* satisfied once it crosses the requirement's configured min_percent.
* - watch_video / listen_audio — only touched when the caller identifies which block sent
* the update (`blockId`/`blockType`); tracks each matching block's own
* running max in `block_progress`, satisfied once EVERY block of that
* type currently on the lesson's page is at 100 (re-checked against the
* live block set on every write, not a snapshot taken when configured).
* No-ops (returns null) if the lesson has neither type configured.
*/
async function recordWatchProgress(userId, {
lessonId, lessonUuid, unitId = null, unitUuid = null, courseId = null, courseUuid = null,
percent, blockId = null, blockType = null,
}) {
const blockRequirementType = blockType === 'video' ? 'watch_video' : blockType === 'audio' ? 'listen_audio' : null;
const requirements = await CompletionRequirement.findAll({
where: {
entity_type: 'lesson',
entity_id: lessonId,
type: blockRequirementType ? ['watch_percent', blockRequirementType] : 'watch_percent',
},
});
if (!requirements.length) return null;
const percentRequirement = requirements.find((r) => r.type === 'watch_percent');
const blockRequirement = blockId ? requirements.find((r) => r.type === blockRequirementType) : null;
const t = await sequelize.transaction();
try {
let anyCompleted = false;
let aggregatePercent = null;
if (percentRequirement) {
const existing = await CompletionRequirementProgress.findOne({
where: { requirement_id: percentRequirement.requirement_id, user_id: userId }, transaction: t,
});
const nextPercent = Math.max(existing?.progress_percent ?? 0, Math.min(100, Math.max(0, Math.round(percent))));
const completed = nextPercent >= (percentRequirement.min_percent ?? 100);
await CompletionRequirementProgress.upsert({
requirement_id: percentRequirement.requirement_id,
user_id: userId,
entity_type: 'lesson',
entity_id: lessonId,
progress_percent: nextPercent,
completed,
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
aggregatePercent = nextPercent;
if (completed) anyCompleted = true;
}
if (blockRequirement) {
const existing = await CompletionRequirementProgress.findOne({
where: { requirement_id: blockRequirement.requirement_id, user_id: userId }, transaction: t,
});
const prevBlockProgress = existing?.block_progress ?? {};
const clampedPercent = Math.min(100, Math.max(0, Math.round(percent)));
const nextBlockProgress = { ...prevBlockProgress, [blockId]: Math.max(prevBlockProgress[blockId] ?? 0, clampedPercent) };
const page = await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'], transaction: t });
const matchingBlockIds = (page?.blocks ?? []).filter((b) => b.type === blockType).map((b) => b.id);
const completed = matchingBlockIds.length > 0 && matchingBlockIds.every((id) => (nextBlockProgress[id] ?? 0) >= 100);
await CompletionRequirementProgress.upsert({
requirement_id: blockRequirement.requirement_id,
user_id: userId,
entity_type: 'lesson',
entity_id: lessonId,
block_progress: nextBlockProgress,
completed,
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
if (completed) anyCompleted = true;
}
const cascade = await recomputeCascade(userId, {
courseId, courseUuid, unitId, unitUuid,
lessonId, lessonUuid,
// The lesson's own raw fact is irrelevant for a configured lesson (its evaluateEntity
// call ignores it and ANDs the actual per-type checks instead), but recomputeCascade
// still writes it for consistency with any other consumer reading the raw flag directly.
lessonStatus: anyCompleted ? 'completed' : 'in_progress',
}, t);
await t.commit();
// recomputeCascade skipped its own task-sync since it ran under our externalTransaction
// (would've read pre-commit state) — run it now that everything is durable.
const completedTasks = courseId
? await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid })
: [];
return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } };
} catch (err) {
await t.rollback();
throw err;
}
}
/**
* Flip a manual_complete requirement's progress row to completed, then cascade upward from
* whichever entity it's configured on. No-ops (returns null) if no manual_complete requirement
* is configured for that entity.
*/
async function recordManualComplete(userId, { entityType, entityId, lessonId = null, lessonUuid = null, unitId = null, unitUuid = null, courseId = null, courseUuid = null }) {
const requirement = await CompletionRequirement.findOne({
where: { entity_type: entityType, entity_id: entityId, type: 'manual_complete' },
});
if (!requirement) return null;
const t = await sequelize.transaction();
try {
await CompletionRequirementProgress.upsert({
requirement_id: requirement.requirement_id,
user_id: userId,
entity_type: entityType,
entity_id: entityId,
completed: true,
completed_at: new Date(),
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
let result;
if (entityType === 'lesson') {
result = await recomputeCascade(userId, {
courseId, courseUuid, unitId, unitUuid,
lessonId: entityId, lessonUuid,
lessonStatus: 'completed',
}, t);
} else if (entityType === 'unit') {
result = { unit: await recomputeAndPersist({ entityType: 'unit', entityId, userId, courseId, referenceId: unitUuid }, t) };
if (courseId) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['uuid'], transaction: t });
if (course) result.course = await recomputeAndPersist({ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t);
}
} else {
result = { course: await recomputeAndPersist({ entityType: 'course', entityId, userId, courseId: entityId, referenceId: courseUuid }, t) };
}
await t.commit();
// Same reasoning as recordWatchProgress — recomputeCascade (lesson branch) skipped its
// own sync under our externalTransaction; the unit/course branches never called it at all.
const syncUuids = entityType === 'lesson'
? { lessonUuid, unitUuid, courseUuid }
: entityType === 'unit'
? { unitUuid, courseUuid }
: { courseUuid };
result.completed_tasks = courseId || entityType === 'course'
? await syncCompletedEntitiesToTaskProgress(userId, syncUuids)
: [];
return result;
} catch (err) {
await t.rollback();
throw err;
}
}
module.exports = {
evaluateEntity,
recomputeAndPersist,
recomputeCascade,
recomputeUnitAfterQuiz,
recomputeCourseAfterAssessment,
recordWatchProgress,
recordManualComplete,
};
+111 -6
View File
@@ -1,16 +1,31 @@
/*********************************************************************************************************************************************************************** /***********************************************************************************************************************************************************************
* File Name: task_reading_progress_sync.service.js * File Name: task_reading_progress_sync.service.js
* Type of Program: Service * Type of Program: Service
* Description: Backfills task_progress for read_* task requirements from course_reading_progress. * Description: Bridges course/unit/lesson completion (course_reading_progress) to Task
* requirements of type read_course/read_unit/read_lesson, in both directions:
* *
* This covers the case where a user already completed reading a course/unit/lesson * hydrateReadTaskProgress(userId, requirements)
* before a task requiring that item was created or assigned. * — given a list of TaskRequirement rows (typically when a task/task list is newly
* assigned), backfills task_progress for any that reference content the user has
* already completed reading.
*
* syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid })
* — given the UUIDs of course/unit/lesson entities that just reached 'completed'
* (from any trigger — scroll-to-bottom, watch_percent threshold, manual_complete,
* pass_quiz, assessment pass), finds matching TaskRequirement rows the user is
* assigned to and marks them done, then reports any task whose read-only
* requirements are now ALL satisfied (eligible for "auto turned-in" display).
* Called from every completion.service.js entry point — NOT just the legacy
* lesson-progress endpoint — so a unit/course completing via pass_quiz/manual_complete
* alone (no lesson ever read) still satisfies read_unit/read_course task requirements.
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
'use strict'; 'use strict';
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl'); const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
const { TaskProgress } = require('../models/task/task_progress.mdl'); const { TaskProgress } = require('../models/task/task_progress.mdl');
const { Task, TaskRequirement, TaskListGroup } = require('../models/task/task.mdl');
const { mdl_UserGroupMembers } = require('../models/users/user_groups.mdl');
const READ_TYPE_TO_PROGRESS_TYPE = { const READ_TYPE_TO_PROGRESS_TYPE = {
read_course: 'course', read_course: 'course',
@@ -132,7 +147,97 @@ async function hydrateReadTaskProgress(userId, requirements = [], options = {})
return missingRows; return missingRows;
} }
// ─── Accessible task lists (via group membership) ────────────────────────────
async function getAccessibleTaskListIds(userId) {
const memberships = await mdl_UserGroupMembers.findAll({
where: { user_id: userId, deletedAt: null },
attributes: ['group_id'],
});
const groupIds = memberships.map((m) => m.group_id);
if (!groupIds.length) return [];
const taskListGroups = await TaskListGroup.findAll({
where: { group_id: groupIds },
attributes: ['task_list_id'],
});
return [...new Set(taskListGroups.map((tlg) => tlg.task_list_id))];
}
// ─── Sync from a completion event (any trigger) → task_progress ─────────────
/**
* Call after ANY course/unit/lesson reaches 'completed' for a user, regardless of which
* completion-requirement type triggered it. Finds TaskRequirement rows (read_course/
* read_unit/read_lesson) referencing the given UUIDs, among task lists the user's groups
* can access, backfills task_progress via hydrateReadTaskProgress, and reports any task
* whose read-only requirements are now ALL satisfied.
*
* @param {number} userId
* @param {{ lessonUuid?: string, unitUuid?: string, courseUuid?: string }} uuids
* @returns {Promise<{ task_id, task_name }[]>}
*/
async function syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid } = {}) {
const referenceIds = [lessonUuid, unitUuid, courseUuid].filter(Boolean);
if (!referenceIds.length) return [];
const taskListIds = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) return [];
const requirements = await TaskRequirement.findAll({
where: {
reference_id: { [Op.in]: referenceIds },
type: { [Op.in]: READ_REQUIREMENT_TYPES },
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 [];
const newlyCompleted = await hydrateReadTaskProgress(userId, requirements);
if (!newlyCompleted.length) return [];
// Check whether any impacted task now has ALL its read-only requirements satisfied —
// tasks with any non-read requirement (upload_file/visit_link/submit_text/pass_quiz)
// still need manual submission, so they're excluded from auto-turn-in.
const taskIds = [...new Set(newlyCompleted.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_REQUIREMENT_TYPES.includes(r.type));
if (hasNonReadReqs) continue;
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 = allReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`));
if (allDone) {
const task = requirements.find((r) => r.task_id === taskId)?.task;
completedTasks.push({ task_id: taskId, task_name: task?.name ?? '' });
}
}
return completedTasks;
}
module.exports = { module.exports = {
hydrateReadTaskProgress, hydrateReadTaskProgress,
syncCompletedEntitiesToTaskProgress,
getAccessibleTaskListIds,
READ_REQUIREMENT_TYPES, READ_REQUIREMENT_TYPES,
}; };
+5 -1
View File
@@ -37,10 +37,14 @@ const getFingerprintHash = (req) => {
return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex'); return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex');
}; };
// sameSite:'none' (not 'strict') in production — the frontend (Vercel) and
// this API (Render) are different sites, so this cookie only travels on the
// cross-site fetch/XHR calls the frontend makes if SameSite allows it.
// 'none' requires secure:true, which is already forced above in production.
const cookieOptions = (maxAge) => ({ const cookieOptions = (maxAge) => ({
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge, maxAge,
}); });
@@ -0,0 +1,233 @@
'use strict';
/***********************************************************************************************************************************************************************
* File Name: completion_requirements.registry.js
* Type of Program: Utility (type → handler registry)
* Description: Declarative dispatch table for the completion-requirement types
* (read_all_content, pass_quiz, watch_percent, watch_video, listen_audio,
* manual_complete), plus the `evaluateEntity` resolver that walks a
* course/unit/lesson's configured
* CompletionRequirement rows (or falls back to today's implicit rule when
* none are configured) and ANDs the results.
*
* `evaluateEntity` lives here rather than in services/courses/completion.service.js
* because unit/course evaluation is recursive (a unit's `read_all_content` rule
* means "every child lesson evaluates to completed", which itself may be governed
* by that lesson's own configured rule) — keeping the resolver next to the handler
* map it recurses through avoids a circular require with the service layer, which
* instead imports `evaluateEntity` from here and layers persistence/cascading on top.
*
* Read dispatch for "is this lesson/unit's own flag completed" branches on whether
* a course context is present:
* - courseId present → CourseReadingProgress (UUID-keyed, global per user+lesson)
* - courseId null → LessonReadingProgress / UnitReadingProgress (BIGINT-keyed,
* the only tables that support a null course_id — required
* for standalone/library unit-and-lesson consumption, since
* CourseReadingProgress.course_id is NOT NULL in the DB).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
const CompletionRequirementProgress = require('../../models/courses/completion_requirement_progress.mdl');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl');
const Lesson = require('../../models/courses/lessons.mdl');
const UnitQuiz = require('../../models/courses/unit_quiz.mdl');
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const { getCourseUnitIds, getUnitLessonIds } = require('./hierarchy.util');
// ─── Leaf read helpers ─────────────────────────────────────────────────────────
// Shared by the type handlers below AND by services/courses/completion.service.js,
// which uses these same reads immediately after writing progress within one transaction.
// Shared by manual_complete / watch_video / listen_audio — all three are computed
// entirely at write time (recordManualComplete / recordWatchProgress) and just read
// back the persisted `completed` flag here.
async function isProgressCompleted({ requirement, userId, t }) {
const progress = await CompletionRequirementProgress.findOne({
where: { requirement_id: requirement.requirement_id, user_id: userId },
transaction: t,
});
return !!progress?.completed;
}
async function isLessonComplete({ lessonId, userId, courseId, t }) {
if (courseId) {
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId }, attributes: ['uuid'], transaction: t });
if (!lesson) return false;
const row = await CourseReadingProgress.findOne({
where: { user_id: userId, type: 'lesson', reference_id: lesson.uuid, status: 'completed' },
transaction: t,
});
return !!row;
}
const row = await LessonReadingProgress.findOne({
where: { user_id: userId, lesson_id: lessonId, status: 'completed' },
transaction: t,
});
return !!row;
}
async function isUnitQuizPassed({ unitId, userId, t }) {
const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId }, attributes: ['quiz_id'], transaction: t });
if (!quiz) return false;
const attempt = await QuizAttempt.findOne({
where: { quiz_id: quiz.quiz_id, user_id: userId, passed: true },
transaction: t,
});
return !!attempt;
}
async function isCourseAssessmentPassed({ courseId, userId, t }) {
const assessment = await CourseAssessment.findOne({ where: { course_id: courseId }, attributes: ['assessment_id'], transaction: t });
if (!assessment) return false;
const attempt = await QuizAttempt.findOne({
where: { assessment_id: assessment.assessment_id, user_id: userId, passed: true },
transaction: t,
});
return !!attempt;
}
// Every attached (non-archived) lesson under this unit must itself evaluate to 'completed'.
async function areAllLessonsComplete({ unitId, userId, courseId, t }) {
const lessonIds = await getUnitLessonIds(unitId);
if (!lessonIds.length) return false; // no lessons attached — matches old deriveUnitStatus's "!links.length → in_progress"
for (const lessonId of lessonIds) {
const result = await evaluateEntity({ entityType: 'lesson', entityId: lessonId, userId, courseId, transaction: t });
if (result.status !== 'completed') return false;
}
return true;
}
// Every attached (non-archived) unit under this course must itself evaluate to 'completed'.
async function areAllUnitsComplete({ courseId, userId, t }) {
const unitIds = await getCourseUnitIds(courseId);
if (!unitIds.length) return false; // matches old deriveCourseStatus's "!links.length → in_progress"
for (const unitId of unitIds) {
const result = await evaluateEntity({ entityType: 'unit', entityId: unitId, userId, courseId, transaction: t });
if (result.status !== 'completed') return false;
}
return true;
}
// ─── Type handlers ──────────────────────────────────────────────────────────────
const TYPE_HANDLERS = {
read_all_content: {
validEntityTypes: ['course', 'unit', 'lesson'],
isSatisfied: async ({ entityType, entityId, userId, courseId, t }) => {
if (entityType === 'lesson') return isLessonComplete({ lessonId: entityId, userId, courseId, t });
if (entityType === 'unit') return areAllLessonsComplete({ unitId: entityId, userId, courseId, t });
if (entityType === 'course') return areAllUnitsComplete({ courseId: entityId, userId, t });
return false;
},
},
pass_quiz: {
validEntityTypes: ['unit', 'course'],
isSatisfied: async ({ entityType, entityId, userId, t }) => {
if (entityType === 'unit') return isUnitQuizPassed({ unitId: entityId, userId, t });
if (entityType === 'course') return isCourseAssessmentPassed({ courseId: entityId, userId, t });
return false;
},
},
watch_percent: {
validEntityTypes: ['lesson'],
isSatisfied: async ({ userId, requirement, t }) => {
const progress = await CompletionRequirementProgress.findOne({
where: { requirement_id: requirement.requirement_id, user_id: userId },
transaction: t,
});
if (!progress) return false;
if (progress.completed) return true;
const minPercent = requirement.min_percent ?? 100;
return (progress.progress_percent ?? 0) >= minPercent;
},
},
// watch_video / listen_audio: unlike watch_percent (one aggregate figure across
// whichever block is playing), these require EVERY block of the matching type on
// the lesson to individually reach 100% — recordWatchProgress computes that against
// the lesson's current block set and persists the result as `completed`, so reading
// it back here is identical to manual_complete's check.
watch_video: {
validEntityTypes: ['lesson'],
isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }),
},
listen_audio: {
validEntityTypes: ['lesson'],
isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }),
},
manual_complete: {
validEntityTypes: ['course', 'unit', 'lesson'],
isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }),
},
};
// ─── Zero-requirements-configured fallback ──────────────────────────────────────
// Reproduces today's exact implicit behavior so existing content doesn't change
// behavior until an admin explicitly opts into configured requirements.
const DEFAULT_HANDLERS = {
lesson: ({ entityId, userId, courseId, t }) => isLessonComplete({ lessonId: entityId, userId, courseId, t }),
unit: ({ entityId, userId, courseId, t }) => areAllLessonsComplete({ unitId: entityId, userId, courseId, t }),
// A course with no assessment ever built can never reach 'completed' via this default —
// matches the exact (if strict) behavior of the old deriveCourseStatus/hasPassedCourseAssessment.
course: async ({ entityId, userId, t }) => {
const allUnitsRead = await areAllUnitsComplete({ courseId: entityId, userId, t });
if (!allUnitsRead) return false;
return isCourseAssessmentPassed({ courseId: entityId, userId, t });
},
};
// ─── Resolver ────────────────────────────────────────────────────────────────────
/**
* Evaluate whether `entityType`/`entityId` is 'completed' for `userId`, either against
* its configured CompletionRequirement rows (AND'd, `is_required` rows only gate status)
* or — when none are configured — the default implicit rule for that entity_type.
*
* @returns {{ status: 'completed'|'in_progress', evaluated_via: 'configured'|'default', satisfied: string[] }}
*/
async function evaluateEntity({ entityType, entityId, userId, courseId = null, transaction = null }) {
const t = transaction;
const rows = await CompletionRequirement.findAll({
where: { entity_type: entityType, entity_id: entityId },
order: [['order', 'ASC']],
transaction: t,
});
if (!rows.length) {
const completed = await DEFAULT_HANDLERS[entityType]({ entityId, userId, courseId, t });
return { status: completed ? 'completed' : 'in_progress', evaluated_via: 'default', satisfied: [] };
}
const satisfied = [];
for (const row of rows) {
const handler = TYPE_HANDLERS[row.type];
if (!handler) continue; // unknown type — ignore rather than hard-fail evaluation
const ok = await handler.isSatisfied({ entityType, entityId, userId, courseId, requirement: row, t });
if (ok) satisfied.push(row.requirement_id);
}
const requiredRows = rows.filter(r => r.is_required);
const allRequiredSatisfied = requiredRows.every(r => satisfied.includes(r.requirement_id));
return { status: allRequiredSatisfied ? 'completed' : 'in_progress', evaluated_via: 'configured', satisfied };
}
// entity_type → allowed requirement types, for admin-side validation (mirrors on the frontend).
const VALID_ENTITY_TYPES = Object.fromEntries(
Object.entries(TYPE_HANDLERS).map(([type, def]) => [type, def.validEntityTypes])
);
module.exports = {
evaluateEntity,
TYPE_HANDLERS,
VALID_ENTITY_TYPES,
// exported for reuse by services/courses/completion.service.js
isLessonComplete,
isUnitQuizPassed,
isCourseAssessmentPassed,
};