// 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 "text"; 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, order: value?.order ?? Infinity, // ← carry order from schema hidden: value?.hidden ?? false, // ← carry hidden flag 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 = {}, context = "" } = {}) { 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", hiddenOnList: false, hiddenOnArchived: true }, { field: "modifiedAt", type: "date", hiddenOnList: false, hiddenOnArchived: true }, { field: "updatedBy", type: "text", hiddenOnList: false, hiddenOnArchived: true }, { field: "createdAt", type: "date", hiddenOnList: false, hiddenOnArchived: true }, { field: "createdBy", type: "text", hiddenOnList: false, hiddenOnArchived: true }, { field: "deletedAt", type: "date", hiddenOnList: true, hiddenOnArchived: false }, { field: "deletedBy", type: "text", hiddenOnList: true, hiddenOnArchived: false }, ]; // ── 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.type); // ← pass only the type 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, order: def.order ?? Infinity, // ← carry order from model hidden: def.hidden ?? false, // ← carry hidden flag options: resolveOptions(def.type) }); } // ── Audit fields in correct sequence ──────────────────────────────────────── for (const { field, type, order, hiddenOnList, hiddenOnArchived } of auditSequence) { if (exclude.includes(field)) continue; if (!rawAttrs[field]) continue; // skip if field doesn't exist on model const isArchived = context === "archived"; attributes.push({ name: defaultTimestampLabels[field], type, field, order, hidden: isArchived ? hiddenOnArchived : hiddenOnList, options: resolveOptions(rawAttrs[field]?.type), }); } // ── Sort by order — DO NOT strip order here, paginate.util does it ─────────── return attributes.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)); } module.exports = { modelToAttributes };