This commit is contained in:
rgrgogu
2026-05-07 23:35:22 +08:00
parent 09660d7503
commit 58ccecd28a
10 changed files with 370 additions and 53 deletions
+246 -29
View File
@@ -21,18 +21,22 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const sequelize = require('../../config/db.config') const sequelize = require('../../config/db.config')
const { Op, Sequelize } = require('sequelize') const { Op, Sequelize } = require('sequelize')
const bcrypt = require('bcryptjs')
const crypto = require('crypto');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const sendEmail = require('../../services/email.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate, auditInclude } = require('../../utils/paginate.util'); const { paginate, auditInclude } = require('../../utils/paginate.util');
const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: usersComputed } = require('../../models/users/users.attributes');
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes'); const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at']; const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at'];
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
exports.getUsers = async (req, res) => { exports.getUsers = async (req, res) => {
@@ -70,6 +74,64 @@ exports.getUser = async (req, res) => {
} }
}; };
// ─── POST add staff user ───────────────────────────────────────────────────────
exports.addStaffUser = async (req, res) => {
try {
const { email, personal_info = {} } = req.body; // ← password removed from body
if (!email)
return R.error(res, 'Email is required.', 400);
if (!personal_info?.name?.given_name || !personal_info?.name?.last_name)
return R.error(res, 'First name and last name are required.', 400);
const existing = await mdl_Users.findOne({ where: { email } });
if (existing)
return R.error(res, 'Email is already in use.', 409);
// ── Auto-generate a temporary password ────────────────────────────────────
const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12);
const hashed = await bcrypt.hash(plainPassword, 12);
// ── Set password expiry (24 hours from now) ───────────────────────────────
const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const enriched = enrichPersonalInfo(personal_info);
const fullName = enriched?.name?.full_name ?? email;
const user = await mdl_Users.create({
email,
password: hashed,
password_expires_at: passwordExpiresAt, // ← requires column in DB
must_change_password: true, // ← force change on first login
acc_type: 'staff',
reg_type: 'system',
is_active: true,
is_verified: true,
createdBy: req.user.user_id,
personal_info: enriched,
});
await sendEmail({
to: email, type: "ADD_STAFF", data: {
name: fullName,
email,
password: plainPassword,
expiryHours: 24,
}
});
return R.success(res, 'Staff user created successfully.', {
user_id: user.user_id,
email: user.email,
acc_type: user.acc_type,
}, 201);
} catch (err) {
console.error('[ADMIN][ADD STAFF USER]', err);
return R.error(res, 'Could not create staff user.', 500);
}
};
// ─── PUT update any user ─────────────────────────────────────────────────────── // ─── PUT update any user ───────────────────────────────────────────────────────
exports.updateUser = async (req, res) => { exports.updateUser = async (req, res) => {
try { try {
@@ -259,21 +321,8 @@ exports.getGroups = async (req, res) => {
const result = await paginate(mdl_UserGroups, req, { const result = await paginate(mdl_UserGroups, req, {
excludeAttributes: groupExclude, excludeAttributes: groupExclude,
jsonbSchemas: groupSchemas, jsonbSchemas: groupSchemas,
computedAttributes: groupComputed, // ← just pass it, paginate handles the rest
auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
findOptions: {
attributes: {
include: [
[
Sequelize.literal(`(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "user_group_members"
WHERE "user_group_members"."group_id" = "UserGroup"."group_id"
)`),
'memberCount',
],
],
},
},
}); });
return R.success(res, 'Groups retrieved.', result); return R.success(res, 'Groups retrieved.', result);
@@ -289,19 +338,16 @@ exports.getGroup = async (req, res) => {
if (!group) return R.error(res, 'Group not found.', 404); if (!group) return R.error(res, 'Group not found.', 404);
const members = await paginate(mdl_Users, req, { const members = await paginate(mdl_Users, req, {
excludeAttributes: usersExclude, excludeAttributes: groupExclude,
jsonbSchemas: usersSchemas, jsonbSchemas: groupSchemas,
jsonbColumn: 'personal_info', computedAttributes: groupComputed,
auditOptions: { mdl_Users, parentAlias: 'User' }, auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
findOptions: { findOptions: {
include: [ paranoid: false,
{ where: {
model: mdl_UserGroupMembers, deletedAt: { [Op.ne]: null },
where: { group_id: req.params.gid }, is_active: false,
attributes: [], },
required: true,
},
],
}, },
}); });
@@ -649,4 +695,175 @@ exports.getArchivedUsers = async (req, res) => {
console.error('[ADMIN][GET ARCHIVED USERS]', err); console.error('[ADMIN][GET ARCHIVED USERS]', err);
return R.error(res, 'Could not retrieve archived users.', 500); return R.error(res, 'Could not retrieve archived users.', 500);
} }
};
// ─── BULK DEACTIVATE groups ───────────────────────────────────────────────────
exports.bulkDeactivateGroups = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0)
return R.error(res, 'No group IDs provided.', 400);
const groups = await mdl_UserGroups.findAll({
where: { group_id: ids },
});
if (!groups.length)
return R.error(res, 'No groups found.', 404);
const activeGroups = groups.filter((g) => g.is_active && !g.deletedAt);
if (!activeGroups.length)
return R.error(res, 'All selected groups are already deactivated.', 400);
const activeIds = activeGroups.map((g) => g.group_id);
await mdl_UserGroups.update(
{ is_active: false, deletedBy: req.user.user_id },
{ where: { group_id: activeIds } }
);
await mdl_UserGroups.destroy({
where: { group_id: activeIds },
});
return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, {
deactivated_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err);
return R.error(res, 'Could not deactivate groups.', 500);
}
};
// ─── BULK RESTORE groups ──────────────────────────────────────────────────────
exports.bulkRestoreGroups = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0)
return R.error(res, 'No group IDs provided.', 400);
const groups = await mdl_UserGroups.findAll({
where: { group_id: ids },
paranoid: false,
});
if (!groups.length)
return R.error(res, 'No groups found.', 404);
const deletedGroups = groups.filter((g) => g.deletedAt);
if (!deletedGroups.length)
return R.error(res, 'All selected groups are already active.', 400);
const deletedIds = deletedGroups.map((g) => g.group_id);
await mdl_UserGroups.restore({ where: { group_id: deletedIds } });
await mdl_UserGroups.update(
{ is_active: true, updatedBy: req.user.user_id, deletedBy: null },
{ where: { group_id: deletedIds }, paranoid: false }
);
return R.success(res, `${deletedIds.length} group(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][BULK RESTORE GROUPS]', err);
return R.error(res, 'Could not restore groups.', 500);
}
};
exports.getArchivedGroups = async (req, res) => {
try {
const result = await paginate(mdl_UserGroups, req, {
excludeAttributes: groupExclude,
jsonbSchemas: groupSchemas,
computedAttributes: groupComputed, // ← just pass it, paginate handles the rest
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
findOptions: {
paranoid: false,
where: {
deletedAt: { [Op.ne]: null },
is_active: false,
},
},
});
return R.success(res, 'Archived groups retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET ARCHIVED GROUPS]', err);
return R.error(res, 'Could not retrieve archived groups.', 500);
}
};
// ─── GET group field values ───────────────────────────────────────────────────
exports.getGroupFieldValues = async (req, res) => {
try {
const { field } = req.query;
if (!field) return R.error(res, 'Field is required.', 400);
const allowedFields = Object.keys(mdl_UserGroups.rawAttributes).filter(
(f) => !['deletedAt'].includes(f)
);
const dateFields = ['createdAt', 'updatedAt', 'deletedAt'];
if (!field.includes('.')) {
if (!allowedFields.includes(field))
return R.error(res, 'Invalid or restricted field.', 400);
// ─── Audit By fields — return names instead of IDs ───────────────────
if (auditByFields.includes(field)) {
const [rows] = await sequelize.query(`
SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value
FROM user_groups g
JOIN users u ON u.user_id = g."${field}"
WHERE g."${field}" IS NOT NULL
AND u."personal_info"->'name'->>'full_name' IS NOT NULL
ORDER BY value ASC
`);
const values = rows.map((r) => r.value).filter(Boolean);
return R.success(res, 'Field values retrieved.', values);
}
// ─── Date fields ──────────────────────────────────────────────────────
if (dateFields.includes(field)) {
const results = await mdl_UserGroups.findAll({
attributes: [
[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value'],
],
where: { [field]: { [Op.ne]: null } },
order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']],
paranoid: false,
raw: true,
});
const values = results.map((r) => r.value).filter(Boolean);
return R.success(res, 'Field values retrieved.', values);
}
// ─── Regular fields ───────────────────────────────────────────────────
const results = await mdl_UserGroups.findAll({
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']],
where: { [field]: { [Op.ne]: null } },
paranoid: false,
raw: true,
});
const values = results.map((r) => r.value).filter(Boolean).sort();
return R.success(res, 'Field values retrieved.', values);
}
return R.error(res, 'JSONB fields are not supported for groups.', 400);
} catch (err) {
console.error('[ADMIN][GET GROUP FIELD VALUES]', err);
return R.error(res, 'Could not retrieve field values.', 500);
}
}; };
+2 -1
View File
@@ -33,7 +33,7 @@ const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util')
const sendEmail = require('../services/email.service'); const sendEmail = require('../services/email.service');
const R = require('../utils/response.util'); const R = require('../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy']; const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
// ─── Helpers ─────────────────────────────────────────────────────────────────── // ─── Helpers ───────────────────────────────────────────────────────────────────
const buildLoginInfo = (req) => ({ const buildLoginInfo = (req) => ({
@@ -264,6 +264,7 @@ exports.refreshToken = async (req, res) => {
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) }); return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
} catch (err) { } catch (err) {
console.error('[AUTH] refresh token error:', err);
return R.error(res, 'Invalid or expired refresh token.', 401); return R.error(res, 'Invalid or expired refresh token.', 401);
} }
}; };
+35
View File
@@ -78,4 +78,39 @@ export const emailTemplates = {
<p style="margin-top:16px">Kindly ensure completion within the specified timeframe.</p> <p style="margin-top:16px">Kindly ensure completion within the specified timeframe.</p>
`, `,
}), }),
ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({
subject: "Your Staff Account Has Been Created - STARR System",
title: "Staff Account Created",
body: `
<p>Dear ${name},</p>
<p>Your staff account has been successfully created in the STARR System.
Below are your login credentials:</p>
<div style="margin:24px 0;padding:16px;border:1px solid #e5e7eb;border-radius:6px;background:#f9fafb">
<table style="width:100%;font-size:14px">
<tr>
<td style="padding:6px 0;color:#6b7280;width:100px"><strong>Email</strong></td>
<td style="padding:6px 0">${email}</td>
</tr>
<tr>
<td style="padding:6px 0;color:#6b7280"><strong>Password</strong></td>
<td style="padding:6px 0;font-size:18px;color:#1d4ed8">${password}</td>
</tr>
</table>
</div>
<p>⚠️ This temporary password is valid for <strong>${expiryHours} hours</strong>.
You will be required to change it upon first login.</p>
<p>If you did not request this account or believe this was created in error,
please contact your administrator immediately to have it deactivated.</p>
<p style="margin-top:16px;font-size:13px;color:#6b7280">
For security, please do not share your credentials with anyone.
If you need a new password, contact your administrator.
</p>
`,
}),
}; };
+17 -2
View File
@@ -1,5 +1,5 @@
const excludeAttributes = [ const excludeAttributes = [
// Add here "description"
]; ];
const jsonbSchemas = { const jsonbSchemas = {
@@ -19,4 +19,19 @@ const userExclude = [
"deleted_at", "deleted_at",
]; ];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas }; const computedAttributes = [
{
key: "memberCount",
label: "Members",
type: "number",
order: 5,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "user_group_members"
WHERE "user_group_members"."group_id" = "UserGroup"."group_id"
AND "user_group_members"."deletedAt" IS NULL
)`,
},
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes };
+4 -4
View File
@@ -16,10 +16,10 @@ const mdl_Users = require('./users.mdl');
// ─── UserGroups ──────────────────────────────────────────────────────────────── // ─── UserGroups ────────────────────────────────────────────────────────────────
const mdl_UserGroups = sequelize.define('UserGroup', { const mdl_UserGroups = sequelize.define('UserGroup', {
group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Group ID", order: 1, hidden: true },
name: { type: DataTypes.STRING(50), allowNull: false }, name: { type: DataTypes.STRING(50), allowNull: false, label: "Group Name", order: 2 },
description: { type: DataTypes.TEXT }, description: { type: DataTypes.TEXT, label: "Description", order: 3 },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 4 },
// ── Audit trails ──────────────────────────────────────────────────────────── // ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
+11 -2
View File
@@ -1,5 +1,5 @@
const excludeAttributes = [ const excludeAttributes = [
"password", "otp_code", "otp_expires_at", "password", "otp_code", "otp_expires_at", 'must_change_password', 'password_expires_at',
"personal_info.name.given_name", "personal_info.name.given_name",
"personal_info.name.middle_name", "personal_info.name.middle_name",
"personal_info.name.last_name", "personal_info.name.last_name",
@@ -46,4 +46,13 @@ const userExclude = [
"deleted_at", "deleted_at",
]; ];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas }; const computedAttributes = [
// {
// key: "memberCount",
// label: "Members",
// type: "number",
// order: 99, // adjust order as needed
// },
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes };
+4
View File
@@ -42,6 +42,10 @@ const mdl_Users = sequelize.define('User', {
*/ */
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 },
// ── Password policy ─────────────────────────────────────────────────────────
must_change_password: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Must Change Password" },
password_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Password Expires At" },
// OTP fields (stored temporarily during verification flow) // OTP fields (stored temporarily during verification flow)
otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" }, otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" },
otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" }, otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" },
+5
View File
@@ -40,6 +40,7 @@ router.post('/users/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreUse
router.delete('/users/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateUsers); // ← before /:id router.delete('/users/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateUsers); // ← before /:id
router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id router.get('/users/field-values', usersCtrl.getUserFieldValues); // ← before /:id
router.get('/users/archived', usersCtrl.getArchivedUsers); // ← before /:id router.get('/users/archived', usersCtrl.getArchivedUsers); // ← before /:id
router.post('/users/staff', sensitiveOpsLimiter, usersCtrl.addStaffUser);
router.get('/users', usersCtrl.getUsers); router.get('/users', usersCtrl.getUsers);
router.get('/users/:id', usersCtrl.getUser); router.get('/users/:id', usersCtrl.getUser);
router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser); router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
@@ -51,6 +52,10 @@ router.get('/users/:id/sessions', usersCtrl.getUserSessions);
router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession); router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession);
// ─── Groups Management ─────────────────────────────────────────────────────────────── // ─── Groups Management ───────────────────────────────────────────────────────────────
router.get('/groups/field-values', usersCtrl.getGroupFieldValues); // ← before /:gid
router.post('/groups/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreGroups);
router.delete('/groups/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateGroups);
router.get('/groups/archived', usersCtrl.getArchivedGroups);
router.get('/groups', usersCtrl.getGroups); router.get('/groups', usersCtrl.getGroups);
router.get('/groups/:gid', usersCtrl.getGroup); router.get('/groups/:gid', usersCtrl.getGroup);
router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup); router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup);
+5 -9
View File
@@ -147,26 +147,22 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
}); });
} }
// ─── Sort all fields globally by order ─────────────────────────────────────
attributes.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
// ─── Strip order from final output (frontend doesn't need it) ──────────────
const sorted = attributes.map(({ order: _, ...rest }) => rest);
// ── Audit fields in correct sequence ──────────────────────────────────────── // ── Audit fields in correct sequence ────────────────────────────────────────
for (const { field, type } of auditSequence) { for (const { field, type, order } of auditSequence) {
if (exclude.includes(field)) continue; if (exclude.includes(field)) continue;
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
sorted.push({ attributes.push({
name: defaultTimestampLabels[field], name: defaultTimestampLabels[field],
type, type,
field, field,
order,
options: resolveOptions(rawAttrs[field]?.type), options: resolveOptions(rawAttrs[field]?.type),
}); });
} }
return sorted; // ── 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 }; module.exports = { modelToAttributes };
+41 -6
View File
@@ -80,7 +80,8 @@ async function paginate(model, req, {
jsonbSchemas = {}, jsonbSchemas = {},
jsonbColumn = null, jsonbColumn = null,
findOptions = {}, findOptions = {},
auditOptions = null, // ← { mdl_Users, parentAlias } auditOptions = null, // ← { mdl_Users, parentAlias },
computedAttributes = []
} = {}) { } = {}) {
const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); 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 limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT);
@@ -104,7 +105,18 @@ async function paginate(model, req, {
? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes ? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes
: []; : [];
const extraIncludes = findOptions.attributes?.include ?? []; const extraIncludes = findOptions.attributes?.include ?? [];
const mergedAttributeIncludes = [...baseIncludes, ...auditAttrs, ...extraIncludes];
// ← 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 { attributes: _attr, ...restFindOptions } = findOptions;
@@ -122,12 +134,35 @@ async function paginate(model, req, {
const data = rows const data = rows
.map((row) => row.toJSON?.() ?? row) .map((row) => row.toJSON?.() ?? row)
.map(auditIdToName); .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;
});
// ─── Flatten JSONB columns into dot-notation keys ──────────────────────────
const jsonbCols = jsonbColumn ? [jsonbColumn] : [];
const totalPages = Math.ceil(count / limit); 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 { return {
data, data,
pagination: { pagination: {
@@ -138,7 +173,7 @@ async function paginate(model, req, {
hasPrevPage: page > PAGE_START, hasPrevPage: page > PAGE_START,
hasNextPage: page < totalPages, hasNextPage: page < totalPages,
}, },
attributes, attributes: mergedAttributes,
}; };
} }