From dcae93eb3506fc74898b47f347383b7beac81569 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Thu, 6 Aug 2026 08:11:05 +0800 Subject: [PATCH] good morning Signed-off-by: Kenneth Obsequio --- controllers/admin/courses.controller.js | 53 ------------------- controllers/admin/task.controller.js | 30 +---------- controllers/auth.controller.js | 32 +++++++++-- controllers/client/courses.controller.js | 46 ---------------- controllers/client/task.controller.js | 30 +++-------- .../client/task_progress.controller.js | 32 +---------- ...20270101000036-create-task-requirements.js | 8 +-- .../20270101000040-create-task-progress.js | 7 ++- models/task/task.mdl.js | 6 +-- models/task/task_progress.mdl.js | 4 +- routes/admin/courses.routes.js | 1 - routes/client/courses.routes.js | 1 - .../task_reading_progress_sync.service.js | 2 +- utils/defaultGroup.util.js | 32 ++++++++++- validators/auth.validator.js | 1 + 15 files changed, 81 insertions(+), 204 deletions(-) diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 770d513..f07f516 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -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 // ══════════════════════════════════════════════════════════════════════════════ diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index aa59be2..c33903a 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -33,8 +33,6 @@ const LessonReadingProgress = require('../../models/courses/lesson_reading_progr const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl'); const Lesson = require('../../models/courses/lessons.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 ── const normalizeUrl = (url) => { @@ -679,14 +677,14 @@ const PREREQUISITE_INCLUDE = { class TaskValidationError extends Error {} // ─── 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'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 // 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. 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) { 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 ── const results = []; for (const r of contentReqs) { diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index cab7d47..3e5404d 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -39,7 +39,7 @@ const mdl_Users = require('../models/users/users.mdl'); const mdl_UserSessions = require('../models/users/user_sessions.mdl'); const { checkAccountStatus } = require('../services/accountStatus.service'); 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 { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util'); const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); @@ -366,7 +366,7 @@ exports.resendOTP = async (req, res) => { // ─── System Login ────────────────────────────────────────────────────────────── exports.login = async (req, res) => { try { - const { email, password } = req.body; + const { email, password, group_code } = req.body; const user = await mdl_Users.findOne({ where: { email } }); 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); 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 // entirely — otherwise fall through to the usual fresh-OTP flow. Tokens // are only ever minted via mintSession (called here or from verifyOTP). @@ -431,10 +441,14 @@ exports.googleRedirect = (req, res) => { const state = generateState(); const nonce = generateNonce(); 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 // 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, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', @@ -470,7 +484,7 @@ exports.googleCallback = async (req, res) => { 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) { setGoogleResultCookie(res, { error: 'state_mismatch' }); @@ -554,6 +568,14 @@ exports.googleCallback = async (req, res) => { 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 // 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 diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 7ec696d..548ba4e 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -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 // content in one call, with or without a parent course. exports.getLessonsByUnitUuid = async (req, res) => { diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index 9ebed0e..95b5045 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -17,7 +17,6 @@ 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'); @@ -135,15 +134,9 @@ const isMember = async (userId, groupId) => { // ─── 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([ +// one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins). +const getTaskCompletionSignals = async (userId, taskIds) => { + const [completions, linkVisits, progressRows] = await Promise.all([ taskIds.length ? TaskCompletion.findAll({ 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'], }) : [], - 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. @@ -181,7 +168,6 @@ const getTaskCompletionSignals = async (userId, taskIds, requirements) => { 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))), }; }; @@ -200,8 +186,6 @@ const isRequirementDone = (r, signals) => { 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 } @@ -241,7 +225,7 @@ 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); + const signals = await getTaskCompletionSignals(userId, [taskId]); return plainReqs.every((r) => isRequirementDone(r, signals)); }; @@ -359,8 +343,7 @@ exports.getGroupTaskList = async (req, res) => { await hydrateReadTaskProgress(userId, readRequirements); // ── Fetch user's completion signals for these tasks ──────────────────── - const allRequirements = tasks.flatMap((task) => task.requirements ?? []); - const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements); + const signals = await getTaskCompletionSignals(userId, taskIds); const now = Date.now(); @@ -490,8 +473,7 @@ exports.getGroupTaskLists = async (req, res) => { await hydrateReadTaskProgress(userId, readRequirements); // ── Fetch user's completion signals for these tasks ──────────────────── - const allRequirements = allTasks.flatMap((task) => task.requirements ?? []); - const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements); + const signals = await getTaskCompletionSignals(userId, taskIds); const now = Date.now(); diff --git a/controllers/client/task_progress.controller.js b/controllers/client/task_progress.controller.js index 8c7318b..63a3d5f 100644 --- a/controllers/client/task_progress.controller.js +++ b/controllers/client/task_progress.controller.js @@ -31,7 +31,6 @@ const { mdl_UserGroupMembers } = require('../../models/users/use 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'); @@ -141,35 +140,6 @@ 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 }, @@ -183,7 +153,7 @@ exports.getTaskProgress = async (req, res) => { return R.success(res, 'Task progress retrieved.', { link_visits: linkVisits, - progress: [...progress, ...quizPassedRows], + progress, }); } catch (err) { console.error('[CLIENT][GET TASK PROGRESS]', err); diff --git a/database/migrations/20270101000036-create-task-requirements.js b/database/migrations/20270101000036-create-task-requirements.js index 1df1732..02153a4 100644 --- a/database/migrations/20270101000036-create-task-requirements.js +++ b/database/migrations/20270101000036-create-task-requirements.js @@ -1,15 +1,15 @@ 'use strict'; // The live enum type backing `type` is named task_requirement_type (not the -// Sequelize-default enum_task_requirements_type) — submit_text/pass_quiz were -// added to it later via ALTER TYPE (20260715000001). Declared here with the -// full merged value set. +// Sequelize-default enum_task_requirements_type) — submit_text was added to +// it later via ALTER TYPE (20260715000001). Declared here with the full +// merged value set. module.exports = { async up(queryInterface, Sequelize) { await queryInterface.createTable('task_requirements', { 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' }, - 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_label: { type: Sequelize.STRING, allowNull: true }, allowed_file_types: { type: Sequelize.JSONB, allowNull: true }, diff --git a/database/migrations/20270101000040-create-task-progress.js b/database/migrations/20270101000040-create-task-progress.js index fea48b1..ed385b2 100644 --- a/database/migrations/20270101000040-create-task-progress.js +++ b/database/migrations/20270101000040-create-task-progress.js @@ -1,8 +1,7 @@ 'use strict'; -// `type` is STRING + an explicit CHECK constraint (check_type) — pass_quiz -// was added to the value set via a DROP/ADD CONSTRAINT swap (20260709000003), -// not ALTER TYPE, confirming this column isn't a native enum on this DB. +// `type` is STRING + an explicit CHECK constraint (check_type) — this +// column isn't a native enum on this DB. module.exports = { async up(queryInterface, Sequelize) { await queryInterface.createTable('task_progress', { @@ -22,7 +21,7 @@ module.exports = { await queryInterface.sequelize.query( `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' }); diff --git a/models/task/task.mdl.js b/models/task/task.mdl.js index 53460ab..3b9ecf4 100644 --- a/models/task/task.mdl.js +++ b/models/task/task.mdl.js @@ -59,7 +59,7 @@ const Task = sequelize.define('Task', { const TaskRequirement = sequelize.define('TaskRequirement', { requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, 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 ────────────────────────────────────────────────────────── 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 }, max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false }, - // ── read_course / read_unit / read_lesson / pass_quiz ───────────────────── - reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id | quiz_id depending on type', filterable: false }, + // ── read_course / read_unit / read_lesson ────────────────────────────────── + 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 }, // ── submit_text ───────────────────────────────────────────────────────── diff --git a/models/task/task_progress.mdl.js b/models/task/task_progress.mdl.js index cd1c27f..f127a46 100644 --- a/models/task/task_progress.mdl.js +++ b/models/task/task_progress.mdl.js @@ -99,10 +99,10 @@ const TaskProgress = sequelize.define('TaskProgress', { reference_id: { type: DataTypes.UUID, 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: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson', 'pass_quiz'), + type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson'), allowNull: false, }, completed: { diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index 28ba9d3..9b4252c 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -23,7 +23,6 @@ router.get("/flat", ctrl.getCoursesFlat); router.get("/by-subscription", ctrl.getCoursesBySubscription); router.get("/units-flat", ctrl.getUnitsFlat); router.get("/lessons-flat", ctrl.getLessonsFlat); -router.get("/quizzes-flat", ctrl.getQuizzesFlat); router.get("/uuid/:uuid/structure-counts", ctrl.getCourseStructureCounts); router.put("/order", ctrl.reorderCourses); // persist top-level course catalog ordering { course_ids } diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 1a00bec..be16aee 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -19,7 +19,6 @@ router.get('/unit/uuid/:uuid/task-context', progressCtrl.getUnitTaskContext); router.get('/unit/uuid/:uuid', ctrl.getUnitByUuid); router.get('/lesson/uuid/:uuid/task-context', progressCtrl.getLessonTaskContext); router.get('/lesson/uuid/:uuid', ctrl.getLessonByUuid); -router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid); // Courses router.get('/', ctrl.getCourses); diff --git a/services/task_reading_progress_sync.service.js b/services/task_reading_progress_sync.service.js index 1273c54..4c37348 100644 --- a/services/task_reading_progress_sync.service.js +++ b/services/task_reading_progress_sync.service.js @@ -275,7 +275,7 @@ async function syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUui if (!newlyCompleted.length) return []; // 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. const taskIds = [...new Set(newlyCompleted.map((r) => r.task_id))]; const completedTasks = []; diff --git a/utils/defaultGroup.util.js b/utils/defaultGroup.util.js index 13617dd..bb3aa37 100644 --- a/utils/defaultGroup.util.js +++ b/utils/defaultGroup.util.js @@ -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 }; diff --git a/validators/auth.validator.js b/validators/auth.validator.js index c138b39..61feafa 100644 --- a/validators/auth.validator.js +++ b/validators/auth.validator.js @@ -28,6 +28,7 @@ const registerValidator = [ const loginValidator = [ body('email').isEmail().withMessage('Valid email is required.'), body('password').notEmpty().withMessage('Password is required.'), + body('group_code').optional().isString().trim(), ]; const verifyOTPValidator = [