/*********************************************************************************************************************************************************************** * 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' ? Math.min(100, Math.max(1, Math.round(Number(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);