mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added new requirements for lessons and units
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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);
|
||||
@@ -37,6 +37,8 @@ const {
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
UnitReadingProgress, LessonReadingProgress,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
const CompletionRequirement = require("../../models/courses/completion_requirement.mdl");
|
||||
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
@@ -248,6 +250,17 @@ exports.createUnitFull = async (req, res) => {
|
||||
if (lessonInput.blocks?.length > CREATE_UNIT_FULL_LIMITS.maxBlocks) {
|
||||
return R.error(res, `A lesson page can have at most ${CREATE_UNIT_FULL_LIMITS.maxBlocks} blocks.`, 400);
|
||||
}
|
||||
if (lessonInput.requirements !== undefined) {
|
||||
if (!Array.isArray(lessonInput.requirements)) return R.error(res, "Lesson requirements must be a list.", 400);
|
||||
const seenTypes = new Set();
|
||||
for (const r of lessonInput.requirements) {
|
||||
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
|
||||
if (!allowedEntityTypes) return R.error(res, `Unknown requirement type "${r.type}".`, 400);
|
||||
if (!allowedEntityTypes.includes("lesson")) return R.error(res, `"${r.type}" cannot be configured on a lesson.`, 400);
|
||||
if (seenTypes.has(r.type)) return R.error(res, `Duplicate "${r.type}" requirement — only one per lesson is allowed.`, 400);
|
||||
seenTypes.add(r.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const by = createdBy ?? req.user?.user_id ?? null;
|
||||
@@ -278,6 +291,21 @@ exports.createUnitFull = async (req, res) => {
|
||||
|
||||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
|
||||
|
||||
if (lessonInput.requirements?.length) {
|
||||
const rows = lessonInput.requirements.map((r, ri) => ({
|
||||
entity_type: "lesson",
|
||||
entity_id: lesson.lesson_id,
|
||||
type: r.type,
|
||||
order: r.order ?? ri,
|
||||
min_percent: r.type === "watch_percent" ? (r.min_percent ?? 100) : null,
|
||||
button_label: r.type === "manual_complete" ? (r.button_label || null) : null,
|
||||
is_required: r.is_required ?? true,
|
||||
createdBy: by,
|
||||
updatedBy: by,
|
||||
}));
|
||||
await CompletionRequirement.bulkCreate(rows, { transaction: t });
|
||||
}
|
||||
|
||||
await UnitLesson.create({
|
||||
unit_id: unit.unit_id,
|
||||
lesson_id: lesson.lesson_id,
|
||||
|
||||
@@ -66,7 +66,10 @@ const setRefreshCookie = (res, refreshToken) => {
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
||||
// 'none' (not 'strict') in production — frontend (Vercel) and this API
|
||||
// (Render) are cross-site, so 'strict'/'lax' silently drop the cookie on
|
||||
// every fetch/XHR refresh call. 'none' requires secure:true (set above).
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
};
|
||||
@@ -83,7 +86,10 @@ const setGoogleResultCookie = (res, payload) => {
|
||||
res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
// Set right after a top-level redirect (survives 'lax'), but read back via
|
||||
// a cross-site fetch from GET /auth/google/result — 'lax' drops it there
|
||||
// in production since frontend (Vercel) and this API (Render) are cross-site.
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land
|
||||
signed: true,
|
||||
});
|
||||
|
||||
@@ -29,8 +29,7 @@
|
||||
const { Op } = require('sequelize');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
|
||||
const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service');
|
||||
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
|
||||
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
||||
const Certificate = require('../../models/courses/certificate.mdl');
|
||||
const {
|
||||
@@ -63,100 +62,12 @@ async function getAccessibleTaskListIds(userId) {
|
||||
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
|
||||
}
|
||||
|
||||
// ─── Internal helper: sync task_progress after a lesson read ─────────────────
|
||||
// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID
|
||||
// (only for tasks the user is assigned to) and marks them completed in task_progress.
|
||||
// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements
|
||||
// are now satisfied — these are eligible for display as "auto turned-in" on the frontend.
|
||||
async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) {
|
||||
if (lessonStatus !== 'completed') return [];
|
||||
|
||||
const { taskListIds } = await getAccessibleTaskListIds(userId);
|
||||
if (!taskListIds.length) return [];
|
||||
|
||||
// Collect UUIDs to match based on what became completed
|
||||
const matchUuids = [lessonUuid];
|
||||
if (unitStatus === 'completed') matchUuids.push(unitUuid);
|
||||
if (courseStatus === 'completed') matchUuids.push(courseUuid);
|
||||
|
||||
const requirements = await TaskRequirement.findAll({
|
||||
where: {
|
||||
reference_id: { [Op.in]: matchUuids },
|
||||
type: { [Op.in]: ['read_lesson', 'read_unit', 'read_course'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: [{
|
||||
model: Task,
|
||||
as: 'task',
|
||||
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
|
||||
required: true,
|
||||
attributes: ['task_id', 'name', 'task_list_id'],
|
||||
}],
|
||||
attributes: ['requirement_id', 'task_id', 'type', 'reference_id'],
|
||||
});
|
||||
|
||||
if (!requirements.length) return [];
|
||||
|
||||
// Filter to (type, reference_id) pairs that actually became completed this call
|
||||
const toComplete = requirements.filter((req) => {
|
||||
if (req.type === 'read_lesson' && req.reference_id === lessonUuid) return true;
|
||||
if (req.type === 'read_unit' && req.reference_id === unitUuid && unitStatus === 'completed') return true;
|
||||
if (req.type === 'read_course' && req.reference_id === courseUuid && courseStatus === 'completed') return true;
|
||||
return false;
|
||||
});
|
||||
if (!toComplete.length) return [];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Upsert TaskProgress as completed for each matching requirement
|
||||
await Promise.all(toComplete.map((req) =>
|
||||
TaskProgress.upsert(
|
||||
{
|
||||
task_id: req.task_id,
|
||||
requirement_id: req.requirement_id,
|
||||
user_id: userId,
|
||||
reference_id: req.reference_id,
|
||||
type: req.type,
|
||||
completed: true,
|
||||
completed_at: now,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
},
|
||||
{ conflictFields: ['requirement_id', 'user_id', 'reference_id'] }
|
||||
)
|
||||
));
|
||||
|
||||
// Check if any impacted task now has ALL its read requirements done
|
||||
// (only auto-turn-in pure read tasks — tasks with upload_file/visit_link need manual submission)
|
||||
const taskIds = [...new Set(toComplete.map((r) => r.task_id))];
|
||||
const completedTasks = [];
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const allReqs = await TaskRequirement.findAll({
|
||||
where: { task_id: taskId, deletedAt: null },
|
||||
attributes: ['requirement_id', 'type', 'reference_id'],
|
||||
});
|
||||
|
||||
const hasNonReadReqs = allReqs.some((r) => !['read_course', 'read_unit', 'read_lesson'].includes(r.type));
|
||||
if (hasNonReadReqs) continue; // let the user manually submit
|
||||
|
||||
const readReqs = allReqs; // all are read-type at this point
|
||||
|
||||
const doneProgress = await TaskProgress.findAll({
|
||||
where: { task_id: taskId, user_id: userId, completed: true },
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
});
|
||||
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
||||
const allDone = readReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`));
|
||||
|
||||
if (allDone) {
|
||||
const taskName = toComplete.find((r) => r.task_id === taskId)?.task?.name ?? '';
|
||||
completedTasks.push({ task_id: taskId, task_name: taskName });
|
||||
}
|
||||
}
|
||||
|
||||
return completedTasks;
|
||||
}
|
||||
// Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in
|
||||
// services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called
|
||||
// directly from completion_requirements.service.js's cascade/recompute functions — covers every
|
||||
// completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just
|
||||
// this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still
|
||||
// needs its richer { taskListIds, taskListToGroup } shape.
|
||||
|
||||
// =============================================================================
|
||||
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
|
||||
@@ -453,10 +364,11 @@ exports.getCourseTaskContext = async (req, res) => {
|
||||
//
|
||||
// Flow:
|
||||
// 1. Resolve course / unit / lesson to get their UUIDs
|
||||
// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx
|
||||
// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables)
|
||||
// 4. Side-effect B: sync task_progress for matching task requirements
|
||||
// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
|
||||
// 2. Delegate to recomputeCascade (completion_requirements service) — lesson + unit + course
|
||||
// evaluated against any configured CompletionRequirement rows (or the default implicit rule),
|
||||
// all in one transaction
|
||||
// 3. Side-effect: sync task_progress for matching task requirements
|
||||
// 4. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
|
||||
|
||||
exports.upsertLessonProgress = async (req, res) => {
|
||||
try {
|
||||
@@ -485,43 +397,108 @@ exports.upsertLessonProgress = async (req, res) => {
|
||||
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
// ── 1. Primary write: course_reading_progress ─────────────────────────
|
||||
const result = await upsertLessonRead(userId, {
|
||||
// ── 1. Consolidated evaluation + persistence: lesson → unit → course ──
|
||||
const result = await recomputeCascade(userId, {
|
||||
courseId: course.course_id,
|
||||
courseUuid: course.uuid,
|
||||
unitId: unit.unit_id,
|
||||
unitUuid: unit.uuid,
|
||||
lessonId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
lessonStatus: status,
|
||||
});
|
||||
|
||||
// ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ──
|
||||
upsertReadingProgress(userId, {
|
||||
courseId: course.course_id,
|
||||
unitId: unit.unit_id,
|
||||
lessonId: lesson.lesson_id,
|
||||
lessonStatus: status,
|
||||
}).catch((e) => console.error('[READING PROGRESS] piggyback write failed:', e));
|
||||
|
||||
// ── 3. Side-effect B: sync task_progress ──────────────────────────────
|
||||
const completedTasks = await syncTaskProgress(userId, {
|
||||
lessonUuid: lesson.uuid,
|
||||
unitUuid: unit.uuid,
|
||||
courseUuid: course.uuid,
|
||||
lessonStatus: status,
|
||||
unitStatus: result.unit.status,
|
||||
courseStatus: result.course.status,
|
||||
});
|
||||
|
||||
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran
|
||||
// inside recomputeCascade — result.completed_tasks reflects it directly.
|
||||
logActivity(userId, 'lesson_read', {
|
||||
entityType: 'lesson',
|
||||
entityId: lesson.lesson_id,
|
||||
details: { lesson_uuid: lesson.uuid, status },
|
||||
});
|
||||
|
||||
return R.success(res, 'Progress updated.', { ...result, completed_tasks: completedTasks }, 200);
|
||||
return R.success(res, 'Progress updated.', result, 200);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
|
||||
return R.error(res, 'Could not update progress.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── WATCH PROGRESS (watch_percent completion requirement) ────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/watch-progress
|
||||
// Body: { percent, block_id?, block_type? } — running max % of video/audio watched, 0-100.
|
||||
// block_id/block_type identify which block on the lesson's page sent this update — needed
|
||||
// to drive watch_video/listen_audio (every block of that type must individually reach 100);
|
||||
// omit them and only the aggregate watch_percent requirement (if configured) is touched.
|
||||
// No-ops (still 200s) if the lesson has neither requirement type configured.
|
||||
exports.upsertWatchProgress = async (req, res) => {
|
||||
try {
|
||||
const { courseId, unitId, lessonId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const percent = Number(req.body.percent);
|
||||
if (!Number.isFinite(percent)) return R.error(res, 'percent must be a number.', 400);
|
||||
const blockId = req.body.block_id ?? null;
|
||||
const blockType = req.body.block_type ?? null;
|
||||
|
||||
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
|
||||
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
|
||||
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
|
||||
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
|
||||
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
||||
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
||||
]);
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
const result = await recordWatchProgress(userId, {
|
||||
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
|
||||
unitId: unit.unit_id, unitUuid: unit.uuid,
|
||||
courseId: course.course_id, courseUuid: course.uuid,
|
||||
percent, blockId, blockType,
|
||||
});
|
||||
|
||||
return R.success(res, 'Watch progress updated.', result, 200);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][WATCH PROGRESS]', err);
|
||||
return R.error(res, 'Could not update watch progress.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── MARK COMPLETE (manual_complete completion requirement) ───────────────────
|
||||
// =============================================================================
|
||||
|
||||
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/mark-complete
|
||||
// No-ops (still 200s) if the lesson has no configured manual_complete requirement.
|
||||
exports.markLessonComplete = async (req, res) => {
|
||||
try {
|
||||
const { courseId, unitId, lessonId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
|
||||
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
|
||||
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
|
||||
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
|
||||
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
||||
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
||||
]);
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
const result = await recordManualComplete(userId, {
|
||||
entityType: 'lesson', entityId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
unitId: unit.unit_id, unitUuid: unit.uuid,
|
||||
courseId: course.course_id, courseUuid: course.uuid,
|
||||
});
|
||||
|
||||
return R.success(res, 'Lesson marked complete.', result, 200);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][MARK COMPLETE]', err);
|
||||
return R.error(res, 'Could not mark lesson complete.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,6 +37,12 @@ const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl
|
||||
const { gradeSubmission } = require("../../utils/courses/grading.util");
|
||||
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
|
||||
const { onCourseCompleted } = require('../../services/achievements.service');
|
||||
const {
|
||||
evaluateEntity,
|
||||
recomputeUnitAfterQuiz,
|
||||
recomputeCourseAfterAssessment,
|
||||
} = require('../../services/completion_requirements.service');
|
||||
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
|
||||
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
|
||||
const Certificate = require('../../models/courses/certificate.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
@@ -403,21 +409,53 @@ exports.getCourse = async (req, res) => {
|
||||
}));
|
||||
}
|
||||
|
||||
let is_completed = false;
|
||||
// Attach each lesson's resolved completion trigger (read_all_content [default] /
|
||||
// watch_percent / manual_complete) so the reader can dispatch the right UI without a
|
||||
// second round trip. A lesson may have zero configured rows (→ default scroll trigger)
|
||||
// or one row of one of these types (pass_quiz isn't valid on a lesson).
|
||||
const allLessonIds = plain.units?.flatMap((u) => (u.lessons ?? []).map((l) => l.lesson_id)) ?? [];
|
||||
if (allLessonIds.length) {
|
||||
const lessonRequirements = await CompletionRequirement.findAll({
|
||||
where: { entity_type: 'lesson', entity_id: allLessonIds },
|
||||
attributes: ['entity_id', 'type', 'min_percent', 'button_label'],
|
||||
});
|
||||
const byLessonId = new Map();
|
||||
lessonRequirements.forEach((r) => { if (!byLessonId.has(String(r.entity_id))) byLessonId.set(String(r.entity_id), r); });
|
||||
|
||||
plain.units = plain.units.map((u) => ({
|
||||
...u,
|
||||
lessons: (u.lessons ?? []).map((l) => {
|
||||
const row = byLessonId.get(String(l.lesson_id));
|
||||
return {
|
||||
...l,
|
||||
completion: row
|
||||
? { type: row.type, min_percent: row.min_percent, button_label: row.button_label }
|
||||
: { type: 'read_all_content', min_percent: null, button_label: null },
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// has_passed reflects the assessment attempt alone; is_completed is the consolidated
|
||||
// evaluator's result (default rule: all units read AND assessment passed, if one exists —
|
||||
// was previously hardcoded to assessment-pass alone here too, same bug fixed in
|
||||
// submitCourseAssessment/getLessonsByUnitUuid — this is the 5th call site of that bug).
|
||||
if (plain.assessment) {
|
||||
const passedAttempt = await QuizAttempt.findOne({
|
||||
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
|
||||
});
|
||||
is_completed = !!passedAttempt;
|
||||
const questionCount = plain.assessment.questions?.length ?? 0;
|
||||
plain.assessment = {
|
||||
...plain.assessment,
|
||||
has_passed: is_completed,
|
||||
has_passed: !!passedAttempt,
|
||||
question_count: questionCount,
|
||||
questions: undefined,
|
||||
};
|
||||
}
|
||||
plain.is_completed = is_completed;
|
||||
const courseEvaluation = await evaluateEntity({
|
||||
entityType: 'course', entityId: course.course_id, userId: req.user.user_id, courseId: course.course_id,
|
||||
});
|
||||
plain.is_completed = courseEvaluation.status === 'completed';
|
||||
|
||||
const plan_tier = plain.subscription ?? null;
|
||||
|
||||
@@ -920,6 +958,16 @@ exports.submitUnitQuiz = async (req, res) => {
|
||||
{ where: { quiz_id: quiz.quiz_id, user_id, status: 'in_progress' } }
|
||||
);
|
||||
|
||||
// Passing a unit quiz can satisfy a pass_quiz completion requirement on the unit (and
|
||||
// cascade to the course) — previously this endpoint never touched reading progress at all.
|
||||
// Also syncs any read_unit/read_course task requirements the unit/course completion now
|
||||
// satisfies, even though no lesson was read (the gap task-sync used to miss).
|
||||
let completedTasks = [];
|
||||
if (passed) {
|
||||
const evaluation = await recomputeUnitAfterQuiz(user_id, { unitId, courseId });
|
||||
completedTasks = evaluation?.completed_tasks ?? [];
|
||||
}
|
||||
|
||||
return R.success(res, "Quiz submitted.", {
|
||||
attempt_id: attempt.attempt_id,
|
||||
attempt_number: attempt.attempt_number,
|
||||
@@ -928,6 +976,7 @@ exports.submitUnitQuiz = async (req, res) => {
|
||||
passing_score: attempt.passing_score,
|
||||
total_points: totalPoints,
|
||||
earned_points: earnedPoints,
|
||||
completed_tasks: completedTasks,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][QUIZ][SUBMIT]", err);
|
||||
@@ -1039,9 +1088,19 @@ exports.submitCourseAssessment = async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
// BUG FIX: this used to be `course_completed = passed` — certification/achievements fired
|
||||
// on assessment-pass alone, never checking whether the learner had actually read the course.
|
||||
// Now gated on the consolidated evaluator (default rule: all units read AND assessment passed;
|
||||
// or whatever the admin has explicitly configured via CompletionRequirement rows).
|
||||
let course_completed = false;
|
||||
let completedTasks = [];
|
||||
if (passed) {
|
||||
course_completed = true;
|
||||
const evaluation = await recomputeCourseAfterAssessment(user_id, courseId);
|
||||
course_completed = evaluation?.status === 'completed';
|
||||
completedTasks = evaluation?.completed_tasks ?? [];
|
||||
}
|
||||
|
||||
if (course_completed) {
|
||||
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid', 'title'] });
|
||||
const totalCompleted = await QuizAttempt.count({
|
||||
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
|
||||
@@ -1081,6 +1140,7 @@ exports.submitCourseAssessment = async (req, res) => {
|
||||
total_points: totalPoints,
|
||||
earned_points: earnedPoints,
|
||||
course_completed,
|
||||
completed_tasks: completedTasks,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ASSESSMENT][SUBMIT]", err);
|
||||
@@ -1240,20 +1300,29 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
}
|
||||
|
||||
const plain = unit.toJSON();
|
||||
const userId = req.user.user_id;
|
||||
|
||||
// Per-lesson completion for the requesting user. NOTE: lesson_reading_progress
|
||||
// upserts on (user_id, lesson_id) only — a lesson's completion is a property
|
||||
// of the lesson itself, not scoped to whichever unit it was read under.
|
||||
// Per-lesson completion for the requesting user, evaluated against each lesson's own
|
||||
// configured CompletionRequirement rows (or the default implicit rule when none are
|
||||
// configured) via the consolidated evaluator. courseId is null here — this route is
|
||||
// reached both course-scoped and standalone with no course context, and a lesson's
|
||||
// completion is a property of the lesson itself, not scoped to whichever unit/course
|
||||
// it was read under (see completion_requirements.registry.js's courseId-null dispatch,
|
||||
// which reads lesson_reading_progress rather than course_reading_progress).
|
||||
const flatLessons = flattenLessons(plain.lessons);
|
||||
const progressRows = flatLessons.length
|
||||
const lessonCompletedAtRows = flatLessons.length
|
||||
? await LessonReadingProgress.findAll({
|
||||
where: { user_id: req.user.user_id, lesson_id: flatLessons.map((l) => l.lesson_id) },
|
||||
attributes: ["lesson_id", "status", "completed_at"],
|
||||
where: { user_id: userId, lesson_id: flatLessons.map((l) => l.lesson_id) },
|
||||
attributes: ["lesson_id", "completed_at"],
|
||||
})
|
||||
: [];
|
||||
const progressMap = new Map(progressRows.map((p) => [String(p.lesson_id), p]));
|
||||
const completedAtMap = new Map(lessonCompletedAtRows.map((p) => [String(p.lesson_id), p.completed_at]));
|
||||
|
||||
const lessons = flatLessons.map((l) => ({
|
||||
const lessonEvaluations = await Promise.all(
|
||||
flatLessons.map((l) => evaluateEntity({ entityType: "lesson", entityId: l.lesson_id, userId, courseId: null }))
|
||||
);
|
||||
|
||||
const lessons = flatLessons.map((l, i) => ({
|
||||
lesson_id: l.lesson_id,
|
||||
uuid: l.uuid,
|
||||
title: l.title,
|
||||
@@ -1262,20 +1331,24 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
duration_seconds: l.duration_seconds ?? 0,
|
||||
blocks: l.page?.blocks ?? [],
|
||||
objectives: (l.objectives ?? []).slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
|
||||
status: progressMap.get(String(l.lesson_id))?.status ?? "not_started",
|
||||
completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null,
|
||||
status: lessonEvaluations[i].status === "completed" ? "completed" : (completedAtMap.has(String(l.lesson_id)) ? "in_progress" : "not_started"),
|
||||
completed_at: lessonEvaluations[i].status === "completed" ? (completedAtMap.get(String(l.lesson_id)) ?? null) : null,
|
||||
}));
|
||||
|
||||
// Attach has_passed to the quiz stub — same pattern as getCourse's unit list.
|
||||
let quiz = null;
|
||||
if (plain.quiz) {
|
||||
const passedAttempt = await QuizAttempt.findOne({
|
||||
where: { quiz_id: plain.quiz.quiz_id, user_id: req.user.user_id, passed: true },
|
||||
where: { quiz_id: plain.quiz.quiz_id, user_id: userId, passed: true },
|
||||
});
|
||||
quiz = { ...plain.quiz, has_passed: !!passedAttempt };
|
||||
}
|
||||
|
||||
const is_completed = lessons.length > 0 && lessons.every((l) => l.status === "completed");
|
||||
// Consolidated evaluator — replaces the old inline `lessons.every(status === "completed")`
|
||||
// re-derivation, which drifted from the POST-progress path's own unit derivation. Now both
|
||||
// read and write paths go through the same evaluateEntity() call.
|
||||
const unitEvaluation = await evaluateEntity({ entityType: "unit", entityId: unit.unit_id, userId, courseId: null });
|
||||
const is_completed = unitEvaluation.status === "completed";
|
||||
|
||||
return R.success(res, "Unit lessons retrieved.", {
|
||||
unit_id: unit.unit_id,
|
||||
|
||||
@@ -43,7 +43,7 @@ const {
|
||||
const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers
|
||||
const { gradeSubmission } = require("../../utils/courses/grading.util");
|
||||
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
|
||||
const { upsertLessonRead } = require("../../services/reading_progress.service");
|
||||
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
@@ -343,8 +343,11 @@ exports.saveUnitQuizDraft = async (req, res) => {
|
||||
|
||||
// ─── STANDALONE LESSON PROGRESS ───────────────────────────────────────────────
|
||||
// POST /client/lessons/:uuid/progress Body: { status, unit_uuid? }
|
||||
// Writes lesson_reading_progress with course_id NULL. When unit_uuid is given
|
||||
// (unit context, still no course) the parent unit row is derived + upserted too.
|
||||
// Evaluated + written via completion_requirements.service#recomputeCascade with
|
||||
// courseId null, which persists to lesson_reading_progress/unit_reading_progress
|
||||
// (the tables that tolerate a null course_id) instead of course_reading_progress.
|
||||
// When unit_uuid is given (unit context, still no course) the parent unit is
|
||||
// re-evaluated + upserted too, against any configured CompletionRequirement rows.
|
||||
|
||||
exports.upsertStandaloneLessonProgress = async (req, res) => {
|
||||
try {
|
||||
@@ -369,10 +372,12 @@ exports.upsertStandaloneLessonProgress = async (req, res) => {
|
||||
unitId = unit.unit_id;
|
||||
}
|
||||
|
||||
const result = await upsertLessonRead(userId, {
|
||||
const result = await recomputeCascade(userId, {
|
||||
courseId: null,
|
||||
unitId,
|
||||
unitUuid,
|
||||
lessonId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
lessonStatus: status,
|
||||
});
|
||||
|
||||
@@ -389,6 +394,82 @@ exports.upsertStandaloneLessonProgress = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── STANDALONE WATCH PROGRESS ─────────────────────────────────────────────────
|
||||
// POST /client/lessons/:uuid/watch-progress Body: { percent, unit_uuid?, block_id?, block_type? }
|
||||
// block_id/block_type identify which block on the lesson's page sent this update — needed
|
||||
// to drive watch_video/listen_audio; omit them and only watch_percent (if configured) is touched.
|
||||
exports.upsertStandaloneWatchProgress = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const percent = Number(req.body.percent);
|
||||
if (!Number.isFinite(percent)) return R.error(res, "percent must be a number.", 400);
|
||||
const unitUuid = req.body.unit_uuid ?? null;
|
||||
const blockId = req.body.block_id ?? null;
|
||||
const blockType = req.body.block_type ?? null;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
|
||||
return R.error(res, "You do not have access to this lesson.", 403);
|
||||
}
|
||||
|
||||
let unitId = null;
|
||||
if (unitUuid) {
|
||||
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
unitId = unit.unit_id;
|
||||
}
|
||||
|
||||
const result = await recordWatchProgress(userId, {
|
||||
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
|
||||
unitId, unitUuid,
|
||||
courseId: null, courseUuid: null,
|
||||
percent, blockId, blockType,
|
||||
});
|
||||
|
||||
return R.success(res, "Watch progress updated.", result, 200);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][STANDALONE WATCH PROGRESS]", err);
|
||||
return R.error(res, "Could not update watch progress.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── STANDALONE MARK COMPLETE ──────────────────────────────────────────────────
|
||||
// POST /client/lessons/:uuid/mark-complete Body: { unit_uuid? }
|
||||
exports.markStandaloneLessonComplete = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const unitUuid = req.body.unit_uuid ?? null;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
|
||||
return R.error(res, "You do not have access to this lesson.", 403);
|
||||
}
|
||||
|
||||
let unitId = null;
|
||||
if (unitUuid) {
|
||||
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
unitId = unit.unit_id;
|
||||
}
|
||||
|
||||
const result = await recordManualComplete(userId, {
|
||||
entityType: "lesson", entityId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
unitId, unitUuid,
|
||||
courseId: null, courseUuid: null,
|
||||
});
|
||||
|
||||
return R.success(res, "Lesson marked complete.", result, 200);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][STANDALONE MARK COMPLETE]", err);
|
||||
return R.error(res, "Could not mark lesson complete.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Shared UUID handlers re-exported for the standalone routes ───────────────
|
||||
|
||||
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
|
||||
|
||||
@@ -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'));
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -28,8 +28,19 @@
|
||||
const csurf = require('csurf');
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
@@ -24,6 +24,8 @@ const CourseReadingProgress = require("./course_reading_progress.mdl");
|
||||
const UnitReadingProgress = require("./unit_reading_progress.mdl");
|
||||
const LessonReadingProgress = require("./lesson_reading_progress.mdl");
|
||||
const CourseAchievement = require("./course_achievement.mdl");
|
||||
const CompletionRequirement = require("./completion_requirement.mdl");
|
||||
const CompletionRequirementProgress = require("./completion_requirement_progress.mdl");
|
||||
|
||||
// ── CourseReadingProgress ─────────────────────────────────────────────────────
|
||||
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' });
|
||||
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) ─────────────────────────────────
|
||||
// Units and Lessons are standalone entities. Membership + ordering live on the
|
||||
// course_units / unit_lessons junction rows (order_index).
|
||||
@@ -138,4 +148,5 @@ module.exports = {
|
||||
mdl_Category, Certificate, CourseInstructor,
|
||||
CourseReadingProgress, UnitReadingProgress, LessonReadingProgress,
|
||||
CourseAchievement,
|
||||
CompletionRequirement, CompletionRequirementProgress,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const router = require("express").Router();
|
||||
const ctrl = require("../../controllers/admin/courses.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 { requireAdmin } = require("../../middleware/rbac.middleware");
|
||||
@@ -59,6 +60,13 @@ router.put("/:courseId/achievements", ctrl.syncCourseAchievements);
|
||||
router.get("/:courseId/prerequisites", ctrl.getPrerequisites);
|
||||
router.put("/:courseId/prerequisites", ctrl.syncPrerequisites);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COMPLETION REQUIREMENTS
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
router.get("/:courseId/requirements", requirementsCtrl.getCourseRequirements);
|
||||
router.put("/:courseId/requirements", requirementsCtrl.syncCourseRequirements);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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/permanent", ctrl.permanentlyDeleteUnit);
|
||||
|
||||
router.get("/:courseId/units/:unitId/requirements", requirementsCtrl.getUnitRequirements);
|
||||
router.put("/:courseId/units/:unitId/requirements", requirementsCtrl.syncUnitRequirements);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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.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
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
const router = require("express").Router();
|
||||
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)
|
||||
|
||||
@@ -36,4 +37,8 @@ router.delete("/:lessonId/permanent", ctrl.permanentlyDeleteLesson);
|
||||
router.get("/:lessonId/page", ctrl.getLessonPage);
|
||||
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;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
const router = require("express").Router();
|
||||
const ctrl = require("../../controllers/admin/units.controller");
|
||||
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)
|
||||
|
||||
@@ -35,6 +36,10 @@ router.put("/:unitId", ctrl.updateUnit);
|
||||
router.delete("/:unitId", ctrl.archiveUnit);
|
||||
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) ─────────────────────────────
|
||||
router.post("/:unitId/lessons", ctrl.attachLessons);
|
||||
router.put("/:unitId/lessons/order", ctrl.reorderLessons);
|
||||
|
||||
@@ -44,5 +44,7 @@ router.get( '/:courseId/progress/summary', prog
|
||||
router.get( '/:courseId/progress', progressCtrl.getCourseProgress);
|
||||
router.get( '/:courseId/task-context', progressCtrl.getCourseTaskContext);
|
||||
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;
|
||||
@@ -18,5 +18,7 @@ const ctrl = require('../../controllers/client/units.controller');
|
||||
router.get('/', ctrl.getLessons);
|
||||
router.get('/:uuid', ctrl.getLessonByUuid);
|
||||
router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress);
|
||||
router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress);
|
||||
router.post('/:uuid/mark-complete', ctrl.markStandaloneLessonComplete);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -105,7 +105,14 @@ app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'change-me',
|
||||
resave: 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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,16 +1,31 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: task_reading_progress_sync.service.js
|
||||
* 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
|
||||
* before a task requiring that item was created or assigned.
|
||||
* hydrateReadTaskProgress(userId, requirements)
|
||||
* — 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';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
||||
const { TaskProgress } = require('../models/task/task_progress.mdl');
|
||||
const { Op } = require('sequelize');
|
||||
const CourseReadingProgress = require('../models/courses/course_reading_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 = {
|
||||
read_course: 'course',
|
||||
@@ -132,7 +147,97 @@ async function hydrateReadTaskProgress(userId, requirements = [], options = {})
|
||||
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 = {
|
||||
hydrateReadTaskProgress,
|
||||
syncCompletedEntitiesToTaskProgress,
|
||||
getAccessibleTaskListIds,
|
||||
READ_REQUIREMENT_TYPES,
|
||||
};
|
||||
|
||||
@@ -37,10 +37,14 @@ const getFingerprintHash = (req) => {
|
||||
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) => ({
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user