mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added new requirements for lessons and units
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: completion_requirements.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin CRUD for CompletionRequirement rows — what counts as "complete" for a
|
||||
* given course/unit/lesson. One shared implementation per entity_type, mounted at
|
||||
* nested routes (routes/admin/courses.routes.js) AND standalone-library routes
|
||||
* (routes/admin/units.routes.js, routes/admin/lessons.routes.js), matching the
|
||||
* existing dual-mount convention already used for quiz routes.
|
||||
*
|
||||
* getXRequirements — GET, list configured rows for one entity.
|
||||
* syncXRequirements — PUT, soft-delete-and-recreate the full set for one entity (same pattern
|
||||
* as controllers/admin/task.controller.js#updateTask, proactively applying
|
||||
* its documented fix: strip requirement_id/timestamps before bulkCreate so
|
||||
* the fresh rows never collide with the just-soft-deleted PKs). Validates
|
||||
* each row's `type` against the registry's validEntityTypes for this
|
||||
* entity_type, and rejects duplicate types on one entity — validation the
|
||||
* Task requirements system doesn't have.
|
||||
*
|
||||
* When a pass_quiz requirement is added/removed on a unit/course, the corresponding
|
||||
* UnitQuiz.is_required / CourseAssessment.is_required is flipped in lockstep, so the
|
||||
* existing sequential quiz-lock (which reads is_required) and the new completion
|
||||
* requirement never disagree with each other.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 14, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
|
||||
const { Course, Unit, Lesson, UnitQuiz, CourseAssessment } = require('../../models/courses/courses.associations');
|
||||
const { VALID_ENTITY_TYPES } = require('../../utils/courses/completion_requirements.registry');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
const ENTITY_MODELS = {
|
||||
course: { model: Course, pk: 'course_id', label: 'Course' },
|
||||
unit: { model: Unit, pk: 'unit_id', label: 'Unit' },
|
||||
lesson: { model: Lesson, pk: 'lesson_id', label: 'Lesson' },
|
||||
};
|
||||
|
||||
async function fetchRequirements(entityType, entityId, t) {
|
||||
return CompletionRequirement.findAll({
|
||||
where: { entity_type: entityType, entity_id: entityId },
|
||||
order: [['order', 'ASC']],
|
||||
transaction: t,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getRequirements(entityType, entityId, req, res) {
|
||||
try {
|
||||
const { model, pk, label } = ENTITY_MODELS[entityType];
|
||||
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted } });
|
||||
if (!entity) return R.error(res, `${label} not found.`, 404);
|
||||
|
||||
const rows = await fetchRequirements(entityType, entityId);
|
||||
return R.success(res, 'Completion requirements retrieved.', rows);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][COMPLETION REQUIREMENTS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve completion requirements.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT (soft-delete-and-recreate) ──────────────────────────────────────────
|
||||
|
||||
async function syncRequirements(entityType, entityId, req, res) {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { model, pk, label } = ENTITY_MODELS[entityType];
|
||||
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted }, transaction: t });
|
||||
if (!entity) {
|
||||
await t.rollback();
|
||||
return R.error(res, `${label} not found.`, 404);
|
||||
}
|
||||
|
||||
const requirements = Array.isArray(req.body.requirements) ? req.body.requirements : [];
|
||||
|
||||
// Server-side validation the Task requirements system doesn't have: type must be a
|
||||
// known type, valid for this entity_type, and configured at most once per entity.
|
||||
const seenTypes = new Set();
|
||||
for (const r of requirements) {
|
||||
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
|
||||
if (!allowedEntityTypes) {
|
||||
await t.rollback();
|
||||
return R.error(res, `Unknown requirement type "${r.type}".`, 400);
|
||||
}
|
||||
if (!allowedEntityTypes.includes(entityType)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `"${r.type}" cannot be configured on a ${entityType}.`, 400);
|
||||
}
|
||||
if (seenTypes.has(r.type)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `Duplicate "${r.type}" requirement — only one per entity is allowed.`, 400);
|
||||
}
|
||||
seenTypes.add(r.type);
|
||||
}
|
||||
|
||||
// Soft-delete existing rows, then bulkCreate the replacement set. Requirement_id and
|
||||
// timestamps are stripped from each incoming row (server-owned) so the fresh insert
|
||||
// never collides with the just-soft-deleted row still occupying that requirement_id PK —
|
||||
// see controllers/admin/task.controller.js#updateTask for the bug this proactively avoids.
|
||||
await CompletionRequirement.destroy({
|
||||
where: { entity_type: entityType, entity_id: entityId },
|
||||
force: false,
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (requirements.length) {
|
||||
const rows = requirements.map((r, i) => {
|
||||
const { requirement_id, createdAt, updatedAt, deletedAt, entity_type, entity_id, ...rest } = r;
|
||||
return {
|
||||
...rest,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
order: rest.order ?? i,
|
||||
min_percent: rest.type === 'watch_percent' ? (rest.min_percent ?? 100) : null,
|
||||
button_label: rest.type === 'manual_complete' ? (rest.button_label || null) : null,
|
||||
is_required: rest.is_required ?? true,
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
};
|
||||
});
|
||||
await CompletionRequirement.bulkCreate(rows, { transaction: t });
|
||||
}
|
||||
|
||||
// Reconcile the pass_quiz requirement with the pre-existing UnitQuiz/CourseAssessment
|
||||
// is_required flag, so ModifyQuiz.jsx's "required to proceed" toggle and this new
|
||||
// completion-requirements editor never disagree about the same underlying boolean.
|
||||
const hasPassQuiz = requirements.some((r) => r.type === 'pass_quiz');
|
||||
if (entityType === 'unit') {
|
||||
await UnitQuiz.update({ is_required: hasPassQuiz }, { where: { unit_id: entityId }, transaction: t });
|
||||
} else if (entityType === 'course') {
|
||||
await CourseAssessment.update({ is_required: hasPassQuiz }, { where: { course_id: entityId }, transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const rows = await fetchRequirements(entityType, entityId);
|
||||
return R.success(res, 'Completion requirements updated.', rows);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][COMPLETION REQUIREMENTS][SYNC]', err);
|
||||
return R.error(res, 'Could not update completion requirements.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route-bound exports ──────────────────────────────────────────────────────
|
||||
|
||||
exports.getCourseRequirements = (req, res) => getRequirements('course', req.params.courseId, req, res);
|
||||
exports.syncCourseRequirements = (req, res) => syncRequirements('course', req.params.courseId, req, res);
|
||||
|
||||
exports.getUnitRequirements = (req, res) => getRequirements('unit', req.params.unitId, req, res);
|
||||
exports.syncUnitRequirements = (req, res) => syncRequirements('unit', req.params.unitId, req, res);
|
||||
|
||||
exports.getLessonRequirements = (req, res) => getRequirements('lesson', req.params.lessonId, req, res);
|
||||
exports.syncLessonRequirements = (req, res) => syncRequirements('lesson', req.params.lessonId, req, res);
|
||||
@@ -37,6 +37,8 @@ const {
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
UnitReadingProgress, LessonReadingProgress,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
const CompletionRequirement = require("../../models/courses/completion_requirement.mdl");
|
||||
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
@@ -248,6 +250,17 @@ exports.createUnitFull = async (req, res) => {
|
||||
if (lessonInput.blocks?.length > CREATE_UNIT_FULL_LIMITS.maxBlocks) {
|
||||
return R.error(res, `A lesson page can have at most ${CREATE_UNIT_FULL_LIMITS.maxBlocks} blocks.`, 400);
|
||||
}
|
||||
if (lessonInput.requirements !== undefined) {
|
||||
if (!Array.isArray(lessonInput.requirements)) return R.error(res, "Lesson requirements must be a list.", 400);
|
||||
const seenTypes = new Set();
|
||||
for (const r of lessonInput.requirements) {
|
||||
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
|
||||
if (!allowedEntityTypes) return R.error(res, `Unknown requirement type "${r.type}".`, 400);
|
||||
if (!allowedEntityTypes.includes("lesson")) return R.error(res, `"${r.type}" cannot be configured on a lesson.`, 400);
|
||||
if (seenTypes.has(r.type)) return R.error(res, `Duplicate "${r.type}" requirement — only one per lesson is allowed.`, 400);
|
||||
seenTypes.add(r.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const by = createdBy ?? req.user?.user_id ?? null;
|
||||
@@ -278,6 +291,21 @@ exports.createUnitFull = async (req, res) => {
|
||||
|
||||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
|
||||
|
||||
if (lessonInput.requirements?.length) {
|
||||
const rows = lessonInput.requirements.map((r, ri) => ({
|
||||
entity_type: "lesson",
|
||||
entity_id: lesson.lesson_id,
|
||||
type: r.type,
|
||||
order: r.order ?? ri,
|
||||
min_percent: r.type === "watch_percent" ? (r.min_percent ?? 100) : null,
|
||||
button_label: r.type === "manual_complete" ? (r.button_label || null) : null,
|
||||
is_required: r.is_required ?? true,
|
||||
createdBy: by,
|
||||
updatedBy: by,
|
||||
}));
|
||||
await CompletionRequirement.bulkCreate(rows, { transaction: t });
|
||||
}
|
||||
|
||||
await UnitLesson.create({
|
||||
unit_id: unit.unit_id,
|
||||
lesson_id: lesson.lesson_id,
|
||||
|
||||
Reference in New Issue
Block a user