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) {