diff --git a/controllers/admin/completion_requirements.controller.js b/controllers/admin/completion_requirements.controller.js new file mode 100644 index 0000000..a3479e4 --- /dev/null +++ b/controllers/admin/completion_requirements.controller.js @@ -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); diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js index 6ef61a9..2416f78 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -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, diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index ddf34a8..8b78fde 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -66,7 +66,10 @@ const setRefreshCookie = (res, refreshToken) => { res.cookie('refreshToken', refreshToken, { httpOnly: true, // ← JS cannot read this secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection + // 'none' (not 'strict') in production — frontend (Vercel) and this API + // (Render) are cross-site, so 'strict'/'lax' silently drop the cookie on + // every fetch/XHR refresh call. 'none' requires secure:true (set above). + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); }; @@ -83,7 +86,10 @@ const setGoogleResultCookie = (res, payload) => { res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), { httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', + // Set right after a top-level redirect (survives 'lax'), but read back via + // a cross-site fetch from GET /auth/google/result — 'lax' drops it there + // in production since frontend (Vercel) and this API (Render) are cross-site. + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land signed: true, }); diff --git a/controllers/client/course_reading_progress.controller.js b/controllers/client/course_reading_progress.controller.js index d7c981d..6f133b1 100644 --- a/controllers/client/course_reading_progress.controller.js +++ b/controllers/client/course_reading_progress.controller.js @@ -29,8 +29,7 @@ const { Op } = require('sequelize'); const R = require('../../utils/response.util'); const logActivity = require('../../utils/logActivity.util'); -const { upsertLessonRead } = require('../../services/course_reading_progress.service'); -const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service'); +const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service'); const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); const Certificate = require('../../models/courses/certificate.mdl'); const { @@ -63,100 +62,12 @@ async function getAccessibleTaskListIds(userId) { return { taskListIds: Object.keys(taskListToGroup), taskListToGroup }; } -// ─── Internal helper: sync task_progress after a lesson read ───────────────── -// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID -// (only for tasks the user is assigned to) and marks them completed in task_progress. -// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements -// are now satisfied — these are eligible for display as "auto turned-in" on the frontend. -async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) { - if (lessonStatus !== 'completed') return []; - - const { taskListIds } = await getAccessibleTaskListIds(userId); - if (!taskListIds.length) return []; - - // Collect UUIDs to match based on what became completed - const matchUuids = [lessonUuid]; - if (unitStatus === 'completed') matchUuids.push(unitUuid); - if (courseStatus === 'completed') matchUuids.push(courseUuid); - - const requirements = await TaskRequirement.findAll({ - where: { - reference_id: { [Op.in]: matchUuids }, - type: { [Op.in]: ['read_lesson', 'read_unit', 'read_course'] }, - deletedAt: null, - }, - include: [{ - model: Task, - as: 'task', - where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, - required: true, - attributes: ['task_id', 'name', 'task_list_id'], - }], - attributes: ['requirement_id', 'task_id', 'type', 'reference_id'], - }); - - if (!requirements.length) return []; - - // Filter to (type, reference_id) pairs that actually became completed this call - const toComplete = requirements.filter((req) => { - if (req.type === 'read_lesson' && req.reference_id === lessonUuid) return true; - if (req.type === 'read_unit' && req.reference_id === unitUuid && unitStatus === 'completed') return true; - if (req.type === 'read_course' && req.reference_id === courseUuid && courseStatus === 'completed') return true; - return false; - }); - if (!toComplete.length) return []; - - const now = new Date(); - - // Upsert TaskProgress as completed for each matching requirement - await Promise.all(toComplete.map((req) => - TaskProgress.upsert( - { - task_id: req.task_id, - requirement_id: req.requirement_id, - user_id: userId, - reference_id: req.reference_id, - type: req.type, - completed: true, - completed_at: now, - createdBy: userId, - updatedBy: userId, - }, - { conflictFields: ['requirement_id', 'user_id', 'reference_id'] } - ) - )); - - // Check if any impacted task now has ALL its read requirements done - // (only auto-turn-in pure read tasks — tasks with upload_file/visit_link need manual submission) - const taskIds = [...new Set(toComplete.map((r) => r.task_id))]; - const completedTasks = []; - - for (const taskId of taskIds) { - const allReqs = await TaskRequirement.findAll({ - where: { task_id: taskId, deletedAt: null }, - attributes: ['requirement_id', 'type', 'reference_id'], - }); - - const hasNonReadReqs = allReqs.some((r) => !['read_course', 'read_unit', 'read_lesson'].includes(r.type)); - if (hasNonReadReqs) continue; // let the user manually submit - - const readReqs = allReqs; // all are read-type at this point - - const doneProgress = await TaskProgress.findAll({ - where: { task_id: taskId, user_id: userId, completed: true }, - attributes: ['requirement_id', 'reference_id'], - }); - const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); - const allDone = readReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`)); - - if (allDone) { - const taskName = toComplete.find((r) => r.task_id === taskId)?.task?.name ?? ''; - completedTasks.push({ task_id: taskId, task_name: taskName }); - } - } - - return completedTasks; -} +// Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in +// services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called +// directly from completion_requirements.service.js's cascade/recompute functions — covers every +// completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just +// this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still +// needs its richer { taskListIds, taskListToGroup } shape. // ============================================================================= // ── IN-PROGRESS COURSES — profile learning progress card ────────────────────── @@ -453,10 +364,11 @@ exports.getCourseTaskContext = async (req, res) => { // // Flow: // 1. Resolve course / unit / lesson to get their UUIDs -// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx -// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables) -// 4. Side-effect B: sync task_progress for matching task requirements -// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied) +// 2. Delegate to recomputeCascade (completion_requirements service) — lesson + unit + course +// evaluated against any configured CompletionRequirement rows (or the default implicit rule), +// all in one transaction +// 3. Side-effect: sync task_progress for matching task requirements +// 4. Return result + completed_tasks (tasks whose all read requirements are now satisfied) exports.upsertLessonProgress = async (req, res) => { try { @@ -485,43 +397,108 @@ exports.upsertLessonProgress = async (req, res) => { if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404); if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404); - // ── 1. Primary write: course_reading_progress ───────────────────────── - const result = await upsertLessonRead(userId, { + // ── 1. Consolidated evaluation + persistence: lesson → unit → course ── + const result = await recomputeCascade(userId, { courseId: course.course_id, courseUuid: course.uuid, unitId: unit.unit_id, unitUuid: unit.uuid, + lessonId: lesson.lesson_id, lessonUuid: lesson.uuid, lessonStatus: status, }); - // ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ── - upsertReadingProgress(userId, { - courseId: course.course_id, - unitId: unit.unit_id, - lessonId: lesson.lesson_id, - lessonStatus: status, - }).catch((e) => console.error('[READING PROGRESS] piggyback write failed:', e)); - - // ── 3. Side-effect B: sync task_progress ────────────────────────────── - const completedTasks = await syncTaskProgress(userId, { - lessonUuid: lesson.uuid, - unitUuid: unit.uuid, - courseUuid: course.uuid, - lessonStatus: status, - unitStatus: result.unit.status, - courseStatus: result.course.status, - }); - + // Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran + // inside recomputeCascade — result.completed_tasks reflects it directly. logActivity(userId, 'lesson_read', { entityType: 'lesson', entityId: lesson.lesson_id, details: { lesson_uuid: lesson.uuid, status }, }); - return R.success(res, 'Progress updated.', { ...result, completed_tasks: completedTasks }, 200); + return R.success(res, 'Progress updated.', result, 200); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err); return R.error(res, 'Could not update progress.', 500); } }; + +// ============================================================================= +// ── WATCH PROGRESS (watch_percent completion requirement) ──────────────────── +// ============================================================================= + +// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/watch-progress +// Body: { percent, block_id?, block_type? } — running max % of video/audio watched, 0-100. +// block_id/block_type identify which block on the lesson's page sent this update — needed +// to drive watch_video/listen_audio (every block of that type must individually reach 100); +// omit them and only the aggregate watch_percent requirement (if configured) is touched. +// No-ops (still 200s) if the lesson has neither requirement type configured. +exports.upsertWatchProgress = async (req, res) => { + try { + const { courseId, unitId, lessonId } = req.params; + const userId = req.user.user_id; + const percent = Number(req.body.percent); + if (!Number.isFinite(percent)) return R.error(res, 'percent must be a number.', 400); + const blockId = req.body.block_id ?? null; + const blockType = req.body.block_type ?? null; + + const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([ + Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }), + Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }), + Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }), + CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }), + UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }), + ]); + if (!course) return R.error(res, 'Course not found.', 404); + if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404); + if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404); + + const result = await recordWatchProgress(userId, { + lessonId: lesson.lesson_id, lessonUuid: lesson.uuid, + unitId: unit.unit_id, unitUuid: unit.uuid, + courseId: course.course_id, courseUuid: course.uuid, + percent, blockId, blockType, + }); + + return R.success(res, 'Watch progress updated.', result, 200); + } catch (err) { + console.error('[CLIENT][COURSE READING PROGRESS][WATCH PROGRESS]', err); + return R.error(res, 'Could not update watch progress.', 500); + } +}; + +// ============================================================================= +// ── MARK COMPLETE (manual_complete completion requirement) ─────────────────── +// ============================================================================= + +// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/mark-complete +// No-ops (still 200s) if the lesson has no configured manual_complete requirement. +exports.markLessonComplete = async (req, res) => { + try { + const { courseId, unitId, lessonId } = req.params; + const userId = req.user.user_id; + + const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([ + Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }), + Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }), + Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }), + CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }), + UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }), + ]); + if (!course) return R.error(res, 'Course not found.', 404); + if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404); + if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404); + + const result = await recordManualComplete(userId, { + entityType: 'lesson', entityId: lesson.lesson_id, + lessonUuid: lesson.uuid, + unitId: unit.unit_id, unitUuid: unit.uuid, + courseId: course.course_id, courseUuid: course.uuid, + }); + + return R.success(res, 'Lesson marked complete.', result, 200); + } catch (err) { + console.error('[CLIENT][COURSE READING PROGRESS][MARK COMPLETE]', err); + return R.error(res, 'Could not mark lesson complete.', 500); + } +}; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 0e5e2d4..f249c35 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -37,6 +37,12 @@ const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl const { gradeSubmission } = require("../../utils/courses/grading.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); const { onCourseCompleted } = require('../../services/achievements.service'); +const { + evaluateEntity, + recomputeUnitAfterQuiz, + recomputeCourseAfterAssessment, +} = require('../../services/completion_requirements.service'); +const CompletionRequirement = require('../../models/courses/completion_requirement.mdl'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); @@ -403,21 +409,53 @@ exports.getCourse = async (req, res) => { })); } - let is_completed = false; + // Attach each lesson's resolved completion trigger (read_all_content [default] / + // watch_percent / manual_complete) so the reader can dispatch the right UI without a + // second round trip. A lesson may have zero configured rows (→ default scroll trigger) + // or one row of one of these types (pass_quiz isn't valid on a lesson). + const allLessonIds = plain.units?.flatMap((u) => (u.lessons ?? []).map((l) => l.lesson_id)) ?? []; + if (allLessonIds.length) { + const lessonRequirements = await CompletionRequirement.findAll({ + where: { entity_type: 'lesson', entity_id: allLessonIds }, + attributes: ['entity_id', 'type', 'min_percent', 'button_label'], + }); + const byLessonId = new Map(); + lessonRequirements.forEach((r) => { if (!byLessonId.has(String(r.entity_id))) byLessonId.set(String(r.entity_id), r); }); + + plain.units = plain.units.map((u) => ({ + ...u, + lessons: (u.lessons ?? []).map((l) => { + const row = byLessonId.get(String(l.lesson_id)); + return { + ...l, + completion: row + ? { type: row.type, min_percent: row.min_percent, button_label: row.button_label } + : { type: 'read_all_content', min_percent: null, button_label: null }, + }; + }), + })); + } + + // has_passed reflects the assessment attempt alone; is_completed is the consolidated + // evaluator's result (default rule: all units read AND assessment passed, if one exists — + // was previously hardcoded to assessment-pass alone here too, same bug fixed in + // submitCourseAssessment/getLessonsByUnitUuid — this is the 5th call site of that bug). if (plain.assessment) { const passedAttempt = await QuizAttempt.findOne({ where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true }, }); - is_completed = !!passedAttempt; const questionCount = plain.assessment.questions?.length ?? 0; plain.assessment = { ...plain.assessment, - has_passed: is_completed, + has_passed: !!passedAttempt, question_count: questionCount, questions: undefined, }; } - plain.is_completed = is_completed; + const courseEvaluation = await evaluateEntity({ + entityType: 'course', entityId: course.course_id, userId: req.user.user_id, courseId: course.course_id, + }); + plain.is_completed = courseEvaluation.status === 'completed'; const plan_tier = plain.subscription ?? null; @@ -920,6 +958,16 @@ exports.submitUnitQuiz = async (req, res) => { { where: { quiz_id: quiz.quiz_id, user_id, status: 'in_progress' } } ); + // Passing a unit quiz can satisfy a pass_quiz completion requirement on the unit (and + // cascade to the course) — previously this endpoint never touched reading progress at all. + // Also syncs any read_unit/read_course task requirements the unit/course completion now + // satisfies, even though no lesson was read (the gap task-sync used to miss). + let completedTasks = []; + if (passed) { + const evaluation = await recomputeUnitAfterQuiz(user_id, { unitId, courseId }); + completedTasks = evaluation?.completed_tasks ?? []; + } + return R.success(res, "Quiz submitted.", { attempt_id: attempt.attempt_id, attempt_number: attempt.attempt_number, @@ -928,6 +976,7 @@ exports.submitUnitQuiz = async (req, res) => { passing_score: attempt.passing_score, total_points: totalPoints, earned_points: earnedPoints, + completed_tasks: completedTasks, }); } catch (err) { console.error("[CLIENT][QUIZ][SUBMIT]", err); @@ -1039,9 +1088,19 @@ exports.submitCourseAssessment = async (req, res) => { ); } + // BUG FIX: this used to be `course_completed = passed` — certification/achievements fired + // on assessment-pass alone, never checking whether the learner had actually read the course. + // Now gated on the consolidated evaluator (default rule: all units read AND assessment passed; + // or whatever the admin has explicitly configured via CompletionRequirement rows). let course_completed = false; + let completedTasks = []; if (passed) { - course_completed = true; + const evaluation = await recomputeCourseAfterAssessment(user_id, courseId); + course_completed = evaluation?.status === 'completed'; + completedTasks = evaluation?.completed_tasks ?? []; + } + + if (course_completed) { const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid', 'title'] }); const totalCompleted = await QuizAttempt.count({ where: { user_id, passed: true, assessment_id: { [Op.ne]: null } }, @@ -1081,6 +1140,7 @@ exports.submitCourseAssessment = async (req, res) => { total_points: totalPoints, earned_points: earnedPoints, course_completed, + completed_tasks: completedTasks, }); } catch (err) { console.error("[CLIENT][ASSESSMENT][SUBMIT]", err); @@ -1240,20 +1300,29 @@ exports.getLessonsByUnitUuid = async (req, res) => { } const plain = unit.toJSON(); + const userId = req.user.user_id; - // Per-lesson completion for the requesting user. NOTE: lesson_reading_progress - // upserts on (user_id, lesson_id) only — a lesson's completion is a property - // of the lesson itself, not scoped to whichever unit it was read under. + // Per-lesson completion for the requesting user, evaluated against each lesson's own + // configured CompletionRequirement rows (or the default implicit rule when none are + // configured) via the consolidated evaluator. courseId is null here — this route is + // reached both course-scoped and standalone with no course context, and a lesson's + // completion is a property of the lesson itself, not scoped to whichever unit/course + // it was read under (see completion_requirements.registry.js's courseId-null dispatch, + // which reads lesson_reading_progress rather than course_reading_progress). const flatLessons = flattenLessons(plain.lessons); - const progressRows = flatLessons.length + const lessonCompletedAtRows = flatLessons.length ? await LessonReadingProgress.findAll({ - where: { user_id: req.user.user_id, lesson_id: flatLessons.map((l) => l.lesson_id) }, - attributes: ["lesson_id", "status", "completed_at"], + where: { user_id: userId, lesson_id: flatLessons.map((l) => l.lesson_id) }, + attributes: ["lesson_id", "completed_at"], }) : []; - const progressMap = new Map(progressRows.map((p) => [String(p.lesson_id), p])); + const completedAtMap = new Map(lessonCompletedAtRows.map((p) => [String(p.lesson_id), p.completed_at])); - const lessons = flatLessons.map((l) => ({ + const lessonEvaluations = await Promise.all( + flatLessons.map((l) => evaluateEntity({ entityType: "lesson", entityId: l.lesson_id, userId, courseId: null })) + ); + + const lessons = flatLessons.map((l, i) => ({ lesson_id: l.lesson_id, uuid: l.uuid, title: l.title, @@ -1262,20 +1331,24 @@ exports.getLessonsByUnitUuid = async (req, res) => { duration_seconds: l.duration_seconds ?? 0, blocks: l.page?.blocks ?? [], objectives: (l.objectives ?? []).slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)), - status: progressMap.get(String(l.lesson_id))?.status ?? "not_started", - completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null, + status: lessonEvaluations[i].status === "completed" ? "completed" : (completedAtMap.has(String(l.lesson_id)) ? "in_progress" : "not_started"), + completed_at: lessonEvaluations[i].status === "completed" ? (completedAtMap.get(String(l.lesson_id)) ?? null) : null, })); // Attach has_passed to the quiz stub — same pattern as getCourse's unit list. let quiz = null; if (plain.quiz) { const passedAttempt = await QuizAttempt.findOne({ - where: { quiz_id: plain.quiz.quiz_id, user_id: req.user.user_id, passed: true }, + where: { quiz_id: plain.quiz.quiz_id, user_id: userId, passed: true }, }); quiz = { ...plain.quiz, has_passed: !!passedAttempt }; } - const is_completed = lessons.length > 0 && lessons.every((l) => l.status === "completed"); + // Consolidated evaluator — replaces the old inline `lessons.every(status === "completed")` + // re-derivation, which drifted from the POST-progress path's own unit derivation. Now both + // read and write paths go through the same evaluateEntity() call. + const unitEvaluation = await evaluateEntity({ entityType: "unit", entityId: unit.unit_id, userId, courseId: null }); + const is_completed = unitEvaluation.status === "completed"; return R.success(res, "Unit lessons retrieved.", { unit_id: unit.unit_id, diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index f680a67..e3fa13f 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -43,7 +43,7 @@ const { const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers const { gradeSubmission } = require("../../utils/courses/grading.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); -const { upsertLessonRead } = require("../../services/reading_progress.service"); +const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service"); const notDeleted = { deletedAt: null }; @@ -343,8 +343,11 @@ exports.saveUnitQuizDraft = async (req, res) => { // ─── STANDALONE LESSON PROGRESS ─────────────────────────────────────────────── // POST /client/lessons/:uuid/progress Body: { status, unit_uuid? } -// Writes lesson_reading_progress with course_id NULL. When unit_uuid is given -// (unit context, still no course) the parent unit row is derived + upserted too. +// Evaluated + written via completion_requirements.service#recomputeCascade with +// courseId null, which persists to lesson_reading_progress/unit_reading_progress +// (the tables that tolerate a null course_id) instead of course_reading_progress. +// When unit_uuid is given (unit context, still no course) the parent unit is +// re-evaluated + upserted too, against any configured CompletionRequirement rows. exports.upsertStandaloneLessonProgress = async (req, res) => { try { @@ -369,10 +372,12 @@ exports.upsertStandaloneLessonProgress = async (req, res) => { unitId = unit.unit_id; } - const result = await upsertLessonRead(userId, { + const result = await recomputeCascade(userId, { courseId: null, unitId, + unitUuid, lessonId: lesson.lesson_id, + lessonUuid: lesson.uuid, lessonStatus: status, }); @@ -389,6 +394,82 @@ exports.upsertStandaloneLessonProgress = async (req, res) => { } }; +// ─── STANDALONE WATCH PROGRESS ───────────────────────────────────────────────── +// POST /client/lessons/:uuid/watch-progress Body: { percent, unit_uuid?, block_id?, block_type? } +// block_id/block_type identify which block on the lesson's page sent this update — needed +// to drive watch_video/listen_audio; omit them and only watch_percent (if configured) is touched. +exports.upsertStandaloneWatchProgress = async (req, res) => { + try { + const { uuid } = req.params; + const userId = req.user.user_id; + const percent = Number(req.body.percent); + if (!Number.isFinite(percent)) return R.error(res, "percent must be a number.", 400); + const unitUuid = req.body.unit_uuid ?? null; + const blockId = req.body.block_id ?? null; + const blockType = req.body.block_type ?? null; + + const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] }); + if (!lesson) return R.error(res, "Lesson not found.", 404); + if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) { + return R.error(res, "You do not have access to this lesson.", 403); + } + + let unitId = null; + if (unitUuid) { + const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] }); + if (!unit) return R.error(res, "Unit not found.", 404); + unitId = unit.unit_id; + } + + const result = await recordWatchProgress(userId, { + lessonId: lesson.lesson_id, lessonUuid: lesson.uuid, + unitId, unitUuid, + courseId: null, courseUuid: null, + percent, blockId, blockType, + }); + + return R.success(res, "Watch progress updated.", result, 200); + } catch (err) { + console.error("[CLIENT][LESSONS][STANDALONE WATCH PROGRESS]", err); + return R.error(res, "Could not update watch progress.", 500); + } +}; + +// ─── STANDALONE MARK COMPLETE ────────────────────────────────────────────────── +// POST /client/lessons/:uuid/mark-complete Body: { unit_uuid? } +exports.markStandaloneLessonComplete = async (req, res) => { + try { + const { uuid } = req.params; + const userId = req.user.user_id; + const unitUuid = req.body.unit_uuid ?? null; + + const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] }); + if (!lesson) return R.error(res, "Lesson not found.", 404); + if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) { + return R.error(res, "You do not have access to this lesson.", 403); + } + + let unitId = null; + if (unitUuid) { + const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] }); + if (!unit) return R.error(res, "Unit not found.", 404); + unitId = unit.unit_id; + } + + const result = await recordManualComplete(userId, { + entityType: "lesson", entityId: lesson.lesson_id, + lessonUuid: lesson.uuid, + unitId, unitUuid, + courseId: null, courseUuid: null, + }); + + return R.success(res, "Lesson marked complete.", result, 200); + } catch (err) { + console.error("[CLIENT][LESSONS][STANDALONE MARK COMPLETE]", err); + return R.error(res, "Could not mark lesson complete.", 500); + } +}; + // ─── Shared UUID handlers re-exported for the standalone routes ─────────────── exports.getUnitByUuid = coursesCtrl.getUnitByUuid; diff --git a/database/junction_revamp.sql b/database/junction_revamp.sql deleted file mode 100644 index 1ecfb37..0000000 --- a/database/junction_revamp.sql +++ /dev/null @@ -1,79 +0,0 @@ --- ═══════════════════════════════════════════════════════════════════════════ --- JUNCTION REVAMP — Units and Lessons run independently (Jul. 7, 2026) --- --- courses ⇄ course_units ⇄ units ⇄ unit_lessons ⇄ lessons --- --- Run order matters: create → backfill → relax progress FKs → drop old columns. --- Postgres / CockroachDB compatible. Take a backup before running. --- ═══════════════════════════════════════════════════════════════════════════ - -BEGIN; - --- ── 1. Junction tables ─────────────────────────────────────────────────────── - -CREATE TABLE course_units ( - course_unit_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - course_id BIGINT NOT NULL REFERENCES courses (course_id) ON DELETE CASCADE, - unit_id BIGINT NOT NULL REFERENCES units (unit_id) ON DELETE CASCADE, - order_index INTEGER NOT NULL DEFAULT 0, - "createdBy" BIGINT NULL, - "updatedBy" BIGINT NULL, - "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uq_course_units_course_unit UNIQUE (course_id, unit_id) -); - -CREATE INDEX idx_course_units_course_id ON course_units (course_id); -CREATE INDEX idx_course_units_unit_id ON course_units (unit_id); -CREATE INDEX idx_course_units_order ON course_units (order_index); - -CREATE TABLE unit_lessons ( - unit_lesson_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - unit_id BIGINT NOT NULL REFERENCES units (unit_id) ON DELETE CASCADE, - lesson_id BIGINT NOT NULL REFERENCES lessons (lesson_id) ON DELETE CASCADE, - order_index INTEGER NOT NULL DEFAULT 0, - "createdBy" BIGINT NULL, - "updatedBy" BIGINT NULL, - "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uq_unit_lessons_unit_lesson UNIQUE (unit_id, lesson_id) -); - -CREATE INDEX idx_unit_lessons_unit_id ON unit_lessons (unit_id); -CREATE INDEX idx_unit_lessons_lesson_id ON unit_lessons (lesson_id); -CREATE INDEX idx_unit_lessons_order ON unit_lessons (order_index); - --- ── 2. Backfill from the old direct FKs (preserves ordering) ───────────────── - -INSERT INTO course_units (course_id, unit_id, order_index, "createdBy", "createdAt", "updatedAt") -SELECT u.course_id, u.unit_id, COALESCE(u.order_index, 0), u."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP -FROM units u -WHERE u.course_id IS NOT NULL; - -INSERT INTO unit_lessons (unit_id, lesson_id, order_index, "createdBy", "createdAt", "updatedAt") -SELECT l.unit_id, l.lesson_id, COALESCE(l.order_index, 0), l."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP -FROM lessons l -WHERE l.unit_id IS NOT NULL; - --- ── 3. Progress tables — standalone reads carry NULL course/unit context ───── - -ALTER TABLE unit_reading_progress ALTER COLUMN course_id DROP NOT NULL; -ALTER TABLE lesson_reading_progress ALTER COLUMN course_id DROP NOT NULL; -ALTER TABLE lesson_reading_progress ALTER COLUMN unit_id DROP NOT NULL; - --- ── 4. Drop the old FK + order columns ─────────────────────────────────────── --- (dependent indexes/constraints on these columns are dropped automatically) - -ALTER TABLE units DROP COLUMN course_id; -ALTER TABLE units DROP COLUMN order_index; -ALTER TABLE lessons DROP COLUMN unit_id; -ALTER TABLE lessons DROP COLUMN order_index; - -COMMIT; - --- ── Sanity checks (run after commit) ───────────────────────────────────────── --- SELECT COUNT(*) FROM course_units; -- should equal old COUNT(*) FROM units WHERE course_id IS NOT NULL --- SELECT COUNT(*) FROM unit_lessons; -- should equal old COUNT(*) FROM lessons WHERE unit_id IS NOT NULL --- SELECT c.title, u.title, cu.order_index FROM course_units cu --- JOIN courses c USING (course_id) JOIN units u USING (unit_id) --- ORDER BY c.title, cu.order_index LIMIT 20; diff --git a/database/migrations/20260714000001-create-completion-requirements.js b/database/migrations/20260714000001-create-completion-requirements.js new file mode 100644 index 0000000..725a7ce --- /dev/null +++ b/database/migrations/20260714000001-create-completion-requirements.js @@ -0,0 +1,45 @@ +'use strict'; + +// Sequelize's createTable() wraps ENUM creation in a `DO $$...$$` block for +// idempotency, which CockroachDB rejects ("CREATE TYPE usage inside a function +// definition is not supported"). Built as raw SQL with STRING + CHECK +// constraints instead, matching the pattern established in +// 20260710000001-add-status-to-courses.js. Sequelize's DataTypes.ENUM at the +// model layer works fine against a STRING+CHECK column — no model-side change +// needed. + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE completion_requirements ( + requirement_id UUID PRIMARY KEY, + entity_type STRING NOT NULL, + entity_id BIGINT NOT NULL, + type STRING NOT NULL, + min_percent INTEGER NULL, + button_label STRING NULL, + is_required BOOLEAN NOT NULL DEFAULT true, + "order" INTEGER NOT NULL DEFAULT 0, + "createdBy" BIGINT NULL, + "updatedBy" BIGINT NULL, + "deletedBy" BIGINT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMPTZ NULL, + CONSTRAINT check_cr_entity_type CHECK (entity_type IN ('course', 'unit', 'lesson')), + CONSTRAINT check_cr_type CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete')) + ); + `); + + await queryInterface.sequelize.query( + `CREATE INDEX idx_cr_entity_type_entity_id ON completion_requirements (entity_type, entity_id);` + ); + await queryInterface.sequelize.query( + `CREATE INDEX idx_cr_type ON completion_requirements (type);` + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS completion_requirements;`); + }, +}; diff --git a/database/migrations/20260714000002-create-completion-requirement-progress.js b/database/migrations/20260714000002-create-completion-requirement-progress.js new file mode 100644 index 0000000..f612387 --- /dev/null +++ b/database/migrations/20260714000002-create-completion-requirement-progress.js @@ -0,0 +1,38 @@ +'use strict'; + +// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as +// 20260714000001-create-completion-requirements.js. + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE completion_requirement_progress ( + progress_id UUID PRIMARY KEY, + requirement_id UUID NOT NULL REFERENCES completion_requirements (requirement_id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + entity_type STRING NOT NULL, + entity_id BIGINT NOT NULL, + progress_percent INTEGER NULL, + completed BOOLEAN NOT NULL DEFAULT false, + completed_at TIMESTAMPTZ NULL, + "createdBy" BIGINT NULL, + "updatedBy" BIGINT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT check_crp2_entity_type CHECK (entity_type IN ('course', 'unit', 'lesson')), + CONSTRAINT uq_crp2_requirement_user UNIQUE (requirement_id, user_id) + ); + `); + + await queryInterface.sequelize.query( + `CREATE INDEX idx_crp2_user_id ON completion_requirement_progress (user_id);` + ); + await queryInterface.sequelize.query( + `CREATE INDEX idx_crp2_entity_type_entity_id ON completion_requirement_progress (entity_type, entity_id);` + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS completion_requirement_progress;`); + }, +}; diff --git a/database/migrations/20260715000001-add-missing-task-requirement-types.js b/database/migrations/20260715000001-add-missing-task-requirement-types.js new file mode 100644 index 0000000..abfa124 --- /dev/null +++ b/database/migrations/20260715000001-add-missing-task-requirement-types.js @@ -0,0 +1,31 @@ +'use strict'; + +// task_requirements.type IS a real DB enum (task_requirement_type), despite +// 20260709000001-add-requires-review-and-prompt-to-task-requirements.js's comment +// claiming otherwise ("Sequelize enforces the ENUM only at the application layer +// for this table, so no constraint migration is needed") — that assumption was +// wrong. submit_text and pass_quiz were added to the Sequelize model (task.mdl.js) +// and the admin RequirementBuilder.jsx UI at the same time, but never to the actual +// DB enum, so creating/querying either type has been failing with +// "invalid input value for enum task_requirement_type" ever since (found while +// verifying the new completion_requirements feature's task auto-complete path, +// which happened to be the first thing to actually query a pass_quiz-typed +// TaskRequirement against this DB). +// +// ALTER TYPE ... ADD VALUE (not CREATE TYPE) is a plain top-level statement, not +// wrapped in Sequelize's problematic DO $$...$$ block, so this runs fine as a +// normal migration — no raw-SQL-outside-migrations workaround needed here. + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TYPE task_requirement_type ADD VALUE IF NOT EXISTS 'submit_text'` + ); + await queryInterface.sequelize.query( + `ALTER TYPE task_requirement_type ADD VALUE IF NOT EXISTS 'pass_quiz'` + ); + }, + + // Postgres/CockroachDB cannot remove a value from an enum type — down is a no-op. + async down() {}, +}; diff --git a/database/migrations/20260715000002-add-watch-video-listen-audio-types.js b/database/migrations/20260715000002-add-watch-video-listen-audio-types.js new file mode 100644 index 0000000..03df3e3 --- /dev/null +++ b/database/migrations/20260715000002-add-watch-video-listen-audio-types.js @@ -0,0 +1,45 @@ +'use strict'; + +// Adds two new completion-requirement types — watch_video and listen_audio — +// alongside the existing watch_percent (adjustable-percent) type. Unlike +// watch_percent, which tracks one aggregate percent across whichever +// video/audio block the learner happens to be playing, these two require +// EVERY block of the matching type on the lesson to individually reach 100%, +// so per-block progress needs its own column (block_progress) rather than +// reusing the single progress_percent figure. +// +// Same CockroachDB constraint as 20260714000001 — DROP+ADD CONSTRAINT instead +// of an ENUM ALTER, since CockroachDB rejects CREATE TYPE inside the DO $$...$$ +// block Sequelize wraps enum changes in. + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE completion_requirements DROP CONSTRAINT check_cr_type; + `); + await queryInterface.sequelize.query(` + ALTER TABLE completion_requirements + ADD CONSTRAINT check_cr_type + CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete', 'watch_video', 'listen_audio')); + `); + + await queryInterface.sequelize.query(` + ALTER TABLE completion_requirement_progress + ADD COLUMN block_progress JSONB NULL; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE completion_requirement_progress DROP COLUMN IF EXISTS block_progress; + `); + await queryInterface.sequelize.query(` + ALTER TABLE completion_requirements DROP CONSTRAINT check_cr_type; + `); + await queryInterface.sequelize.query(` + ALTER TABLE completion_requirements + ADD CONSTRAINT check_cr_type + CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete')); + `); + }, +}; diff --git a/middleware/csrf.middleware.js b/middleware/csrf.middleware.js index a0477da..c26950e 100644 --- a/middleware/csrf.middleware.js +++ b/middleware/csrf.middleware.js @@ -28,8 +28,19 @@ const csurf = require('csurf'); const R = require('../utils/response.util'); -/** csurf instance — stores token in a signed cookie */ -const csrfProtection = csurf({ cookie: { httpOnly: true, sameSite: 'strict' } }); +/** + * csurf instance — stores token in a signed cookie. + * sameSite:'none' (not 'strict') in production — frontend (Vercel) and this + * API (Render) are cross-site, so 'strict' drops the cookie on every + * fetch/XHR call. 'none' requires secure:true, set alongside it below. + */ +const csrfProtection = csurf({ + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + }, +}); /** * GET /api/csrf-token diff --git a/models/courses/completion_requirement.mdl.js b/models/courses/completion_requirement.mdl.js new file mode 100644 index 0000000..d753fb0 --- /dev/null +++ b/models/courses/completion_requirement.mdl.js @@ -0,0 +1,60 @@ +/*********************************************************************************************************************************************************************** + * File Name: completion_requirement.mdl.js + * Type of Program: Model + * Description: Admin-configured rule describing what "complete" means for a course, unit, or lesson. + * + * CompletionRequirement — polymorphic via (entity_type, entity_id); entity_id is the BIGINT PK of the + * owning courses/units/lessons row (not a UUID reference like TaskRequirement — + * this row is OWNED by the entity, not pointing at an arbitrary target). + * type dispatches through utils/courses/completion_requirements.registry.js. + * Multiple rows on one entity are AND'd together by services/courses/completion.service.js. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 14, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const CompletionRequirement = sequelize.define('CompletionRequirement', { + requirement_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + entity_type: { + type: DataTypes.ENUM('course', 'unit', 'lesson'), + allowNull: false, + filterable: true, + }, + entity_id: { + type: DataTypes.BIGINT, + allowNull: false, + comment: 'course_id | unit_id | lesson_id depending on entity_type. No DB-level FK (polymorphic across 3 tables).', + filterable: true, + }, + type: { + type: DataTypes.ENUM('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete'), + allowNull: false, + filterable: true, + }, + + // ── watch_percent ─────────────────────────────────────────────────────── + min_percent: { type: DataTypes.INTEGER, allowNull: true, comment: 'watch_percent only — minimum % of media watched.', filterable: false }, + + // ── manual_complete ───────────────────────────────────────────────────── + button_label: { type: DataTypes.STRING, allowNull: true, comment: 'manual_complete only — optional custom CTA text.', filterable: false }, + + is_required: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, filterable: true }, + order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true }, + + // ── Audit trails ──────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: 'completion_requirements', + paranoid: true, + timestamps: true, +}); + +module.exports = CompletionRequirement; diff --git a/models/courses/completion_requirement_progress.mdl.js b/models/courses/completion_requirement_progress.mdl.js new file mode 100644 index 0000000..721681f --- /dev/null +++ b/models/courses/completion_requirement_progress.mdl.js @@ -0,0 +1,84 @@ +/*********************************************************************************************************************************************************************** + * File Name: completion_requirement_progress.mdl.js + * Type of Program: Model + * Description: Per-user tracking state for a CompletionRequirement, for the requirement types that need + * genuinely new storage (watch_percent's running progress_percent, watch_video/listen_audio's + * per-block progress_percent map, and manual_complete's completed flag — pass_quiz reads + * QuizAttempt.passed directly, and read_all_content reads CourseReadingProgress directly; + * neither needs a row here). + * + * CompletionRequirementProgress — UPSERT key: (requirement_id, user_id). + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 14, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const CompletionRequirementProgress = sequelize.define('CompletionRequirementProgress', { + progress_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + requirement_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: 'completion_requirements', key: 'requirement_id' }, + onDelete: 'CASCADE', + }, + user_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'users', key: 'user_id' }, + onDelete: 'CASCADE', + }, + entity_type: { + type: DataTypes.ENUM('course', 'unit', 'lesson'), + allowNull: false, + comment: 'Denormalized from the requirement row to avoid a join on every read.', + }, + entity_id: { + type: DataTypes.BIGINT, + allowNull: false, + comment: 'Denormalized from the requirement row.', + }, + progress_percent: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'watch_percent only — running max % watched.', + }, + block_progress: { + type: DataTypes.JSONB, + allowNull: true, + comment: 'watch_video/listen_audio only — { [block_id]: percent } running max per matching block; completed once every current block of that type is at 100.', + }, + completed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + completed_at: { + type: DataTypes.DATE, + allowNull: true, + }, + + // ── Audit trails ──────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: 'completion_requirement_progress', + timestamps: true, + paranoid: false, // progress rows are never soft-deleted, matches CourseReadingProgress + indexes: [ + { + unique: true, + fields: ['requirement_id', 'user_id'], + name: 'uq_crp2_requirement_user', + }, + { fields: ['user_id'], name: 'idx_crp2_user_id' }, + { fields: ['entity_type', 'entity_id'], name: 'idx_crp2_entity_type_entity_id' }, + ], +}); + +module.exports = CompletionRequirementProgress; diff --git a/models/courses/courses.associations.js b/models/courses/courses.associations.js index 0a77ef9..fee975a 100644 --- a/models/courses/courses.associations.js +++ b/models/courses/courses.associations.js @@ -24,6 +24,8 @@ const CourseReadingProgress = require("./course_reading_progress.mdl"); const UnitReadingProgress = require("./unit_reading_progress.mdl"); const LessonReadingProgress = require("./lesson_reading_progress.mdl"); const CourseAchievement = require("./course_achievement.mdl"); +const CompletionRequirement = require("./completion_requirement.mdl"); +const CompletionRequirementProgress = require("./completion_requirement_progress.mdl"); // ── CourseReadingProgress ───────────────────────────────────────────────────── CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); @@ -46,6 +48,14 @@ LessonReadingProgress.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesso mdl_Users.hasMany(LessonReadingProgress, { foreignKey: 'user_id', as: 'lessonReadingProgress' }); Lesson.hasMany(LessonReadingProgress, { foreignKey: 'lesson_id', as: 'readingProgress' }); +// ── CompletionRequirement ───────────────────────────────────────────────────── +// Polymorphic across course/unit/lesson via (entity_type, entity_id) — no belongsTo +// to Course/Unit/Lesson here (can't express a 3-way FK); entity resolution happens +// in services/courses/completion.service.js. +CompletionRequirement.hasMany(CompletionRequirementProgress, { foreignKey: 'requirement_id', as: 'progress' }); +CompletionRequirementProgress.belongsTo(CompletionRequirement, { foreignKey: 'requirement_id', as: 'requirement' }); +CompletionRequirementProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); + // ── Course ⇄ Unit / Unit ⇄ Lesson (junctions) ───────────────────────────────── // Units and Lessons are standalone entities. Membership + ordering live on the // course_units / unit_lessons junction rows (order_index). @@ -138,4 +148,5 @@ module.exports = { mdl_Category, Certificate, CourseInstructor, CourseReadingProgress, UnitReadingProgress, LessonReadingProgress, CourseAchievement, + CompletionRequirement, CompletionRequirementProgress, }; \ No newline at end of file diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index f06bfe6..0959bab 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -1,6 +1,7 @@ const router = require("express").Router(); const ctrl = require("../../controllers/admin/courses.controller"); const progressCtrl = require("../../controllers/admin/course_reading_progress.controller"); +const requirementsCtrl = require("../../controllers/admin/completion_requirements.controller"); const { authenticate } = require("../../middleware/auth.middleware"); const { requireAdmin } = require("../../middleware/rbac.middleware"); @@ -59,6 +60,13 @@ router.put("/:courseId/achievements", ctrl.syncCourseAchievements); router.get("/:courseId/prerequisites", ctrl.getPrerequisites); router.put("/:courseId/prerequisites", ctrl.syncPrerequisites); +// ══════════════════════════════════════════════════════════════════════════════ +// COMPLETION REQUIREMENTS +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/requirements", requirementsCtrl.getCourseRequirements); +router.put("/:courseId/requirements", requirementsCtrl.syncCourseRequirements); + // ══════════════════════════════════════════════════════════════════════════════ // COURSE ASSESSMENT // ══════════════════════════════════════════════════════════════════════════════ @@ -117,6 +125,9 @@ router.put("/:courseId/units/:unitId", ctrl.updateUnit); router.delete("/:courseId/units/:unitId", ctrl.archiveUnit); router.delete("/:courseId/units/:unitId/permanent", ctrl.permanentlyDeleteUnit); +router.get("/:courseId/units/:unitId/requirements", requirementsCtrl.getUnitRequirements); +router.put("/:courseId/units/:unitId/requirements", requirementsCtrl.syncUnitRequirements); + // ══════════════════════════════════════════════════════════════════════════════ // UNIT QUIZ // ══════════════════════════════════════════════════════════════════════════════ @@ -177,6 +188,13 @@ router.delete("/:courseId/units/:unitId/lessons/:lessonId/permanent", ctrl.perma router.get("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.getLessonPage); router.put("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.upsertLessonPage); +// ══════════════════════════════════════════════════════════════════════════════ +// LESSON COMPLETION REQUIREMENTS +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/units/:unitId/lessons/:lessonId/requirements", requirementsCtrl.getLessonRequirements); +router.put("/:courseId/units/:unitId/lessons/:lessonId/requirements", requirementsCtrl.syncLessonRequirements); + // ══════════════════════════════════════════════════════════════════════════════ // COURSE READING PROGRESS // ══════════════════════════════════════════════════════════════════════════════ diff --git a/routes/admin/lessons.routes.js b/routes/admin/lessons.routes.js index e55d368..c993a90 100644 --- a/routes/admin/lessons.routes.js +++ b/routes/admin/lessons.routes.js @@ -10,6 +10,7 @@ ***********************************************************************************************************************************************************************/ const router = require("express").Router(); const ctrl = require("../../controllers/admin/lessons.controller"); +const requirementsCtrl = require("../../controllers/admin/completion_requirements.controller"); // Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter) @@ -36,4 +37,8 @@ router.delete("/:lessonId/permanent", ctrl.permanentlyDeleteLesson); router.get("/:lessonId/page", ctrl.getLessonPage); router.put("/:lessonId/page", ctrl.upsertLessonPage); +// ── Completion requirements (shared with the nested courses.routes.js mount) ── +router.get("/:lessonId/requirements", requirementsCtrl.getLessonRequirements); +router.put("/:lessonId/requirements", requirementsCtrl.syncLessonRequirements); + module.exports = router; diff --git a/routes/admin/units.routes.js b/routes/admin/units.routes.js index 62f80cc..1e66004 100644 --- a/routes/admin/units.routes.js +++ b/routes/admin/units.routes.js @@ -11,6 +11,7 @@ const router = require("express").Router(); const ctrl = require("../../controllers/admin/units.controller"); const questionCtrl = require("../../controllers/admin/courses.controller"); // shared quiz-question handlers (keyed by quizId) +const requirementsCtrl = require("../../controllers/admin/completion_requirements.controller"); // Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter) @@ -35,6 +36,10 @@ router.put("/:unitId", ctrl.updateUnit); router.delete("/:unitId", ctrl.archiveUnit); router.delete("/:unitId/permanent", ctrl.permanentlyDeleteUnit); +// ── Completion requirements (shared with the nested courses.routes.js mount) ── +router.get("/:unitId/requirements", requirementsCtrl.getUnitRequirements); +router.put("/:unitId/requirements", requirementsCtrl.syncUnitRequirements); + // ── Lesson membership (attach / detach / reorder) ───────────────────────────── router.post("/:unitId/lessons", ctrl.attachLessons); router.put("/:unitId/lessons/order", ctrl.reorderLessons); diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 41273f1..4b1a8eb 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -44,5 +44,7 @@ router.get( '/:courseId/progress/summary', prog router.get( '/:courseId/progress', progressCtrl.getCourseProgress); router.get( '/:courseId/task-context', progressCtrl.getCourseTaskContext); router.post('/:courseId/units/:unitId/lessons/:lessonId/progress', progressCtrl.upsertLessonProgress); +router.post('/:courseId/units/:unitId/lessons/:lessonId/watch-progress', progressCtrl.upsertWatchProgress); +router.post('/:courseId/units/:unitId/lessons/:lessonId/mark-complete', progressCtrl.markLessonComplete); module.exports = router; \ No newline at end of file diff --git a/routes/client/lessons.routes.js b/routes/client/lessons.routes.js index cb736cc..a0fbfb7 100644 --- a/routes/client/lessons.routes.js +++ b/routes/client/lessons.routes.js @@ -18,5 +18,7 @@ const ctrl = require('../../controllers/client/units.controller'); router.get('/', ctrl.getLessons); router.get('/:uuid', ctrl.getLessonByUuid); router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress); +router.post('/:uuid/watch-progress', ctrl.upsertStandaloneWatchProgress); +router.post('/:uuid/mark-complete', ctrl.markStandaloneLessonComplete); module.exports = router; diff --git a/server.js b/server.js index b96d0ba..6c22b02 100644 --- a/server.js +++ b/server.js @@ -105,7 +105,14 @@ app.use(session({ secret: process.env.SESSION_SECRET || 'change-me', resave: false, saveUninitialized: false, - cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' }, + // sameSite:'none' (not 'strict') in production — frontend (Vercel) and this + // API (Render) are cross-site, so 'strict' drops the cookie on every + // fetch/XHR call. 'none' requires secure:true, already set above. + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'strict', + }, })); // Global rate limiter (1000 req / 15 min) diff --git a/services/completion_requirements.service.js b/services/completion_requirements.service.js new file mode 100644 index 0000000..5d0e177 --- /dev/null +++ b/services/completion_requirements.service.js @@ -0,0 +1,385 @@ +/*********************************************************************************************************************************************************************** + * 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. + let completedTasks = []; + if (!externalTransaction && courseId) { + 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. + */ +async function recordWatchProgress(userId, { + lessonId, lessonUuid, unitId = null, unitUuid = null, courseId = null, courseUuid = null, + percent, blockId = null, blockType = null, +}) { + const blockRequirementType = blockType === 'video' ? '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; + + const t = await sequelize.transaction(); + try { + let anyCompleted = false; + let aggregatePercent = null; + + if (percentRequirement) { + const existing = await CompletionRequirementProgress.findOne({ + where: { requirement_id: percentRequirement.requirement_id, user_id: userId }, transaction: t, + }); + const nextPercent = Math.max(existing?.progress_percent ?? 0, Math.min(100, Math.max(0, Math.round(percent)))); + const completed = 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, + 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 clampedPercent = Math.min(100, Math.max(0, Math.round(percent))); + const nextBlockProgress = { ...prevBlockProgress, [blockId]: Math.max(prevBlockProgress[blockId] ?? 0, clampedPercent) }; + + const page = await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'], transaction: t }); + const matchingBlockIds = (page?.blocks ?? []).filter((b) => b.type === blockType).map((b) => b.id); + const completed = matchingBlockIds.length > 0 && matchingBlockIds.every((id) => (nextBlockProgress[id] ?? 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. + const completedTasks = courseId + ? 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. + const syncUuids = entityType === 'lesson' + ? { lessonUuid, unitUuid, courseUuid } + : entityType === 'unit' + ? { unitUuid, courseUuid } + : { courseUuid }; + result.completed_tasks = courseId || entityType === 'course' + ? await syncCompletedEntitiesToTaskProgress(userId, syncUuids) + : []; + + return result; + } catch (err) { + await t.rollback(); + throw err; + } +} + +module.exports = { + evaluateEntity, + recomputeAndPersist, + recomputeCascade, + recomputeUnitAfterQuiz, + recomputeCourseAfterAssessment, + recordWatchProgress, + recordManualComplete, +}; diff --git a/services/task_reading_progress_sync.service.js b/services/task_reading_progress_sync.service.js index 07d836d..e8d0894 100644 --- a/services/task_reading_progress_sync.service.js +++ b/services/task_reading_progress_sync.service.js @@ -1,16 +1,31 @@ /*********************************************************************************************************************************************************************** * File Name: task_reading_progress_sync.service.js * Type of Program: Service - * Description: Backfills task_progress for read_* task requirements from course_reading_progress. + * Description: Bridges course/unit/lesson completion (course_reading_progress) to Task + * requirements of type read_course/read_unit/read_lesson, in both directions: * - * This covers the case where a user already completed reading a course/unit/lesson - * before a task requiring that item was created or assigned. + * hydrateReadTaskProgress(userId, requirements) + * — given a list of TaskRequirement rows (typically when a task/task list is newly + * assigned), backfills task_progress for any that reference content the user has + * already completed reading. + * + * syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid }) + * — given the UUIDs of course/unit/lesson entities that just reached 'completed' + * (from any trigger — scroll-to-bottom, watch_percent threshold, manual_complete, + * pass_quiz, assessment pass), finds matching TaskRequirement rows the user is + * assigned to and marks them done, then reports any task whose read-only + * requirements are now ALL satisfied (eligible for "auto turned-in" display). + * Called from every completion.service.js entry point — NOT just the legacy + * lesson-progress endpoint — so a unit/course completing via pass_quiz/manual_complete + * alone (no lesson ever read) still satisfies read_unit/read_course task requirements. ***********************************************************************************************************************************************************************/ 'use strict'; -const { Op } = require('sequelize'); -const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl'); -const { TaskProgress } = require('../models/task/task_progress.mdl'); +const { Op } = require('sequelize'); +const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl'); +const { TaskProgress } = require('../models/task/task_progress.mdl'); +const { Task, TaskRequirement, TaskListGroup } = require('../models/task/task.mdl'); +const { mdl_UserGroupMembers } = require('../models/users/user_groups.mdl'); const READ_TYPE_TO_PROGRESS_TYPE = { read_course: 'course', @@ -132,7 +147,97 @@ async function hydrateReadTaskProgress(userId, requirements = [], options = {}) return missingRows; } +// ─── Accessible task lists (via group membership) ──────────────────────────── + +async function getAccessibleTaskListIds(userId) { + const memberships = await mdl_UserGroupMembers.findAll({ + where: { user_id: userId, deletedAt: null }, + attributes: ['group_id'], + }); + const groupIds = memberships.map((m) => m.group_id); + if (!groupIds.length) return []; + + const taskListGroups = await TaskListGroup.findAll({ + where: { group_id: groupIds }, + attributes: ['task_list_id'], + }); + return [...new Set(taskListGroups.map((tlg) => tlg.task_list_id))]; +} + +// ─── Sync from a completion event (any trigger) → task_progress ───────────── + +/** + * Call after ANY course/unit/lesson reaches 'completed' for a user, regardless of which + * completion-requirement type triggered it. Finds TaskRequirement rows (read_course/ + * read_unit/read_lesson) referencing the given UUIDs, among task lists the user's groups + * can access, backfills task_progress via hydrateReadTaskProgress, and reports any task + * whose read-only requirements are now ALL satisfied. + * + * @param {number} userId + * @param {{ lessonUuid?: string, unitUuid?: string, courseUuid?: string }} uuids + * @returns {Promise<{ task_id, task_name }[]>} + */ +async function syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid } = {}) { + const referenceIds = [lessonUuid, unitUuid, courseUuid].filter(Boolean); + if (!referenceIds.length) return []; + + const taskListIds = await getAccessibleTaskListIds(userId); + if (!taskListIds.length) return []; + + const requirements = await TaskRequirement.findAll({ + where: { + reference_id: { [Op.in]: referenceIds }, + type: { [Op.in]: READ_REQUIREMENT_TYPES }, + deletedAt: null, + }, + include: [{ + model: Task, + as: 'task', + where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, + required: true, + attributes: ['task_id', 'name', 'task_list_id'], + }], + attributes: ['requirement_id', 'task_id', 'type', 'reference_id'], + }); + if (!requirements.length) return []; + + const newlyCompleted = await hydrateReadTaskProgress(userId, requirements); + if (!newlyCompleted.length) return []; + + // Check whether any impacted task now has ALL its read-only requirements satisfied — + // tasks with any non-read requirement (upload_file/visit_link/submit_text/pass_quiz) + // still need manual submission, so they're excluded from auto-turn-in. + const taskIds = [...new Set(newlyCompleted.map((r) => r.task_id))]; + const completedTasks = []; + + for (const taskId of taskIds) { + const allReqs = await TaskRequirement.findAll({ + where: { task_id: taskId, deletedAt: null }, + attributes: ['requirement_id', 'type', 'reference_id'], + }); + + const hasNonReadReqs = allReqs.some((r) => !READ_REQUIREMENT_TYPES.includes(r.type)); + if (hasNonReadReqs) continue; + + const doneProgress = await TaskProgress.findAll({ + where: { task_id: taskId, user_id: userId, completed: true }, + attributes: ['requirement_id', 'reference_id'], + }); + const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); + const allDone = allReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`)); + + if (allDone) { + const task = requirements.find((r) => r.task_id === taskId)?.task; + completedTasks.push({ task_id: taskId, task_name: task?.name ?? '' }); + } + } + + return completedTasks; +} + module.exports = { hydrateReadTaskProgress, + syncCompletedEntitiesToTaskProgress, + getAccessibleTaskListIds, READ_REQUIREMENT_TYPES, }; diff --git a/services/trustedDevice.service.js b/services/trustedDevice.service.js index 9bd77a1..f5e6c2e 100644 --- a/services/trustedDevice.service.js +++ b/services/trustedDevice.service.js @@ -37,10 +37,14 @@ const getFingerprintHash = (req) => { return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex'); }; +// sameSite:'none' (not 'strict') in production — the frontend (Vercel) and +// this API (Render) are different sites, so this cookie only travels on the +// cross-site fetch/XHR calls the frontend makes if SameSite allows it. +// 'none' requires secure:true, which is already forced above in production. const cookieOptions = (maxAge) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', maxAge, }); diff --git a/utils/courses/completion_requirements.registry.js b/utils/courses/completion_requirements.registry.js new file mode 100644 index 0000000..6b0152f --- /dev/null +++ b/utils/courses/completion_requirements.registry.js @@ -0,0 +1,233 @@ +'use strict'; + +/*********************************************************************************************************************************************************************** + * File Name: completion_requirements.registry.js + * Type of Program: Utility (type → handler registry) + * Description: Declarative dispatch table for the completion-requirement types + * (read_all_content, pass_quiz, watch_percent, watch_video, listen_audio, + * manual_complete), plus the `evaluateEntity` resolver that walks a + * course/unit/lesson's configured + * CompletionRequirement rows (or falls back to today's implicit rule when + * none are configured) and ANDs the results. + * + * `evaluateEntity` lives here rather than in services/courses/completion.service.js + * because unit/course evaluation is recursive (a unit's `read_all_content` rule + * means "every child lesson evaluates to completed", which itself may be governed + * by that lesson's own configured rule) — keeping the resolver next to the handler + * map it recurses through avoids a circular require with the service layer, which + * instead imports `evaluateEntity` from here and layers persistence/cascading on top. + * + * Read dispatch for "is this lesson/unit's own flag completed" branches on whether + * a course context is present: + * - courseId present → CourseReadingProgress (UUID-keyed, global per user+lesson) + * - courseId null → LessonReadingProgress / UnitReadingProgress (BIGINT-keyed, + * the only tables that support a null course_id — required + * for standalone/library unit-and-lesson consumption, since + * CourseReadingProgress.course_id is NOT NULL in the DB). + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 14, 2026 + ***********************************************************************************************************************************************************************/ + +const CompletionRequirement = require('../../models/courses/completion_requirement.mdl'); +const CompletionRequirementProgress = require('../../models/courses/completion_requirement_progress.mdl'); +const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); +const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl'); +const Lesson = require('../../models/courses/lessons.mdl'); +const UnitQuiz = require('../../models/courses/unit_quiz.mdl'); +const CourseAssessment = require('../../models/courses/course_assessment.mdl'); +const QuizAttempt = require('../../models/courses/quiz_attempt.mdl'); +const { getCourseUnitIds, getUnitLessonIds } = require('./hierarchy.util'); + +// ─── Leaf read helpers ───────────────────────────────────────────────────────── +// Shared by the type handlers below AND by services/courses/completion.service.js, +// which uses these same reads immediately after writing progress within one transaction. + +// Shared by manual_complete / watch_video / listen_audio — all three are computed +// entirely at write time (recordManualComplete / recordWatchProgress) and just read +// back the persisted `completed` flag here. +async function isProgressCompleted({ requirement, userId, t }) { + const progress = await CompletionRequirementProgress.findOne({ + where: { requirement_id: requirement.requirement_id, user_id: userId }, + transaction: t, + }); + return !!progress?.completed; +} + +async function isLessonComplete({ lessonId, userId, courseId, t }) { + if (courseId) { + const lesson = await Lesson.findOne({ where: { lesson_id: lessonId }, attributes: ['uuid'], transaction: t }); + if (!lesson) return false; + const row = await CourseReadingProgress.findOne({ + where: { user_id: userId, type: 'lesson', reference_id: lesson.uuid, status: 'completed' }, + transaction: t, + }); + return !!row; + } + const row = await LessonReadingProgress.findOne({ + where: { user_id: userId, lesson_id: lessonId, status: 'completed' }, + transaction: t, + }); + return !!row; +} + +async function isUnitQuizPassed({ unitId, userId, t }) { + const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId }, attributes: ['quiz_id'], transaction: t }); + if (!quiz) return false; + const attempt = await QuizAttempt.findOne({ + where: { quiz_id: quiz.quiz_id, user_id: userId, passed: true }, + transaction: t, + }); + return !!attempt; +} + +async function isCourseAssessmentPassed({ courseId, userId, t }) { + const assessment = await CourseAssessment.findOne({ where: { course_id: courseId }, attributes: ['assessment_id'], transaction: t }); + if (!assessment) return false; + const attempt = await QuizAttempt.findOne({ + where: { assessment_id: assessment.assessment_id, user_id: userId, passed: true }, + transaction: t, + }); + return !!attempt; +} + +// Every attached (non-archived) lesson under this unit must itself evaluate to 'completed'. +async function areAllLessonsComplete({ unitId, userId, courseId, t }) { + const lessonIds = await getUnitLessonIds(unitId); + if (!lessonIds.length) return false; // no lessons attached — matches old deriveUnitStatus's "!links.length → in_progress" + for (const lessonId of lessonIds) { + const result = await evaluateEntity({ entityType: 'lesson', entityId: lessonId, userId, courseId, transaction: t }); + if (result.status !== 'completed') return false; + } + return true; +} + +// Every attached (non-archived) unit under this course must itself evaluate to 'completed'. +async function areAllUnitsComplete({ courseId, userId, t }) { + const unitIds = await getCourseUnitIds(courseId); + if (!unitIds.length) return false; // matches old deriveCourseStatus's "!links.length → in_progress" + for (const unitId of unitIds) { + const result = await evaluateEntity({ entityType: 'unit', entityId: unitId, userId, courseId, transaction: t }); + if (result.status !== 'completed') return false; + } + return true; +} + +// ─── Type handlers ────────────────────────────────────────────────────────────── + +const TYPE_HANDLERS = { + read_all_content: { + validEntityTypes: ['course', 'unit', 'lesson'], + isSatisfied: async ({ entityType, entityId, userId, courseId, t }) => { + if (entityType === 'lesson') return isLessonComplete({ lessonId: entityId, userId, courseId, t }); + if (entityType === 'unit') return areAllLessonsComplete({ unitId: entityId, userId, courseId, t }); + if (entityType === 'course') return areAllUnitsComplete({ courseId: entityId, userId, t }); + return false; + }, + }, + pass_quiz: { + validEntityTypes: ['unit', 'course'], + isSatisfied: async ({ entityType, entityId, userId, t }) => { + if (entityType === 'unit') return isUnitQuizPassed({ unitId: entityId, userId, t }); + if (entityType === 'course') return isCourseAssessmentPassed({ courseId: entityId, userId, t }); + return false; + }, + }, + watch_percent: { + validEntityTypes: ['lesson'], + isSatisfied: async ({ userId, requirement, t }) => { + const progress = await CompletionRequirementProgress.findOne({ + where: { requirement_id: requirement.requirement_id, user_id: userId }, + transaction: t, + }); + if (!progress) return false; + if (progress.completed) return true; + const minPercent = requirement.min_percent ?? 100; + return (progress.progress_percent ?? 0) >= minPercent; + }, + }, + // watch_video / listen_audio: unlike watch_percent (one aggregate figure across + // whichever block is playing), these require EVERY block of the matching type on + // the lesson to individually reach 100% — recordWatchProgress computes that against + // the lesson's current block set and persists the result as `completed`, so reading + // it back here is identical to manual_complete's check. + watch_video: { + validEntityTypes: ['lesson'], + isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }), + }, + listen_audio: { + validEntityTypes: ['lesson'], + isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }), + }, + manual_complete: { + validEntityTypes: ['course', 'unit', 'lesson'], + isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }), + }, +}; + +// ─── Zero-requirements-configured fallback ────────────────────────────────────── +// Reproduces today's exact implicit behavior so existing content doesn't change +// behavior until an admin explicitly opts into configured requirements. + +const DEFAULT_HANDLERS = { + lesson: ({ entityId, userId, courseId, t }) => isLessonComplete({ lessonId: entityId, userId, courseId, t }), + unit: ({ entityId, userId, courseId, t }) => areAllLessonsComplete({ unitId: entityId, userId, courseId, t }), + // A course with no assessment ever built can never reach 'completed' via this default — + // matches the exact (if strict) behavior of the old deriveCourseStatus/hasPassedCourseAssessment. + course: async ({ entityId, userId, t }) => { + const allUnitsRead = await areAllUnitsComplete({ courseId: entityId, userId, t }); + if (!allUnitsRead) return false; + return isCourseAssessmentPassed({ courseId: entityId, userId, t }); + }, +}; + +// ─── Resolver ──────────────────────────────────────────────────────────────────── + +/** + * Evaluate whether `entityType`/`entityId` is 'completed' for `userId`, either against + * its configured CompletionRequirement rows (AND'd, `is_required` rows only gate status) + * or — when none are configured — the default implicit rule for that entity_type. + * + * @returns {{ status: 'completed'|'in_progress', evaluated_via: 'configured'|'default', satisfied: string[] }} + */ +async function evaluateEntity({ entityType, entityId, userId, courseId = null, transaction = null }) { + const t = transaction; + const rows = await CompletionRequirement.findAll({ + where: { entity_type: entityType, entity_id: entityId }, + order: [['order', 'ASC']], + transaction: t, + }); + + if (!rows.length) { + const completed = await DEFAULT_HANDLERS[entityType]({ entityId, userId, courseId, t }); + return { status: completed ? 'completed' : 'in_progress', evaluated_via: 'default', satisfied: [] }; + } + + const satisfied = []; + for (const row of rows) { + const handler = TYPE_HANDLERS[row.type]; + if (!handler) continue; // unknown type — ignore rather than hard-fail evaluation + const ok = await handler.isSatisfied({ entityType, entityId, userId, courseId, requirement: row, t }); + if (ok) satisfied.push(row.requirement_id); + } + + const requiredRows = rows.filter(r => r.is_required); + const allRequiredSatisfied = requiredRows.every(r => satisfied.includes(r.requirement_id)); + + return { status: allRequiredSatisfied ? 'completed' : 'in_progress', evaluated_via: 'configured', satisfied }; +} + +// entity_type → allowed requirement types, for admin-side validation (mirrors on the frontend). +const VALID_ENTITY_TYPES = Object.fromEntries( + Object.entries(TYPE_HANDLERS).map(([type, def]) => [type, def.validEntityTypes]) +); + +module.exports = { + evaluateEntity, + TYPE_HANDLERS, + VALID_ENTITY_TYPES, + // exported for reuse by services/courses/completion.service.js + isLessonComplete, + isUnitQuizPassed, + isCourseAssessmentPassed, +};