// 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 };