chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
+207
View File
@@ -0,0 +1,207 @@
// utils/paginate.util.js
const { Sequelize, Op } = 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();
// Falls back to email when full_name hasn't been filled in (e.g. a
// freshly self-registered account) — better than surfacing the raw
// numeric user_id, which is meaningless in the admin UI.
const fullNameSubquery = (foreignKey) =>
Sequelize.literal(`(
SELECT COALESCE(NULLIF((u."personal_info"->>'name')::jsonb->>'full_name', ''), u."email")
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 = [],
context = "list",
} = {}) {
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;
// The stripped jsonbAttr projection is aliased to the same name as the raw
// column (e.g. "personal_info") — without excluding the raw column too, it
// gets selected twice under the same alias, which Postgres/CockroachDB
// accept in a plain SELECT but reject as ambiguous the moment that alias is
// referenced in ORDER BY (e.g. sorting a jsonb-path column).
if (jsonbAttr && jsonbColumn && !topLevelExclude.includes(jsonbColumn)) {
topLevelExclude.push(jsonbColumn);
}
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context });
const ALLOWED_FIELDS = attributes.map((a) => a.field);
const computedFieldKeys = computedAttributes.map((c) => c.key);
const dateFieldKeys = attributes.filter((a) => a.type === 'date').map((a) => a.field);
// Sequelize aliases the main model's table with the model's name by default
// (e.g. `FROM "users" AS "User"`) — needed to qualify Sequelize.col()
// references so they don't collide with same-named columns on joined tables.
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS, computedFieldKeys, model.name, dateFieldKeys);
// 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;
// Nest rather than shallow-spread — `where` and `restFindOptions.where` can
// both carry an [Op.and] key (the same global Symbol), and spreading two
// objects that share a Symbol key silently drops the first one's value.
const combinedWhere = restFindOptions.where
? { [Op.and]: [where, restFindOptions.where] }
: where;
const { count, rows } = await model.findAndCountAll({
...restFindOptions,
where: combinedWhere,
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, filterable, hidden }) => ({
name: label ?? key,
type: type ?? 'text',
field: key,
order: ord ?? Infinity,
options: {},
filterable: filterable,
hidden: hidden ?? false,
}));
// ← 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 };