course,tasklist,task and completed validation

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-17 13:04:27 +08:00
parent 612805acaf
commit d49e3be4d2
26 changed files with 1174 additions and 69 deletions
+7 -7
View File
@@ -18,26 +18,26 @@ async function syncObjectivesCreate(Model, parentField, parentId, objectives, tr
}
/**
* For UPDATE — upsert by objective_id, hard delete removed ones
* 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) {
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.objective_id), o]));
const existingMap = new Map(existing.map((o) => [String(o[pkField]), o]));
const incomingIds = new Set(
objectives.filter((o) => o.objective_id).map((o) => String(o.objective_id))
objectives.filter((o) => o[pkField]).map((o) => String(o[pkField]))
);
// Hard delete removed
const toDelete = existing.filter((o) => !incomingIds.has(String(o.objective_id)));
const toDelete = existing.filter((o) => !incomingIds.has(String(o[pkField])));
if (toDelete.length) {
await Model.destroy({
where: { objective_id: toDelete.map((o) => o.objective_id) },
where: { [pkField]: toDelete.map((o) => o[pkField]) },
force: true,
transaction,
});
@@ -46,7 +46,7 @@ async function syncObjectivesUpdate(Model, parentField, parentId, objectives, tr
// Update existing or create new
for (let i = 0; i < objectives.length; i++) {
const item = objectives[i];
const record = item.objective_id ? existingMap.get(String(item.objective_id)) : null;
const record = item[pkField] ? existingMap.get(String(item[pkField])) : null;
if (record) {
await record.update({ text: item.text, order_index: i }, { transaction });
@@ -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 };
+63
View File
@@ -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 };