mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user