diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 819140f..fa8be2d 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -10,6 +10,7 @@ const R = require('../../utils/response.util'); const { paginate } = require("../../utils/paginate.util"); const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes"); const mdl_Users = require('../../models/users/users.mdl'); +const { getFieldValues } = require("../../utils/fieldValues.util"); const { Op, Sequelize } = require('sequelize'); const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; @@ -581,48 +582,4 @@ exports.getArchivedAssets = async (req, res) => { } }; -exports.getAssetFieldValues = async (req, res) => { - try { - const { field } = req.query; - if (!field) return R.error(res, "Field is required.", 400); - - const allowedFields = Object.keys(Asset.rawAttributes); - const dateFields = ["createdAt", "updatedAt", "deletedAt"]; - - if (!allowedFields.includes(field)) - return R.error(res, "Invalid or restricted field.", 400); - - if (auditByFields.includes(field)) { - const [rows] = await sequelize.query(` - SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value - FROM assets a - JOIN users u ON u.user_id = a."${field}" - WHERE a."${field}" IS NOT NULL - AND u."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC - `); - return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean)); - } - - if (dateFields.includes(field)) { - const results = await Asset.findAll({ - attributes: [[Sequelize.fn("DISTINCT", Sequelize.fn("DATE", Sequelize.col(field))), "value"]], - where: { [field]: { [Op.ne]: null } }, - order: [[Sequelize.fn("DATE", Sequelize.col(field)), "DESC"]], - raw: true, - }); - return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean)); - } - - const results = await Asset.findAll({ - attributes: [[Sequelize.fn("DISTINCT", Sequelize.col(field)), "value"]], - where: { [field]: { [Op.ne]: null } }, - raw: true, - }); - return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort()); - - } catch (err) { - console.error("[ASSET][GET FIELD VALUES]", err); - return R.error(res, "Could not retrieve field values.", 500); - } -}; \ No newline at end of file +exports.getAssetFieldValues = getFieldValues(Asset, "ASSET"); \ No newline at end of file diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index e0f8151..b36ef2e 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -9,6 +9,7 @@ const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/cour const { syncJunction } = require("../../utils/courses/junction.util"); const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); +const { getFieldValues } = require("../../utils/fieldValues.util"); // ── Models ──────────────────────────────────────────────────────────────────── @@ -408,11 +409,11 @@ exports.updateUnit = async (req, res) => { } }; -exports.deleteUnit = async (req, res) => { +exports.archiveUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; - const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.body.deletedBy, t); + const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Unit not found.", 404); await t.commit(); return R.success(res, "Unit archived."); @@ -427,13 +428,13 @@ exports.bulkArchiveUnits = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId } = req.params; - const { ids = [], deletedBy } = req.body; + const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId, ...notDeleted } }); const validIds = units.map((u) => u.unit_id); - const count = await archiveMany(Unit, "unit_id", validIds, deletedBy, t); + const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t); await t.commit(); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`); } catch (err) { @@ -453,7 +454,7 @@ exports.getArchivedUnits = async (req, res) => { const result = await paginate(Unit, req, { excludeAttributes: adminExclude, auditOptions: { mdl_Users, parentAlias: "Unit" }, - context: "list", + context: "archived", findOptions: { where: { course_id: courseId, ...onlyDeleted }, paranoid: false, @@ -487,7 +488,7 @@ exports.restoreUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; - const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...onlyDeleted }, req.body.restoredBy, t); + const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...onlyDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Archived unit not found.", 404); await t.commit(); return R.success(res, "Unit restored.", { data: record }); @@ -502,13 +503,13 @@ exports.bulkRestoreUnits = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId } = req.params; - const { ids = [], restoredBy } = req.body; + const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId, ...onlyDeleted }, paranoid: false }); const validIds = units.map((u) => u.unit_id); - const count = await restoreMany(Unit, "unit_id", validIds, restoredBy, t); + const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t); await t.commit(); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`); } catch (err) { @@ -1315,50 +1316,6 @@ exports.restoreAssessment = async (req, res) => { } }; -exports.getCourseFieldValues = async (req, res) => { - try { - const { field } = req.query; - if (!field) return R.error(res, "Field is required.", 400); - - const allowedFields = Object.keys(Course.rawAttributes).filter( - (key) => Course.rawAttributes[key].filterable === true - ); - const dateFields = ["createdAt", "updatedAt", "deletedAt"]; - - if (!allowedFields.includes(field) && !dateFields.includes(field)) - return R.error(res, "Invalid or restricted field.", 400); - - if (auditByFields.includes(field)) { - const [rows] = await sequelize.query(` - SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value - FROM courses c - JOIN users u ON u.user_id = c."${field}" - WHERE c."${field}" IS NOT NULL - AND u."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC - `); - return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean)); - } - - if (dateFields.includes(field)) { - const results = await Course.findAll({ - attributes: [[Sequelize.fn("DISTINCT", Sequelize.fn("DATE", Sequelize.col(field))), "value"]], - where: { [field]: { [Op.ne]: null } }, - order: [[Sequelize.fn("DATE", Sequelize.col(field)), "DESC"]], - raw: true, - }); - return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean)); - } - - const results = await Course.findAll({ - attributes: [[Sequelize.fn("DISTINCT", Sequelize.col(field)), "value"]], - where: { [field]: { [Op.ne]: null } }, - raw: true, - }); - return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort()); - - } catch (err) { - console.error("[COURSE][GET FIELD VALUES]", err); - return R.error(res, "Could not retrieve field values.", 500); - } -}; \ No newline at end of file +exports.getCourseFieldValues = getFieldValues(Course, "COURSE"); +exports.getUnitFieldValues = getFieldValues(Unit, "UNIT"); +exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON"); \ No newline at end of file diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 9aabd02..11d0948 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -14,6 +14,7 @@ const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/use const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); +const { getFieldValues } = require("../../utils/fieldValues.util"); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes'); @@ -236,58 +237,10 @@ exports.getArchivedGroups = async (req, res) => { // ─── FIELD VALUES ───────────────────────────────────────────────────────────── -exports.getGroupFieldValues = async (req, res) => { - try { - const { field } = req.query; - if (!field) return R.error(res, 'Field is required.', 400); - - const allowedFields = Object.keys(mdl_UserGroups.rawAttributes).filter( - (f) => !['deletedAt'].includes(f) - ); - const dateFields = ['createdAt', 'updatedAt', 'deletedAt']; - - if (!field.includes('.')) { - if (!allowedFields.includes(field)) - return R.error(res, 'Invalid or restricted field.', 400); - - if (auditByFields.includes(field)) { - const [rows] = await sequelize.query(` - SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value - FROM user_groups g - JOIN users u ON u.user_id = g."${field}" - WHERE g."${field}" IS NOT NULL - AND u."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC - `); - return R.success(res, 'Field values retrieved.', rows.map((r) => r.value).filter(Boolean)); - } - - if (dateFields.includes(field)) { - const results = await mdl_UserGroups.findAll({ - attributes: [[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value']], - where: { [field]: { [Op.ne]: null } }, - order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']], - paranoid: false, - raw: true, - }); - return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean)); - } - - const results = await mdl_UserGroups.findAll({ - attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], - where: { [field]: { [Op.ne]: null } }, - paranoid: false, - raw: true, - }); - return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort()); - } - - return R.error(res, 'JSONB fields are not supported for groups.', 400); - } catch (err) { - console.error('[ADMIN][GET GROUP FIELD VALUES]', err); - return R.error(res, 'Could not retrieve field values.', 500); - } -}; +exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, "GROUP", { + blockedFields: ["deletedAt"], + paranoid: false, +}); // ─── MEMBERSHIP ─────────────────────────────────────────────────────────────── diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 91b7c5a..89351e7 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -19,6 +19,7 @@ const sendEmail = require('../../services/email.service'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); +const { getFieldValues } = require("../../utils/fieldValues.util"); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); @@ -302,70 +303,12 @@ exports.terminateSession = async (req, res) => { // ─── FIELD VALUES ───────────────────────────────────────────────────────────── -exports.getUserFieldValues = async (req, res) => { - try { - const { field } = req.query; - if (!field) return R.error(res, 'Field is required.', 400); - - const allowedFields = Object.keys(mdl_Users.rawAttributes).filter( - (f) => !['password', 'otp_code', 'otp_expires_at', 'personal_info'].includes(f) - ); - const dateFields = ['createdAt', 'updatedAt', 'deletedAt', 'modifiedAt']; - - if (!field.includes('.')) { - if (!allowedFields.includes(field)) - return R.error(res, 'Invalid or restricted field.', 400); - - if (auditByFields.includes(field)) { - const [rows] = await sequelize.query(` - SELECT DISTINCT u2."personal_info"->'name'->>'full_name' AS value - FROM users u1 - JOIN users u2 ON u2.user_id = u1."${field}" - WHERE u1."${field}" IS NOT NULL - AND u2."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC - `); - return R.success(res, 'Field values retrieved.', rows.map((r) => r.value).filter(Boolean)); - } - - if (dateFields.includes(field)) { - const results = await mdl_Users.findAll({ - attributes: [[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value']], - where: { [field]: { [Op.ne]: null } }, - order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']], - raw: true, - }); - return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean)); - } - - const results = await mdl_Users.findAll({ - attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], - where: { [field]: { [Op.ne]: null } }, - raw: true, - }); - return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort()); - } - - // ─── JSONB dot-notation ─────────────────────────────────────────────── - const [column, ...pathParts] = field.split('.'); - const keys = [...pathParts]; - const lastKey = keys.pop(); - const jsonbPath = keys.length - ? `"${column}"->${keys.map((k) => `'${k}'`).join('->')}->>'${lastKey}'` - : `"${column}"->>'${lastKey}'`; - - const results = await mdl_Users.findAll({ - attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']], - where: Sequelize.literal(`${jsonbPath} IS NOT NULL`), - raw: true, - }); - - return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort()); - } catch (err) { - console.error('[ADMIN][GET USER FIELD VALUES]', err); - return R.error(res, 'Could not retrieve field values.', 500); - } -}; +exports.getUserFieldValues = getFieldValues(mdl_Users, "USER", { + blockedFields: ["password", "otp_code", "otp_expires_at", "personal_info"], + extraDateFields: ["modifiedAt"], + allowJsonb: true, + selfJoin: true, +}); // ─── ARCHIVED ───────────────────────────────────────────────────────────────── diff --git a/models/courses/units.mdl.js b/models/courses/units.mdl.js index a10ae34..3bb5160 100644 --- a/models/courses/units.mdl.js +++ b/models/courses/units.mdl.js @@ -2,19 +2,16 @@ const { DataTypes } = require("sequelize"); const sequelize = require("../../config/db.config"); const Unit = sequelize.define("Unit", { - unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0 }, - uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 }, - course_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0 }, - - title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1 }, - description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: false, order: 2 }, - order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 3 }, - - duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, - - createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, - updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, - deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, + 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 }, + course_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0, filterable: false }, + title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true }, + description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false }, + order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 2, filterable: false }, + duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 3, 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 }, }, { tableName: "units", timestamps: true, diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index c14cf16..d286fed 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -66,6 +66,7 @@ router.patch("/:courseId/assessment/:assessmentId/questions/:questionId/restore" // UNITS // ══════════════════════════════════════════════════════════════════════════════ +router.get("/:courseId/field-values", ctrl.getUnitFieldValues); router.get("/:courseId/units", ctrl.getUnits); router.post("/:courseId/units", ctrl.createUnit); @@ -78,7 +79,7 @@ router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit); router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit); router.get("/:courseId/units/:unitId", ctrl.getUnit); router.put("/:courseId/units/:unitId", ctrl.updateUnit); -router.delete("/:courseId/units/:unitId", ctrl.deleteUnit); +router.delete("/:courseId/units/:unitId", ctrl.archiveUnit); // ══════════════════════════════════════════════════════════════════════════════ // UNIT QUIZ @@ -111,6 +112,7 @@ router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId/restor // LESSONS // ══════════════════════════════════════════════════════════════════════════════ +router.get("/:courseId/units/:unitId/field-values", ctrl.getLessonFieldValues); router.get("/:courseId/units/:unitId/lessons", ctrl.getLessons); router.post("/:courseId/units/:unitId/lessons", ctrl.createLesson); diff --git a/utils/fieldValues.util.js b/utils/fieldValues.util.js new file mode 100644 index 0000000..bf67b4e --- /dev/null +++ b/utils/fieldValues.util.js @@ -0,0 +1,98 @@ +const { Op } = require("sequelize"); +const sequelize = require("../config/db.config"); +const Sequelize = require("sequelize"); +const R = require("../utils/response.util"); // adjust path as needed + +const auditByFields = ["createdBy", "updatedBy", "deletedBy"]; + +const getFieldValues = (Model, logTag, options = {}) => async (req, res) => { + const { + blockedFields = [], + extraDateFields = [], + allowJsonb = false, + paranoid = true, + selfJoin = false, // for users auditing users + } = options; + + try { + const { field } = req.query; + if (!field) return R.error(res, "Field is required.", 400); + + const dateFields = ["createdAt", "updatedAt", "deletedAt", ...extraDateFields]; + + // ─── Non-JSONB path ─────────────────────────────────────────────────── + if (!field.includes(".")) { + const allowedFields = blockedFields.length + ? Object.keys(Model.rawAttributes).filter((f) => !blockedFields.includes(f)) + : Object.keys(Model.rawAttributes).filter((f) => Model.rawAttributes[f].filterable === true); + + if (!allowedFields.includes(field) && !dateFields.includes(field)) + return R.error(res, "Invalid or restricted field.", 400); + + if (auditByFields.includes(field)) { + const tableName = Model.getTableName(); + const [rows] = selfJoin + ? await sequelize.query(` + SELECT DISTINCT u2."personal_info"->'name'->>'full_name' AS value + FROM "${tableName}" u1 + JOIN "${tableName}" u2 ON u2.user_id = u1."${field}" + WHERE u1."${field}" IS NOT NULL + AND u2."personal_info"->'name'->>'full_name' IS NOT NULL + ORDER BY value ASC + `) + : await sequelize.query(` + SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value + FROM "${tableName}" t + JOIN users u ON u.user_id = t."${field}" + WHERE t."${field}" IS NOT NULL + AND u."personal_info"->'name'->>'full_name' IS NOT NULL + ORDER BY value ASC + `); + return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean)); + } + + if (dateFields.includes(field)) { + const results = await Model.findAll({ + attributes: [[Sequelize.fn("DISTINCT", Sequelize.fn("DATE", Sequelize.col(field))), "value"]], + where: { [field]: { [Op.ne]: null } }, + order: [[Sequelize.fn("DATE", Sequelize.col(field)), "DESC"]], + paranoid, + raw: true, + }); + return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean)); + } + + const results = await Model.findAll({ + attributes: [[Sequelize.fn("DISTINCT", Sequelize.col(field)), "value"]], + where: { [field]: { [Op.ne]: null } }, + paranoid, + raw: true, + }); + return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort()); + } + + // ─── JSONB dot-notation path ────────────────────────────────────────── + if (!allowJsonb) + return R.error(res, "JSONB fields are not supported.", 400); + + const [column, ...pathParts] = field.split("."); + const keys = [...pathParts]; + const lastKey = keys.pop(); + const jsonbPath = keys.length + ? `"${column}"->${keys.map((k) => `'${k}'`).join("->")}->>'${lastKey}'` + : `"${column}"->>'${lastKey}'`; + + const results = await Model.findAll({ + attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), "value"]], + where: Sequelize.literal(`${jsonbPath} IS NOT NULL`), + raw: true, + }); + return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort()); + + } catch (err) { + console.error(`[${logTag}][GET FIELD VALUES]`, err); + return R.error(res, "Could not retrieve field values.", 500); + } +}; + +module.exports = { getFieldValues }; \ No newline at end of file