mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
course,tasklist,task and completed validation
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user