units,lesson as standalone

This commit is contained in:
2026-07-10 11:44:46 +08:00
parent 86fba50b95
commit e1ffdab190
46 changed files with 1463 additions and 197 deletions
+106 -13
View File
@@ -19,6 +19,9 @@ const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl");
const mdl_PlanPolicy = require("../../models/tiers/plan_policies.mdl");
const { mdl_UserGroupMembers } = require("../../models/users/user_groups.mdl");
const { evaluateCourseAccess } = require("../../utils/accessPolicy.util");
const {
Course,
@@ -79,7 +82,9 @@ async function expireSession(session, passingScore) {
return expiredAttempt;
}
// Builds minimal user context: active tier slug + live tier rank map
// Builds user context for evaluateCourseAccess: active tier slug, live tier
// rank map, the active plan's access_rules (if any), and group memberships
// (needed for the group_restriction rule type).
async function buildUserContext(user_id) {
const activeTier = await getActiveTier(user_id);
const tier = activeTier?.tier ?? 'free';
@@ -88,7 +93,19 @@ async function buildUserContext(user_id) {
const tierRankMap = {};
for (const c of categories) tierRankMap[c.slug] = c.rank;
return { tier, tierRankMap };
let access_rules = [];
if (activeTier?.plan_id) {
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: activeTier.plan_id } });
access_rules = policy?.access_rules ?? [];
}
const memberships = await mdl_UserGroupMembers.findAll({
where: { user_id, deletedAt: null },
attributes: ['group_id'],
});
const group_ids = memberships.map((m) => m.group_id);
return { tier, tierRankMap, access_rules, group_ids };
}
// ─── Shared tier + purchase access check ─────────────────────────────────────
@@ -96,16 +113,15 @@ async function buildUserContext(user_id) {
// Returns false → user's tier is too low AND no valid individual purchase.
async function canAccessCourse(user_id, course_id) {
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
const requiredTier = course?.subscription ?? 'free';
if (!course) return false;
const userCtx = await buildUserContext(user_id);
// Rank-0 slugs (default/free tier) are always accessible — resolved dynamically
const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity;
if (courseRank === 0) return true;
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
if (userRank >= courseRank) return true;
// evaluateCourseAccess already falls back to plain rank comparison when the
// active plan has no access_rules configured — same behavior as before for
// every course/plan combination that hasn't opted into the richer engine.
const { allowed } = evaluateCourseAccess(userCtx, course, userCtx.tierRankMap);
if (allowed) return true;
// Individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id } });
@@ -129,8 +145,19 @@ async function canAccessCourse(user_id, course_id) {
// content locked while letting genuinely standalone content run independently.
async function canAccessUnit(user_id, unit_id) {
// A unit's own subscription (standalone tier-gating) is an additional,
// OR'd access path alongside any attached course's access — most
// standalone units have zero course links anyway, but a unit that somehow
// has both should be unlockable via either.
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
if (unit?.subscription) {
const userCtx = await buildUserContext(user_id);
const { allowed } = evaluateCourseAccess(userCtx, { subscription: unit.subscription }, userCtx.tierRankMap);
if (allowed) return true;
}
const links = await CourseUnit.findAll({ where: { unit_id }, attributes: ['course_id'] });
if (!links.length) return true;
if (!links.length) return !unit?.subscription;
for (const link of links) {
if (await canAccessCourse(user_id, link.course_id)) return true;
}
@@ -1083,6 +1110,52 @@ exports.getUnitByUuid = async (req, res) => {
}
};
// "Quiz (self-enrich for pass_quiz task requirement blocks)" — mirrors
// getUnitByUuid/getLessonByUuid's uuid-lookup pattern. A quiz is always
// unit-scoped (unit_quizzes.unit_id unique) so access resolves through its
// one parent unit, same rule canAccessUnit already implements.
exports.getQuizByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const quiz = await UnitQuiz.findOne({
where: { uuid, ...notDeleted },
attributes: ["quiz_id", "uuid", "title", "is_required", "passing_score"],
include: [{
model: Unit, as: "unit",
attributes: ["unit_id", "uuid", "title"],
include: [{
model: Course, as: "courses",
where: notDeleted, required: false,
attributes: ["course_id", "title", "subscription"],
through: { attributes: [] },
}],
}],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
if (!await canAccessUnit(req.user.user_id, quiz.unit.unit_id)) {
const first = quiz.unit.courses?.[0] ?? null;
return res.status(403).json({
status: "error",
message: "You do not have access to this quiz.",
course: first ? { title: first.title, subscription: first.subscription } : null,
});
}
const passedAttempt = await QuizAttempt.findOne({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, passed: true },
});
const plain = quiz.toJSON();
plain.unit.course = plain.unit.courses?.[0] ?? null; // back-compat singular field
plain.has_passed = !!passedAttempt;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][QUIZ][BY UUID]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
// "Units → Lessons (returns all data)" — a Unit resolves all of its lesson
// content in one call, with or without a parent course.
exports.getLessonsByUnitUuid = async (req, res) => {
@@ -1105,7 +1178,10 @@ exports.getLessonsByUnitUuid = async (req, res) => {
required: false,
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
through: { attributes: ["order_index"] },
include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }],
include: [
{ model: LessonPage, as: "page", attributes: ["blocks"], required: false },
{ model: LessonObjective, as: "objectives", required: false, attributes: ["objective_id", "text", "order_index"] },
],
},
{
model: UnitQuiz, as: "quiz",
@@ -1147,6 +1223,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
order_index: l.order_index ?? 0,
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,
}));
@@ -1187,7 +1264,7 @@ exports.getLessonByUuid = async (req, res) => {
const { uuid } = req.params;
const lesson = await Lesson.findOne({
where: { uuid, ...notDeleted },
attributes: ["lesson_id", "uuid", "title", "description"],
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
include: [
{
model: LessonPage,
@@ -1195,6 +1272,12 @@ exports.getLessonByUuid = async (req, res) => {
attributes: ["blocks"],
required: false,
},
{
model: LessonObjective,
as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
{
model: Unit,
as: "units",
@@ -1209,6 +1292,7 @@ exports.getLessonByUuid = async (req, res) => {
}],
},
],
order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
@@ -1221,6 +1305,11 @@ exports.getLessonByUuid = async (req, res) => {
});
}
const progress = await LessonReadingProgress.findOne({
where: { user_id: req.user.user_id, lesson_id: lesson.lesson_id },
attributes: ["status", "completed_at"],
});
const plain = lesson.toJSON();
const firstUnit = plain.units?.[0] ?? null;
const data = {
@@ -1228,8 +1317,12 @@ exports.getLessonByUuid = async (req, res) => {
uuid: plain.uuid,
title: plain.title,
description: plain.description,
duration_seconds: plain.duration_seconds ?? 0,
blocks: plain.page?.blocks ?? [],
unit: firstUnit ? { unit_id: firstUnit.unit_id, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field
objectives: plain.objectives ?? [],
status: progress?.status ?? "not_started",
completed_at: progress?.completed_at ?? null,
unit: firstUnit ? { unit_id: firstUnit.unit_id, uuid: firstUnit.uuid, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field
units: plain.units ?? [],
};
return R.success(res, "Lesson retrieved.", data);
+2 -1
View File
@@ -260,7 +260,8 @@ exports.streamAsset = async (req, res) => {
let presignedUrl;
try {
presignedUrl = await s3.getSignedDownloadUrl(storage_key, 60);
console.log("Presigned URL:", presignedUrl);
// ── Just comment out for debug if S3_ENDPOINT is undefined ────────────────
// console.log("Presigned URL:", presignedUrl);
} catch (err) {
console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message);
return res.status(500).json({ message: "Could not resolve media stream." });
+25 -3
View File
@@ -23,7 +23,7 @@ async function list(req, res) {
const offset = (page - 1) * limit;
const { count, rows } = await UserNotification.findAndCountAll({
where: { user_id: userId },
where: { user_id: userId, show_in_notifications: true },
order: [['createdAt', 'DESC']],
limit,
offset,
@@ -44,7 +44,7 @@ async function unseenCount(req, res) {
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
try {
const count = await UserNotification.count({
where: { user_id: req.user.user_id, seen: false },
where: { user_id: req.user.user_id, seen: false, show_in_notifications: true },
});
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
@@ -53,6 +53,28 @@ async function unseenCount(req, res) {
}
}
// ─── GET /client/notifications/sticky ─────────────────────────────────────
async function stickyAnnouncement(req, res) {
try {
const notification = await UserNotification.findOne({
where: {
user_id: req.user.user_id,
seen: false,
show_in_sticky: true,
type: "announcement",
},
order: [["createdAt", "DESC"]],
});
return R.success(res, "Sticky announcement fetched.", {
announcement: notification,
});
} catch (err) {
console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
return R.error(res, "Failed to fetch sticky announcement.");
}
}
// ─── PATCH /client/notifications/:id/seen ────────────────────────────────────
async function markSeen(req, res) {
try {
@@ -97,4 +119,4 @@ async function clearAll(req, res) {
}
}
module.exports = { list, unseenCount, markSeen, markAllSeen, clearAll };
module.exports = { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen, clearAll };
+200 -105
View File
@@ -17,12 +17,16 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const { QuizAttempt } = require('../../models/courses/courses.associations');
const { userExclude } = require('../../models/task/task.attributes');
const { clientExclude } = require('../../models/task/task_completion.attributes');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.service');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUUID = (v) => UUID_RE.test(v);
@@ -129,6 +133,150 @@ const isMember = async (userId, groupId) => {
return !!membership;
};
// ─── Helper: per-user completion signals for a batch of tasks ─────────────────
// Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share
// one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins);
// pass_quiz is computed live from QuizAttempt, same as unit-quiz has_passed
// (courses.controller.js) rather than a separately-synced TaskProgress row.
const getTaskCompletionSignals = async (userId, taskIds, requirements) => {
const quizIds = [...new Set(
requirements.filter((r) => r.type === 'pass_quiz' && r.reference_id).map((r) => r.reference_id)
)];
const [completions, linkVisits, progressRows, passedAttempts] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'status', 'submitted_at'],
order: [['submitted_at', 'DESC']],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
quizIds.length
? QuizAttempt.findAll({
where: { quiz_id: { [Op.in]: quizIds }, user_id: userId, passed: true },
attributes: ['quiz_id'],
})
: [],
]);
// First row per task_id wins — completions are ordered submitted_at DESC.
const latestCompletionByTask = new Map();
for (const c of completions) {
if (!latestCompletionByTask.has(c.task_id)) latestCompletionByTask.set(c.task_id, c);
}
return {
latestCompletionByTask,
visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)),
completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)),
passedQuizIds: new Set(passedAttempts.map((a) => String(a.quiz_id))),
};
};
// ─── Helper: has this requirement been satisfied by the user? ─────────────────
const isRequirementDone = (r, signals) => {
switch (r.type) {
case 'upload_file':
case 'submit_text': {
const completion = signals.latestCompletionByTask.get(r.task_id);
if (!completion) return false;
return r.requires_review ? completion.status === 'approved' : true;
}
case 'visit_link':
return signals.visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
case 'pass_quiz':
return signals.passedQuizIds.has(String(r.reference_id));
default:
return true; // unknown requirement types don't block completion
}
};
// ─── Helper: server-side sequencing gate ───────────────────────────────────
// Rejects a completion write if any earlier *required* task in the same list
// isn't complete yet — the enforcement piece the unit-quiz sequencing
// precedent (UnitList.jsx) does NOT have (that one is client-lock-only).
// Shared by this file's submitTask and task_progress.controller.js's
// visitLink/updateProgress.
const assertTaskUnlocked = async (userId, taskListId, orderIndex) => {
const earlierRequired = await Task.findAll({
where: { task_list_id: taskListId, order_index: { [Op.lt]: orderIndex }, is_required: true },
include: [{ model: TaskRequirement, as: 'requirements' }],
});
if (!earlierRequired.length) return true;
const taskIds = earlierRequired.map((t) => t.task_id);
const allRequirements = earlierRequired.flatMap((t) => (t.requirements ?? []).map((r) => r.toJSON()));
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
return earlierRequired.every((t) => {
const reqs = (t.requirements ?? []);
return reqs.length > 0 && reqs.every((r) => isRequirementDone(r.toJSON ? r.toJSON() : r, signals));
});
};
// ─── Helper: is this one task fully done for this user, right now? ─────────
const checkTaskCompletion = async (userId, taskId) => {
const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } });
if (!reqs.length) return false;
const plainReqs = reqs.map((r) => r.toJSON());
const signals = await getTaskCompletionSignals(userId, [taskId], plainReqs);
return plainReqs.every((r) => isRequirementDone(r, signals));
};
// ─── Helper: fire task_completed (+ task_list_finisher achievement) on the
// 0→1 completion transition. Callers compute `wasComplete` themselves right
// before their write, then call this after, so it only fires once per task.
const fireTaskCompletedEvent = async (userId, taskId) => {
try {
const task = await Task.findByPk(taskId);
if (!task) return;
try {
const notify = await renderNotification({ type: 'task_completed', data: { taskName: task.name } });
await UserNotification.create({ user_id: userId, ...notify, seen: false });
} catch (notifyErr) {
console.error('[TASK][NOTIFY COMPLETED]', notifyErr);
}
await onTaskCompleted(userId, taskId, task.name);
// ── Whole-list completion — check every sibling task too ───────────
const siblingTasks = await Task.findAll({ where: { task_list_id: task.task_list_id } });
const allDone = siblingTasks.length > 0 && (
await Promise.all(siblingTasks.map((t) => checkTaskCompletion(userId, t.task_id)))
).every(Boolean);
if (allDone) {
const taskList = await TaskList.findByPk(task.task_list_id);
if (taskList) await onTaskListCompleted(userId, taskList.task_list_id, taskList.name);
}
} catch (err) {
console.error('[TASK][FIRE COMPLETED EVENT]', err);
}
};
exports.getTaskCompletionSignals = getTaskCompletionSignals;
exports.isRequirementDone = isRequirementDone;
exports.assertTaskUnlocked = assertTaskUnlocked;
exports.checkTaskCompletion = checkTaskCompletion;
exports.fireTaskCompletedEvent = fireTaskCompletedEvent;
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
//
@@ -185,7 +333,7 @@ exports.getGroupTaskList = async (req, res) => {
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
}],
order: [['createdAt', 'ASC']],
order: [['order_index', 'ASC']],
},
],
});
@@ -204,35 +352,8 @@ exports.getGroupTaskList = async (req, res) => {
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const allRequirements = tasks.flatMap((task) => task.requirements ?? []);
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
const now = Date.now();
@@ -240,20 +361,8 @@ exports.getGroupTaskList = async (req, res) => {
const bucketedTasks = tasks.map((task) => {
const requirements = task.requirements ?? [];
const allRequirementsDone = requirements.length > 0 && requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true; // unknown requirement types don't block completion
}
});
const allRequirementsDone = requirements.length > 0 &&
requirements.every((r) => isRequirementDone(r, signals));
const has_completed = allRequirementsDone;
@@ -333,7 +442,7 @@ exports.getGroupTaskLists = async (req, res) => {
order: [['order', 'ASC']],
},
],
order: [['createdAt', 'ASC']],
order: [['order_index', 'ASC']],
},
],
attributes: { exclude: userExclude },
@@ -357,33 +466,8 @@ exports.getGroupTaskLists = async (req, res) => {
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const allRequirements = allTasks.flatMap((task) => task.requirements ?? []);
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
const now = Date.now();
@@ -392,20 +476,7 @@ exports.getGroupTaskLists = async (req, res) => {
const requirements = task.requirements ?? [];
if (requirements.length === 0) return false; // vacuously not done
return requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true;
}
});
return requirements.every((r) => isRequirementDone(r, signals));
};
// ── Bucket each task list based on per-task has_completed ───────────────
@@ -576,7 +647,7 @@ exports.submitTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId } = req.params;
const { note, files = [] } = req.body;
const { note, files = [], response_text } = req.body;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
@@ -584,10 +655,33 @@ exports.submitTask = async (req, res) => {
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (!files.length) {
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) {
await t.rollback();
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
}
const wasComplete = await checkTaskCompletion(req.user.user_id, taskId);
// ── Which submission-based requirement(s) does this task have? ─────────
const submissionRequirements = await TaskRequirement.findAll({
where: { task_id: taskId, type: { [Op.in]: ['upload_file', 'submit_text'] } },
transaction: t,
});
const uploadRequirement = submissionRequirements.find((r) => r.type === 'upload_file');
const textRequirement = submissionRequirements.find((r) => r.type === 'submit_text');
if (!uploadRequirement && !textRequirement) {
await t.rollback();
return R.error(res, 'This task has no requirement that accepts a submission.', 400);
}
if (uploadRequirement && !files.length) {
await t.rollback();
return R.error(res, 'At least one file is required to submit.', 400);
}
if (!uploadRequirement && textRequirement && !(response_text ?? '').trim()) {
await t.rollback();
return R.error(res, 'A response is required to submit.', 400);
}
// Validate file entries have required fields
const invalid = files.some((f) => !f.file_url || !f.file_name);
@@ -596,12 +690,6 @@ exports.submitTask = async (req, res) => {
return R.error(res, 'Each file must have file_url and file_name.', 400);
}
// ── Validate against upload_file requirement (if defined) ──────────────
const uploadRequirement = await TaskRequirement.findOne({
where: { task_id: taskId, type: 'upload_file' },
transaction: t,
});
if (uploadRequirement) {
// ── max_file_count ───────────────────────────────────────────────────
const maxFiles = uploadRequirement.max_file_count;
@@ -641,22 +729,25 @@ exports.submitTask = async (req, res) => {
task_id: taskId,
user_id: req.user.user_id,
note: note || null,
response_text: response_text || null,
submitted_at: new Date(),
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}, { transaction: t });
const fileRows = files.map((f) => ({
completion_id: completion.completion_id,
file_url: f.file_url,
file_name: f.file_name,
file_size: f.file_size ?? null,
mime_type: f.mime_type ?? null,
storage_key: f.storage_key ?? null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}));
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
if (files.length) {
const fileRows = files.map((f) => ({
completion_id: completion.completion_id,
file_url: f.file_url,
file_name: f.file_name,
file_size: f.file_size ?? null,
mime_type: f.mime_type ?? null,
storage_key: f.storage_key ?? null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}));
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
}
await t.commit();
@@ -675,6 +766,10 @@ exports.submitTask = async (req, res) => {
entityId: Number(taskId),
});
if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) {
fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget
}
return R.success(res, 'Task submitted successfully.', full, 201);
} catch (err) {
await t.rollback();
+55 -1
View File
@@ -25,11 +25,13 @@ const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
const { assertTaskUnlocked, checkTaskCompletion, fireTaskCompletedEvent } = require('./task.controller');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { Course } = require('../../models/courses/courses.mdl');
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const UnitQuiz = require('../../models/courses/unit_quiz.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
@@ -139,6 +141,35 @@ exports.getTaskProgress = async (req, res) => {
await hydrateReadTaskProgress(req.user.user_id, readRequirements);
// pass_quiz completion is computed live from QuizAttempt (same source
// has_passed already uses for unit quizzes) rather than a synced
// TaskProgress row — reference_id on the requirement is the quiz's
// uuid, so resolve to quiz_id first.
const quizRequirements = await TaskRequirement.findAll({
where: { task_id: taskId, type: 'pass_quiz' },
attributes: ['requirement_id', 'reference_id'],
});
const quizPassedRows = [];
if (quizRequirements.length) {
const quizUuids = [...new Set(quizRequirements.map((r) => r.reference_id).filter(Boolean))];
const quizzes = await UnitQuiz.findAll({ where: { uuid: quizUuids }, attributes: ['quiz_id', 'uuid'] });
const quizIdByUuid = new Map(quizzes.map((q) => [q.uuid, q.quiz_id]));
const quizIds = quizzes.map((q) => q.quiz_id);
const passedAttempts = quizIds.length
? await QuizAttempt.findAll({ where: { quiz_id: { [Op.in]: quizIds }, user_id: req.user.user_id, passed: true }, attributes: ['quiz_id'] })
: [];
const passedQuizIds = new Set(passedAttempts.map((a) => String(a.quiz_id)));
for (const r of quizRequirements) {
const quizId = quizIdByUuid.get(r.reference_id);
quizPassedRows.push({
requirement_id: r.requirement_id,
reference_id: r.reference_id,
completed: quizId ? passedQuizIds.has(String(quizId)) : false,
completed_at: null,
});
}
}
const [linkVisits, progress] = await Promise.all([
TaskLinkVisit.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
@@ -150,7 +181,10 @@ exports.getTaskProgress = async (req, res) => {
}),
]);
return R.success(res, 'Task progress retrieved.', { link_visits: linkVisits, progress });
return R.success(res, 'Task progress retrieved.', {
link_visits: linkVisits,
progress: [...progress, ...quizPassedRows],
});
} catch (err) {
console.error('[CLIENT][GET TASK PROGRESS]', err);
return R.error(res, 'Could not retrieve task progress.', 500);
@@ -182,6 +216,15 @@ exports.visitLink = async (req, res) => {
return R.error(res, 'Requirement is not a visit_link type.', 400);
}
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) {
await t.rollback();
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
}
const wasComplete = await checkTaskCompletion(req.user.user_id, taskId);
const now = new Date();
const [record, created] = await TaskLinkVisit.upsert(
@@ -210,6 +253,10 @@ exports.visitLink = async (req, res) => {
});
}
if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) {
fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget
}
return R.success(
res,
created ? 'Link visited.' : 'Link visit updated.',
@@ -302,6 +349,7 @@ exports.updateProgress = async (req, res) => {
const now = new Date();
const userId = req.user.user_id;
const wasComplete = await checkTaskCompletion(userId, taskId);
// ── Direct UPSERT for read_unit / read_course ─────────────────────────
if (requirement.type === 'read_unit' || requirement.type === 'read_course') {
@@ -323,6 +371,9 @@ exports.updateProgress = async (req, res) => {
}
);
await t.commit();
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
}
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
@@ -403,6 +454,9 @@ exports.updateProgress = async (req, res) => {
}
await t.commit();
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
}
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
+1 -1
View File
@@ -105,7 +105,7 @@ exports.getPlans = async (req, res) => {
try {
const plans = await mdl_TierPlans.findAll({
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'description', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'],
attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'],
include: [
{
model: Course,
+85 -5
View File
@@ -4,7 +4,8 @@
* 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 → INDEPENDENT units only (no course affiliation)
* GET /client/lessons → INDEPENDENT lessons only (no unit is course-affiliated)
* 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
@@ -16,6 +17,13 @@
* be able to access at least one attached course. Lessons resolve through
* their parent units the same way.
*
* Discovery rule (getUnits/getLessons only): only course-free content is
* listed at all — a unit with any course affiliation, or a lesson with any
* unit that has a course affiliation, is excluded outright rather than
* listed-but-locked. This does not affect the single-item endpoints above
* (:uuid) — those still enforce access normally for direct links, and
* course-scoped consumption runs through a separate controller entirely.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 7, 2026
***********************************************************************************************************************************************************************/
@@ -53,11 +61,17 @@ function sanitizeQuestions(questions = []) {
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing only ever shows INDEPENDENT content —
// anything affiliated with a course (directly, or for a lesson, through any
// of its attached units) is excluded from these listings entirely, not just
// flagged locked. This does not affect course-scoped consumption (which runs
// through ClientCoursesContext/getCourse, a separate path) or direct-link
// access to UnitDetails/LessonDetails, which still enforce access normally.
exports.getUnits = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
u.unit_id, u.uuid, u.title, u.description, u.duration_seconds,
u.unit_id, u.uuid, u.title, u.subscription, 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,
@@ -68,6 +82,11 @@ exports.getUnits = async (req, res) => {
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
AND NOT EXISTS (
SELECT 1 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
)
ORDER BY u.title ASC
`, { type: sequelize.QueryTypes.SELECT });
@@ -89,11 +108,12 @@ exports.getUnits = async (req, res) => {
coursesByUnit.set(row.unit_id, list);
}
// is_locked mirrors canAccessUnit: standalone units are open, attached units
// need at least one accessible course.
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
// least one attached course needs an access check; a fully open standalone
// unit (no subscription, no course links) is never locked.
const result = [];
for (const row of rows) {
const is_locked = Number(row.course_count) > 0
const is_locked = (row.subscription || 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 });
@@ -106,6 +126,66 @@ exports.getUnits = async (req, res) => {
}
};
// ─── LESSON LIBRARY (learner view) ────────────────────────────────────────────
// Mirrors getUnits above — a Lesson may sit in several Units (each possibly in
// different courses), so is_locked/courses are resolved across ALL attached
// units rather than a single direct course link.
exports.getLessons = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds,
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
WHERE ul.lesson_id = l.lesson_id) AS unit_count
FROM lessons l
WHERE l."deletedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE ul.lesson_id = l.lesson_id
)
ORDER BY l.title ASC
`, { type: sequelize.QueryTypes.SELECT });
// Batch-fetch every course reachable through any attached unit, for every
// returned lesson, in one query — same batching style as getUnits.
const lessonIds = rows.map((r) => r.lesson_id);
const courseLinkRows = lessonIds.length ? await sequelize.query(`
SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription
FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE ul.lesson_id IN (:lessonIds)
`, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : [];
const coursesByLesson = new Map();
for (const row of courseLinkRows) {
const list = coursesByLesson.get(row.lesson_id) ?? [];
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
coursesByLesson.set(row.lesson_id, list);
}
// is_locked mirrors canAccessLesson: standalone/unattached lessons are
// open, attached lessons need at least one accessible course through
// any attached unit.
const result = [];
for (const row of rows) {
const is_locked = Number(row.unit_count) > 0
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
: false;
result.push({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], is_locked });
}
return R.success(res, "Lessons retrieved.", result);
} catch (err) {
console.error("[CLIENT][LESSONS][GET ALL]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
// ─── STANDALONE UNIT QUIZ ─────────────────────────────────────────────────────
exports.getUnitQuiz = async (req, res) => {