course,tasklist,task and completed validation

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-17 13:04:27 +08:00
parent 612805acaf
commit d49e3be4d2
26 changed files with 1174 additions and 69 deletions
+91 -16
View File
@@ -144,9 +144,11 @@ async function recomputeCascade(userId, {
// 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.
// running it here would read pre-commit state. Runs regardless of courseId — a
// standalone (no parent course) lesson/unit read_lesson/read_unit task requirement
// needs this too, not just course-scoped ones.
let completedTasks = [];
if (!externalTransaction && courseId) {
if (!externalTransaction) {
completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
}
@@ -224,6 +226,52 @@ async function recomputeCourseAfterAssessment(userId, courseId, t) {
// blocks needs both kinds counted together when checking "every video block hit 100%".
const VIDEO_LIKE_BLOCK_TYPES = ['video', 'text-video'];
// ─── Anti-gaming: wall-clock validation for reported watch progress ─────────────
// A single client-reported percent (whether from a scrubbed-to-the-end seek or a
// direct API call) is never trusted at face value. Each sample is checked against
// how much real wall-clock time has elapsed since that block's last recorded
// sample, allowing for playback up to MAX_PLAYBACK_SPEED — anything reported
// faster than that is clamped down to what's actually plausible.
const BASELINE_CEILING_PERCENT = 5; // a block's very first-ever sample is capped here
// Small fixed jitter buffer for network/DB latency between consecutive samples — kept
// low because it's an ABSOLUTE-seconds allowance added on top of real elapsed time, so a
// large value would let two back-to-back calls claim a big percent jump on short clips
// regardless of actual elapsed time. Legitimate throttled playback (~10s apart) is still
// credited in full since real elapsed time carries most of the allowance there.
const TOLERANCE_SECONDS = 2;
const MAX_PLAYBACK_SPEED = 2; // matches the client UI's speed cap — do not diverge from it
function getBlockDuration(page, blockId) {
const block = (page?.blocks ?? []).find((b) => String(b.id) === String(blockId));
return Number(block?.content?.duration_seconds) || 0;
}
// Returns the percent a sample should actually be credited with, after validating
// it against real elapsed wall-clock time since the block's last recorded sample.
function reconcileBlockSample({ prevEntry, reportedPercent, durationSeconds, now }) {
const clampedReported = Math.min(100, Math.max(0, Math.round(reportedPercent)));
if (!prevEntry) {
// Cold start: no prior sample to check elapsed time against. Accept as a
// baseline only, hard-capped — a bare first-ever call (e.g. a direct API
// replay bypassing the UI entirely) can never claim large/complete progress.
return { percent: Math.min(clampedReported, BASELINE_CEILING_PERCENT), updatedAt: now, isFirstSample: true };
}
const delta = clampedReported - prevEntry.percent;
if (delta <= 0 || !durationSeconds) {
// Not a forward increase (rewatch/no-op), or duration unknown — fail open
// rather than blocking tracking for content whose duration hasn't backfilled.
return { percent: Math.max(prevEntry.percent, clampedReported), updatedAt: now, isFirstSample: false };
}
const elapsedSeconds = Math.max(0, (now.getTime() - new Date(prevEntry.updatedAt).getTime()) / 1000);
const maxPlausibleDelta = ((elapsedSeconds + TOLERANCE_SECONDS) * MAX_PLAYBACK_SPEED / durationSeconds) * 100;
const creditedDelta = Math.min(delta, maxPlausibleDelta);
return { percent: Math.min(100, Math.round(prevEntry.percent + creditedDelta)), updatedAt: now, isFirstSample: false };
}
async function recordWatchProgress(userId, {
lessonId, lessonUuid, unitId = null, unitUuid = null, courseId = null, courseUuid = null,
percent, blockId = null, blockType = null,
@@ -243,17 +291,42 @@ async function recordWatchProgress(userId, {
const percentRequirement = requirements.find((r) => r.type === 'watch_percent');
const blockRequirement = blockId ? requirements.find((r) => r.type === blockRequirementType) : null;
// Both branches need the block's canonical duration for wall-clock validation —
// fetched once up front rather than lazily inside the blockRequirement branch.
const page = (percentRequirement || blockRequirement)
? await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'] })
: null;
const durationSeconds = blockId ? getBlockDuration(page, blockId) : 0;
const t = await sequelize.transaction();
try {
let anyCompleted = false;
let aggregatePercent = null;
const now = new Date();
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);
const prevBlockProgress = existing?.block_progress ?? {};
let creditedPercent = Math.min(100, Math.max(0, Math.round(percent)));
let isFirstSample = false;
let nextBlockProgress = prevBlockProgress;
if (blockId) {
const result = reconcileBlockSample({ prevEntry: prevBlockProgress[blockId], reportedPercent: percent, durationSeconds, now });
creditedPercent = result.percent;
isFirstSample = result.isFirstSample;
nextBlockProgress = { ...prevBlockProgress, [blockId]: { percent: creditedPercent, updatedAt: result.updatedAt } };
}
// No blockId supplied — fall back to the unvalidated legacy behavior
// (shouldn't happen given current callers, but don't hard-fail the endpoint).
const nextPercent = Math.max(existing?.progress_percent ?? 0, creditedPercent);
// A block's very first-ever sample can never itself complete the requirement —
// guarantees at least one real elapsed-time check ran before crediting completion.
const completed = !isFirstSample && nextPercent >= (percentRequirement.min_percent ?? 100);
await CompletionRequirementProgress.upsert({
requirement_id: percentRequirement.requirement_id,
@@ -261,6 +334,7 @@ async function recordWatchProgress(userId, {
entity_type: 'lesson',
entity_id: lessonId,
progress_percent: nextPercent,
block_progress: nextBlockProgress,
completed,
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
updatedBy: userId,
@@ -275,14 +349,15 @@ async function recordWatchProgress(userId, {
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 result = reconcileBlockSample({ prevEntry: prevBlockProgress[blockId], reportedPercent: percent, durationSeconds, now });
const nextBlockProgress = { ...prevBlockProgress, [blockId]: { percent: result.percent, updatedAt: result.updatedAt } };
const page = await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'], transaction: t });
const matchingBlockIds = (page?.blocks ?? [])
.filter((b) => isVideoLikeBlock ? VIDEO_LIKE_BLOCK_TYPES.includes(b.type) : b.type === blockType)
.map((b) => b.id);
const completed = matchingBlockIds.length > 0 && matchingBlockIds.every((id) => (nextBlockProgress[id] ?? 0) >= 100);
const completed = matchingBlockIds.length > 0
&& !result.isFirstSample
&& matchingBlockIds.every((id) => (nextBlockProgress[id]?.percent ?? 0) >= 100);
await CompletionRequirementProgress.upsert({
requirement_id: blockRequirement.requirement_id,
@@ -310,10 +385,10 @@ async function recordWatchProgress(userId, {
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 })
: [];
// (would've read pre-commit state) — run it now that everything is durable. Runs
// regardless of courseId — a standalone (no parent course) lesson can satisfy a
// read_lesson task requirement via watch_video/listen_audio/watch_percent too.
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } };
} catch (err) {
@@ -365,15 +440,15 @@ async function recordManualComplete(userId, { entityType, entityId, lessonId = n
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.
// own sync under our externalTransaction; the unit/course branches never called it at
// all. Runs regardless of courseId — a standalone (no parent course) lesson/unit
// manual_complete can satisfy a read_lesson/read_unit task requirement too.
const syncUuids = entityType === 'lesson'
? { lessonUuid, unitUuid, courseUuid }
: entityType === 'unit'
? { unitUuid, courseUuid }
: { courseUuid };
result.completed_tasks = courseId || entityType === 'course'
? await syncCompletedEntitiesToTaskProgress(userId, syncUuids)
: [];
result.completed_tasks = await syncCompletedEntitiesToTaskProgress(userId, syncUuids);
return result;
} catch (err) {
+40
View File
@@ -0,0 +1,40 @@
/***********************************************************************************************************************************************************************
* File Name: playback_position.service.js
* Type of Program: Service
* Description: Last-known video/audio playback position per (user, lesson, block) — entirely
* decoupled from CompletionRequirement, tracked for ANY video/audio block regardless
* of whether the lesson has a watch-type completion requirement configured. Powers
* "resume where I left off" only; no anti-cheat/wall-clock validation here since
* there's nothing being gated — see completion_requirements.service.js for that.
*
* recordPlaybackPosition — last-write-wins upsert (not a ratcheted max — a deliberate rewind
* should resume there, not snap back to a prior high-water mark).
* getPlaybackPositions — { [block_id]: percent } for every block tracked on a lesson.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 17, 2026
***********************************************************************************************************************************************************************/
'use strict';
const MediaPlaybackPosition = require('../models/courses/media_playback_position.mdl');
async function recordPlaybackPosition(userId, { lessonId, blockId, percent }) {
if (!blockId) return;
await MediaPlaybackPosition.upsert({
user_id: userId,
lesson_id: lessonId,
block_id: blockId,
percent: Math.min(100, Math.max(0, Math.round(percent))),
}, { conflictFields: ['user_id', 'lesson_id', 'block_id'] });
}
async function getPlaybackPositions(userId, lessonId) {
const rows = await MediaPlaybackPosition.findAll({
where: { user_id: userId, lesson_id: lessonId },
attributes: ['block_id', 'percent'],
});
return Object.fromEntries(rows.map((r) => [r.block_id, r.percent]));
}
module.exports = { recordPlaybackPosition, getPlaybackPositions };
+74 -4
View File
@@ -1,8 +1,10 @@
/***********************************************************************************************************************************************************************
* File Name: task_reading_progress_sync.service.js
* Type of Program: Service
* Description: Bridges course/unit/lesson completion (course_reading_progress) to Task
* requirements of type read_course/read_unit/read_lesson, in both directions:
* Description: Bridges course/unit/lesson completion (course_reading_progress, plus the
* standalone lesson_reading_progress/unit_reading_progress system-of-record
* used when a lesson/unit has no parent course — see the junction revamp) to
* Task requirements of type read_course/read_unit/read_lesson, in both directions:
*
* hydrateReadTaskProgress(userId, requirements)
* — given a list of TaskRequirement rows (typically when a task/task list is newly
@@ -23,6 +25,10 @@
const { Op } = require('sequelize');
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl');
const UnitReadingProgress = require('../models/courses/unit_reading_progress.mdl');
const Lesson = require('../models/courses/lessons.mdl');
const Unit = require('../models/courses/units.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');
@@ -56,6 +62,60 @@ function normalizeRequirement(row) {
};
}
// ─── Standalone (no parent course) lesson/unit completions ───────────────────
// lesson_reading_progress/unit_reading_progress are keyed by numeric PK, but
// TaskRequirement.reference_id is the lesson/unit UUID — resolve UUID -> PK
// first, then look up completion, then map back to the `type:uuid` key shape
// hydrateReadTaskProgress's caller expects. read_course has no standalone
// table (a course always has a courseId by definition), so it's skipped.
async function getStandaloneCompletedReading(userId, referencesByProgressType, transaction) {
const entries = [];
const lessonUuids = [...(referencesByProgressType.lesson ?? [])];
if (lessonUuids.length) {
const lessons = await Lesson.findAll({
where: { uuid: { [Op.in]: lessonUuids } },
attributes: ['lesson_id', 'uuid'],
transaction,
});
if (lessons.length) {
const uuidByLessonId = new Map(lessons.map((l) => [readAttr(l, 'lesson_id'), readAttr(l, 'uuid')]));
const rows = await LessonReadingProgress.findAll({
where: { user_id: userId, lesson_id: { [Op.in]: [...uuidByLessonId.keys()] }, status: 'completed' },
attributes: ['lesson_id', 'completed_at'],
transaction,
});
for (const row of rows) {
const uuid = uuidByLessonId.get(readAttr(row, 'lesson_id'));
if (uuid) entries.push([`lesson:${uuid}`, readAttr(row, 'completed_at')]);
}
}
}
const unitUuids = [...(referencesByProgressType.unit ?? [])];
if (unitUuids.length) {
const units = await Unit.findAll({
where: { uuid: { [Op.in]: unitUuids } },
attributes: ['unit_id', 'uuid'],
transaction,
});
if (units.length) {
const uuidByUnitId = new Map(units.map((u) => [readAttr(u, 'unit_id'), readAttr(u, 'uuid')]));
const rows = await UnitReadingProgress.findAll({
where: { user_id: userId, unit_id: { [Op.in]: [...uuidByUnitId.keys()] }, status: 'completed' },
attributes: ['unit_id', 'completed_at'],
transaction,
});
for (const row of rows) {
const uuid = uuidByUnitId.get(readAttr(row, 'unit_id'));
if (uuid) entries.push([`unit:${uuid}`, readAttr(row, 'completed_at')]);
}
}
}
return entries;
}
async function hydrateReadTaskProgress(userId, requirements = [], options = {}) {
const readRequirements = requirements
.map(normalizeRequirement)
@@ -85,8 +145,6 @@ async function hydrateReadTaskProgress(userId, requirements = [], options = {})
transaction: options.transaction,
});
if (!completedReadingRows.length) return [];
const completedReading = new Map(
completedReadingRows.map((row) => [
`${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`,
@@ -94,6 +152,18 @@ async function hydrateReadTaskProgress(userId, requirements = [], options = {})
])
);
// ── Standalone lessons/units (no parent course) don't have a
// CourseReadingProgress row at all — their system-of-record is
// lesson_reading_progress/unit_reading_progress instead (see
// completion_requirements.service.js#persistStatus). Merge those in too,
// resolving UUID (the TaskRequirement's reference_id) -> numeric PK first.
const standaloneEntries = await getStandaloneCompletedReading(userId, referencesByProgressType, options.transaction);
for (const [key, completedAt] of standaloneEntries) {
if (!completedReading.has(key)) completedReading.set(key, completedAt);
}
if (!completedReading.size) return [];
const now = new Date();
const rowsToUpsert = readRequirements.filter((req) =>
completedReading.has(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`)