From d5fcc574a476542a2f1edb527712c53dbaf439b0 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Wed, 6 May 2026 14:15:04 +0800 Subject: [PATCH] adjusted --- config/db.config.js | 3 +- controllers/admin/users.controller.js | 101 +++++++++++++-- controllers/auth.controller.js | 27 ++-- models/users/users.attributes.js | 20 +-- models/users/users.mdl.js | 22 ++-- routes/admin/admin.routes.js | 33 ++--- utils/buildQuery.util.js | 37 ++++-- utils/excludeJSONBPaths.js | 73 ----------- utils/excludeJSONBPaths.util.js | 94 ++++++++++++++ ...ttributes.js => modelToAttributes.util.js} | 122 ++++++++++-------- utils/paginate.util.js | 61 ++++++--- utils/token.util.js | 15 ++- 12 files changed, 389 insertions(+), 219 deletions(-) delete mode 100644 utils/excludeJSONBPaths.js create mode 100644 utils/excludeJSONBPaths.util.js rename utils/{modelToAttributes.js => modelToAttributes.util.js} (50%) diff --git a/config/db.config.js b/config/db.config.js index 48c9c82..b7ebdba 100644 --- a/config/db.config.js +++ b/config/db.config.js @@ -28,7 +28,8 @@ const sequelize = new Sequelize( rejectUnauthorized: false, // or provide CA cert if strict }, }, - logging: process.env.NODE_ENV === 'development' ? console.log : false, + // logging: process.env.NODE_ENV === 'development' ? console.log : false, + logging: false, pool: { max: 10, min: 0, diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 640e1c9..583f85a 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -19,6 +19,7 @@ * Author: rgrgogu * Date Created: Oct. 6, 2025 ***********************************************************************************************************************************************************************/ +const sequelize = require('../../config/db.config') const { Op, Sequelize } = require('sequelize') const mdl_Users = require('../../models/users/users.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); @@ -32,14 +33,15 @@ const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require( const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes'); const EXCLUDED = ['password', 'otp_code', 'otp_expires_at']; +const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; exports.getUsers = async (req, res) => { try { const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + auditOptions: { mdl_Users, parentAlias: 'User' }, }); return R.success(res, 'Users retrieved.', result); @@ -154,8 +156,8 @@ exports.getGroups = async (req, res) => { try { const result = await paginate(mdl_UserGroups, req, { excludeAttributes: groupExclude, - jsonbSchemas: groupSchemas, - auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, + jsonbSchemas: groupSchemas, + auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, findOptions: { attributes: { include: [ @@ -186,14 +188,14 @@ exports.getGroup = async (req, res) => { const members = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + auditOptions: { mdl_Users, parentAlias: 'User' }, findOptions: { include: [ { - model: mdl_UserGroupMembers, - where: { group_id: req.params.gid }, + model: mdl_UserGroupMembers, + where: { group_id: req.params.gid }, attributes: [], required: true, }, @@ -443,4 +445,83 @@ exports.terminateSession = async (req, res) => { } catch (err) { return R.error(res, 'Could not terminate session.', 500); } +}; + +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); + + // ─── Audit By fields — return names instead of IDs ─────────────────── + 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 + `); + + const values = rows.map(r => r.value).filter(Boolean); + return R.success(res, 'Field values retrieved.', values); + } + + // ─── Date fields ────────────────────────────────────────────────────── + 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, + }); + + const values = results.map(r => r.value).filter(Boolean); + return R.success(res, 'Field values retrieved.', values); + } + + // ─── Regular fields ─────────────────────────────────────────────────── + const results = await mdl_Users.findAll({ + attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], + where: { [field]: { [Op.ne]: null } }, + raw: true, + }); + + const values = results.map(r => r.value).filter(Boolean).sort(); + return R.success(res, 'Field values retrieved.', values); + } + + // ─── JSONB dot-notation fields ───────────────────────────────────────── + 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, + }); + + const values = results.map(r => r.value).filter(Boolean).sort(); + return R.success(res, 'Field values retrieved.', values); + + } catch (err) { + console.error('[ADMIN][GET USER FIELD VALUES]', err); + return R.error(res, 'Could not retrieve field values.', 500); + } }; \ No newline at end of file diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 9fc0124..0905bf2 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -28,7 +28,7 @@ const bcrypt = require('bcryptjs'); const sequelize = require('../config/db.config') const mdl_Users = require('../models/users/users.mdl'); const mdl_UserSessions = require('../models/users/user_sessions.mdl'); -const { generateTokens, verifyRefreshToken, hashToken } = require('../utils/token.util'); +const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util'); const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); const sendEmail = require('../services/email.service'); const R = require('../utils/response.util'); @@ -124,7 +124,7 @@ exports.verifyOTP = async (req, res) => { sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); - + return R.success(res, 'Email verified successfully. You are now logged in.', { accessToken, refreshToken, @@ -234,13 +234,9 @@ exports.refreshToken = async (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken) return R.error(res, 'Refresh token is required.', 400); - console.log('[REFRESH] called, token tail:', refreshToken?.slice(-10)) - console.log('[REFRESH] hash:', hashToken(refreshToken)) - const decoded = verifyRefreshToken(refreshToken); const tokenHash = hashToken(refreshToken); - console.log({ user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true }) const session = await mdl_UserSessions.findOne({ where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true }, }); @@ -249,20 +245,21 @@ exports.refreshToken = async (req, res) => { const user = await mdl_Users.findByPk(decoded.user_id); if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401); - const tokens = generateTokens(user); + // Check if refresh token is expired + if (!shouldRotateRefreshToken(decoded)) { + const { accessToken } = generateTokens(user); + return R.success(res, 'Token refreshed.', { accessToken, user: safeUser(user) }); + } - // Rotate refresh token + // ─── Rotate refresh token ─────────────────────────────────────────────────── + const tokens = generateTokens(user); await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) }); - console.log('[REFRESH] old token tail:', refreshToken?.slice(-10)) - console.log('[REFRESH] new token tail:', tokens.refreshToken?.slice(-10)) - console.log('[REFRESH] are they different:', refreshToken !== tokens.refreshToken) - res.cookie('refreshToken', tokens.refreshToken, { - httpOnly: true, // ← JS cannot read this + httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, }); return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) }); diff --git a/models/users/users.attributes.js b/models/users/users.attributes.js index 81814e9..4592481 100644 --- a/models/users/users.attributes.js +++ b/models/users/users.attributes.js @@ -4,12 +4,14 @@ const excludeAttributes = [ "personal_info.name.middle_name", "personal_info.name.last_name", "personal_info.name.extension_name", + "personal_info.addresses[]", "personal_info.addresses[].city", "personal_info.addresses[].country", "personal_info.addresses[].street", "personal_info.addresses[].zip", "personal_info.addresses[].address_type", "personal_info.addresses[].state", + "personal_info.phone_number[]", "personal_info.phone_number[].country_code", "personal_info.phone_number[].number", "personal_info.phone_number[].phone_type", @@ -18,16 +20,16 @@ const excludeAttributes = [ const jsonbSchemas = { personal_info: { name: { - full_name: { type: "text", label: "Full Name" }, - }, - date_of_birth: { type: "date", label: "Date of Birth" }, - occupation: { type: "text", label: "Occupation" }, - addresses: { - full_address: { type: "text", label: "Full Address" }, - }, - phone_number: { - full_number: { type: "text", label: "Phone Number" }, + full_name: { type: "text", label: "Full Name", order: 2 }, // ← slot 2 }, + date_of_birth: { type: "date", label: "Date of Birth", order: 4 }, + occupation: { type: "text", label: "Occupation", order: 5 }, + // addresses: { + // full_address: { type: "text", label: "Full Address" }, + // }, + // phone_number: { + // full_number: { type: "text", label: "Phone Number" }, + // }, }, }; diff --git a/models/users/users.mdl.js b/models/users/users.mdl.js index 9103036..4825bd4 100644 --- a/models/users/users.mdl.js +++ b/models/users/users.mdl.js @@ -15,19 +15,19 @@ const { DataTypes } = require('sequelize'); const sequelize = require('../../config/db.config'); const mdl_Users = sequelize.define('User', { - user_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "User ID" }, - email: { type: DataTypes.STRING(255), allowNull: false, unique: true, label: "Email Address", validate: { isEmail: true } }, + user_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "User ID", order: 1, hidden: true }, + email: { type: DataTypes.STRING(255), allowNull: false, unique: true, label: "Email Address", validate: { isEmail: true }, order: 3 }, password: { type: DataTypes.TEXT, label: "Password" }, - is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active" }, - is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified" }, - reg_type: { type: DataTypes.ENUM('system', 'google'), defaultValue: 'system', label: "Registration Type" }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 6 }, + is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified", order: 7 }, + reg_type: { type: DataTypes.ENUM('google', 'system'), defaultValue: 'system', label: "Registration Type", order: 8 }, /** * acc_type drives RBAC: * - "user" → Client endpoints only * - "staff" → Client + Staff endpoints * - "admin" → All endpoints */ - acc_type: { type: DataTypes.ENUM('user', 'staff', 'admin'), defaultValue: 'user', label: "Account Type" }, + acc_type: { type: DataTypes.ENUM('admin', 'staff', 'user'), defaultValue: 'user', label: "Account Type", order: 9 }, /** * personal_info JSONB structure: * { @@ -40,20 +40,20 @@ const mdl_Users = sequelize.define('User', { * album: [{ uuid, file_url, original_name, uploaded_at, order_index }] * } */ - personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info" }, + personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, // OTP fields (stored temporarily during verification flow) otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" }, otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" }, // ── Audit trails ──────────────────────────────────────────────────────────── - createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, - updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, - deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, }, { tableName: 'users', timestamps: true, - paranoid: true, // enables soft delete — sets deleted_at instead of DELETE + paranoid: true, // enables soft delete — sets deleted_at instead of DELETE }); module.exports = mdl_Users; \ No newline at end of file diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 449f26b..868f39e 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -24,8 +24,8 @@ * Author: rgrgogu * Date Created: Oct. 6, 2025 ***********************************************************************************************************************************************************************/ -const express = require('express'); -const router = express.Router(); +const express = require('express'); +const router = express.Router(); const usersCtrl = require('../../controllers/admin/users.controller'); const { authenticate } = require('../../middleware/auth.middleware'); @@ -36,28 +36,29 @@ const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimi router.use(authenticate, requireAdmin(), adminLimiter); // ── User management ──────────────────────────────────────────────────────────── -router.get('/users', usersCtrl.getUsers); -router.get('/users/:id', usersCtrl.getUser); -router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser); -router.delete('/users/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); // soft delete +router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id +router.get('/users', usersCtrl.getUsers); +router.get('/users/:id', usersCtrl.getUser); +router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser); +router.delete('/users/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); // soft delete router.post('/users/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); // restore // ── Session management ───────────────────────────────────────────────────────── -router.get('/users/:id/sessions', usersCtrl.getUserSessions); +router.get('/users/:id/sessions', usersCtrl.getUserSessions); router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession); // ─── Groups Management ─────────────────────────────────────────────────────────────── -router.get('/groups', usersCtrl.getGroups); -router.get('/groups/:gid', usersCtrl.getGroup); -router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup); -router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup); +router.get('/groups', usersCtrl.getGroups); +router.get('/groups/:gid', usersCtrl.getGroup); +router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup); +router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup); router.patch('/groups/:gid/deactivate', sensitiveOpsLimiter, usersCtrl.deactivateGroup); -router.patch('/groups/:gid/restore', sensitiveOpsLimiter, usersCtrl.restoreGroup); +router.patch('/groups/:gid/restore', sensitiveOpsLimiter, usersCtrl.restoreGroup); // ─── Group membership ────────────────────────────────────────────────────────── -router.get('/groups/:gid/users', usersCtrl.getUsersInGroup); -router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup); -router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup); -router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup); +router.get('/groups/:gid/users', usersCtrl.getUsersInGroup); +router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup); +router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup); +router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup); module.exports = router; \ No newline at end of file diff --git a/utils/buildQuery.util.js b/utils/buildQuery.util.js index 3601245..ac42e74 100644 --- a/utils/buildQuery.util.js +++ b/utils/buildQuery.util.js @@ -1,6 +1,12 @@ // utils/queryBuilder.js const { Sequelize, Op } = require("sequelize"); +// ─── Custom sort order for ENUM fields ──────────────────────────────────────── +const ENUM_SORT_ORDER = { + acc_type: ["admin", "staff", "user"], + reg_type: ["system", "google"], +}; + /** * Builds a Sequelize `where` clause from an array of filters. * @@ -12,8 +18,6 @@ function buildWhere(filters = [], allowedFields = new Set()) { for (const { id, value } of filters) { if (!id || value === undefined || value === null || value === "") continue; - - // Reject fields not in whitelist (if whitelist is provided) if (allowedFields.size && !allowedFields.has(id)) continue; const values = Array.isArray(value) ? value : [value]; @@ -48,15 +52,30 @@ function buildOrder(sort = [], allowedFields = new Set()) { for (const { id, desc } of sort) { if (!id) continue; - - // Reject fields not in whitelist (if whitelist is provided) if (allowedFields.size && !allowedFields.has(id)) continue; - order.push( - id.startsWith("personal_info.") - ? [Sequelize.json(id), desc ? "DESC" : "ASC"] - : [id, desc ? "DESC" : "ASC"] - ); + // ─── ENUM fields — use CASE for custom sort order ───────────────────── + if (ENUM_SORT_ORDER[id]) { + const sequence = desc + ? [...ENUM_SORT_ORDER[id]].reverse() + : ENUM_SORT_ORDER[id]; + + const caseExpr = sequence + .map((val, i) => `WHEN '${val}' THEN ${i}`) + .join(" "); + + order.push([Sequelize.literal(`CASE "${id}" ${caseExpr} END`)]); + continue; + } + + // ─── JSONB dot-notation fields ───────────────────────────────────────── + if (id.startsWith("personal_info.")) { + order.push([Sequelize.json(id), desc ? "DESC" : "ASC"]); + continue; + } + + // ─── Regular fields ─────────────────────────────────────────────────── + order.push([id, desc ? "DESC" : "ASC"]); } return order.length ? order : [["createdAt", "DESC"]]; diff --git a/utils/excludeJSONBPaths.js b/utils/excludeJSONBPaths.js deleted file mode 100644 index ed8ef9b..0000000 --- a/utils/excludeJSONBPaths.js +++ /dev/null @@ -1,73 +0,0 @@ -const { Sequelize } = require('sequelize') - -/** - * Strips a key from every element in a JSONB array using PostgreSQL's - * jsonb_agg + #- operator in a subquery. - * - * @param {string} column - JSONB column e.g. "personal_info" - * @param {string} arrayField - array field name e.g. "addresses" - * @param {string[]} keys - keys to strip from each array element e.g. ["street", "zip"] - * @returns {string} SQL fragment - */ -function buildArrayStrip(column, arrayField, keys) { - const keyRemovals = keys.reduce( - (acc, key) => `(${acc} #- '{${key}}')`, - "elem" - ); - - return `( - SELECT jsonb_agg(${keyRemovals}) - FROM jsonb_array_elements("${column}"->'${arrayField}') AS elem - )`; -} - -/** - * Builds a Sequelize literal that strips JSONB paths at the DB level. - * Supports: - * - nested keys: "personal_info.name.given_name" - * - array item keys: "personal_info.addresses[].street" - * - * @param {string} column - JSONB column name e.g. "personal_info" - * @param {string[]} excludePaths - dot-notation paths - * @returns {Array|null} Sequelize literal attribute tuple - */ -function excludeJsonbPaths(column, excludePaths = []) { - // Separate nested paths from array paths - const nestedPaths = excludePaths.filter( - (p) => p.startsWith(`${column}.`) && !p.includes("[]") - ); - const arrayPaths = excludePaths.filter( - (p) => p.startsWith(`${column}.`) && p.includes("[]") - ); - - // Group array paths by their field name - // e.g. { addresses: ["street", "zip"], phone_number: ["country_code"] } - const arrayGroups = {}; - for (const path of arrayPaths) { - const stripped = path.replace(`${column}.`, ""); // addresses[].street - const [arrayField, key] = stripped.split("[]."); // ["addresses", "street"] - if (!arrayGroups[arrayField]) arrayGroups[arrayField] = []; - arrayGroups[arrayField].push(key); - } - - if (!nestedPaths.length && !Object.keys(arrayGroups).length) return null; - - // Start with the column and chain #- for nested paths - let literal = `"${column}"`; - - // Strip nested keys first - for (const path of nestedPaths) { - const keys = path.replace(`${column}.`, "").split("."); - literal = `(${literal} #- '{${keys.join(",")}}')`; - } - - // Then rebuild array fields with stripped keys using jsonb_set - for (const [arrayField, keys] of Object.entries(arrayGroups)) { - const arrayStrip = buildArrayStrip(column, arrayField, keys); - literal = `jsonb_set(${literal}, '{${arrayField}}', COALESCE(${arrayStrip}, '[]'))`; - } - - return [Sequelize.literal(literal), column]; -} - -module.exports = { excludeJsonbPaths }; \ No newline at end of file diff --git a/utils/excludeJSONBPaths.util.js b/utils/excludeJSONBPaths.util.js new file mode 100644 index 0000000..56779d6 --- /dev/null +++ b/utils/excludeJSONBPaths.util.js @@ -0,0 +1,94 @@ +const { Sequelize } = require('sequelize') + +/** + * Strips a key from every element in a JSONB array using PostgreSQL's + * jsonb_agg + #- operator in a subquery. + * + * @param {string} column - JSONB column e.g. "personal_info" + * @param {string} arrayField - array field name e.g. "addresses" + * @param {string[]} keys - keys to strip from each array element e.g. ["street", "zip"] + * @returns {string} SQL fragment + */ +function buildArrayStrip(column, arrayField, keys) { + const keyRemovals = keys.reduce( + (acc, key) => `(${acc} #- '{${key}}')`, + "elem" + ); + + return `( + SELECT jsonb_agg(${keyRemovals}) + FROM jsonb_array_elements("${column}"->'${arrayField}') AS elem + )`; +} + +/** + * Builds a Sequelize literal that strips JSONB paths at the DB level. + * Supports: + * - nested keys: "personal_info.name.given_name" + * - array item keys: "personal_info.addresses[].street" + * + * @param {string} column - JSONB column name e.g. "personal_info" + * @param {string[]} excludePaths - dot-notation paths + * @returns {Array|null} Sequelize literal attribute tuple + */ +function excludeJsonbPaths(column, excludePaths = []) { + const nestedPaths = excludePaths.filter( + (p) => p.startsWith(`${column}.`) && !p.includes("[]") + ); + + const arrayPaths = excludePaths.filter( + (p) => p.startsWith(`${column}.`) && p.includes("[]") + ); + + // ─── Group array paths ────────────────────────────────────────────────────── + // Separate bare array strips → "personal_info.addresses[]" + // from key-level strips → "personal_info.addresses[].city" + const bareArrayStrips = new Set(); // fields to remove entirely e.g. "addresses" + const arrayGroups = {}; // fields to partially strip e.g. { addresses: ["city"] } + + for (const path of arrayPaths) { + const stripped = path.replace(`${column}.`, ""); // "addresses[].city" or "addresses[]" + + if (stripped.endsWith("[]")) { + // ─── Bare array strip — remove the whole field ───────────────────────── + const arrayField = stripped.replace("[]", ""); + bareArrayStrips.add(arrayField); + } else { + // ─── Key-level strip — remove specific keys inside each element ──────── + const [arrayPart, key] = stripped.split("[]."); // ["addresses", "city"] + const arrayField = arrayPart.replace("[]", ""); + + // Skip if the whole array is already being stripped entirely + if (bareArrayStrips.has(arrayField)) continue; + + if (!arrayGroups[arrayField]) arrayGroups[arrayField] = []; + arrayGroups[arrayField].push(key); + } + } + + if (!nestedPaths.length && !bareArrayStrips.size && !Object.keys(arrayGroups).length) + return null; + + let literal = `"${column}"`; + + // ─── 1. Strip nested keys ─────────────────────────────────────────────────── + for (const path of nestedPaths) { + const keys = path.replace(`${column}.`, "").split("."); + literal = `(${literal} #- '{${keys.join(",")}}')`; + } + + // ─── 2. Strip entire array fields ────────────────────────────────────────── + for (const arrayField of bareArrayStrips) { + literal = `(${literal} #- '{${arrayField}}')`; + } + + // ─── 3. Strip specific keys inside array elements ────────────────────────── + for (const [arrayField, keys] of Object.entries(arrayGroups)) { + const arrayStrip = buildArrayStrip(column, arrayField, keys); + literal = `jsonb_set(${literal}, '{${arrayField}}', COALESCE(${arrayStrip}, '[]'))`; + } + + return [Sequelize.literal(literal), column]; +} + +module.exports = { excludeJsonbPaths }; \ No newline at end of file diff --git a/utils/modelToAttributes.js b/utils/modelToAttributes.util.js similarity index 50% rename from utils/modelToAttributes.js rename to utils/modelToAttributes.util.js index 4c118a1..c2e1638 100644 --- a/utils/modelToAttributes.js +++ b/utils/modelToAttributes.util.js @@ -5,46 +5,46 @@ const { DataTypes } = require("sequelize"); * Maps Sequelize DataType to a simple UI type string. */ function resolveType(dataType) { - if (!dataType) return "text"; + if (!dataType) return "text"; - const type = dataType.constructor?.key || dataType.key || ""; + const type = dataType.constructor?.key || dataType.key || ""; - if (["BIGINT", "INTEGER", "FLOAT", "DOUBLE", "DECIMAL"].includes(type)) return "number"; - if (["DATE", "DATEONLY"].includes(type)) return "date"; - if (["BOOLEAN"].includes(type)) return "enum"; - if (["ENUM"].includes(type)) return "enum"; - if (["JSONB", "JSON"].includes(type)) return "jsonb"; + if (["BIGINT", "INTEGER", "FLOAT", "DOUBLE", "DECIMAL"].includes(type)) return "number"; + if (["DATE", "DATEONLY"].includes(type)) return "date"; + if (["BOOLEAN"].includes(type)) return "enum"; + if (["ENUM"].includes(type)) return "enum"; + if (["JSONB", "JSON"].includes(type)) return "jsonb"; - return "text"; + return "text"; } /** * Resolves options (e.g. enum choices) from a Sequelize field definition. */ function resolveOptions(dataType) { - if (!dataType) return {}; + if (!dataType) return "text"; - const type = dataType.constructor?.key || dataType.key || ""; + const type = dataType.constructor?.key || dataType.key || ""; - if (type === "ENUM") { - return { choices: dataType.values ?? [] }; - } + if (type === "ENUM") { + return { choices: dataType.values ?? [] }; + } - if (type === "BOOLEAN") { - return { choices: ["true", "false"] }; - } + if (type === "BOOLEAN") { + return { choices: ["true", "false"] }; + } - return {}; + return {}; } /** * Converts a camelCase or snake_case field name to a readable label. */ function toLabel(field) { - return field - .replace(/_/g, " ") - .replace(/([a-z])([A-Z])/g, "$1 $2") - .replace(/\b\w/g, (c) => c.toUpperCase()); + return field + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\b\w/g, (c) => c.toUpperCase()); } /** @@ -55,27 +55,29 @@ function toLabel(field) { * @returns {Array} flat attribute entries for each leaf path */ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) { - const result = []; + const result = []; - for (const [key, value] of Object.entries(jsonbSchema)) { - const path = prefix ? `${prefix}.${key}` : key; + for (const [key, value] of Object.entries(jsonbSchema)) { + const path = prefix ? `${prefix}.${key}` : key; - if (exclude.includes(path)) continue; + if (exclude.includes(path)) continue; - // Nested object (no `type` key = it's a group, not a leaf) - if (value && typeof value === "object" && !value.type) { - result.push(...flattenJsonb(value, path, exclude)); - } else { - result.push({ - name: value?.label || toLabel(key), // <-- prefer label - type: value?.type ?? "text", - field: path, - options: {}, - }); - } + // Nested object (no `type` key = it's a group, not a leaf) + if (value && typeof value === "object" && !value.type) { + result.push(...flattenJsonb(value, path, exclude)); + } else { + result.push({ + name: value?.label || toLabel(key), // <-- prefer label + type: value?.type ?? "text", + field: path, + order: value?.order ?? Infinity, // ← carry order from schema + hidden: value?.hidden ?? false, // ← carry hidden flag + options: {}, + }); } + } - return result; + return result; } /** @@ -92,13 +94,13 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa const attributes = []; const defaultTimestampLabels = { - createdAt: "Created", + createdAt: "Created", modifiedAt: "Modified", - updatedAt: "Modified", - deletedAt: "Deleted", - createdBy: "Created By", - updatedBy: "Modified By", - deletedBy: "Deleted By", + updatedAt: "Modified", + deletedAt: "Deleted", + createdBy: "Created By", + updatedBy: "Modified By", + deletedBy: "Deleted By", ...timestampLabels, }; @@ -111,13 +113,13 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa // Ordered audit sequence const auditSequence = [ - { field: 'updatedAt', type: 'date' }, + { field: 'updatedAt', type: 'date' }, { field: 'modifiedAt', type: 'date' }, - { field: 'updatedBy', type: 'text' }, - { field: 'createdAt', type: 'date' }, - { field: 'createdBy', type: 'text' }, - { field: 'deletedAt', type: 'date' }, - { field: 'deletedBy', type: 'text' }, + { field: 'updatedBy', type: 'text' }, + { field: 'createdAt', type: 'date' }, + { field: 'createdBy', type: 'text' }, + { field: 'deletedAt', type: 'date' }, + { field: 'deletedBy', type: 'text' }, ]; // ── Normal fields (excluding audit) ───────────────────────────────────────── @@ -126,7 +128,7 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa if (auditFields.includes(field)) continue; // skip audit — added later in order const dataType = def.type; - const type = resolveType(def); + const type = resolveType(def.type); // ← pass only the type if (type === "jsonb" && jsonbSchemas[field]) { attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude)); @@ -136,27 +138,35 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa if (type === "jsonb") continue; attributes.push({ - name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field), + name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field), type, field, - options: resolveOptions(def), + order: def.order ?? Infinity, // ← carry order from model + hidden: def.hidden ?? false, // ← carry hidden flag + options: resolveOptions(def.type) }); } + // ─── Sort all fields globally by order ───────────────────────────────────── + attributes.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)); + + // ─── Strip order from final output (frontend doesn't need it) ────────────── + const sorted = attributes.map(({ order: _, ...rest }) => rest); + // ── Audit fields in correct sequence ──────────────────────────────────────── for (const { field, type } of auditSequence) { if (exclude.includes(field)) continue; if (!rawAttrs[field]) continue; // skip if field doesn't exist on model - attributes.push({ - name: defaultTimestampLabels[field], + sorted.push({ + name: defaultTimestampLabels[field], type, field, - options: {}, + options: resolveOptions(rawAttrs[field]?.type), }); } - return attributes; + return sorted; } module.exports = { modelToAttributes }; \ No newline at end of file diff --git a/utils/paginate.util.js b/utils/paginate.util.js index 44da562..e63f4ba 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -1,12 +1,12 @@ // utils/paginate.util.js const { Sequelize } = require('sequelize'); -const { modelToAttributes } = require('./modelToAttributes'); -const { excludeJsonbPaths } = require('./excludeJsonbPaths'); +const { modelToAttributes } = require('./modelToAttributes.util'); +const { excludeJsonbPaths } = require('./excludeJSONBPaths.util'); const { buildQuery } = require('./buildQuery.util'); const PAGE_START = 1; -const PAGE_SIZE = 10; -const MAX_LIMIT = 100; +const PAGE_SIZE = 10; +const MAX_LIMIT = 1000; function safeParseJSON(value, fallback = []) { try { @@ -44,6 +44,25 @@ function auditInclude(mdl_Users, parentAlias = 'User') { }; } +const auditIdToName = (row) => { + const map = { + createdBy: 'createdByName', + updatedBy: 'updatedByName', + deletedBy: 'deletedByName', + }; + + const result = { ...row }; + + for (const [idField, nameField] of Object.entries(map)) { + if (result[idField] !== null && result[idField] !== undefined) { + result[idField] = result[nameField] ?? result[idField]; // ← use name if available, fallback to id + } + delete result[nameField]; // ← remove the redundant *ByName fields + } + + return result; +}; + /** * Reusable paginated findAndCountAll * @@ -58,30 +77,30 @@ function auditInclude(mdl_Users, parentAlias = 'User') { */ async function paginate(model, req, { excludeAttributes = [], - jsonbSchemas = {}, - jsonbColumn = null, - findOptions = {}, - auditOptions = null, // ← { mdl_Users, parentAlias } + jsonbSchemas = {}, + jsonbColumn = null, + findOptions = {}, + auditOptions = null, // ← { mdl_Users, parentAlias } } = {}) { - const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); - const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT); + const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); + const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT); const offset = (page - PAGE_START) * limit; const filters = safeParseJSON(req.query.filters); - const sort = safeParseJSON(req.query.sort); + const sort = safeParseJSON(req.query.sort); const topLevelExclude = excludeAttributes.filter((f) => !f.includes('.')); - const jsonbExclude = excludeAttributes.filter((f) => f.includes('.')); - const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null; + const jsonbExclude = excludeAttributes.filter((f) => f.includes('.')); + const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null; - const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas }); + const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas }); const ALLOWED_FIELDS = attributes.map((a) => a.field); const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS); // Build attribute includes: jsonb + audit subqueries + any extra from findOptions - const baseIncludes = jsonbAttr ? [jsonbAttr] : []; - const auditAttrs = auditOptions + const baseIncludes = jsonbAttr ? [jsonbAttr] : []; + const auditAttrs = auditOptions ? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes : []; const extraIncludes = findOptions.attributes?.include ?? []; @@ -91,7 +110,7 @@ async function paginate(model, req, { const { count, rows } = await model.findAndCountAll({ ...restFindOptions, - where: { ...where, ...(restFindOptions.where ?? {}) }, + where: { ...where, ...(restFindOptions.where ?? {}) }, order, limit, offset, @@ -101,10 +120,16 @@ async function paginate(model, req, { }, }); + const data = rows + .map((row) => row.toJSON?.() ?? row) + .map(auditIdToName); + + // ─── Flatten JSONB columns into dot-notation keys ────────────────────────── + const jsonbCols = jsonbColumn ? [jsonbColumn] : []; const totalPages = Math.ceil(count / limit); return { - data: rows, + data, pagination: { page, limit, diff --git a/utils/token.util.js b/utils/token.util.js index 501f0c8..0629c28 100644 --- a/utils/token.util.js +++ b/utils/token.util.js @@ -67,4 +67,17 @@ const verifyRefreshToken = (token) => const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex'); -module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken }; \ No newline at end of file +/** + * SHA-256 hash a token string for safe DB storage. + * @param {string} decoded + * @param {number} thresholdDays + * @returns {string} if expired or not + */ +const shouldRotateRefreshToken = (decoded, thresholdDays = 1) => { + const now = Math.floor(Date.now() / 1000); + const timeLeft = decoded.exp - now; + const threshold = thresholdDays * 24 * 60 * 60; + return timeLeft <= threshold; +}; + +module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken, shouldRotateRefreshToken }; \ No newline at end of file