Files
starr-philproperties/controllers/admin/users.controller.js
T
2026-05-06 14:15:04 +08:00

527 lines
19 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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);
}
};
// ─── 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
});
return R.success(res, 'User restored successfully.');
} catch (err) {
console.error("[ADMIN][RESTORE USER]", err);
return R.error(res, 'Could not restore user.', 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);
}
};