This commit is contained in:
rgrgogu
2026-05-06 14:15:04 +08:00
parent a8f10a25d7
commit d5fcc574a4
12 changed files with 389 additions and 219 deletions
+2 -1
View File
@@ -28,7 +28,8 @@ const sequelize = new Sequelize(
rejectUnauthorized: false, // or provide CA cert if strict 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: { pool: {
max: 10, max: 10,
min: 0, min: 0,
+91 -10
View File
@@ -19,6 +19,7 @@
* Author: rgrgogu * Author: rgrgogu
* Date Created: Oct. 6, 2025 * Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const sequelize = require('../../config/db.config')
const { Op, Sequelize } = require('sequelize') const { Op, Sequelize } = require('sequelize')
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
@@ -32,14 +33,15 @@ const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require(
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes'); const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at']; const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
exports.getUsers = async (req, res) => { exports.getUsers = async (req, res) => {
try { try {
const result = await paginate(mdl_Users, req, { const result = await paginate(mdl_Users, req, {
excludeAttributes: usersExclude, excludeAttributes: usersExclude,
jsonbSchemas: usersSchemas, jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info', jsonbColumn: 'personal_info',
auditOptions: { mdl_Users, parentAlias: 'User' }, auditOptions: { mdl_Users, parentAlias: 'User' },
}); });
return R.success(res, 'Users retrieved.', result); return R.success(res, 'Users retrieved.', result);
@@ -154,8 +156,8 @@ exports.getGroups = async (req, res) => {
try { try {
const result = await paginate(mdl_UserGroups, req, { const result = await paginate(mdl_UserGroups, req, {
excludeAttributes: groupExclude, excludeAttributes: groupExclude,
jsonbSchemas: groupSchemas, jsonbSchemas: groupSchemas,
auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
findOptions: { findOptions: {
attributes: { attributes: {
include: [ include: [
@@ -186,14 +188,14 @@ exports.getGroup = async (req, res) => {
const members = await paginate(mdl_Users, req, { const members = await paginate(mdl_Users, req, {
excludeAttributes: usersExclude, excludeAttributes: usersExclude,
jsonbSchemas: usersSchemas, jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info', jsonbColumn: 'personal_info',
auditOptions: { mdl_Users, parentAlias: 'User' }, auditOptions: { mdl_Users, parentAlias: 'User' },
findOptions: { findOptions: {
include: [ include: [
{ {
model: mdl_UserGroupMembers, model: mdl_UserGroupMembers,
where: { group_id: req.params.gid }, where: { group_id: req.params.gid },
attributes: [], attributes: [],
required: true, required: true,
}, },
@@ -444,3 +446,82 @@ exports.terminateSession = async (req, res) => {
return R.error(res, 'Could not terminate session.', 500); 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);
}
};
+11 -14
View File
@@ -28,7 +28,7 @@ const bcrypt = require('bcryptjs');
const sequelize = require('../config/db.config') const sequelize = require('../config/db.config')
const mdl_Users = require('../models/users/users.mdl'); const mdl_Users = require('../models/users/users.mdl');
const mdl_UserSessions = require('../models/users/user_sessions.mdl'); const mdl_UserSessions = require('../models/users/user_sessions.mdl');
const { 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 { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
const sendEmail = require('../services/email.service'); const sendEmail = require('../services/email.service');
const R = require('../utils/response.util'); const R = require('../utils/response.util');
@@ -234,13 +234,9 @@ exports.refreshToken = async (req, res) => {
const refreshToken = req.cookies.refreshToken; const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return R.error(res, 'Refresh token is required.', 400); 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 decoded = verifyRefreshToken(refreshToken);
const tokenHash = hashToken(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({ const session = await mdl_UserSessions.findOne({
where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true }, 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); const user = await mdl_Users.findByPk(decoded.user_id);
if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401); 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) }); 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, { res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true, // ← JS cannot read this httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days maxAge: 7 * 24 * 60 * 60 * 1000,
}); });
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) }); return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
+11 -9
View File
@@ -4,12 +4,14 @@ const excludeAttributes = [
"personal_info.name.middle_name", "personal_info.name.middle_name",
"personal_info.name.last_name", "personal_info.name.last_name",
"personal_info.name.extension_name", "personal_info.name.extension_name",
"personal_info.addresses[]",
"personal_info.addresses[].city", "personal_info.addresses[].city",
"personal_info.addresses[].country", "personal_info.addresses[].country",
"personal_info.addresses[].street", "personal_info.addresses[].street",
"personal_info.addresses[].zip", "personal_info.addresses[].zip",
"personal_info.addresses[].address_type", "personal_info.addresses[].address_type",
"personal_info.addresses[].state", "personal_info.addresses[].state",
"personal_info.phone_number[]",
"personal_info.phone_number[].country_code", "personal_info.phone_number[].country_code",
"personal_info.phone_number[].number", "personal_info.phone_number[].number",
"personal_info.phone_number[].phone_type", "personal_info.phone_number[].phone_type",
@@ -18,16 +20,16 @@ const excludeAttributes = [
const jsonbSchemas = { const jsonbSchemas = {
personal_info: { personal_info: {
name: { name: {
full_name: { type: "text", label: "Full Name" }, full_name: { type: "text", label: "Full Name", order: 2 }, // ← slot 2
},
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" },
}, },
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" },
// },
}, },
}; };
+11 -11
View File
@@ -15,19 +15,19 @@ const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const mdl_Users = sequelize.define('User', { const mdl_Users = sequelize.define('User', {
user_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "User ID" }, 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 } }, email: { type: DataTypes.STRING(255), allowNull: false, unique: true, label: "Email Address", validate: { isEmail: true }, order: 3 },
password: { type: DataTypes.TEXT, label: "Password" }, password: { type: DataTypes.TEXT, label: "Password" },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active" }, is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 6 },
is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified" }, is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified", order: 7 },
reg_type: { type: DataTypes.ENUM('system', 'google'), defaultValue: 'system', label: "Registration Type" }, reg_type: { type: DataTypes.ENUM('google', 'system'), defaultValue: 'system', label: "Registration Type", order: 8 },
/** /**
* acc_type drives RBAC: * acc_type drives RBAC:
* - "user" → Client endpoints only * - "user" → Client endpoints only
* - "staff" → Client + Staff endpoints * - "staff" → Client + Staff endpoints
* - "admin" → All 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: * personal_info JSONB structure:
* { * {
@@ -40,20 +40,20 @@ const mdl_Users = sequelize.define('User', {
* album: [{ uuid, file_url, original_name, uploaded_at, order_index }] * 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 fields (stored temporarily during verification flow)
otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" }, otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" },
otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" }, otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" },
// ── Audit trails ──────────────────────────────────────────────────────────── // ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, { }, {
tableName: 'users', tableName: 'users',
timestamps: true, 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; module.exports = mdl_Users;
+17 -16
View File
@@ -24,8 +24,8 @@
* Author: rgrgogu * Author: rgrgogu
* Date Created: Oct. 6, 2025 * Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const usersCtrl = require('../../controllers/admin/users.controller'); const usersCtrl = require('../../controllers/admin/users.controller');
const { authenticate } = require('../../middleware/auth.middleware'); const { authenticate } = require('../../middleware/auth.middleware');
@@ -36,28 +36,29 @@ const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimi
router.use(authenticate, requireAdmin(), adminLimiter); router.use(authenticate, requireAdmin(), adminLimiter);
// ── User management ──────────────────────────────────────────────────────────── // ── User management ────────────────────────────────────────────────────────────
router.get('/users', usersCtrl.getUsers); router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id
router.get('/users/:id', usersCtrl.getUser); router.get('/users', usersCtrl.getUsers);
router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser); router.get('/users/:id', usersCtrl.getUser);
router.delete('/users/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); // soft delete 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 router.post('/users/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); // restore
// ── Session management ───────────────────────────────────────────────────────── // ── 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); router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession);
// ─── Groups Management ─────────────────────────────────────────────────────────────── // ─── Groups Management ───────────────────────────────────────────────────────────────
router.get('/groups', usersCtrl.getGroups); router.get('/groups', usersCtrl.getGroups);
router.get('/groups/:gid', usersCtrl.getGroup); router.get('/groups/:gid', usersCtrl.getGroup);
router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup); router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup);
router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup); router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup);
router.patch('/groups/:gid/deactivate', sensitiveOpsLimiter, usersCtrl.deactivateGroup); 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 ────────────────────────────────────────────────────────── // ─── Group membership ──────────────────────────────────────────────────────────
router.get('/groups/:gid/users', usersCtrl.getUsersInGroup); router.get('/groups/:gid/users', usersCtrl.getUsersInGroup);
router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup); router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup);
router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup); router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup);
router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup); router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup);
module.exports = router; module.exports = router;
+28 -9
View File
@@ -1,6 +1,12 @@
// utils/queryBuilder.js // utils/queryBuilder.js
const { Sequelize, Op } = require("sequelize"); 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. * 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) { for (const { id, value } of filters) {
if (!id || value === undefined || value === null || value === "") continue; if (!id || value === undefined || value === null || value === "") continue;
// Reject fields not in whitelist (if whitelist is provided)
if (allowedFields.size && !allowedFields.has(id)) continue; if (allowedFields.size && !allowedFields.has(id)) continue;
const values = Array.isArray(value) ? value : [value]; const values = Array.isArray(value) ? value : [value];
@@ -48,15 +52,30 @@ function buildOrder(sort = [], allowedFields = new Set()) {
for (const { id, desc } of sort) { for (const { id, desc } of sort) {
if (!id) continue; if (!id) continue;
// Reject fields not in whitelist (if whitelist is provided)
if (allowedFields.size && !allowedFields.has(id)) continue; if (allowedFields.size && !allowedFields.has(id)) continue;
order.push( // ─── ENUM fields — use CASE for custom sort order ─────────────────────
id.startsWith("personal_info.") if (ENUM_SORT_ORDER[id]) {
? [Sequelize.json(id), desc ? "DESC" : "ASC"] const sequence = desc
: [id, desc ? "DESC" : "ASC"] ? [...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"]]; return order.length ? order : [["createdAt", "DESC"]];
-73
View File
@@ -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 };
+94
View File
@@ -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 };
@@ -5,46 +5,46 @@ const { DataTypes } = require("sequelize");
* Maps Sequelize DataType to a simple UI type string. * Maps Sequelize DataType to a simple UI type string.
*/ */
function resolveType(dataType) { 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 (["BIGINT", "INTEGER", "FLOAT", "DOUBLE", "DECIMAL"].includes(type)) return "number";
if (["DATE", "DATEONLY"].includes(type)) return "date"; if (["DATE", "DATEONLY"].includes(type)) return "date";
if (["BOOLEAN"].includes(type)) return "enum"; if (["BOOLEAN"].includes(type)) return "enum";
if (["ENUM"].includes(type)) return "enum"; if (["ENUM"].includes(type)) return "enum";
if (["JSONB", "JSON"].includes(type)) return "jsonb"; if (["JSONB", "JSON"].includes(type)) return "jsonb";
return "text"; return "text";
} }
/** /**
* Resolves options (e.g. enum choices) from a Sequelize field definition. * Resolves options (e.g. enum choices) from a Sequelize field definition.
*/ */
function resolveOptions(dataType) { 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") { if (type === "ENUM") {
return { choices: dataType.values ?? [] }; return { choices: dataType.values ?? [] };
} }
if (type === "BOOLEAN") { if (type === "BOOLEAN") {
return { choices: ["true", "false"] }; return { choices: ["true", "false"] };
} }
return {}; return {};
} }
/** /**
* Converts a camelCase or snake_case field name to a readable label. * Converts a camelCase or snake_case field name to a readable label.
*/ */
function toLabel(field) { function toLabel(field) {
return field return field
.replace(/_/g, " ") .replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase()); .replace(/\b\w/g, (c) => c.toUpperCase());
} }
/** /**
@@ -55,27 +55,29 @@ function toLabel(field) {
* @returns {Array} flat attribute entries for each leaf path * @returns {Array} flat attribute entries for each leaf path
*/ */
function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) { function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
const result = []; const result = [];
for (const [key, value] of Object.entries(jsonbSchema)) { for (const [key, value] of Object.entries(jsonbSchema)) {
const path = prefix ? `${prefix}.${key}` : key; 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) // Nested object (no `type` key = it's a group, not a leaf)
if (value && typeof value === "object" && !value.type) { if (value && typeof value === "object" && !value.type) {
result.push(...flattenJsonb(value, path, exclude)); result.push(...flattenJsonb(value, path, exclude));
} else { } else {
result.push({ result.push({
name: value?.label || toLabel(key), // <-- prefer label name: value?.label || toLabel(key), // <-- prefer label
type: value?.type ?? "text", type: value?.type ?? "text",
field: path, field: path,
options: {}, 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 attributes = [];
const defaultTimestampLabels = { const defaultTimestampLabels = {
createdAt: "Created", createdAt: "Created",
modifiedAt: "Modified", modifiedAt: "Modified",
updatedAt: "Modified", updatedAt: "Modified",
deletedAt: "Deleted", deletedAt: "Deleted",
createdBy: "Created By", createdBy: "Created By",
updatedBy: "Modified By", updatedBy: "Modified By",
deletedBy: "Deleted By", deletedBy: "Deleted By",
...timestampLabels, ...timestampLabels,
}; };
@@ -111,13 +113,13 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
// Ordered audit sequence // Ordered audit sequence
const auditSequence = [ const auditSequence = [
{ field: 'updatedAt', type: 'date' }, { field: 'updatedAt', type: 'date' },
{ field: 'modifiedAt', type: 'date' }, { field: 'modifiedAt', type: 'date' },
{ field: 'updatedBy', type: 'text' }, { field: 'updatedBy', type: 'text' },
{ field: 'createdAt', type: 'date' }, { field: 'createdAt', type: 'date' },
{ field: 'createdBy', type: 'text' }, { field: 'createdBy', type: 'text' },
{ field: 'deletedAt', type: 'date' }, { field: 'deletedAt', type: 'date' },
{ field: 'deletedBy', type: 'text' }, { field: 'deletedBy', type: 'text' },
]; ];
// ── Normal fields (excluding audit) ───────────────────────────────────────── // ── 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 if (auditFields.includes(field)) continue; // skip audit — added later in order
const dataType = def.type; const dataType = def.type;
const type = resolveType(def); const type = resolveType(def.type); // ← pass only the type
if (type === "jsonb" && jsonbSchemas[field]) { if (type === "jsonb" && jsonbSchemas[field]) {
attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude)); attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude));
@@ -136,27 +138,35 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
if (type === "jsonb") continue; if (type === "jsonb") continue;
attributes.push({ attributes.push({
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field), name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
type, type,
field, 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 ──────────────────────────────────────── // ── Audit fields in correct sequence ────────────────────────────────────────
for (const { field, type } of auditSequence) { for (const { field, type } of auditSequence) {
if (exclude.includes(field)) continue; if (exclude.includes(field)) continue;
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
attributes.push({ sorted.push({
name: defaultTimestampLabels[field], name: defaultTimestampLabels[field],
type, type,
field, field,
options: {}, options: resolveOptions(rawAttrs[field]?.type),
}); });
} }
return attributes; return sorted;
} }
module.exports = { modelToAttributes }; module.exports = { modelToAttributes };
+43 -18
View File
@@ -1,12 +1,12 @@
// utils/paginate.util.js // utils/paginate.util.js
const { Sequelize } = require('sequelize'); const { Sequelize } = require('sequelize');
const { modelToAttributes } = require('./modelToAttributes'); const { modelToAttributes } = require('./modelToAttributes.util');
const { excludeJsonbPaths } = require('./excludeJsonbPaths'); const { excludeJsonbPaths } = require('./excludeJSONBPaths.util');
const { buildQuery } = require('./buildQuery.util'); const { buildQuery } = require('./buildQuery.util');
const PAGE_START = 1; const PAGE_START = 1;
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const MAX_LIMIT = 100; const MAX_LIMIT = 1000;
function safeParseJSON(value, fallback = []) { function safeParseJSON(value, fallback = []) {
try { 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 * Reusable paginated findAndCountAll
* *
@@ -58,30 +77,30 @@ function auditInclude(mdl_Users, parentAlias = 'User') {
*/ */
async function paginate(model, req, { async function paginate(model, req, {
excludeAttributes = [], excludeAttributes = [],
jsonbSchemas = {}, jsonbSchemas = {},
jsonbColumn = null, jsonbColumn = null,
findOptions = {}, findOptions = {},
auditOptions = null, // ← { mdl_Users, parentAlias } auditOptions = null, // ← { mdl_Users, parentAlias }
} = {}) { } = {}) {
const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); 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 limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT);
const offset = (page - PAGE_START) * limit; const offset = (page - PAGE_START) * limit;
const filters = safeParseJSON(req.query.filters); 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 topLevelExclude = excludeAttributes.filter((f) => !f.includes('.'));
const jsonbExclude = excludeAttributes.filter((f) => f.includes('.')); const jsonbExclude = excludeAttributes.filter((f) => f.includes('.'));
const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null; 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 ALLOWED_FIELDS = attributes.map((a) => a.field);
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS); const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);
// Build attribute includes: jsonb + audit subqueries + any extra from findOptions // Build attribute includes: jsonb + audit subqueries + any extra from findOptions
const baseIncludes = jsonbAttr ? [jsonbAttr] : []; const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
const auditAttrs = auditOptions const auditAttrs = auditOptions
? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes ? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes
: []; : [];
const extraIncludes = findOptions.attributes?.include ?? []; const extraIncludes = findOptions.attributes?.include ?? [];
@@ -91,7 +110,7 @@ async function paginate(model, req, {
const { count, rows } = await model.findAndCountAll({ const { count, rows } = await model.findAndCountAll({
...restFindOptions, ...restFindOptions,
where: { ...where, ...(restFindOptions.where ?? {}) }, where: { ...where, ...(restFindOptions.where ?? {}) },
order, order,
limit, limit,
offset, 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); const totalPages = Math.ceil(count / limit);
return { return {
data: rows, data,
pagination: { pagination: {
page, page,
limit, limit,
+14 -1
View File
@@ -67,4 +67,17 @@ const verifyRefreshToken = (token) =>
const hashToken = (token) => const hashToken = (token) =>
crypto.createHash('sha256').update(token).digest('hex'); crypto.createHash('sha256').update(token).digest('hex');
module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken }; /**
* 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 };