/*********************************************************************************************************************************************************************** * File Name: users.controller.js (admin) * Type of Program: Controller * Description: Admin-level user management — full CRUD on any user. * * Author: rgrgogu * Date Created: Oct. 6, 2025 ***********************************************************************************************************************************************************************/ 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 } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at']; const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getUsers = async (req, res) => { try { const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, jsonbColumn: 'personal_info', auditOptions: { mdl_Users, parentAlias: 'User' }, }); return R.success(res, 'Users retrieved.', result); } catch (err) { console.error('[ADMIN][GET ALL USERS]', err); return R.error(res, 'Could not retrieve users.', 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── exports.getUser = async (req, res) => { try { const { id } = req.params; if (!id || id === 'undefined') return R.error(res, 'Invalid User ID.', 400); const user = await mdl_Users.findByPk(id, { attributes: { exclude: EXCLUDED }, include: [{ model: mdl_UserGroups, as: 'groups', through: { attributes: [] }, }], }); if (!user) return R.error(res, 'User not found.', 404); return R.success(res, 'User retrieved.', user); } catch (err) { console.error('[ADMIN][GET USER]', err); return R.error(res, 'Could not retrieve user.', 500); } }; // ─── ADD STAFF ──────────────────────────────────────────────────────────────── exports.addStaffUser = async (req, res) => { try { const { email, personal_info = {} } = req.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); const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12); const hashed = await bcrypt.hash(plainPassword, 12); 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, must_change_password: true, 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); } }; // ─── UPDATE ─────────────────────────────────────────────────────────────────── exports.updateUser = async (req, res) => { try { const user = await mdl_Users.findByPk(req.params.id); if (!user) return R.error(res, 'User not found.', 404); const allowed = ['acc_type', 'is_active', 'personal_info']; const updates = {}; allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; }); if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined) return R.error(res, 'Admins cannot change their own role.', 400); if (updates.personal_info) updates.personal_info = enrichPersonalInfo(updates.personal_info); updates.updatedBy = req.user.user_id; await user.update(updates); const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } }); return R.success(res, 'User updated.', updated); } catch (err) { console.error('[ADMIN][UPDATE USER]', err); return R.error(res, 'Could not update user.', 500); } }; // ─── DEACTIVATE (soft delete) ───────────────────────────────────────────────── exports.deactivateUser = async (req, res) => { try { if (Number(req.params.id) === req.user.user_id) return R.error(res, 'You cannot deactivate your own account.', 400); const user = await mdl_Users.findByPk(req.params.id); if (!user) return R.error(res, 'User not found.', 404); if (!user.is_active && user.deletedAt) return R.error(res, 'User is already deactivated.', 400); await user.update({ is_active: false, deletedBy: req.user.user_id }); await user.destroy(); await mdl_UserSessions.update( { is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } }, { where: { user_id: req.params.id } } ); return R.success(res, 'User deactivated successfully.'); } catch (err) { console.error('[ADMIN][DEACTIVATE USER]', err); return R.error(res, 'Could not deactivate user.', 500); } }; // ─── BULK DEACTIVATE ────────────────────────────────────────────────────────── exports.bulkDeactivateUsers = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No user IDs provided.', 400); if (ids.includes(req.user.user_id)) return R.error(res, 'You cannot deactivate your own account.', 400); const users = await mdl_Users.findAll({ where: { user_id: ids } }); if (!users.length) return R.error(res, 'No users found.', 404); const activeUsers = users.filter((u) => u.is_active && !u.deletedAt); if (!activeUsers.length) return R.error(res, 'All selected users are already deactivated.', 400); const activeIds = activeUsers.map((u) => u.user_id); await mdl_Users.update( { is_active: false, deletedBy: req.user.user_id }, { where: { user_id: activeIds } } ); await mdl_Users.destroy({ where: { user_id: activeIds } }); await mdl_UserSessions.update( { is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } }, { where: { user_id: activeIds } } ); return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, { deactivated_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK DEACTIVATE USERS]', err); return R.error(res, 'Could not deactivate users.', 500); } }; // ─── RESTORE ────────────────────────────────────────────────────────────────── exports.restoreUser = async (req, res) => { try { const user = await mdl_Users.findOne({ where: { user_id: req.params.id }, paranoid: false, }); if (!user) return R.error(res, 'User not found.', 404); if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400); await user.restore(); await user.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null }); return R.success(res, 'User restored successfully.'); } catch (err) { console.error('[ADMIN][RESTORE USER]', err); return R.error(res, 'Could not restore user.', 500); } }; // ─── BULK RESTORE ───────────────────────────────────────────────────────────── exports.bulkRestoreUsers = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No user IDs provided.', 400); const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false }); if (!users.length) return R.error(res, 'No users found.', 404); const deletedUsers = users.filter((u) => u.deletedAt); if (!deletedUsers.length) return R.error(res, 'All selected users are already active.', 400); const deletedIds = deletedUsers.map((u) => u.user_id); await mdl_Users.restore({ where: { user_id: deletedIds } }); await mdl_Users.update( { is_active: true, updatedBy: req.user.user_id, deletedBy: null }, { where: { user_id: deletedIds }, paranoid: false } ); return R.success(res, `${deletedIds.length} user(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK RESTORE USERS]', err); return R.error(res, 'Could not restore users.', 500); } }; // ─── SESSIONS ───────────────────────────────────────────────────────────────── exports.getUserSessions = async (req, res) => { try { const sessions = await mdl_UserSessions.findAll({ where: { user_id: req.params.id }, attributes: { exclude: ['refresh_token_hash'] }, order: [['createdAt', 'DESC']], }); return R.success(res, 'Sessions retrieved.', sessions); } catch (err) { console.error('[ADMIN][GET USER SESSIONS]', err); return R.error(res, 'Could not retrieve sessions.', 500); } }; exports.terminateSession = async (req, res) => { try { const session = await mdl_UserSessions.findByPk(req.params.sid); if (!session) return R.error(res, 'Session not found.', 404); await session.update({ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id }, }); return R.success(res, 'Session terminated.'); } catch (err) { console.error('[ADMIN][TERMINATE SESSION]', err); return R.error(res, 'Could not terminate session.', 500); } }; // ─── FIELD VALUES ───────────────────────────────────────────────────────────── exports.getUserFieldValues = async (req, res) => { try { const { field } = req.query; if (!field) return R.error(res, 'Field is required.', 400); const allowedFields = Object.keys(mdl_Users.rawAttributes).filter( (f) => !['password', 'otp_code', 'otp_expires_at', 'personal_info'].includes(f) ); const dateFields = ['createdAt', 'updatedAt', 'deletedAt', 'modifiedAt']; if (!field.includes('.')) { if (!allowedFields.includes(field)) return R.error(res, 'Invalid or restricted field.', 400); if (auditByFields.includes(field)) { const [rows] = await sequelize.query(` SELECT DISTINCT u2."personal_info"->'name'->>'full_name' AS value FROM users u1 JOIN users u2 ON u2.user_id = u1."${field}" WHERE u1."${field}" IS NOT NULL AND u2."personal_info"->'name'->>'full_name' IS NOT NULL ORDER BY value ASC `); return R.success(res, 'Field values retrieved.', rows.map((r) => r.value).filter(Boolean)); } if (dateFields.includes(field)) { const results = await mdl_Users.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']], raw: true, }); return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean)); } const results = await mdl_Users.findAll({ attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], where: { [field]: { [Op.ne]: null } }, raw: true, }); return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort()); } // ─── JSONB dot-notation ─────────────────────────────────────────────── const [column, ...pathParts] = field.split('.'); const keys = [...pathParts]; const lastKey = keys.pop(); const jsonbPath = keys.length ? `"${column}"->${keys.map((k) => `'${k}'`).join('->')}->>'${lastKey}'` : `"${column}"->>'${lastKey}'`; const results = await mdl_Users.findAll({ attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']], where: Sequelize.literal(`${jsonbPath} IS NOT NULL`), raw: true, }); return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort()); } catch (err) { console.error('[ADMIN][GET USER FIELD VALUES]', err); return R.error(res, 'Could not retrieve field values.', 500); } }; // ─── ARCHIVED ───────────────────────────────────────────────────────────────── exports.getArchivedUsers = async (req, res) => { try { const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, jsonbColumn: 'personal_info', auditOptions: { mdl_Users, parentAlias: 'User' }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null }, is_active: false }, }, }); return R.success(res, 'Archived users retrieved.', result); } catch (err) { console.error('[ADMIN][GET ARCHIVED USERS]', err); return R.error(res, 'Could not retrieve archived users.', 500); } };