mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user