From 2c70f4714e7400f07bb39a88cc3f3db5466c0334 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sun, 19 Jul 2026 14:59:33 +0800 Subject: [PATCH] fix some issues Signed-off-by: Kenneth Obsequio --- controllers/admin/courses.controller.js | 49 +++++++++++++++++++++++-- controllers/admin/units.controller.js | 3 +- models/courses/units.mdl.js | 5 ++- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 354cbca..a1dd269 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -16,6 +16,8 @@ const { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const CompletionRequirement = require('../../models/courses/completion_requirement.mdl'); +const { VALID_ENTITY_TYPES } = require('../../utils/courses/completion_requirements.registry'); // ── Models ──────────────────────────────────────────────────────────────────── @@ -218,6 +220,23 @@ exports.createCourseFull = async (req, res) => { await t.rollback(); return R.error(res, "Each new unit needs a title.", 400); } + // Requirements only apply to units being created fresh here — units + // attached by unit_id already have their own configured set. + if (!unit.unit_id && unit.requirements?.length) { + const seenTypes = new Set(); + for (const r of unit.requirements) { + const allowedEntityTypes = VALID_ENTITY_TYPES[r.type]; + if (!allowedEntityTypes || !allowedEntityTypes.includes("unit")) { + await t.rollback(); + return R.error(res, `"${r.type}" cannot be configured on a unit.`, 400); + } + if (seenTypes.has(r.type)) { + await t.rollback(); + return R.error(res, `Duplicate "${r.type}" requirement — only one per unit is allowed.`, 400); + } + seenTypes.add(r.type); + } + } for (const lesson of unit.lessons ?? []) { if (!lesson.lesson_id && !lesson.title) { await t.rollback(); @@ -301,6 +320,21 @@ exports.createCourseFull = async (req, res) => { duration_seconds: 0, createdBy: by, }, { transaction: t }); + + if (unitInput.requirements?.length) { + const rows = unitInput.requirements.map((r, ri) => ({ + entity_type: "unit", + entity_id: unit.unit_id, + type: r.type, + order: r.order ?? ri, + min_percent: r.type === "watch_percent" ? (r.min_percent ?? 100) : null, + button_label: r.type === "manual_complete" ? (r.button_label || null) : null, + is_required: r.is_required ?? true, + createdBy: by, + updatedBy: by, + })); + await CompletionRequirement.bulkCreate(rows, { transaction: t }); + } } await CourseUnit.create({ @@ -658,10 +692,15 @@ exports.syncPrerequisites = async (req, res) => { // ══════════════════════════════════════════════════════════════════════════════ // Literal fragments that resolve a unit's order within THIS course. +// course_id is a 19-digit snowflake-style BIGINT — well past +// Number.MAX_SAFE_INTEGER, so Number(courseId) silently rounds to the wrong +// id and these subqueries match nothing. BigInt preserves full precision +// (and throws on non-numeric input, same effect as the old NaN would've had +// via the caller's try/catch). const courseOrderLiteral = (courseId) => - `(SELECT cu.order_index FROM course_units cu WHERE cu.course_id = ${Number(courseId)} AND cu.unit_id = "Unit"."unit_id" LIMIT 1)`; + `(SELECT cu.order_index FROM course_units cu WHERE cu.course_id = ${BigInt(courseId)} AND cu.unit_id = "Unit"."unit_id" LIMIT 1)`; const courseMembershipLiteral = (courseId) => - `(SELECT cu.unit_id FROM course_units cu WHERE cu.course_id = ${Number(courseId)})`; + `(SELECT cu.unit_id FROM course_units cu WHERE cu.course_id = ${BigInt(courseId)})`; exports.getUnits = async (req, res) => { try { @@ -1197,10 +1236,12 @@ exports.getUnitPermanentDeleteImpact = async (req, res) => { // Entity-level archive/permanent-delete lives in /admin/lessons (the library). // ══════════════════════════════════════════════════════════════════════════════ +// Same BigInt fix as courseOrderLiteral/courseMembershipLiteral above — +// unit_id is a 19-digit snowflake BIGINT, past Number.MAX_SAFE_INTEGER. const unitOrderLiteral = (unitId) => - `(SELECT ul.order_index FROM unit_lessons ul WHERE ul.unit_id = ${Number(unitId)} AND ul.lesson_id = "Lesson"."lesson_id" LIMIT 1)`; + `(SELECT ul.order_index FROM unit_lessons ul WHERE ul.unit_id = ${BigInt(unitId)} AND ul.lesson_id = "Lesson"."lesson_id" LIMIT 1)`; const unitMembershipLiteral = (unitId) => - `(SELECT ul.lesson_id FROM unit_lessons ul WHERE ul.unit_id = ${Number(unitId)})`; + `(SELECT ul.lesson_id FROM unit_lessons ul WHERE ul.unit_id = ${BigInt(unitId)})`; exports.getLessons = async (req, res) => { try { diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js index 2416f78..bce7bda 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -73,8 +73,9 @@ const UNIT_LIST_COMPUTED = [ }, { key: "course_count", - label: "Used in courses", + label: "Affiliated", type: "number", + order: 2, // 1: Title, 2: Affiliated, 3: Subscription, 4: Duration — see units.mdl.js literal: `( SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu diff --git a/models/courses/units.mdl.js b/models/courses/units.mdl.js index 5fe58f5..a06dd40 100644 --- a/models/courses/units.mdl.js +++ b/models/courses/units.mdl.js @@ -7,9 +7,10 @@ const Unit = sequelize.define("Unit", { unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: false }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true }, - subscription: { type: DataTypes.STRING(50), allowNull: true, label: "Subscription", hidden: false, order: 1.5, filterable: true, comment: "Optional direct tier gate for standalone units — null means open (or gated only via an attached course)." }, + // order 2 is reserved for the computed "Affiliated" (course_count) column — see UNIT_LIST_COMPUTED in units.controller.js + subscription: { type: DataTypes.STRING(50), allowNull: true, label: "Subscription", hidden: false, order: 3, filterable: true, comment: "Optional direct tier gate for standalone units — null means open (or gated only via an attached course)." }, description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false }, - duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 2, filterable: false }, + duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 4, filterable: false }, createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },