Files
starr-philproperties/controllers/client/units.controller.js
T
kennethobsequio bb7e8fde08 add: ver()
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-08 11:31:40 +08:00

328 lines
14 KiB
JavaScript

/***********************************************************************************************************************************************************************
* File Name: units.controller.js (client)
* Type of Program: Controller
* Description: Standalone Unit / Lesson consumption — the junction revamp lets
* learners run Units and Lessons outside any Course:
*
* GET /client/units → all units w/ lesson counts + is_locked
* GET /client/units/:uuid → unit metadata (shared handler)
* GET /client/units/:uuid/lessons → unit + ALL lesson data (shared handler)
* GET /client/units/:uuid/quiz → the unit's quiz, no course context
* POST /client/units/:uuid/quiz/:quizId/submit→ graded attempt with course_id NULL
* GET /client/lessons/:uuid → single lesson, runs independently (shared handler)
* POST /client/lessons/:uuid/progress → standalone reading progress (course NULL, unit optional)
*
* Access rule: a unit attached to no course is open; otherwise the user must
* be able to access at least one attached course. Lessons resolve through
* their parent units the same way.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 7, 2026
***********************************************************************************************************************************************************************/
"use strict";
const R = require("../../utils/response.util");
const logActivity = require("../../utils/logActivity.util");
const sequelize = require("../../config/db.config");
const {
Unit, Lesson,
CourseUnit, UnitLesson,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, QuizSession,
} = require("../../models/courses/courses.associations");
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 notDeleted = { deletedAt: null };
// Strip correct-answer data (same policy as course-scoped quiz endpoints)
function sanitizeQuestions(questions = []) {
return questions.map((q) => {
const plain = q.toJSON ? q.toJSON() : { ...q };
if (plain.type === "multi_select") {
plain.correct_count = (plain.options ?? []).filter((o) => o.is_correct).length;
}
plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
delete plain.explanation;
return plain;
});
}
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
exports.getUnits = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
u.unit_id, u.uuid, u.title, u.description, u.duration_seconds,
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
WHERE ul.unit_id = u.unit_id) AS lesson_count,
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE cu.unit_id = u.unit_id) AS course_count,
(SELECT quiz_id FROM unit_quizzes q
WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id
FROM units u
WHERE u."deletedAt" IS NULL
ORDER BY u.title ASC
`, { type: sequelize.QueryTypes.SELECT });
// Batch-fetch attached courses for every returned unit in one query, so the
// learner-facing upsell modal can say which course(s)/tier(s) unlock a unit
// (a unit may sit under several courses at different tiers — no single "Buy").
const unitIds = rows.map((r) => r.unit_id);
const courseLinkRows = unitIds.length ? await sequelize.query(`
SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription
FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE cu.unit_id IN (:unitIds)
`, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : [];
const coursesByUnit = new Map();
for (const row of courseLinkRows) {
const list = coursesByUnit.get(row.unit_id) ?? [];
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
coursesByUnit.set(row.unit_id, list);
}
// is_locked mirrors canAccessUnit: standalone units are open, attached units
// need at least one accessible course.
const result = [];
for (const row of rows) {
const is_locked = Number(row.course_count) > 0
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
: false;
result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked });
}
return R.success(res, "Units retrieved.", result);
} catch (err) {
console.error("[CLIENT][UNITS][GET ALL]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
// ─── STANDALONE UNIT QUIZ ─────────────────────────────────────────────────────
exports.getUnitQuiz = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
if (!await coursesCtrl.canAccessUnit(req.user.user_id, unit.unit_id)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { unit_id: unit.unit_id, ...notDeleted },
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions", "shuffle_questions",
],
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{
model: QuizOption, as: "options",
attributes: ["option_id", "text", "order_index", "is_correct"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const plain = quiz.toJSON();
let qs = sanitizeQuestions(plain.questions ?? []);
if (plain.shuffle_questions) qs = shuffleQuestions(qs);
plain.questions = shuffleOptions(qs);
const attempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
});
const status = getAttemptStatus(attempts, "quiz");
plain.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt;
plain.attempts_remaining = status.attempts_remaining;
plain.cooldown_until = status.cooldown_until;
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
const activeSession = await QuizSession.findOne({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, status: "in_progress" },
attributes: ["session_id", "draft_answers", "started_at", "last_saved_at"],
});
plain.active_session = activeSession ?? null;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
exports.submitUnitQuiz = async (req, res) => {
try {
const { uuid, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
if (!await coursesCtrl.canAccessUnit(user_id, unit.unit_id)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unit.unit_id, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options" }],
}],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const priorAttempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"],
});
const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers);
const passed = score >= (quiz.passing_score ?? 70);
const attempt = await QuizAttempt.create({
user_id,
quiz_id: quiz.quiz_id,
course_id: null, // standalone — no course context
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: quiz.passing_score ?? 70,
passed,
});
await QuizSession.update(
{ status: "submitted" },
{ where: { quiz_id: quiz.quiz_id, user_id, status: "in_progress" } }
);
return R.success(res, "Quiz submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
});
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][SUBMIT]", err);
return R.error(res, "Could not submit quiz.", 500);
}
};
// ─── STANDALONE UNIT QUIZ DRAFT ───────────────────────────────────────────────
// PATCH /client/units/:uuid/quiz/:quizId/draft — mirrors the course-scoped
// saveQuizDraft in courses.controller.js; only quiz_id + user_id are needed to
// locate the session, course_id/unit_id are just extra nullable columns on it.
exports.saveUnitQuizDraft = async (req, res) => {
try {
const { uuid, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const [updatedCount] = await QuizSession.update(
{ draft_answers: answers, last_saved_at: new Date() },
{ where: { quiz_id: quizId, user_id, status: "in_progress" } }
);
if (updatedCount === 0) {
await QuizSession.create({
quiz_id: quizId,
user_id,
course_id: null,
unit_id: unit.unit_id,
draft_answers: answers,
started_at: new Date(),
});
}
return res.status(204).end();
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][DRAFT]", err);
return R.error(res, "Could not save quiz draft.", 500);
}
};
// ─── 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.
exports.upsertStandaloneLessonProgress = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const status = req.body.status === "completed" ? "completed" : "in_progress";
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);
const link = await UnitLesson.findOne({ where: { unit_id: unit.unit_id, lesson_id: lesson.lesson_id } });
if (!link) return R.error(res, "Lesson is not attached to this unit.", 404);
unitId = unit.unit_id;
}
const result = await upsertLessonRead(userId, {
courseId: null,
unitId,
lessonId: lesson.lesson_id,
lessonStatus: status,
});
logActivity(userId, "lesson_read", {
entityType: "lesson",
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status, standalone: true },
});
return R.success(res, "Progress updated.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE PROGRESS]", err);
return R.error(res, "Could not update progress.", 500);
}
};
// ─── Shared UUID handlers re-exported for the standalone routes ───────────────
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;