// utils/paginate.util.js const { Sequelize } = require('sequelize'); const { modelToAttributes } = require('./modelToAttributes.util'); const { excludeJsonbPaths } = require('./excludeJSONBPaths.util'); const { buildQuery } = require('./buildQuery.util'); const PAGE_START = 1; const PAGE_SIZE = 10; const MAX_LIMIT = 1000; 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'], ], }; } 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 * * @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 }, computedAttributes = [] } = {}) { 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 ?? []; // ← Convert { key, literal } objects into Sequelize [literal, alias] tuples const computedIncludes = computedAttributes .filter((c) => c.literal) .map((c) => [Sequelize.literal(c.literal), c.key]); const mergedAttributeIncludes = [ ...baseIncludes, ...auditAttrs, ...extraIncludes, ...computedIncludes, // ← now proper tuples ]; 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 data = rows .map((row) => row.toJSON?.() ?? row) .map(auditIdToName) .map((row) => { // ← Coerce computed types after toJSON const result = { ...row }; for (const { key, type } of computedAttributes) { if (result[key] !== undefined && result[key] !== null) { if (type === 'number') result[key] = parseInt(result[key], 10); if (type === 'float') result[key] = parseFloat(result[key]); } } return result; }); const totalPages = Math.ceil(count / limit); // ← Append computed metadata const computedMeta = computedAttributes.map(({ key, label, type, order: ord }) => ({ name: label ?? key, type: type ?? 'text', field: key, order: ord ?? Infinity, options: {}, })); // ← Merge, sort, THEN strip order const mergedAttributes = [...attributes, ...computedMeta] .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) .map(({ order: _, ...rest }) => rest); return { data, pagination: { page, limit, totalRecords: count, totalPages, hasPrevPage: page > PAGE_START, hasNextPage: page < totalPages, }, attributes: mergedAttributes, }; } module.exports = { paginate, auditInclude, safeParseJSON };