added new requirements for lessons and units

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-15 16:26:09 +08:00
parent b7d62b3b18
commit 9c82b0de09
25 changed files with 1569 additions and 233 deletions
+385
View File
@@ -0,0 +1,385 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirements.service.js
* Type of Program: Service
* Description: Consolidated completion evaluation + persistence. Replaces the duplicated
* derivation bodies previously spread across reading_progress.service.js
* (deriveUnitStatus), course_reading_progress.service.js (deriveUnitStatus /
* deriveCourseStatus), and an inline re-derivation in
* controllers/client/courses.controller.js#getLessonsByUnitUuid — all three now
* delegate to evaluateEntity() from utils/courses/completion_requirements.registry.js.
*
* evaluateEntity — re-exported from the registry (read-only, no persistence).
* recomputeAndPersist — evaluate one entity and upsert the result into the entity's
* system-of-record progress table (course-scoped → CourseReadingProgress;
* standalone/library → Unit/LessonReadingProgress, the only tables that
* tolerate a null course_id).
* recomputeCascade — the lesson-progress entry point: writes the raw client-asserted
* lesson fact, then re-evaluates+persists lesson → unit → course in one
* transaction. Replaces upsertLessonRead in both legacy services.
* recomputeUnitAfterQuiz / recomputeCourseAfterAssessment
* — thin wrappers called after a quiz/assessment submit, so passing a
* quiz/assessment immediately re-triggers parent evaluation instead of
* requiring a subsequent lesson read to notice (closes the gap where
* submitUnitQuiz/submitCourseAssessment never touched reading progress).
* recordWatchProgress / recordManualComplete
* — entry points backing the watch_percent / watch_video / listen_audio /
* manual_complete requirement types, writing CompletionRequirementProgress
* then cascading.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sequelize = require('../config/db.config');
const { evaluateEntity } = require('../utils/courses/completion_requirements.registry');
const CompletionRequirement = require('../models/courses/completion_requirement.mdl');
const CompletionRequirementProgress = require('../models/courses/completion_requirement_progress.mdl');
const Course = require('../models/courses/courses.mdl').Course;
const Unit = require('../models/courses/units.mdl');
const Lesson = require('../models/courses/lessons.mdl');
const LessonPage = require('../models/courses/lesson_page.mdl');
const { upsertProgress } = require('./course_reading_progress.service');
const {
upsertLessonProgress, upsertUnitProgress,
upsertLessonRead: mirrorLessonRead,
} = require('./reading_progress.service');
const { syncCompletedEntitiesToTaskProgress } = require('./task_reading_progress_sync.service');
// ─── Persist one entity's evaluated status into its system-of-record table ──────
async function persistStatus({ entityType, entityId, userId, courseId, referenceId, status }, t) {
if (courseId) {
// Course-scoped: CourseReadingProgress, UUID-keyed, requires a non-null course_id.
await upsertProgress({ userId, courseId, type: entityType, referenceId, status }, t);
return;
}
// Standalone/library: Unit/LessonReadingProgress, the tables that tolerate null course_id.
if (entityType === 'lesson') {
await upsertLessonProgress({ userId, courseId: null, unitId: null, lessonId: entityId, status }, t);
} else if (entityType === 'unit') {
await upsertUnitProgress({ userId, courseId: null, unitId: entityId, status }, t);
}
// No standalone system-of-record for 'course' — courses always have a courseId by definition.
}
/**
* Evaluate one entity and persist the result. Read-then-write — callers inside a cascade
* pass the same transaction so the write is visible to the next level's evaluation.
*/
async function recomputeAndPersist({ entityType, entityId, userId, courseId = null, referenceId }, t) {
const result = await evaluateEntity({ entityType, entityId, userId, courseId, transaction: t });
await persistStatus({ entityType, entityId, userId, courseId, referenceId, status: result.status }, t);
return result;
}
// ─── Main entry point: lesson read → unit → course cascade ──────────────────────
/**
* Called when a user reads (or finishes reading) a lesson via the legacy scroll-trigger
* endpoint. Writes the raw asserted lesson fact, then re-evaluates+persists lesson → unit →
* course in one transaction (a lesson configured with watch_percent/manual_complete/pass_quiz
* will simply ignore the raw fact during its own re-evaluation — see the registry's per-type
* dispatch — so this stays safe to call regardless of what's configured on the lesson).
*
* @param {number} userId
* @param {Object} payload
* @param {number|null} payload.courseId — course BIGINT PK, or null for standalone reads
* @param {string|null} payload.courseUuid
* @param {number|null} payload.unitId — unit BIGINT PK, or null for standalone lesson-only reads
* @param {string|null} payload.unitUuid
* @param {number} payload.lessonId
* @param {string} payload.lessonUuid
* @param {string} payload.lessonStatus — 'in_progress' | 'completed', the client's raw assertion
* @param {import('sequelize').Transaction} [externalTransaction] — reuse an already-open
* transaction (e.g. from recordWatchProgress/recordManualComplete) instead of opening
* and committing a new one, so the requirement-progress write and the cascade stay atomic.
* @returns {{ lesson, unit, course }}
*/
async function recomputeCascade(userId, {
courseId = null, courseUuid = null,
unitId = null, unitUuid = null,
lessonId, lessonUuid, lessonStatus = 'in_progress',
}, externalTransaction = null) {
const t = externalTransaction ?? await sequelize.transaction();
try {
// 1. Raw asserted fact — the data source for read_all_content / default lesson evaluation.
await persistStatus({ entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid, status: lessonStatus }, t);
// 2. Re-evaluate the lesson (respects whatever type is actually configured on it).
const lessonResult = await recomputeAndPersist(
{ entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid }, t
);
// 3. Unit — derived from all sibling lessons (skipped for lesson-only standalone reads).
let unitResult = null;
if (unitId) {
unitResult = await recomputeAndPersist(
{ entityType: 'unit', entityId: unitId, userId, courseId, referenceId: unitUuid }, t
);
}
// 4. Course — derived from all units (skipped for standalone reads, which have no courseId).
let courseResult = null;
if (courseId) {
courseResult = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: courseUuid }, t
);
}
if (!externalTransaction) await t.commit();
// Best-effort mirror into lesson_reading_progress/unit_reading_progress for course-scoped
// reads — standalone reads already write these tables directly as their system of record
// (see persistStatus above). Several admin dashboards (controllers/admin/units.controller.js,
// controllers/admin/courses.controller.js) still read these tables for completion stats;
// this keeps them populated without making them a decision source for the evaluator itself.
// Fired only after our own transaction is durable, and never awaited/allowed to fail the request.
if (courseId && !externalTransaction) {
mirrorLessonRead(userId, { courseId, unitId, lessonId, lessonStatus })
.catch((e) => console.error('[COMPLETION REQUIREMENTS] reading-progress mirror write failed:', e));
}
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) only runs once
// this transaction is durable. When called with an externalTransaction (from
// recordWatchProgress/recordManualComplete), that caller commits and syncs itself instead —
// running it here would read pre-commit state.
let completedTasks = [];
if (!externalTransaction && courseId) {
completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
}
return {
lesson: { lesson_id: lessonId, reference_id: lessonUuid, status: lessonResult.status },
unit: unitId ? { unit_id: unitId, reference_id: unitUuid, status: unitResult.status } : null,
course: courseId ? { course_id: courseId, reference_id: courseUuid, status: courseResult.status } : null,
completed_tasks: completedTasks,
};
} catch (err) {
if (!externalTransaction) await t.rollback();
throw err;
}
}
// ─── Quiz / assessment submit hooks ──────────────────────────────────────────────
// submitUnitQuiz / submitCourseAssessment never touched reading progress before this feature —
// these close that gap so a pass_quiz-configured unit/course reflects completion immediately.
async function recomputeUnitAfterQuiz(userId, { unitId, courseId }, t) {
const unit = await Unit.findOne({ where: { unit_id: unitId }, attributes: ['unit_id', 'uuid'], transaction: t });
if (!unit) return null;
const unitResult = await recomputeAndPersist(
{ entityType: 'unit', entityId: unitId, userId, courseId: courseId ?? null, referenceId: unit.uuid }, t
);
let courseResult = null;
let courseUuid = null;
if (courseId) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t });
if (course) {
courseUuid = course.uuid;
courseResult = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t
);
}
}
// Passing a quiz can complete a read_unit/read_course task requirement even though no
// lesson was ever read — the gap this whole function exists to close, so the task-sync
// needs to run here too, not just from the lesson-progress cascade.
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { unitUuid: unit.uuid, courseUuid });
return { unit: unitResult, course: courseResult, completed_tasks: completedTasks };
}
async function recomputeCourseAfterAssessment(userId, courseId, t) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t });
if (!course) return null;
const result = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t
);
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { courseUuid: course.uuid });
return { ...result, completed_tasks: completedTasks };
}
// ─── watch_percent / manual_complete entry points ────────────────────────────────
/**
* Upsert a lesson's watch progress, then cascade lesson → unit → course. Handles two
* independent requirement types in one write since both can be configured on the same
* lesson simultaneously:
* - watch_percent — one aggregate percent across whichever block is playing, monotonic,
* satisfied once it crosses the requirement's configured min_percent.
* - watch_video / listen_audio — only touched when the caller identifies which block sent
* the update (`blockId`/`blockType`); tracks each matching block's own
* running max in `block_progress`, satisfied once EVERY block of that
* type currently on the lesson's page is at 100 (re-checked against the
* live block set on every write, not a snapshot taken when configured).
* No-ops (returns null) if the lesson has neither type configured.
*/
async function recordWatchProgress(userId, {
lessonId, lessonUuid, unitId = null, unitUuid = null, courseId = null, courseUuid = null,
percent, blockId = null, blockType = null,
}) {
const blockRequirementType = blockType === 'video' ? 'watch_video' : blockType === 'audio' ? 'listen_audio' : null;
const requirements = await CompletionRequirement.findAll({
where: {
entity_type: 'lesson',
entity_id: lessonId,
type: blockRequirementType ? ['watch_percent', blockRequirementType] : 'watch_percent',
},
});
if (!requirements.length) return null;
const percentRequirement = requirements.find((r) => r.type === 'watch_percent');
const blockRequirement = blockId ? requirements.find((r) => r.type === blockRequirementType) : null;
const t = await sequelize.transaction();
try {
let anyCompleted = false;
let aggregatePercent = null;
if (percentRequirement) {
const existing = await CompletionRequirementProgress.findOne({
where: { requirement_id: percentRequirement.requirement_id, user_id: userId }, transaction: t,
});
const nextPercent = Math.max(existing?.progress_percent ?? 0, Math.min(100, Math.max(0, Math.round(percent))));
const completed = nextPercent >= (percentRequirement.min_percent ?? 100);
await CompletionRequirementProgress.upsert({
requirement_id: percentRequirement.requirement_id,
user_id: userId,
entity_type: 'lesson',
entity_id: lessonId,
progress_percent: nextPercent,
completed,
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
aggregatePercent = nextPercent;
if (completed) anyCompleted = true;
}
if (blockRequirement) {
const existing = await CompletionRequirementProgress.findOne({
where: { requirement_id: blockRequirement.requirement_id, user_id: userId }, transaction: t,
});
const prevBlockProgress = existing?.block_progress ?? {};
const clampedPercent = Math.min(100, Math.max(0, Math.round(percent)));
const nextBlockProgress = { ...prevBlockProgress, [blockId]: Math.max(prevBlockProgress[blockId] ?? 0, clampedPercent) };
const page = await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'], transaction: t });
const matchingBlockIds = (page?.blocks ?? []).filter((b) => b.type === blockType).map((b) => b.id);
const completed = matchingBlockIds.length > 0 && matchingBlockIds.every((id) => (nextBlockProgress[id] ?? 0) >= 100);
await CompletionRequirementProgress.upsert({
requirement_id: blockRequirement.requirement_id,
user_id: userId,
entity_type: 'lesson',
entity_id: lessonId,
block_progress: nextBlockProgress,
completed,
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
if (completed) anyCompleted = true;
}
const cascade = await recomputeCascade(userId, {
courseId, courseUuid, unitId, unitUuid,
lessonId, lessonUuid,
// The lesson's own raw fact is irrelevant for a configured lesson (its evaluateEntity
// call ignores it and ANDs the actual per-type checks instead), but recomputeCascade
// still writes it for consistency with any other consumer reading the raw flag directly.
lessonStatus: anyCompleted ? 'completed' : 'in_progress',
}, t);
await t.commit();
// recomputeCascade skipped its own task-sync since it ran under our externalTransaction
// (would've read pre-commit state) — run it now that everything is durable.
const completedTasks = courseId
? await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid })
: [];
return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } };
} catch (err) {
await t.rollback();
throw err;
}
}
/**
* Flip a manual_complete requirement's progress row to completed, then cascade upward from
* whichever entity it's configured on. No-ops (returns null) if no manual_complete requirement
* is configured for that entity.
*/
async function recordManualComplete(userId, { entityType, entityId, lessonId = null, lessonUuid = null, unitId = null, unitUuid = null, courseId = null, courseUuid = null }) {
const requirement = await CompletionRequirement.findOne({
where: { entity_type: entityType, entity_id: entityId, type: 'manual_complete' },
});
if (!requirement) return null;
const t = await sequelize.transaction();
try {
await CompletionRequirementProgress.upsert({
requirement_id: requirement.requirement_id,
user_id: userId,
entity_type: entityType,
entity_id: entityId,
completed: true,
completed_at: new Date(),
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
let result;
if (entityType === 'lesson') {
result = await recomputeCascade(userId, {
courseId, courseUuid, unitId, unitUuid,
lessonId: entityId, lessonUuid,
lessonStatus: 'completed',
}, t);
} else if (entityType === 'unit') {
result = { unit: await recomputeAndPersist({ entityType: 'unit', entityId, userId, courseId, referenceId: unitUuid }, t) };
if (courseId) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['uuid'], transaction: t });
if (course) result.course = await recomputeAndPersist({ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t);
}
} else {
result = { course: await recomputeAndPersist({ entityType: 'course', entityId, userId, courseId: entityId, referenceId: courseUuid }, t) };
}
await t.commit();
// Same reasoning as recordWatchProgress — recomputeCascade (lesson branch) skipped its
// own sync under our externalTransaction; the unit/course branches never called it at all.
const syncUuids = entityType === 'lesson'
? { lessonUuid, unitUuid, courseUuid }
: entityType === 'unit'
? { unitUuid, courseUuid }
: { courseUuid };
result.completed_tasks = courseId || entityType === 'course'
? await syncCompletedEntitiesToTaskProgress(userId, syncUuids)
: [];
return result;
} catch (err) {
await t.rollback();
throw err;
}
}
module.exports = {
evaluateEntity,
recomputeAndPersist,
recomputeCascade,
recomputeUnitAfterQuiz,
recomputeCourseAfterAssessment,
recordWatchProgress,
recordManualComplete,
};
+111 -6
View File
@@ -1,16 +1,31 @@
/***********************************************************************************************************************************************************************
* File Name: task_reading_progress_sync.service.js
* 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,
};
+5 -1
View File
@@ -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,
});