mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
469 lines
26 KiB
JavaScript
469 lines
26 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* 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. 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) {
|
|
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.
|
|
*/
|
|
// text-video counts as a video block for watch_video purposes — same <video> element
|
|
// under the hood, just paired with text. A lesson mixing plain "video" and "text-video"
|
|
// 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,
|
|
}) {
|
|
const isVideoLikeBlock = VIDEO_LIKE_BLOCK_TYPES.includes(blockType);
|
|
const blockRequirementType = isVideoLikeBlock ? '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;
|
|
|
|
// 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 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,
|
|
user_id: 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,
|
|
}, { 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 result = reconcileBlockSample({ prevEntry: prevBlockProgress[blockId], reportedPercent: percent, durationSeconds, now });
|
|
const nextBlockProgress = { ...prevBlockProgress, [blockId]: { percent: result.percent, updatedAt: result.updatedAt } };
|
|
|
|
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
|
|
&& !result.isFirstSample
|
|
&& matchingBlockIds.every((id) => (nextBlockProgress[id]?.percent ?? 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. 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) {
|
|
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. 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 = await syncCompletedEntitiesToTaskProgress(userId, syncUuids);
|
|
|
|
return result;
|
|
} catch (err) {
|
|
await t.rollback();
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
evaluateEntity,
|
|
recomputeAndPersist,
|
|
recomputeCascade,
|
|
recomputeUnitAfterQuiz,
|
|
recomputeCourseAfterAssessment,
|
|
recordWatchProgress,
|
|
recordManualComplete,
|
|
};
|