mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
adjusted
This commit is contained in:
+2
-1
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +33,7 @@ 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 {
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
// },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +40,7 @@ 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" },
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimi
|
|||||||
router.use(authenticate, requireAdmin(), adminLimiter);
|
router.use(authenticate, requireAdmin(), adminLimiter);
|
||||||
|
|
||||||
// ── User management ────────────────────────────────────────────────────────────
|
// ── User management ────────────────────────────────────────────────────────────
|
||||||
|
router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id
|
||||||
router.get('/users', usersCtrl.getUsers);
|
router.get('/users', usersCtrl.getUsers);
|
||||||
router.get('/users/:id', usersCtrl.getUser);
|
router.get('/users/:id', usersCtrl.getUser);
|
||||||
router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
|
router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
|
||||||
|
|||||||
@@ -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"]];
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
||||||
@@ -22,7 +22,7 @@ function resolveType(dataType) {
|
|||||||
* 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 || "";
|
||||||
|
|
||||||
@@ -70,6 +70,8 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
|
|||||||
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,
|
||||||
|
order: value?.order ?? Infinity, // ← carry order from schema
|
||||||
|
hidden: value?.hidden ?? false, // ← carry hidden flag
|
||||||
options: {},
|
options: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
@@ -139,24 +141,32 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
|
|||||||
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 };
|
||||||
+29
-4
@@ -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
|
||||||
*
|
*
|
||||||
@@ -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
@@ -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 };
|
||||||
Reference in New Issue
Block a user