chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
+31
View File
@@ -0,0 +1,31 @@
"use strict";
/**
* Single archive — soft delete one record
*/
async function archiveOne(Model, where, deletedBy, transaction) {
const record = await Model.findOne({ where, transaction });
if (!record) return null;
await record.update({ deletedBy: deletedBy ?? null }, { transaction });
await record.destroy({ transaction });
return record;
}
/**
* Bulk archive — soft delete multiple records by primary key
*/
async function archiveMany(Model, pkField, ids, deletedBy, transaction) {
if (!ids?.length) return 0;
const records = await Model.findAll({ where: { [pkField]: ids, deletedAt: null } });
if (!records.length) return 0;
for (const record of records) {
await record.update({ deletedBy: deletedBy ?? null }, { transaction });
await record.destroy({ transaction });
}
return records.length;
}
module.exports = { archiveOne, archiveMany };
@@ -0,0 +1,245 @@
'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;
// CockroachDB returns INTEGER columns as strings over the wire (same class of
// issue as the BigInt precision fix) — without Number() coercion this becomes a
// lexicographic string comparison, where e.g. "7" >= "100" is true.
const minPercent = Number(requirement.min_percent ?? 100);
return Number(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;
// Units always complete by passing their own quiz — overrides whatever
// completion_requirement type (if any) is configured on the unit. A unit
// with no quiz attached never completes (isUnitQuizPassed returns false).
if (entityType === 'unit') {
const completed = await isUnitQuizPassed({ unitId: entityId, userId, t });
return { status: completed ? 'completed' : 'in_progress', evaluated_via: 'quiz_required', satisfied: [] };
}
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,
};
+57
View File
@@ -0,0 +1,57 @@
"use strict";
/**
* Grades a submission against the stored correct answers.
*
* The breakdown returned here is what gets sent back to the client, so it
* deliberately never includes which option(s) were correct — only whether
* the user's own answer for each question was right or wrong. Explanation
* text is included only when the question was answered correctly, since
* showing it for a wrong answer would effectively reveal the correct one.
*
* @param {Array} questions - QuizQuestion rows w/ .options (incl. is_correct), already fetched
* @param {Object} answers - { [question_id]: optionId | optionId[] } submitted by the client
*/
function gradeSubmission(questions, answers = {}) {
let totalPoints = 0;
let earnedPoints = 0;
const breakdown = questions.map((q) => {
const points = q.points ?? 1;
totalPoints += points;
const correctIds = (q.options ?? [])
.filter((o) => o.is_correct)
.map((o) => o.option_id);
const submitted = answers[q.question_id];
const submittedIds = Array.isArray(submitted)
? submitted
: (submitted !== undefined && submitted !== null ? [submitted] : []);
const isCorrect =
submittedIds.length === correctIds.length &&
correctIds.every((id) => submittedIds.includes(id));
if (isCorrect) earnedPoints += points;
return {
question_id: q.question_id,
type: q.type,
question: q.question,
points,
is_correct: isCorrect,
selected_option_ids: submittedIds,
explanation: isCorrect ? (q.explanation ?? null) : null,
options: (q.options ?? []).map((o) => ({
option_id: o.option_id,
text: o.text,
})),
};
});
const score = totalPoints > 0 ? Math.round((earnedPoints / totalPoints) * 100) : 0;
return { totalPoints, earnedPoints, score, breakdown };
}
module.exports = { gradeSubmission };
+114
View File
@@ -0,0 +1,114 @@
"use strict";
/***********************************************************************************************************************************************************************
* File Name: hierarchy.util.js
* Type of Program: Utility
* Description: Helpers for the junction-based course hierarchy
* (courses ⇄ course_units ⇄ units ⇄ unit_lessons ⇄ lessons).
*
* Sequelize belongsToMany includes surface the junction row under the through-
* model key ("CourseUnit" / "UnitLesson"). These helpers flatten that back to
* the flat `order_index` field the API has always exposed, so response shapes
* stay identical to the pre-junction era.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 7, 2026
***********************************************************************************************************************************************************************/
const sequelize = require("../../config/db.config");
// ── Response flattening ───────────────────────────────────────────────────────
/** Sort lessons by their unit_lessons.order_index and flatten it onto each row. */
function flattenLessons(lessons = []) {
return [...lessons]
.map((l) => {
const { UnitLesson: link, ...rest } = l;
return { ...rest, order_index: link?.order_index ?? rest.order_index ?? 0 };
})
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0));
}
/** Sort units by their course_units.order_index, flatten it, and recurse into lessons. */
function flattenUnits(units = []) {
return [...units]
.map((u) => {
const { CourseUnit: link, ...rest } = u;
return {
...rest,
order_index: link?.order_index ?? rest.order_index ?? 0,
...(rest.lessons ? { lessons: flattenLessons(rest.lessons) } : {}),
};
})
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0));
}
// ── Junction row maintenance ──────────────────────────────────────────────────
/** Next order_index for appending to a parent (max + 1, or 0 when empty). */
async function nextOrderIndex(JunctionModel, where, transaction) {
const max = await JunctionModel.max("order_index", { where, transaction });
return Number.isFinite(max) ? max + 1 : 0;
}
/**
* Persist a full ordering: ids[i] gets order_index i.
* Ignores ids without an existing junction row.
*/
async function reorderJunction(JunctionModel, parentField, parentId, childField, orderedIds = [], transaction) {
await Promise.all(orderedIds.map((id, i) =>
JunctionModel.update(
{ order_index: i },
{ where: { [parentField]: parentId, [childField]: id }, transaction }
)
));
}
// ── Structure counts (raw SQL — junction traversals) ──────────────────────────
/** Count non-archived lessons reachable from a course through its attached units. */
async function countCourseLessons(courseId) {
const [row] = await sequelize.query(`
SELECT CAST(COUNT(DISTINCT l.lesson_id) AS INTEGER) AS total
FROM lessons l
JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
JOIN course_units cu ON cu.unit_id = u.unit_id AND cu.course_id = :courseId
WHERE l."deletedAt" IS NULL
`, { replacements: { courseId }, type: sequelize.QueryTypes.SELECT });
return Number(row?.total ?? 0);
}
/** Non-archived unit ids attached to a course, in course order. */
async function getCourseUnitIds(courseId) {
const rows = await sequelize.query(`
SELECT u.unit_id
FROM units u
JOIN course_units cu ON cu.unit_id = u.unit_id AND cu.course_id = :courseId
WHERE u."deletedAt" IS NULL
ORDER BY cu.order_index ASC
`, { replacements: { courseId }, type: sequelize.QueryTypes.SELECT });
return rows.map((r) => r.unit_id);
}
/** Non-archived lesson ids attached to a unit, in unit order. */
async function getUnitLessonIds(unitId) {
const rows = await sequelize.query(`
SELECT l.lesson_id
FROM lessons l
JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id AND ul.unit_id = :unitId
WHERE l."deletedAt" IS NULL
ORDER BY ul.order_index ASC
`, { replacements: { unitId }, type: sequelize.QueryTypes.SELECT });
return rows.map((r) => r.lesson_id);
}
module.exports = {
flattenLessons,
flattenUnits,
nextOrderIndex,
reorderJunction,
countCourseLessons,
getCourseUnitIds,
getUnitLessonIds,
};
+16
View File
@@ -0,0 +1,16 @@
"use strict";
/**
* Replace all junction rows for a course in one shot (delete + bulk create).
*/
async function syncJunction(Model, courseId, ids, fkField, transaction) {
await Model.destroy({ where: { course_id: courseId }, transaction });
if (ids?.length) {
await Model.bulkCreate(
ids.map((id) => ({ course_id: courseId, [fkField]: id })),
{ transaction }
);
}
}
module.exports = { syncJunction };
+62
View File
@@ -0,0 +1,62 @@
"use strict";
/**
* For CREATE — plain text array, no IDs needed
* objectives: ["text1"] or [{ text: "text1" }]
*/
async function syncObjectivesCreate(Model, parentField, parentId, objectives, transaction) {
if (!objectives?.length) return;
await Model.bulkCreate(
objectives.map((o, i) => ({
[parentField]: parentId,
text: typeof o === "string" ? o : o.text,
order_index: i,
})),
{ transaction }
);
}
/**
* For UPDATE — upsert by PK field (default "objective_id"), hard delete removed ones
* objectives: [{ objective_id: "123", text: "text1" }, { text: "new" }]
*/
async function syncObjectivesUpdate(Model, parentField, parentId, objectives, transaction, pkField = "objective_id") {
if (!objectives?.length) {
await Model.destroy({ where: { [parentField]: parentId }, force: true, transaction });
return;
}
const existing = await Model.findAll({ where: { [parentField]: parentId }, transaction });
const existingMap = new Map(existing.map((o) => [String(o[pkField]), o]));
const incomingIds = new Set(
objectives.filter((o) => o[pkField]).map((o) => String(o[pkField]))
);
// Hard delete removed
const toDelete = existing.filter((o) => !incomingIds.has(String(o[pkField])));
if (toDelete.length) {
await Model.destroy({
where: { [pkField]: toDelete.map((o) => o[pkField]) },
force: true,
transaction,
});
}
// Update existing or create new
for (let i = 0; i < objectives.length; i++) {
const item = objectives[i];
const record = item[pkField] ? existingMap.get(String(item[pkField])) : null;
if (record) {
await record.update({ text: item.text, order_index: i }, { transaction });
} else {
await Model.create(
{ [parentField]: parentId, text: item.text, order_index: i },
{ transaction }
);
}
}
}
module.exports = { syncObjectivesCreate, syncObjectivesUpdate };
@@ -0,0 +1,45 @@
"use strict";
const { Op } = require("sequelize");
const onlyDeleted = { deletedAt: { [Op.not]: null } };
/**
* Permanently delete a single archived record.
* Returns the record on success, null if not found, false if found but not archived.
*
* @param {Model} Model - Sequelize model
* @param {object} where - Where clause (primary key lookup)
* @param {object} t - Sequelize transaction
*/
async function permanentDeleteOne(Model, where, t) {
const record = await Model.findOne({ where, paranoid: false, transaction: t });
if (!record) return null;
if (!record.deletedAt) return false;
await record.destroy({ force: true, transaction: t });
return record;
}
/**
* Permanently delete many archived records by primary key.
* Returns the count of deleted records.
*
* @param {Model} Model - Sequelize model
* @param {string} pkColumn - Primary key column name (e.g. "course_id")
* @param {Array} ids - Array of primary key values to delete
* @param {object} t - Sequelize transaction
*/
async function permanentDeleteMany(Model, pkColumn, ids, t) {
if (!ids?.length) return 0;
const count = await Model.destroy({
where: { [pkColumn]: ids, ...onlyDeleted },
transaction: t,
paranoid: false,
force: true,
});
return count;
}
module.exports = { permanentDeleteOne, permanentDeleteMany };
@@ -0,0 +1,99 @@
// Fisher-Yates in-place shuffle — shared by shuffleOptions and shuffleQuestions.
function fisherYates(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Randomises the ORDER OF OPTIONS within each question. Pure — returns new
// arrays/objects, never mutates input. Grading is unaffected since
// submitUnitQuiz/submitCourseAssessment always re-fetch questions fresh
// from the DB and never trust the shuffled client-facing order.
function shuffleOptions(questions) {
return questions.map((q) => ({ ...q, options: fisherYates([...(q.options ?? [])]) }));
}
// Randomises the ORDER OF QUESTIONS. Pure — returns a new array.
// Safe: grading re-fetches questions from DB in stored order; client position
// has no effect on correctness checks.
function shuffleQuestions(questions) {
return fisherYates([...questions]);
}
// Single source of truth for both the GET-time info fields and the
// submit-time enforcement check.
//
// type = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open
// type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles)
// maxFails / cooldownHours come from the assessment row; null = feature off (no limit/cooldown).
function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) {
const attempt_count = attempts.length;
const has_passed = attempts.some((a) => a.passed);
const best_attempt = attempts.reduce(
(best, a) => (!best || a.score > best.score ? a : best),
null
);
if (type === 'quiz') {
return {
attempt_count,
has_passed,
best_attempt,
attempts_remaining: null,
cooldown_until: null,
window_reset_at: null,
can_attempt: true,
};
}
// Assessment: simulate rolling cycles — N failed attempts → cooldown.
// null means the feature is off: no attempt cap / no cooldown.
const failLimit = maxFails ?? null;
const lockHours = cooldownHours ?? null;
if (failLimit === null || lockHours === null) {
return {
attempt_count,
has_passed,
best_attempt,
attempts_remaining: null,
cooldown_until: null,
window_reset_at: null,
can_attempt: true,
};
}
const sorted = [...attempts].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
let cycle_end = null; // when the current cooldown expires (null = no active or past cooldown)
let failed_in_cycle = 0;
for (const a of sorted) {
// Skip attempts that fall inside a previous cooldown window (they shouldn't exist, but guard anyway)
if (cycle_end && new Date(a.createdAt) < cycle_end) continue;
if (!a.passed) {
failed_in_cycle++;
if (failed_in_cycle >= failLimit) {
cycle_end = new Date(new Date(a.createdAt).getTime() + lockHours * 3600000);
failed_in_cycle = 0;
}
}
}
const now = new Date();
const cooldown_until = (cycle_end && cycle_end > now) ? cycle_end.toISOString() : null;
return {
attempt_count,
has_passed,
best_attempt,
attempts_remaining: null,
cooldown_until,
window_reset_at: null,
can_attempt: !cooldown_until,
};
}
module.exports = { shuffleOptions, shuffleQuestions, getAttemptStatus };
@@ -0,0 +1,99 @@
"use strict";
const REF_TYPE_CONFIG = {
course: { pk: "course_id" },
unit: { pk: "unit_id" },
lesson: { pk: "lesson_id" },
};
/**
* Enriches CoursePrerequisite rows ({ref_type, ref_id, ...}) with the
* referenced entity's title (and subscription tier slug, where the entity
* has one) — one batched findAll per ref_type instead of a query per row.
* Rows whose referenced entity no longer exists (deleted) get title: null
* so callers can drop or flag them instead of crashing.
*/
async function resolvePrerequisiteTitles(prereqs, { Course, Unit, Lesson }) {
if (!prereqs?.length) return prereqs ?? [];
const models = { course: Course, unit: Unit, lesson: Lesson };
// Only Course/Unit carry a subscription tier gate — Lesson has none.
const attrsByType = { course: ["title", "subscription"], unit: ["title", "subscription"], lesson: ["title"] };
const idsByType = { course: new Set(), unit: new Set(), lesson: new Set() };
for (const p of prereqs) {
if (idsByType[p.ref_type]) idsByType[p.ref_type].add(p.ref_id);
}
const infoMaps = {};
for (const [type, { pk }] of Object.entries(REF_TYPE_CONFIG)) {
const ids = [...idsByType[type]];
infoMaps[type] = new Map();
if (!ids.length) continue;
const rows = await models[type].findAll({
where: { [pk]: ids },
attributes: [pk, ...attrsByType[type]],
});
for (const row of rows) infoMaps[type].set(String(row[pk]), row);
}
return prereqs.map((p) => {
const info = infoMaps[p.ref_type]?.get(String(p.ref_id));
return {
...p,
title: info?.title ?? null,
subscription: info?.subscription ?? null,
};
});
}
/**
* Enriches prerequisite rows (already carrying `title`) with `completed` —
* whether the given learner has finished the referenced course/unit/lesson.
* "Completed" means: a Certificate exists for a course prereq, or the
* matching UnitReadingProgress/LessonReadingProgress row has status
* "completed" for a unit/lesson prereq.
*/
async function resolvePrerequisiteCompletion(prereqs, { Certificate, UnitReadingProgress, LessonReadingProgress }, userId) {
if (!prereqs?.length) return prereqs ?? [];
const idsByType = { course: new Set(), unit: new Set(), lesson: new Set() };
for (const p of prereqs) {
if (idsByType[p.ref_type]) idsByType[p.ref_type].add(p.ref_id);
}
const completedIds = { course: new Set(), unit: new Set(), lesson: new Set() };
const courseIds = [...idsByType.course];
if (courseIds.length) {
const certs = await Certificate.findAll({
where: { user_id: userId, course_id: courseIds },
attributes: ["course_id"],
});
certs.forEach((c) => completedIds.course.add(String(c.course_id)));
}
const unitIds = [...idsByType.unit];
if (unitIds.length) {
const rows = await UnitReadingProgress.findAll({
where: { user_id: userId, unit_id: unitIds, status: "completed" },
attributes: ["unit_id"],
});
rows.forEach((r) => completedIds.unit.add(String(r.unit_id)));
}
const lessonIds = [...idsByType.lesson];
if (lessonIds.length) {
const rows = await LessonReadingProgress.findAll({
where: { user_id: userId, lesson_id: lessonIds, status: "completed" },
attributes: ["lesson_id"],
});
rows.forEach((r) => completedIds.lesson.add(String(r.lesson_id)));
}
return prereqs.map((p) => ({
...p,
completed: completedIds[p.ref_type]?.has(String(p.ref_id)) ?? false,
}));
}
module.exports = { resolvePrerequisiteTitles, resolvePrerequisiteCompletion };
+44
View File
@@ -0,0 +1,44 @@
"use strict";
const { Op } = require("sequelize");
const onlyDeleted = { deletedAt: { [Op.not]: null } };
/**
* Soft-restore a single record by clearing deletedAt / deletedBy.
* Returns the record on success, null if not found.
*
* @param {Model} Model - Sequelize model
* @param {object} where - Where clause (must include onlyDeleted or equivalent)
* @param {*} restoredBy - User ID stamped onto updatedBy
* @param {object} t - Sequelize transaction
*/
async function restoreOne(Model, where, restoredBy, t) {
const record = await Model.findOne({ where, paranoid: false });
if (!record) return null;
await record.restore();
await record.update({ updatedBy: restoredBy, deletedBy: null })
return record;
}
/**
* Soft-restore many records by their primary key column.
* Returns the count of restored records.
*
* @param {Model} Model - Sequelize model
* @param {string} pkColumn - Primary key column name (e.g. "course_id")
* @param {Array} ids - Array of primary key values to restore
* @param {*} restoredBy - User ID stamped onto updatedBy
* @param {object} t - Sequelize transaction
*/
async function restoreMany(Model, pkColumn, ids, restoredBy, t) {
const [count] = await Model.update(
{ deletedAt: null, deletedBy: null, updatedBy: restoredBy ?? null },
{ where: { [pkColumn]: ids, ...onlyDeleted }, transaction: t, paranoid: false }
);
return count;
}
module.exports = { restoreOne, restoreMany };
@@ -0,0 +1,63 @@
"use strict";
/***********************************************************************************************************************************************************************
* File Name: taskPrerequisites.util.js
* Type of Program: Utility
* Description: Cycle detection for the explicit Task ↔ Task prerequisite graph
* (task_prerequisites junction table). A task's prerequisite set is
* only meaningful if the graph stays a DAG — this checks whether
* persisting a proposed edge set would introduce a cycle.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 17, 2026
***********************************************************************************************************************************************************************/
const { TaskPrerequisite } = require("../../models/task/task.mdl");
/**
* Would setting `taskId`'s prerequisites to `newPrereqIds` introduce a cycle?
*
* Loads every existing task_prerequisites edge for the task list, overlays the
* proposed edges for `taskId` (replacing whatever it currently points to), then
* DFS from `taskId` looking for a path back to itself.
*/
async function wouldCreateCycle(taskId, newPrereqIds, taskListId, transaction) {
const { Task } = require("../../models/task/task.mdl");
const siblingTasks = await Task.findAll({
where: { task_list_id: taskListId },
attributes: ["task_id"],
transaction,
});
const siblingIds = siblingTasks.map((t) => t.task_id);
const existingEdges = await TaskPrerequisite.findAll({
where: { task_id: siblingIds },
attributes: ["task_id", "prerequisite_task_id"],
transaction,
});
// adjacency: task_id -> Set(prerequisite_task_id) ("depends on")
const adjacency = new Map();
for (const { task_id, prerequisite_task_id } of existingEdges) {
if (task_id === taskId) continue; // overlay taskId's edges with the proposed set below
if (!adjacency.has(task_id)) adjacency.set(task_id, new Set());
adjacency.get(task_id).add(prerequisite_task_id);
}
adjacency.set(taskId, new Set(newPrereqIds));
// DFS from taskId looking for a path back to taskId
const visited = new Set();
const stack = [...adjacency.get(taskId)];
while (stack.length) {
const current = stack.pop();
if (current === taskId) return true;
if (visited.has(current)) continue;
visited.add(current);
for (const next of adjacency.get(current) ?? []) stack.push(next);
}
return false;
}
module.exports = { wouldCreateCycle };