good morning

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-06 08:11:05 +08:00
parent 141b7ab592
commit dcae93eb35
15 changed files with 81 additions and 204 deletions
-53
View File
@@ -2535,59 +2535,6 @@ exports.getLessonsFlat = async (req, res) => {
} }
}; };
// One row per unit quiz (a quiz is always unit-scoped, unit_id unique on
// unit_quizzes) — `courses[]` is the deduped set of courses the parent unit
// is attached to, batch-fetched the same way as getUnitsFlat/getLessonsFlat.
// Used by the pass_quiz task requirement picker — same "no content yet"
// convention as read_*: question_count === 0 is flagged the same way
// duration_seconds === 0 is for content requirements.
exports.getQuizzesFlat = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
q.quiz_id, q.uuid, q.title, u.title AS unit_title,
(SELECT CAST(COUNT(*) AS INTEGER) FROM quiz_questions qq
WHERE qq.quiz_id = q.quiz_id AND qq."deletedAt" IS NULL) AS question_count
FROM unit_quizzes q
JOIN units u ON u.unit_id = q.unit_id AND u."deletedAt" IS NULL
WHERE q."deletedAt" IS NULL
ORDER BY u.title ASC, q.title ASC
`, { type: sequelize.QueryTypes.SELECT });
const quizIds = rows.map((r) => r.quiz_id);
const courseLinkRows = quizIds.length ? await sequelize.query(`
SELECT q.quiz_id, c.uuid, c.title
FROM unit_quizzes q
JOIN course_units cu ON cu.unit_id = q.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE q.quiz_id IN (:quizIds)
ORDER BY c.title ASC
`, { replacements: { quizIds }, type: sequelize.QueryTypes.SELECT }) : [];
const coursesByQuiz = new Map();
for (const row of courseLinkRows) {
const list = coursesByQuiz.get(row.quiz_id) ?? [];
list.push({ uuid: row.uuid, title: row.title });
coursesByQuiz.set(row.quiz_id, list);
}
const data = rows.map((r) => ({
uuid: r.uuid,
title: r.title || `${r.unit_title} Quiz`,
unit_title: r.unit_title ?? "",
courses: coursesByQuiz.get(r.quiz_id) ?? [],
question_count: Number(r.question_count ?? 0),
// duration_seconds doesn't apply to quizzes — ContentPicker's "no
// content" check keys off duration_seconds === 0, so surface the same
// signal under that name rather than adding a second code path.
duration_seconds: Number(r.question_count ?? 0),
}));
return R.success(res, "Quizzes retrieved.", data);
} catch (err) {
console.error("[QUIZ][GET FLAT]", err);
return R.error(res, "Could not retrieve quizzes.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// COURSE INSTRUCTORS // COURSE INSTRUCTORS
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
+2 -28
View File
@@ -33,8 +33,6 @@ const LessonReadingProgress = require('../../models/courses/lesson_reading_progr
const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl'); const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl');
const Lesson = require('../../models/courses/lessons.mdl'); const Lesson = require('../../models/courses/lessons.mdl');
const Unit = require('../../models/courses/units.mdl'); const Unit = require('../../models/courses/units.mdl');
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const UnitQuiz = require('../../models/courses/unit_quiz.mdl');
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ── // ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
const normalizeUrl = (url) => { const normalizeUrl = (url) => {
@@ -679,14 +677,14 @@ const PREREQUISITE_INCLUDE = {
class TaskValidationError extends Error {} class TaskValidationError extends Error {}
// ─── Pre-completed assignees check ──────────────────────────────────────────── // ─── Pre-completed assignees check ────────────────────────────────────────────
// A read_course/read_unit/read_lesson/pass_quiz requirement can reference content // A read_course/read_unit/read_lesson requirement can reference content
// that a task list's assignees already finished BEFORE this requirement existed. // that a task list's assignees already finished BEFORE this requirement existed.
// That's not an error — task_reading_progress_sync.service.js's hydrateReadTaskProgress // That's not an error — task_reading_progress_sync.service.js's hydrateReadTaskProgress
// (client-side, on task list load) already auto-marks it done for them — but the // (client-side, on task list load) already auto-marks it done for them — but the
// admin creating/editing the task has no visibility into it otherwise. This is a // admin creating/editing the task has no visibility into it otherwise. This is a
// heads-up, not a validator: it never blocks create/update, only informs. // heads-up, not a validator: it never blocks create/update, only informs.
const READ_TYPE_TO_PROGRESS_TYPE = { read_course: 'course', read_unit: 'unit', read_lesson: 'lesson' }; const READ_TYPE_TO_PROGRESS_TYPE = { read_course: 'course', read_unit: 'unit', read_lesson: 'lesson' };
const progressKeyType = (reqType) => (reqType === 'pass_quiz' ? 'pass_quiz' : READ_TYPE_TO_PROGRESS_TYPE[reqType]); const progressKeyType = (reqType) => READ_TYPE_TO_PROGRESS_TYPE[reqType];
async function getPreCompletedAssignees(taskListId, requirements, transaction) { async function getPreCompletedAssignees(taskListId, requirements, transaction) {
const contentReqs = (requirements ?? []).filter( const contentReqs = (requirements ?? []).filter(
@@ -772,30 +770,6 @@ async function getPreCompletedAssignees(taskListId, requirements, transaction) {
} }
} }
// ── pass_quiz ────────────────────────────────────────────────────────────
const quizReqs = contentReqs.filter((r) => r.type === 'pass_quiz');
if (quizReqs.length) {
const quizUuids = [...new Set(quizReqs.map((r) => r.reference_id))];
const quizzes = await UnitQuiz.findAll({
where: { uuid: { [Op.in]: quizUuids } },
attributes: ['quiz_id', 'uuid'],
transaction,
});
const uuidByQuizId = new Map(quizzes.map((q) => [q.quiz_id, q.uuid]));
const quizIds = quizzes.map((q) => q.quiz_id);
if (quizIds.length) {
const rows = await QuizAttempt.findAll({
where: { quiz_id: { [Op.in]: quizIds }, user_id: { [Op.in]: userIds }, passed: true },
attributes: ['user_id', 'quiz_id'],
transaction,
});
for (const row of rows) {
const uuid = uuidByQuizId.get(row.quiz_id);
if (uuid) markCompleted('pass_quiz', uuid, row.user_id);
}
}
}
// ── One entry per requirement that has at least one already-completed assignee ── // ── One entry per requirement that has at least one already-completed assignee ──
const results = []; const results = [];
for (const r of contentReqs) { for (const r of contentReqs) {
+27 -5
View File
@@ -39,7 +39,7 @@ const mdl_Users = require('../models/users/users.mdl');
const mdl_UserSessions = require('../models/users/user_sessions.mdl'); const mdl_UserSessions = require('../models/users/user_sessions.mdl');
const { checkAccountStatus } = require('../services/accountStatus.service'); const { checkAccountStatus } = require('../services/accountStatus.service');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl') const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
const { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup } = require('../utils/defaultGroup.util'); const { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup, switchFromNogrpByCode } = require('../utils/defaultGroup.util');
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util'); const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util'); const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util');
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
@@ -366,7 +366,7 @@ exports.resendOTP = async (req, res) => {
// ─── System Login ────────────────────────────────────────────────────────────── // ─── System Login ──────────────────────────────────────────────────────────────
exports.login = async (req, res) => { exports.login = async (req, res) => {
try { try {
const { email, password } = req.body; const { email, password, group_code } = req.body;
const user = await mdl_Users.findOne({ where: { email } }); const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'Invalid credentials.', 401); if (!user) return R.error(res, 'Invalid credentials.', 401);
@@ -386,7 +386,17 @@ exports.login = async (req, res) => {
const match = await bcrypt.compare(password, user.password); const match = await bcrypt.compare(password, user.password);
if (!match) return R.error(res, 'Invalid credentials.', 401); if (!match) return R.error(res, 'Invalid credentials.', 401);
// Password confirmed. If this device already cleared an OTP recently and // Password confirmed the account is genuinely theirs — same bar register
// uses before enrolling into a group, so an invite link followed by
// "already have an account? sign in" moves a NOGRP user into the group
// right here rather than dead-ending on an invite link that only works
// for brand-new accounts.
if (group_code) {
await switchFromNogrpByCode(user.user_id, group_code)
.catch(err => console.error('[AUTH] login: Failed to switch NOGRP membership:', err));
}
// If this device already cleared an OTP recently and
// its trust window hasn't lapsed or been revoked, skip the OTP gate // its trust window hasn't lapsed or been revoked, skip the OTP gate
// entirely — otherwise fall through to the usual fresh-OTP flow. Tokens // entirely — otherwise fall through to the usual fresh-OTP flow. Tokens
// are only ever minted via mintSession (called here or from verifyOTP). // are only ever minted via mintSession (called here or from verifyOTP).
@@ -431,10 +441,14 @@ exports.googleRedirect = (req, res) => {
const state = generateState(); const state = generateState();
const nonce = generateNonce(); const nonce = generateNonce();
const { codeVerifier, codeChallenge } = generatePKCE(); const { codeVerifier, codeChallenge } = generatePKCE();
// Carried through to the callback below — an invite link's group_code has
// to survive the round trip to Google and back, so it rides in the same
// short-lived signed cookie as state/nonce/codeVerifier.
const group_code = typeof req.query.group_code === 'string' ? req.query.group_code.trim() : null;
// SameSite=Lax is required: the cookie must survive the cross-site redirect // SameSite=Lax is required: the cookie must survive the cross-site redirect
// back from Google (top-level GET navigations are allowed under Lax). // back from Google (top-level GET navigations are allowed under Lax).
res.cookie('_oauth', JSON.stringify({ state, nonce, codeVerifier }), { res.cookie('_oauth', JSON.stringify({ state, nonce, codeVerifier, group_code }), {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
@@ -470,7 +484,7 @@ exports.googleCallback = async (req, res) => {
return res.redirect(CALLBACK_PAGE); return res.redirect(CALLBACK_PAGE);
} }
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie); const { state: expectedState, nonce, codeVerifier, group_code } = JSON.parse(rawCookie);
if (!state || state !== expectedState) { if (!state || state !== expectedState) {
setGoogleResultCookie(res, { error: 'state_mismatch' }); setGoogleResultCookie(res, { error: 'state_mismatch' });
@@ -554,6 +568,14 @@ exports.googleCallback = async (req, res) => {
return res.redirect(CALLBACK_PAGE); return res.redirect(CALLBACK_PAGE);
} }
// Identity is confirmed by Google itself — same bar as a password match on
// the system login path — so a NOGRP user (brand-new or returning) riding
// in on an invite link gets moved into that group right here.
if (group_code) {
await switchFromNogrpByCode(user.user_id, group_code)
.catch(err => console.error('[AUTH] googleCallback: Failed to switch NOGRP membership:', err));
}
// Every Google sign-in (new or returning account) still has to clear the // Every Google sign-in (new or returning account) still has to clear the
// same OTP gate as a manual login, unless this device already cleared one // same OTP gate as a manual login, unless this device already cleared one
// recently and its trust window hasn't lapsed or been revoked — same // recently and its trust window hasn't lapsed or been revoked — same
-46
View File
@@ -1273,52 +1273,6 @@ 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 // "Units → Lessons (returns all data)" — a Unit resolves all of its lesson
// content in one call, with or without a parent course. // content in one call, with or without a parent course.
exports.getLessonsByUnitUuid = async (req, res) => { exports.getLessonsByUnitUuid = async (req, res) => {
+6 -24
View File
@@ -17,7 +17,6 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl'); const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const { QuizAttempt } = require('../../models/courses/courses.associations');
const { userExclude } = require('../../models/task/task.attributes'); const { userExclude } = require('../../models/task/task.attributes');
const { clientExclude } = require('../../models/task/task_completion.attributes'); const { clientExclude } = require('../../models/task/task_completion.attributes');
@@ -135,15 +134,9 @@ const isMember = async (userId, groupId) => {
// ─── Helper: per-user completion signals for a batch of tasks ───────────────── // ─── Helper: per-user completion signals for a batch of tasks ─────────────────
// Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share // Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share
// one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins); // one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins).
// pass_quiz is computed live from QuizAttempt, same as unit-quiz has_passed const getTaskCompletionSignals = async (userId, taskIds) => {
// (courses.controller.js) rather than a separately-synced TaskProgress row. const [completions, linkVisits, progressRows] = await Promise.all([
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 taskIds.length
? TaskCompletion.findAll({ ? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId }, where: { task_id: { [Op.in]: taskIds }, user_id: userId },
@@ -163,12 +156,6 @@ const getTaskCompletionSignals = async (userId, taskIds, requirements) => {
attributes: ['task_id', 'requirement_id', 'reference_id'], 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. // First row per task_id wins — completions are ordered submitted_at DESC.
@@ -181,7 +168,6 @@ const getTaskCompletionSignals = async (userId, taskIds, requirements) => {
latestCompletionByTask, latestCompletionByTask,
visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)), visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)),
completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)), completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)),
passedQuizIds: new Set(passedAttempts.map((a) => String(a.quiz_id))),
}; };
}; };
@@ -200,8 +186,6 @@ const isRequirementDone = (r, signals) => {
case 'read_unit': case 'read_unit':
case 'read_lesson': case 'read_lesson':
return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`); return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
case 'pass_quiz':
return signals.passedQuizIds.has(String(r.reference_id));
default: default:
return true; // unknown requirement types don't block completion return true; // unknown requirement types don't block completion
} }
@@ -241,7 +225,7 @@ const checkTaskCompletion = async (userId, taskId) => {
const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } }); const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } });
if (!reqs.length) return false; if (!reqs.length) return false;
const plainReqs = reqs.map((r) => r.toJSON()); const plainReqs = reqs.map((r) => r.toJSON());
const signals = await getTaskCompletionSignals(userId, [taskId], plainReqs); const signals = await getTaskCompletionSignals(userId, [taskId]);
return plainReqs.every((r) => isRequirementDone(r, signals)); return plainReqs.every((r) => isRequirementDone(r, signals));
}; };
@@ -359,8 +343,7 @@ exports.getGroupTaskList = async (req, res) => {
await hydrateReadTaskProgress(userId, readRequirements); await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ──────────────────── // ── Fetch user's completion signals for these tasks ────────────────────
const allRequirements = tasks.flatMap((task) => task.requirements ?? []); const signals = await getTaskCompletionSignals(userId, taskIds);
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
const now = Date.now(); const now = Date.now();
@@ -490,8 +473,7 @@ exports.getGroupTaskLists = async (req, res) => {
await hydrateReadTaskProgress(userId, readRequirements); await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ──────────────────── // ── Fetch user's completion signals for these tasks ────────────────────
const allRequirements = allTasks.flatMap((task) => task.requirements ?? []); const signals = await getTaskCompletionSignals(userId, taskIds);
const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements);
const now = Date.now(); const now = Date.now();
+1 -31
View File
@@ -31,7 +31,6 @@ const { mdl_UserGroupMembers } = require('../../models/users/use
const { Course } = require('../../models/courses/courses.mdl'); const { Course } = require('../../models/courses/courses.mdl');
const CourseAssessment = require('../../models/courses/course_assessment.mdl'); const CourseAssessment = require('../../models/courses/course_assessment.mdl');
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl'); const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const UnitQuiz = require('../../models/courses/unit_quiz.mdl');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service'); const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
@@ -141,35 +140,6 @@ exports.getTaskProgress = async (req, res) => {
await hydrateReadTaskProgress(req.user.user_id, readRequirements); 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([ const [linkVisits, progress] = await Promise.all([
TaskLinkVisit.findAll({ TaskLinkVisit.findAll({
where: { task_id: taskId, user_id: req.user.user_id }, where: { task_id: taskId, user_id: req.user.user_id },
@@ -183,7 +153,7 @@ exports.getTaskProgress = async (req, res) => {
return R.success(res, 'Task progress retrieved.', { return R.success(res, 'Task progress retrieved.', {
link_visits: linkVisits, link_visits: linkVisits,
progress: [...progress, ...quizPassedRows], progress,
}); });
} catch (err) { } catch (err) {
console.error('[CLIENT][GET TASK PROGRESS]', err); console.error('[CLIENT][GET TASK PROGRESS]', err);
@@ -1,15 +1,15 @@
'use strict'; 'use strict';
// The live enum type backing `type` is named task_requirement_type (not the // The live enum type backing `type` is named task_requirement_type (not the
// Sequelize-default enum_task_requirements_type) — submit_text/pass_quiz were // Sequelize-default enum_task_requirements_type) — submit_text was added to
// added to it later via ALTER TYPE (20260715000001). Declared here with the // it later via ALTER TYPE (20260715000001). Declared here with the full
// full merged value set. // merged value set.
module.exports = { module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_requirements', { await queryInterface.createTable('task_requirements', {
requirement_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true }, requirement_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' }, task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
type: { type: Sequelize.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text', 'pass_quiz'), allowNull: false }, type: { type: Sequelize.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text'), allowNull: false },
link_url: { type: Sequelize.STRING, allowNull: true }, link_url: { type: Sequelize.STRING, allowNull: true },
link_label: { type: Sequelize.STRING, allowNull: true }, link_label: { type: Sequelize.STRING, allowNull: true },
allowed_file_types: { type: Sequelize.JSONB, allowNull: true }, allowed_file_types: { type: Sequelize.JSONB, allowNull: true },
@@ -1,8 +1,7 @@
'use strict'; 'use strict';
// `type` is STRING + an explicit CHECK constraint (check_type) — pass_quiz // `type` is STRING + an explicit CHECK constraint (check_type) — this
// was added to the value set via a DROP/ADD CONSTRAINT swap (20260709000003), // column isn't a native enum on this DB.
// not ALTER TYPE, confirming this column isn't a native enum on this DB.
module.exports = { module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_progress', { await queryInterface.createTable('task_progress', {
@@ -22,7 +21,7 @@ module.exports = {
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`ALTER TABLE task_progress ADD CONSTRAINT check_type `ALTER TABLE task_progress ADD CONSTRAINT check_type
CHECK (type IN ('read_course', 'read_unit', 'read_lesson', 'pass_quiz'))` CHECK (type IN ('read_course', 'read_unit', 'read_lesson'))`
); );
await queryInterface.addIndex('task_progress', { fields: ['requirement_id', 'user_id', 'reference_id'], unique: true, name: 'uq_tp_requirement_user_reference' }); await queryInterface.addIndex('task_progress', { fields: ['requirement_id', 'user_id', 'reference_id'], unique: true, name: 'uq_tp_requirement_user_reference' });
+3 -3
View File
@@ -59,7 +59,7 @@ const Task = sequelize.define('Task', {
const TaskRequirement = sequelize.define('TaskRequirement', { const TaskRequirement = sequelize.define('TaskRequirement', {
requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' } }, task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' } },
type: { type: DataTypes.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text', 'pass_quiz'), allowNull: false, filterable: true }, type: { type: DataTypes.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text'), allowNull: false, filterable: true },
// ── visit_link ────────────────────────────────────────────────────────── // ── visit_link ──────────────────────────────────────────────────────────
link_url: { type: DataTypes.STRING, allowNull: true, filterable: false }, link_url: { type: DataTypes.STRING, allowNull: true, filterable: false },
@@ -69,8 +69,8 @@ const TaskRequirement = sequelize.define('TaskRequirement', {
allowed_file_types: { type: DataTypes.JSONB, allowNull: true, filterable: false }, allowed_file_types: { type: DataTypes.JSONB, allowNull: true, filterable: false },
max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false }, max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false },
// ── read_course / read_unit / read_lesson / pass_quiz ───────────────────── // ── read_course / read_unit / read_lesson ──────────────────────────────────
reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id | quiz_id depending on type', filterable: false }, reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id depending on type', filterable: false },
reference_label: { type: DataTypes.STRING, allowNull: true, comment: 'Cached display name so we do not always join', filterable: false }, reference_label: { type: DataTypes.STRING, allowNull: true, comment: 'Cached display name so we do not always join', filterable: false },
// ── submit_text ───────────────────────────────────────────────────────── // ── submit_text ─────────────────────────────────────────────────────────
+2 -2
View File
@@ -99,10 +99,10 @@ const TaskProgress = sequelize.define('TaskProgress', {
reference_id: { reference_id: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: false,
comment: 'course_id | unit_id | lesson_id | quiz_id depending on type.', comment: 'course_id | unit_id | lesson_id depending on type.',
}, },
type: { type: {
type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson', 'pass_quiz'), type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson'),
allowNull: false, allowNull: false,
}, },
completed: { completed: {
-1
View File
@@ -23,7 +23,6 @@ router.get("/flat", ctrl.getCoursesFlat);
router.get("/by-subscription", ctrl.getCoursesBySubscription); router.get("/by-subscription", ctrl.getCoursesBySubscription);
router.get("/units-flat", ctrl.getUnitsFlat); router.get("/units-flat", ctrl.getUnitsFlat);
router.get("/lessons-flat", ctrl.getLessonsFlat); router.get("/lessons-flat", ctrl.getLessonsFlat);
router.get("/quizzes-flat", ctrl.getQuizzesFlat);
router.get("/uuid/:uuid/structure-counts", ctrl.getCourseStructureCounts); router.get("/uuid/:uuid/structure-counts", ctrl.getCourseStructureCounts);
router.put("/order", ctrl.reorderCourses); // persist top-level course catalog ordering { course_ids } router.put("/order", ctrl.reorderCourses); // persist top-level course catalog ordering { course_ids }
-1
View File
@@ -19,7 +19,6 @@ router.get('/unit/uuid/:uuid/task-context', progressCtrl.getUnitTaskContext);
router.get('/unit/uuid/:uuid', ctrl.getUnitByUuid); router.get('/unit/uuid/:uuid', ctrl.getUnitByUuid);
router.get('/lesson/uuid/:uuid/task-context', progressCtrl.getLessonTaskContext); router.get('/lesson/uuid/:uuid/task-context', progressCtrl.getLessonTaskContext);
router.get('/lesson/uuid/:uuid', ctrl.getLessonByUuid); router.get('/lesson/uuid/:uuid', ctrl.getLessonByUuid);
router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid);
// Courses // Courses
router.get('/', ctrl.getCourses); router.get('/', ctrl.getCourses);
@@ -275,7 +275,7 @@ async function syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUui
if (!newlyCompleted.length) return []; if (!newlyCompleted.length) return [];
// Check whether any impacted task now has ALL its read-only requirements satisfied — // Check whether any impacted task now has ALL its read-only requirements satisfied —
// tasks with any non-read requirement (upload_file/visit_link/submit_text/pass_quiz) // tasks with any non-read requirement (upload_file/visit_link/submit_text)
// still need manual submission, so they're excluded from auto-turn-in. // still need manual submission, so they're excluded from auto-turn-in.
const taskIds = [...new Set(newlyCompleted.map((r) => r.task_id))]; const taskIds = [...new Set(newlyCompleted.map((r) => r.task_id))];
const completedTasks = []; const completedTasks = [];
+31 -1
View File
@@ -65,4 +65,34 @@ async function reconcileDefaultGroup(user_ids, { createdBy = null } = {}) {
); );
} }
module.exports = { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup, dropDefaultGroupMembership, reconcileDefaultGroup }; /**
* Invite-link login: if user_id is currently sitting in NOGRP, move them into
* the group identified by group_code. Never reassigns someone already parked
* in a real (non-NOGRP) group — an invite link used post-login only fills in
* an ungrouped user's group, it doesn't override an existing one. No-op if
* group_code doesn't resolve to an active group.
*/
async function switchFromNogrpByCode(user_id, group_code, { updatedBy = null } = {}) {
if (!group_code) return null;
const group = await mdl_UserGroups.findOne({
where: { group_code: group_code.toUpperCase().trim(), is_active: true },
});
if (!group) return null;
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id },
include: [{ model: mdl_UserGroups, attributes: ['group_code'] }],
});
if (membership) {
if (membership.UserGroup?.group_code !== NOGRP_CODE) return null; // already in a real group — leave it alone
if (Number(membership.group_id) === Number(group.group_id)) return group; // already in this exact group
}
await mdl_UserGroupMembers.create({ group_id: group.group_id, user_id, createdBy: updatedBy });
await dropDefaultGroupMembership([user_id], group.group_id, { updatedBy });
return group;
}
module.exports = { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup, dropDefaultGroupMembership, reconcileDefaultGroup, switchFromNogrpByCode };
+1
View File
@@ -28,6 +28,7 @@ const registerValidator = [
const loginValidator = [ const loginValidator = [
body('email').isEmail().withMessage('Valid email is required.'), body('email').isEmail().withMessage('Valid email is required.'),
body('password').notEmpty().withMessage('Password is required.'), body('password').notEmpty().withMessage('Password is required.'),
body('group_code').optional().isString().trim(),
]; ];
const verifyOTPValidator = [ const verifyOTPValidator = [