This commit is contained in:
rgrgogu
2026-05-05 23:18:47 +08:00
parent 5aeb959e92
commit a8f10a25d7
38 changed files with 5992 additions and 2 deletions
+81
View File
@@ -0,0 +1,81 @@
// utils/queryBuilder.js
const { Sequelize, Op } = require("sequelize");
/**
* Builds a Sequelize `where` clause from an array of filters.
*
* @param {Array<{ id: string, value: any }>} filters
* @returns {Object} Sequelize where clause
*/
function buildWhere(filters = [], allowedFields = new Set()) {
const where = [];
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];
const conditions = values.map((v) =>
id.startsWith("personal_info.")
? Sequelize.where(
Sequelize.json(`personal_info.${id.replace("personal_info.", "")}`),
{ [Op.iLike]: `%${v}%` }
)
: Sequelize.where(
Sequelize.cast(Sequelize.col(id), "TEXT"),
{ [Op.iLike]: `%${v}%` }
)
);
where.push({ [Op.or]: conditions });
}
return where.length ? { [Op.and]: where } : {};
}
/**
* Builds a Sequelize `order` clause from an array of sort descriptors.
* Falls back to [["createdAt", "DESC"]] if no valid sort entries.
*
* @param {Array<{ id: string, desc: boolean }>} sort
* @returns {Array} Sequelize order clause
*/
function buildOrder(sort = [], allowedFields = new Set()) {
const order = [];
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"]
);
}
return order.length ? order : [["createdAt", "DESC"]];
}
/**
* Convenience wrapper — returns both where and order in one call.
*
* @param {Array} filters
* @param {Array} sort
* @returns {{ where: Object, order: Array }}
*/
function buildQuery(filters = [], sort = [], allowedFields = []) {
const fieldSet = new Set(allowedFields);
return {
where: buildWhere(filters, fieldSet),
order: buildOrder(sort, fieldSet),
};
}
module.exports = { buildWhere, buildOrder, buildQuery };
+73
View File
@@ -0,0 +1,73 @@
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 };
+162
View File
@@ -0,0 +1,162 @@
// utils/modelToAttributes.js
const { DataTypes } = require("sequelize");
/**
* Maps Sequelize DataType to a simple UI type string.
*/
function resolveType(dataType) {
if (!dataType) return "text";
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";
return "text";
}
/**
* Resolves options (e.g. enum choices) from a Sequelize field definition.
*/
function resolveOptions(dataType) {
if (!dataType) return {};
const type = dataType.constructor?.key || dataType.key || "";
if (type === "ENUM") {
return { choices: dataType.values ?? [] };
}
if (type === "BOOLEAN") {
return { choices: ["true", "false"] };
}
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());
}
/**
* Flattens JSONB field paths from a schema definition.
*
* @param {Object} jsonbSchema - e.g. { name: { given_name, full_name }, date_of_birth }
* @param {string} prefix - e.g. "personal_info"
* @returns {Array} flat attribute entries for each leaf path
*/
function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
const result = [];
for (const [key, value] of Object.entries(jsonbSchema)) {
const path = prefix ? `${prefix}.${key}` : key;
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: {},
});
}
}
return result;
}
/**
* Generates an attributes array from a Sequelize model + optional JSONB schema map.
*
* @param {Object} model - Sequelize model (e.g. mdl_Users)
* @param {Object} jsonbSchemas - map of JSONB field names to their schema definition
* e.g. { personal_info: { name: { full_name: "text", given_name: "text" }, date_of_birth: "date" } }
* @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"])
* @returns {Array} attributes array
*/
function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) {
const rawAttrs = model.rawAttributes || model.tableAttributes;
const attributes = [];
const defaultTimestampLabels = {
createdAt: "Created",
modifiedAt: "Modified",
updatedAt: "Modified",
deletedAt: "Deleted",
createdBy: "Created By",
updatedBy: "Modified By",
deletedBy: "Deleted By",
...timestampLabels,
};
// Audit fields excluded from normal loop — handled separately at the end
const auditFields = [
'createdAt', 'updatedAt', 'modifiedAt',
'deletedAt',
'createdBy', 'updatedBy', 'deletedBy',
];
// Ordered audit sequence
const auditSequence = [
{ 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' },
];
// ── Normal fields (excluding audit) ─────────────────────────────────────────
for (const [field, def] of Object.entries(rawAttrs)) {
if (exclude.includes(field)) continue;
if (auditFields.includes(field)) continue; // skip audit — added later in order
const dataType = def.type;
const type = resolveType(def);
if (type === "jsonb" && jsonbSchemas[field]) {
attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude));
continue;
}
if (type === "jsonb") continue;
attributes.push({
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
type,
field,
options: resolveOptions(def),
});
}
// ── 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],
type,
field,
options: {},
});
}
return attributes;
}
module.exports = { modelToAttributes };
+44
View File
@@ -0,0 +1,44 @@
/***********************************************************************************************************************************************************************
* File Name: otp.util.js
* Type of Program: Utility
* Description: One-Time Password (OTP) generation and validation helpers.
* - generateOTP() → 6-digit numeric string
* - getOTPExpiry() → Date object N minutes from now
* - isOTPExpired() → boolean check on the stored expiry
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
* user.otp_code = generateOTP();
* user.otp_expires_at = getOTPExpiry();
***********************************************************************************************************************************************************************/
const crypto = require('crypto');
/**
* Generates a cryptographically secure 6-digit OTP.
* @returns {string} e.g. "048291"
*/
const generateOTP = () => {
const bytes = crypto.randomBytes(3); // 3 bytes = 0–16777215
const num = bytes.readUIntBE(0, 3) % 1_000_000; // force to 0–999999
return num.toString().padStart(6, '0');
};
/**
* Returns a Date object N minutes in the future.
* @param {number} [minutes=10]
* @returns {Date}
*/
const getOTPExpiry = (minutes = Number(process.env.OTP_EXPIRY_MINUTES) || 10) => {
return new Date(Date.now() + minutes * 60 * 1000);
};
/**
* Checks whether the stored OTP has expired.
* @param {Date|string} expiresAt
* @returns {boolean}
*/
const isOTPExpired = (expiresAt) => !expiresAt || new Date() > new Date(expiresAt);
module.exports = { generateOTP, getOTPExpiry, isOTPExpired };
+120
View File
@@ -0,0 +1,120 @@
// utils/paginate.util.js
const { Sequelize } = require('sequelize');
const { modelToAttributes } = require('./modelToAttributes');
const { excludeJsonbPaths } = require('./excludeJsonbPaths');
const { buildQuery } = require('./buildQuery.util');
const PAGE_START = 1;
const PAGE_SIZE = 10;
const MAX_LIMIT = 100;
function safeParseJSON(value, fallback = []) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
/**
* Generates audit subquery attributes for createdBy, updatedBy, deletedBy
*
* @param {Object} mdl_Users - Users model
* @param {string} parentAlias - Sequelize model alias e.g. 'User', 'UserGroup'
* @returns {Array} - Sequelize attribute include array
*/
function auditInclude(mdl_Users, parentAlias = 'User') {
const tableName = mdl_Users.getTableName();
const fullNameSubquery = (foreignKey) =>
Sequelize.literal(`(
SELECT (u."personal_info"->>'name')::jsonb->>'full_name'
FROM "${tableName}" AS u
WHERE u."user_id" = "${parentAlias}"."${foreignKey}"
LIMIT 1
)`);
return {
attributes: [
[fullNameSubquery('createdBy'), 'createdByName'],
[fullNameSubquery('updatedBy'), 'updatedByName'],
[fullNameSubquery('deletedBy'), 'deletedByName'],
],
};
}
/**
* Reusable paginated findAndCountAll
*
* @param {Object} model - Sequelize model
* @param {Object} req - Express request
* @param {Object} options
* @param {string[]} options.excludeAttributes - fields to exclude
* @param {Object} options.jsonbSchemas - JSONB schema map
* @param {string} options.jsonbColumn - JSONB column name e.g. "personal_info"
* @param {Object} options.findOptions - extra Sequelize options (include, where, etc.)
* @param {Object} options.auditOptions - { mdl_Users, parentAlias } to auto-include audit subqueries
*/
async function paginate(model, req, {
excludeAttributes = [],
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 offset = (page - PAGE_START) * limit;
const filters = safeParseJSON(req.query.filters);
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 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
? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes
: [];
const extraIncludes = findOptions.attributes?.include ?? [];
const mergedAttributeIncludes = [...baseIncludes, ...auditAttrs, ...extraIncludes];
const { attributes: _attr, ...restFindOptions } = findOptions;
const { count, rows } = await model.findAndCountAll({
...restFindOptions,
where: { ...where, ...(restFindOptions.where ?? {}) },
order,
limit,
offset,
attributes: {
exclude: topLevelExclude,
include: mergedAttributeIncludes,
},
});
const totalPages = Math.ceil(count / limit);
return {
data: rows,
pagination: {
page,
limit,
totalRecords: count,
totalPages,
hasPrevPage: page > PAGE_START,
hasNextPage: page < totalPages,
},
attributes,
};
}
module.exports = { paginate, auditInclude, safeParseJSON };
+59
View File
@@ -0,0 +1,59 @@
// utils/personalInfo.util.js
/**
* Computes full_name, full_address, full_number
* from a personal_info object before saving to DB.
*
* @param {Object} personalInfo
* @returns {Object} enriched personal_info
*/
function enrichPersonalInfo(personalInfo = {}) {
if (!personalInfo || typeof personalInfo !== 'object') return personalInfo;
const pi = { ...personalInfo };
// ── Full Name ────────────────────────────────────────────────────────────────
if (pi.name) {
const { last_name, given_name, middle_name, extension_name } = pi.name;
pi.name = {
...pi.name,
full_name: [
last_name ? `${last_name},` : null,
given_name,
middle_name,
extension_name,
]
.filter((v) => v && v.trim() !== '')
.join(' '),
};
}
// ── Full Addresses ───────────────────────────────────────────────────────────
if (Array.isArray(pi.addresses)) {
pi.addresses = pi.addresses.map((addr) => ({
...addr,
full_address: [
addr.street,
addr.city,
addr.state,
addr.country,
addr.zip,
]
.filter((v) => v && v.trim() !== '')
.join(', '),
}));
}
// ── Full Phone Numbers ───────────────────────────────────────────────────────
if (Array.isArray(pi.phone_number)) {
pi.phone_number = pi.phone_number.map((phone) => ({
...phone,
full_number: `${phone.country_code || ''}${phone.number || ''}`,
}));
}
return pi;
}
module.exports = { enrichPersonalInfo };
+28
View File
@@ -0,0 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: response.util.js
* Type of Program: Utility
* Description: Standardised HTTP response helpers.
* Wraps all responses in a consistent envelope:
* { status, message, data?, errors? }
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const R = require('../utils/response.util');
* return R.success(res, 'User created', user, 201);
* return R.error(res, 'Not found', 404);
***********************************************************************************************************************************************************************/
const success = (res, message = 'OK', data = null, statusCode = 200) =>
res.status(statusCode).json({ status: 'success', message, data });
const error = (res, message = 'An error occurred', statusCode = 500, errors = null) => {
const body = { status: 'error', message };
if (errors) body.errors = errors;
return res.status(statusCode).json(body);
};
const validationError = (res, errors) =>
res.status(422).json({ status: 'error', message: 'Validation failed', errors });
module.exports = { success, error, validationError };
+70
View File
@@ -0,0 +1,70 @@
/***********************************************************************************************************************************************************************
* File Name: token.util.js
* Type of Program: Utility
* Description: JWT access & refresh token helpers.
* - generateTokens() → produces { accessToken, refreshToken }
* - verifyAccessToken() → validates and returns decoded payload
* - verifyRefreshToken() → validates the long-lived refresh token
* - hashToken() → SHA-256 hash used to store refresh tokens in DB
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { generateTokens, verifyAccessToken } = require('../utils/token.util');
* const { accessToken, refreshToken } = generateTokens(user);
***********************************************************************************************************************************************************************/
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
/**
* Generates a signed access token and a signed refresh token.
* @param {object} user - Sequelize User instance
* @returns {{ accessToken: string, refreshToken: string }}
*/
const generateTokens = (user) => {
const payload = {
user_id: user.user_id,
email: user.email,
acc_type: user.acc_type,
reg_type: user.reg_type,
};
const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
});
const refreshToken = jwt.sign(
{ user_id: user.user_id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
);
return { accessToken, refreshToken };
};
/**
* Verifies an access token.
* @param {string} token
* @returns {object} decoded payload
* @throws if invalid / expired
*/
const verifyAccessToken = (token) =>
jwt.verify(token, process.env.JWT_SECRET);
/**
* Verifies a refresh token.
* @param {string} token
* @returns {object} decoded payload
*/
const verifyRefreshToken = (token) =>
jwt.verify(token, process.env.JWT_REFRESH_SECRET);
/**
* SHA-256 hash a token string for safe DB storage.
* @param {string} token
* @returns {string} hex digest
*/
const hashToken = (token) =>
crypto.createHash('sha256').update(token).digest('hex');
module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken };