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 { Op, Sequelize } = require('sequelize')
const bcrypt = require('bcryptjs')
const crypto = require('crypto');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.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 { paginate, auditInclude } = require('../../utils/paginate.util');
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes');
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: usersComputed } = require('../../models/users/users.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'];
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 ───────────────────────────────────────────────────────
exports.updateUser = async (req, res) => {
try {
@@ -259,21 +321,8 @@ exports.getGroups = async (req, res) => {
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: {
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);
@@ -289,19 +338,16 @@ exports.getGroup = async (req, res) => {
if (!group) return R.error(res, 'Group not found.', 404);
const members = await paginate(mdl_Users, req, {
excludeAttributes: usersExclude,
jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info',
auditOptions: { mdl_Users, parentAlias: 'User' },
excludeAttributes: groupExclude,
jsonbSchemas: groupSchemas,
computedAttributes: groupComputed,
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
findOptions: {
include: [
{
model: mdl_UserGroupMembers,
where: { group_id: req.params.gid },
attributes: [],
required: true,
},
],
paranoid: false,
where: {
deletedAt: { [Op.ne]: null },
is_active: false,
},
},
});
@@ -649,4 +695,175 @@ exports.getArchivedUsers = async (req, res) => {
console.error('[ADMIN][GET ARCHIVED USERS]', err);
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 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 ───────────────────────────────────────────────────────────────────
const buildLoginInfo = (req) => ({
@@ -264,6 +264,7 @@ exports.refreshToken = async (req, res) => {
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
} catch (err) {
console.error('[AUTH] refresh token error:', err);
return R.error(res, 'Invalid or expired refresh token.', 401);
}
};