mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
213 lines
8.5 KiB
JavaScript
213 lines
8.5 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 CourseUnit = require('../models/courses/course_units.mdl');
|
|
const UnitLesson = require('../models/courses/unit_lessons.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 attached to it (via unit_lessons)
|
|
// has a completed row for this user.
|
|
async function deriveUnitStatus(userId, courseId, unitId, t) {
|
|
const links = await UnitLesson.findAll({
|
|
where: { unit_id: unitId },
|
|
attributes: ['lesson_id'],
|
|
transaction: t,
|
|
});
|
|
if (!links.length) return 'in_progress';
|
|
|
|
const lessons = await Lesson.findAll({
|
|
where: { lesson_id: links.map(l => l.lesson_id) },
|
|
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 links = await CourseUnit.findAll({
|
|
where: { course_id: courseId },
|
|
attributes: ['unit_id'],
|
|
transaction: t,
|
|
});
|
|
if (!links.length) return 'in_progress';
|
|
|
|
const units = await Unit.findAll({
|
|
where: { unit_id: links.map(l => l.unit_id) },
|
|
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,
|
|
};
|