// utils/queryBuilder.js const { Sequelize, Op } = require("sequelize"); // ─── Custom sort order for ENUM fields ──────────────────────────────────────── const ENUM_SORT_ORDER = { acc_type: ["admin", "staff", "user"], reg_type: ["system", "google"], }; // createdBy/updatedBy/deletedBy filter options come from getFieldValues() // as { value: user_id, label: full_name } — the filter sheet selects by id, // so these need an exact match against the bigint column, never the // substring-on-text-cast path used for everything else (that path compares // against the raw id and can never match a name-looking value). const AUDIT_ID_FIELDS = new Set(["createdBy", "updatedBy", "deletedBy"]); /** * Builds a Sequelize `where` clause from an array of filters. * * @param {Array<{ id: string, value: any }>} filters * @param {Set} allowedFields * @param {string} [parentAlias] - main model's table alias (e.g. "User"). Needed * to qualify Sequelize.col() references — unqualified col() refs throw * "column reference is ambiguous" as soon as a query joins another table * that happens to share a column name (e.g. Users + UserGroups both have * is_active/createdAt/updatedAt/deletedAt). * @param {Set} [dateFields] - fields whose picklist values are * calendar days (see below) — matched by range, not substring. * @returns {Object} Sequelize where clause */ function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null, dateFields = new Set()) { const where = []; for (const { id, value } of filters) { if (!id || value === undefined || value === null || value === "") continue; if (allowedFields.size && !allowedFields.has(id)) continue; const values = Array.isArray(value) ? value : [value]; if (AUDIT_ID_FIELDS.has(id)) { where.push({ [id]: { [Op.in]: values } }); continue; } const qualifiedCol = parentAlias ? `${parentAlias}.${id}` : id; // Date/timestamp columns (createdAt, updatedAt, ...) — the filter sheet's // picklist values are whole calendar days (e.g. "2026-07-16"), but the // column itself is a full timestamp. Casting the timestamp to TEXT and // doing a substring iLike match against just the date portion is a loose // match: it also picks up every OTHER row whose time-of-day component // happens to render into digits that appear elsewhere in the cast string, // so a single selected day can silently pull in unrelated rows. Match by // an explicit [dayStart, nextDayStart) range on the real column instead — // exact, and immune to how the DB happens to stringify the timestamp. if (dateFields.has(id) && !id.startsWith("personal_info.")) { const conditions = values.map((v) => { const datePart = String(v).slice(0, 10); const dayStart = new Date(`${datePart}T00:00:00.000Z`); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); return Sequelize.where(Sequelize.col(qualifiedCol), { [Op.gte]: dayStart, [Op.lt]: dayEnd, }); }); where.push({ [Op.or]: conditions }); continue; } 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(qualifiedCol), "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(), computedFields = new Set(), parentAlias = null) { const order = []; const qualifiedId = (id) => (parentAlias ? `"${parentAlias}"."${id}"` : `"${id}"`); for (const { id, desc } of sort) { if (!id) continue; if (allowedFields.size && !allowedFields.has(id)) continue; // ─── Computed (subquery/literal) columns — order by the unqualified // SELECT alias, since they aren't real columns on the model's table ── if (computedFields.has(id)) { order.push([Sequelize.literal(`"${id}"`), desc ? "DESC" : "ASC"]); continue; } // ─── ENUM fields — use CASE for custom sort order ───────────────────── if (ENUM_SORT_ORDER[id]) { const sequence = desc ? [...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 ${qualifiedId(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"]]; } /** * Convenience wrapper — returns both where and order in one call. * * @param {Array} filters * @param {Array} sort * @param {Array} [dateFields] - fields to match by day-range instead of substring * @returns {{ where: Object, order: Array }} */ function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = [], parentAlias = null, dateFields = []) { const fieldSet = new Set(allowedFields); const computedSet = new Set(computedFields); const orderFieldSet = new Set([...allowedFields, ...computedFields]); const dateFieldSet = new Set(dateFields); return { // Computed (subquery/literal) columns aren't real table columns — filtering // via Sequelize.col() would error, so only allow them in ORDER BY, not WHERE. where: buildWhere(filters, fieldSet, parentAlias, dateFieldSet), order: buildOrder(sort, orderFieldSet, computedSet, parentAlias), }; } module.exports = { buildWhere, buildOrder, buildQuery };