Files
starr-philproperties/services/course_reading_progress.service.js
T
2026-07-07 13:39:46 +08:00

196 lines
7.9 KiB
JavaScript

/***********************************************************************************************************************************************************************
* File Name: course_reading_progress.service.js
* Type of Program: Service
* Description: UPSERT-based progress tracking for user reading activity across the course hierarchy.
*
* upsertLessonRead — main entry point, called when a user reads a lesson.
* UPSERTs the lesson row, then derives and UPSERTs the parent unit and course rows.
* All three writes run in a single transaction.
*
* UPSERT key: (user_id, type, reference_id)
*
* Derivation rules:
* unit → completed when ALL its non-deleted lessons have a completed row for this user
* course → completed when ALL its non-deleted units have a completed row for this user
* AND, if the course has a course assessment, the user has passed it.
* A course with no assessment built yet can never reach 'completed' here —
* reading alone isn't course completion.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sequelize = require('../config/db.config');
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
const Lesson = require('../models/courses/lessons.mdl');
const Unit = require('../models/courses/units.mdl');
const CourseAssessment = require('../models/courses/course_assessment.mdl');
const QuizAttempt = require('../models/courses/quiz_attempt.mdl');
// A course only counts as fully complete once it has a built assessment AND the user passed it.
async function hasPassedCourseAssessment(userId, courseId, t) {
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId },
attributes: ['assessment_id'],
transaction: t,
});
if (!assessment) return false;
const passedAttempt = await QuizAttempt.findOne({
where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true },
transaction: t,
});
return !!passedAttempt;
}
// ─── Core UPSERT ─────────────────────────────────────────────────────────────
async function upsertProgress({ userId, courseId, type, referenceId, status }, t) {
const now = new Date();
const [record] = await CourseReadingProgress.upsert(
{
user_id: userId,
course_id: courseId,
reference_id: referenceId,
type,
status,
completed_at: status === 'completed' ? now : null,
last_accessed_at: now,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['user_id', 'type', 'reference_id'],
returning: true,
transaction: t,
}
);
return record;
}
// ─── Derivation helpers ───────────────────────────────────────────────────────
// Unit is completed when every non-deleted lesson under it has a completed row for this user.
async function deriveUnitStatus(userId, courseId, unitId, t) {
const lessons = await Lesson.findAll({
where: { unit_id: unitId },
attributes: ['uuid'],
transaction: t,
});
if (!lessons.length) return 'in_progress';
const lessonUuids = lessons.map(l => l.uuid);
const completedCount = await CourseReadingProgress.count({
where: {
user_id: userId,
course_id: courseId,
type: 'lesson',
reference_id: lessonUuids,
status: 'completed',
},
transaction: t,
});
return completedCount === lessons.length ? 'completed' : 'in_progress';
}
// Course is completed when every non-deleted unit under it has a completed row for this user
// AND the course's assessment (if one has been built) has been passed by this user.
async function deriveCourseStatus(userId, courseId, t) {
const units = await Unit.findAll({
where: { course_id: courseId },
attributes: ['uuid'],
transaction: t,
});
if (!units.length) return 'in_progress';
const unitUuids = units.map(u => u.uuid);
const completedCount = await CourseReadingProgress.count({
where: {
user_id: userId,
course_id: courseId,
type: 'unit',
reference_id: unitUuids,
status: 'completed',
},
transaction: t,
});
const allUnitsRead = completedCount === units.length;
if (!allUnitsRead) return 'in_progress';
const assessmentPassed = await hasPassedCourseAssessment(userId, courseId, t);
return assessmentPassed ? 'completed' : 'in_progress';
}
// ─── Main entry point ─────────────────────────────────────────────────────────
/**
* Called when a user reads (or finishes reading) a lesson.
* Writes three rows in one transaction: lesson → unit → course.
*
* @param {number} userId
* @param {Object} payload
* @param {number} payload.courseId — course BIGINT PK (FK on course_reading_progress)
* @param {string} payload.courseUuid — course UUID (reference_id for the course row)
* @param {number} payload.unitId — unit BIGINT PK (used to query sibling lessons)
* @param {string} payload.unitUuid — unit UUID (reference_id for the unit row)
* @param {string} payload.lessonUuid — lesson UUID (reference_id for the lesson row)
* @param {string} payload.lessonStatus — 'in_progress' | 'completed'
* @returns {{ lesson, unit, course }} — status snapshot for each level
*/
async function upsertLessonRead(userId, { courseId, courseUuid, unitId, unitUuid, lessonUuid, lessonStatus = 'in_progress' }) {
const t = await sequelize.transaction();
try {
// 1. Lesson
await upsertProgress({
userId,
courseId,
type: 'lesson',
referenceId: lessonUuid,
status: lessonStatus,
}, t);
// 2. Unit — derived from all sibling lessons
const unitStatus = await deriveUnitStatus(userId, courseId, unitId, t);
await upsertProgress({
userId,
courseId,
type: 'unit',
referenceId: unitUuid,
status: unitStatus,
}, t);
// 3. Course — derived from all units
const courseStatus = await deriveCourseStatus(userId, courseId, t);
await upsertProgress({
userId,
courseId,
type: 'course',
referenceId: courseUuid,
status: courseStatus,
}, t);
await t.commit();
return {
lesson: { reference_id: lessonUuid, status: lessonStatus },
unit: { reference_id: unitUuid, status: unitStatus },
course: { reference_id: courseUuid, status: courseStatus },
};
} catch (err) {
await t.rollback();
throw err;
}
}
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
upsertLessonRead,
upsertProgress,
deriveUnitStatus,
deriveCourseStatus,
};