/*********************************************************************************************************************************************************************** * File Name: users.controller.js (admin) * Type of Program: Controller * Description: Admin-level user management — full CRUD on any user. * Admins can: list all users, view any user, update acc_type, * activate/deactivate, hard-delete, manage groups. * * Endpoints (require authenticate → requireAdmin()): * GET /api/admin/users → all users (paginated) * GET /api/admin/users/:id → any user * PUT /api/admin/users/:id → update acc_type, is_active, personal_info * DELETE /api/admin/users/:id → hard delete * POST /api/admin/users/:id/groups/:gid → add user to group * DELETE /api/admin/users/:id/groups/:gid → remove user from group * GET /api/admin/groups → list groups * POST /api/admin/groups → create group * DELETE /api/admin/groups/:gid → delete group * * Author: rgrgogu * Date Created: Oct. 6, 2025 ***********************************************************************************************************************************************************************/ const sequelize = require('../../config/db.config') const { Op, Sequelize } = require('sequelize') 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 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 EXCLUDED = ['password', 'otp_code', 'otp_expires_at']; const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; 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 single user ─────────────────────────────────────────────────────────── exports.getUser = async (req, res) => { try { const user = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED }, include: [{ model: mdl_UserGroups, as: 'groups', // ← matches the association alias 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); } }; // ─── PUT update any user ─────────────────────────────────────────────────────── 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); // Enrich personal_info before saving if (updates.personal_info) { updates.personal_info = enrichPersonalInfo(updates.personal_info); } updates.updatedBy = req.user.user_id; // ← who updated 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 user (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, // ← who deactivated }); 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 users ──────────────────────────────────────────────────── exports.bulkDeactivateUsers = async (req, res) => { try { const { ids } = req.body; // expects { ids: [1, 2, 3] } if (!Array.isArray(ids) || ids.length === 0) 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); // Filter only currently active users — skip already-deactivated ones 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); // Soft-delete + mark who deactivated them await mdl_Users.update( { is_active: false, deletedBy: req.user.user_id }, { where: { user_id: activeIds } } ); // Sequelize paranoid bulk destroy (sets deletedAt) await mdl_Users.destroy({ where: { user_id: activeIds }, }); // Force logout all their active sessions 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, // Inform caller if some were already inactive and got skipped 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 user ────────────────────────────────────────────────────────────── 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, // ← who restored 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 users ──────────────────────────────────────────────────────── exports.bulkRestoreUsers = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) 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); // Filter only soft-deleted users — skip already-active ones 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); } }; exports.getGroups = async (req, res) => { try { const result = await paginate(mdl_UserGroups, req, { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, 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); } catch (err) { console.error('[ADMIN][GET GROUPS]', err); return R.error(res, 'Could not retrieve groups.', 500); } }; exports.getGroup = async (req, res) => { try { const group = await mdl_UserGroups.findByPk(req.params.gid); 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' }, findOptions: { include: [ { model: mdl_UserGroupMembers, where: { group_id: req.params.gid }, attributes: [], required: true, }, ], }, }); return R.success(res, 'Group retrieved.', { group, members }); } catch (err) { console.error('[ADMIN][GET GROUP]', err); return R.error(res, 'Could not retrieve group.', 500); } }; exports.createGroup = async (req, res) => { try { const { name, description } = req.body; if (!name) return R.error(res, 'Group name is required.', 400); const group = await mdl_UserGroups.create({ name, description, createdBy: req.user.user_id, }); return R.success(res, 'Group created.', group, 201); } catch (err) { console.error('[ADMIN][CREATE GROUP]', err); return R.error(res, 'Could not create group.', 500); } }; exports.updateGroup = async (req, res) => { try { const { name, description } = req.body; const group = await mdl_UserGroups.findByPk(req.params.gid); if (!group) return R.error(res, 'Group not found.', 404); if (name !== undefined) group.name = name; if (description !== undefined) group.description = description; group.updatedBy = req.user.user_id; await group.save(); return R.success(res, 'Group updated.', group); } catch (err) { console.error('[ADMIN][UPDATE GROUP]', err); return R.error(res, 'Could not update group.', 500); } }; exports.deactivateGroup = async (req, res) => { try { const group = await mdl_UserGroups.findByPk(req.params.gid); if (!group) return R.error(res, 'Group not found.', 404); if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400); await group.update({ is_active: false, updatedBy: req.user.user_id, deletedBy: req.user.user_id, }); await group.destroy(); return R.success(res, 'Group deactivated.'); } catch (err) { console.error('[ADMIN][DEACTIVATE GROUP]', err); return R.error(res, 'Could not deactivate group.', 500); } }; exports.restoreGroup = async (req, res) => { try { const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false, // ← needed to find soft-deleted rows }); if (!group) return R.error(res, 'Group not found.', 404); if (group.is_active) return R.error(res, 'Group is already active.', 400); await group.restore(); await group.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null, }); return R.success(res, 'Group restored.'); } catch (err) { console.error('[ADMIN][RESTORE GROUP]', err); return R.error(res, 'Could not restore group.', 500); } }; // ─── Group membership ────────────────────────────────────────────────────────── // Get List of Members in a Group, to be added exports.getUsersNotInGroup = async (req, res) => { try { const { gid: group_id } = req.params; const members = await mdl_UserGroupMembers.findAll({ where: { group_id }, attributes: ['user_id'], }); const memberIds = members.map(m => m.user_id); const users = await mdl_Users.findAll({ where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] }, }, attributes: [ 'user_id', [Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'], ], }); return R.success(res, 'Users fetched.', users); } catch (err) { console.error('[ADMIN][GET USERS NOT IN GROUP]', err); return R.error(res, 'Could not fetch users.', 500); } }; // Get List of Members in a Group, to be removed exports.getUsersInGroup = async (req, res) => { try { const { gid: group_id } = req.params; const group = await mdl_UserGroups.findByPk(group_id, { include: [{ model: mdl_Users, as: 'members', through: { attributes: [] }, attributes: [ 'user_id', [Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'], ], }], }); if (!group) return R.error(res, 'Group not found.', 404); return R.success(res, 'Group members fetched.', group.members); } catch (err) { console.error('[ADMIN][GET USERS IN GROUP]', err); return R.error(res, 'Could not fetch group members.', 500); } }; exports.addUserToGroup = async (req, res) => { try { const { gid: group_id } = req.params; const { user_ids } = req.body; if (!Array.isArray(user_ids) || user_ids.length === 0) return R.error(res, 'No users provided.', 400); const existingUsers = await mdl_Users.findAll({ where: { user_id: user_ids }, attributes: ['user_id'], }); const existingUserIds = existingUsers.map(u => u.user_id); const notFound = user_ids.filter(id => !existingUserIds.includes(id)); if (notFound.length > 0) return R.error(res, `Users not found: ${notFound.join(', ')}`, 404); // Restore soft-deleted rows await mdl_UserGroupMembers.restore({ where: { user_id: user_ids, group_id }, }); await mdl_UserGroupMembers.update( { deletedBy: null, updatedBy: req.user.user_id }, { where: { user_id: user_ids, group_id }, paranoid: false } ); // Insert any that didn't exist at all await mdl_UserGroupMembers.bulkCreate( user_ids.map(user_id => ({ user_id, group_id, createdBy: req.user.user_id })), { ignoreDuplicates: true } ); return R.success(res, 'Users added to group.'); } catch (err) { console.error('[ADMIN][ADD USERS TO GROUP]', err); return R.error(res, 'Could not add users to group.', 500); } }; exports.removeUserFromGroup = async (req, res) => { try { const { gid: group_id } = req.params; const { user_ids } = req.body; if (!Array.isArray(user_ids) || user_ids.length === 0) return R.error(res, 'No users provided.', 400); const existingMembers = await mdl_UserGroupMembers.findAll({ where: { user_id: user_ids, group_id }, attributes: ['user_id'], }); const existingMemberIds = existingMembers.map(m => m.user_id); const notFound = user_ids.filter(id => !existingMemberIds.includes(id)); if (notFound.length > 0) return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404); await mdl_UserGroupMembers.update( { deletedBy: req.user.user_id }, { where: { user_id: user_ids, group_id } } ); await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_id }, }); return R.success(res, 'Users removed from group.'); } catch (err) { console.error('[ADMIN][REMOVE USERS FROM GROUP]', err); return R.error(res, 'Could not remove users from group.', 500); } }; // ─── Admin sessions management ───────────────────────────────────────────────── 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) { 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) { return R.error(res, 'Could not terminate session.', 500); } }; 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); // ─── Audit By fields — return names instead of IDs ─────────────────── 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 `); 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_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, }); const values = results.map(r => r.value).filter(Boolean); return R.success(res, 'Field values retrieved.', values); } // ─── Regular fields ─────────────────────────────────────────────────── const results = await mdl_Users.findAll({ attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], where: { [field]: { [Op.ne]: null } }, raw: true, }); const values = results.map(r => r.value).filter(Boolean).sort(); return R.success(res, 'Field values retrieved.', values); } // ─── JSONB dot-notation fields ───────────────────────────────────────── 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, }); const values = results.map(r => r.value).filter(Boolean).sort(); return R.success(res, 'Field values retrieved.', values); } catch (err) { console.error('[ADMIN][GET USER FIELD VALUES]', err); return R.error(res, 'Could not retrieve field values.', 500); } }; 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, // ← include soft-deleted rows where: { deletedAt: { [Op.ne]: null }, // ← only archived ones 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); } };