/*********************************************************************************************************************************************************************** * 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_Achievements = require('../../models/users/achievements.mdl'); const mdl_UserBans = require('../../models/users/user_bans.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl'); const { sendEmail } = require('../../services/email.service'); const trustedDevice = require('../../services/trustedDevice.service'); const { fmtDate } = require('../../utils/datetime.util'); const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); const { getFieldValues } = require("../../utils/fieldValues.util"); const { resolveUserAvatar } = require('../../utils/resolveAvatar.util'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes'); const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at']; const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; // ─── GROUPS FILTER ──────────────────────────────────────────────────────────── // // "groups" is a M2M association, not a real column on `users` — buildQuery's // generic Sequelize.col()-based filtering can't touch it. Pull any `groups` // filter out of the request's filter list (so paginate() never sees a field // it can't resolve) and turn it into an EXISTS subquery instead. EXISTS keeps // the LEFT JOIN group data on each row intact (all of a user's groups still // show up), while only matching users who belong to at least one of the // selected groups. function extractGroupsFilter(req) { let filters = []; try { filters = JSON.parse(req.query.filters || '[]'); } catch { filters = []; } const groupsFilter = filters.find((f) => f.id === 'groups'); const remaining = filters.filter((f) => f.id !== 'groups'); req.query.filters = JSON.stringify(remaining); if (!groupsFilter?.value) return null; const groupIds = (Array.isArray(groupsFilter.value) ? groupsFilter.value : [groupsFilter.value]) .map((v) => parseInt(v, 10)) .filter((v) => !Number.isNaN(v)); return groupIds.length ? groupIds : null; } function groupsExistsWhere(groupIds) { if (!groupIds) return undefined; return Sequelize.literal(`EXISTS ( SELECT 1 FROM "user_group_members" ugm WHERE ugm.user_id = "User"."user_id" AND ugm."deletedAt" IS NULL AND ugm.group_id IN (${groupIds.join(',')}) )`); } // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getUsers = async (req, res) => { try { const groupIds = extractGroupsFilter(req); const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, jsonbColumn: 'personal_info', computedAttributes: userComputed, context: "list", auditOptions: { mdl_Users, parentAlias: 'User' }, findOptions: { where: groupsExistsWhere(groupIds), include: [{ model: mdl_UserGroups, as: 'groups', through: { attributes: [] }, // hide junction columns attributes: ['group_id', 'name', 'group_code'], required: false, // LEFT JOIN — users with no group still appear }], }, }); 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.', await resolveUserAvatar(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, }, }); logActivity(req.user.user_id, 'create_staff', { entityType: 'user', entityId: user.user_id, details: { email } }); 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); logActivity(req.user.user_id, 'update_user', { entityType: 'user', entityId: Number(req.params.id) }); 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_UserGroupMembers.update( { deletedBy: req.user.user_id }, { where: { user_id: req.params.id } } ); await mdl_UserGroupMembers.destroy({ where: { user_id: req.params.id } }); 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 } } ); await trustedDevice.revokeAllForUser(req.params.id); logActivity(req.user.user_id, 'deactivate_user', { entityType: 'user', entityId: Number(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_UserGroupMembers.update( { deletedBy: req.user.user_id }, { where: { user_id: activeIds } } ); await mdl_UserGroupMembers.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 } } ); await trustedDevice.revokeAllForUser(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 }); logActivity(req.user.user_id, 'restore_user', { entityType: 'user', entityId: Number(req.params.id) }); 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); } }; // ─── PERMANENT DELETE ───────────────────────────────────────────────────────── // // The live DB has several FKs on users.user_id that are NO ACTION rather than // the CASCADE the migration source claims (schema drift) — achievements, // quiz_attempts, user_tiers, and payments all block a hard delete unless their // rows for this user are removed first. Also null out the "who did this" // actor columns (granted_by/revoked_by/lifted_by) on OTHER users' records, // since those aren't this user's own data and shouldn't be deleted. // // UserBan.banned_by is NOT NULL (a ban record must always keep its banner), // so a user who has ever banned someone can't be nulled out or hard-deleted — // getBannerBlockedIds() below identifies those and callers must exclude them. async function purgeUserDependents(userIds, t) { await mdl_UserGroupMembers.destroy({ where: { user_id: userIds }, force: true, transaction: t }); await mdl_Achievements.destroy({ where: { user_id: userIds }, transaction: t }); await mdl_QuizAttempt.destroy({ where: { user_id: userIds }, transaction: t }); await mdl_UserTiers.destroy({ where: { user_id: userIds }, transaction: t }); await mdl_Payments.destroy({ where: { user_id: userIds }, transaction: t }); await mdl_Achievements.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t }); await mdl_UserTiers.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t }); await mdl_UserTiers.update({ revoked_by: null }, { where: { revoked_by: userIds }, transaction: t }); await mdl_UserBans.update({ lifted_by: null }, { where: { lifted_by: userIds }, transaction: t }); } async function getBannerBlockedIds(userIds, t) { const bans = await mdl_UserBans.findAll({ where: { banned_by: userIds }, attributes: ['banned_by'], group: ['banned_by'], transaction: t, }); return bans.map((b) => b.banned_by); } exports.permanentlyDeleteUser = async (req, res) => { const t = await sequelize.transaction(); try { if (Number(req.params.id) === req.user.user_id) { await t.rollback(); return R.error(res, 'You cannot permanently delete your own account.', 400); } const user = await mdl_Users.findOne({ where: { user_id: req.params.id }, paranoid: false, transaction: t, }); if (!user) { await t.rollback(); return R.error(res, 'User not found.', 404); } if (!user.deletedAt) { await t.rollback(); return R.error(res, 'User must be deactivated before it can be permanently deleted.', 400); } const bannerBlockedIds = await getBannerBlockedIds([user.user_id], t); if (bannerBlockedIds.length) { await t.rollback(); return R.error(res, 'Cannot delete: this user has banned other users, and ban records must keep their banner on file.', 400); } await purgeUserDependents([user.user_id], t); await user.destroy({ force: true, transaction: t }); await t.commit(); logActivity(req.user.user_id, 'permanently_delete_user', { entityType: 'user', entityId: Number(req.params.id) }); return R.success(res, 'User permanently deleted.'); } catch (err) { await t.rollback(); console.error('[ADMIN][PERMANENT DELETE USER]', err); return R.error(res, 'Could not permanently delete user.', 500); } }; // ─── BULK PERMANENT DELETE ──────────────────────────────────────────────────── exports.bulkPermanentlyDeleteUsers = async (req, res) => { const t = await sequelize.transaction(); try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) { await t.rollback(); return R.error(res, 'No user IDs provided.', 400); } if (ids.includes(req.user.user_id)) { await t.rollback(); return R.error(res, 'You cannot permanently delete your own account.', 400); } const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false, transaction: t }); if (!users.length) { await t.rollback(); return R.error(res, 'No users found.', 404); } const deletedUsers = users.filter((u) => u.deletedAt); if (!deletedUsers.length) { await t.rollback(); return R.error(res, 'All selected users must be deactivated before they can be permanently deleted.', 400); } const archivedIds = deletedUsers.map((u) => u.user_id); const bannerBlockedIds = await getBannerBlockedIds(archivedIds, t); const deletedIds = archivedIds.filter((id) => !bannerBlockedIds.includes(id)); if (!deletedIds.length) { await t.rollback(); return R.error(res, 'Cannot delete: all selected users have banned other users, and ban records must keep their banner on file.', 400); } await purgeUserDependents(deletedIds, t); await mdl_Users.destroy({ where: { user_id: deletedIds }, force: true, transaction: t }); await t.commit(); logActivity(req.user.user_id, 'bulk_permanently_delete_users', { entityType: 'user', details: { ids: deletedIds, count: deletedIds.length }, }); return R.success(res, `${deletedIds.length} user(s) permanently deleted.`, { deleted_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { await t.rollback(); console.error('[ADMIN][BULK PERMANENT DELETE USERS]', err); return R.error(res, 'Could not permanently delete 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 }, }); await trustedDevice.revokeBySessionId(session.session_id); logActivity(req.user.user_id, 'terminate_session', { entityType: 'session', entityId: session.session_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 ───────────────────────────────────────────────────────────── const getUserFieldValuesBase = getFieldValues(mdl_Users, "USER", { blockedFields: ["password", "otp_code", "otp_expires_at", "personal_info"], extraDateFields: ["modifiedAt", "ban_expires_at"], allowJsonb: true, selfJoin: true, }); // "groups" isn't a Users model attribute, so the generic getFieldValues() // helper can't resolve it — special-case it here, delegate everything else. exports.getUserFieldValues = async (req, res) => { if (req.query.field === 'groups') { const groups = await mdl_UserGroups.findAll({ attributes: [['group_id', 'value'], ['name', 'label']], order: [['name', 'ASC']], raw: true, }); return R.success(res, 'Field values retrieved.', groups); } return getUserFieldValuesBase(req, res); }; // ─── ARCHIVED ───────────────────────────────────────────────────────────────── // ─── GET ARCHIVED USERS ─────────────────────────────────────────────────────── exports.getArchivedUsers = async (req, res) => { try { const groupIds = extractGroupsFilter(req); const archivedWhere = { deletedAt: { [Op.ne]: null }, is_active: false }; const groupsWhere = groupsExistsWhere(groupIds); const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, jsonbColumn: 'personal_info', computedAttributes: userComputed, context: 'archived', auditOptions: { mdl_Users, parentAlias: 'User' }, findOptions: { paranoid: false, where: groupsWhere ? { [Op.and]: [archivedWhere, groupsWhere] } : archivedWhere, include: [{ model: mdl_UserGroups, as: 'groups', through: { attributes: [] }, attributes: ['group_id', 'name', 'group_code'], required: 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); } }; // ─── GET USER ACHIEVEMENTS ──────────────────────────────────────────────────── exports.getUserAchievements = async (req, res) => { try { const { id } = req.params; const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] }); if (!user) return R.error(res, 'User not found.', 404); const achievements = await mdl_Achievements.findAll({ where: { user_id: id }, order: [['granted_at', 'DESC']], }); return R.success(res, 'Achievements retrieved.', achievements); } catch (err) { console.error('[ADMIN][GET USER ACHIEVEMENTS]', err); return R.error(res, 'Could not retrieve achievements.', 500); } }; // ─── BAN USER ───────────────────────────────────────────────────────────────── exports.banUser = async (req, res) => { try { const { id } = req.params; if (Number(id) === req.user.user_id) return R.error(res, 'You cannot ban your own account.', 400); const user = await mdl_Users.findByPk(id); if (!user) return R.error(res, 'User not found.', 404); if (user.is_banned) return R.error(res, 'User is already banned.', 400); const { reason, ban_type, expires_at } = req.body; if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400); if (!['temporary', 'permanent'].includes(ban_type)) return R.error(res, 'Invalid ban type. Must be "temporary" or "permanent".', 400); if (ban_type === 'temporary' && !expires_at) return R.error(res, 'Expiry date is required for temporary bans.', 400); if (ban_type === 'temporary' && new Date(expires_at) <= new Date()) return R.error(res, 'Expiry date must be in the future.', 400); const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null; await sequelize.transaction(async (t) => { await mdl_UserBans.create({ user_id: id, banned_by: req.user.user_id, reason: reason.trim(), ban_type, banned_at: new Date(), expires_at: banExpiresAt, }, { transaction: t }); await user.update({ is_banned: true, ban_expires_at: banExpiresAt }, { transaction: t }); await mdl_UserSessions.update( { is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } }, { where: { user_id: id }, transaction: t } ); await trustedDevice.revokeAllForUser(id); }); logActivity(req.user.user_id, 'ban_user', { entityType: 'user', entityId: Number(id), details: { reason: reason.trim(), ban_type, expires_at: banExpiresAt }, }); sendEmail({ to: user.email, type: 'BANNED', data: { name: user.personal_info?.name?.full_name ?? 'User', email: user.email, date: fmtDate(new Date()), reason: reason.trim(), ban_type, }, }).catch((err) => console.error('[ADMIN][BAN USER] Email failed:', err)); return R.success(res, 'User banned successfully.'); } catch (err) { console.error('[ADMIN][BAN USER]', err); return R.error(res, 'Could not ban user.', 500); } }; // ─── UNBAN USER ─────────────────────────────────────────────────────────────── exports.unbanUser = async (req, res) => { try { const { id } = req.params; const user = await mdl_Users.findByPk(id); if (!user) return R.error(res, 'User not found.', 404); if (!user.is_banned) return R.error(res, 'User is not currently banned.', 400); const { lift_reason } = req.body; const activeBan = await mdl_UserBans.findOne({ where: { user_id: id, is_lifted: false }, order: [['banned_at', 'DESC']], }); await sequelize.transaction(async (t) => { if (activeBan) { await activeBan.update({ is_lifted: true, lifted_at: new Date(), lifted_by: req.user.user_id, lift_reason: lift_reason?.trim() || null, }, { transaction: t }); } await user.update({ is_banned: false, ban_expires_at: null }, { transaction: t }); }); logActivity(req.user.user_id, 'unban_user', { entityType: 'user', entityId: Number(id) }); sendEmail({ to: user.email, type: 'BAN_LIFTED', data: { name: user.personal_info?.name?.full_name ?? 'User', email: user.email, date: fmtDate(new Date()), }, }).catch((err) => console.error('[ADMIN][UNBAN USER] Email failed:', err)); return R.success(res, 'User unbanned successfully.'); } catch (err) { console.error('[ADMIN][UNBAN USER]', err); return R.error(res, 'Could not unban user.', 500); } }; // ─── MAKE ADMIN ─────────────────────────────────────────────────────────────── exports.makeAdmin = async (req, res) => { try { const { id } = req.params; if (Number(id) === req.user.user_id) return R.error(res, 'You cannot change your own role.', 400); const user = await mdl_Users.findByPk(id); if (!user) return R.error(res, 'User not found.', 404); if (user.acc_type === 'admin') return R.error(res, 'User is already an administrator.', 400); await user.update({ acc_type: 'admin', updatedBy: req.user.user_id }); logActivity(req.user.user_id, 'make_admin', { entityType: 'user', entityId: Number(id) }); const fullName = user.personal_info?.name?.full_name ?? 'User'; sendEmail({ to: user.email, type: 'MADE_ADMIN', data: { name: fullName.split(' ')[0], email: user.email }, }).catch((err) => console.error('[ADMIN][MAKE ADMIN] Email failed:', err)); return R.success(res, 'User promoted to Administrator.'); } catch (err) { console.error('[ADMIN][MAKE ADMIN]', err); return R.error(res, 'Could not update user role.', 500); } }; // ─── DEMOTE ADMIN ───────────────────────────────────────────────────────────── exports.demoteAdmin = async (req, res) => { try { const { id } = req.params; if (Number(id) === req.user.user_id) return R.error(res, 'You cannot change your own role.', 400); const user = await mdl_Users.findByPk(id); if (!user) return R.error(res, 'User not found.', 404); if (user.acc_type !== 'admin') return R.error(res, 'User is not an administrator.', 400); await user.update({ acc_type: 'user', updatedBy: req.user.user_id }); logActivity(req.user.user_id, 'demote_admin', { entityType: 'user', entityId: Number(id) }); const fullName = user.personal_info?.name?.full_name ?? 'User'; sendEmail({ to: user.email, type: 'DEMOTED_ADMIN', data: { name: fullName.split(' ')[0], email: user.email }, }).catch((err) => console.error('[ADMIN][DEMOTE ADMIN] Email failed:', err)); return R.success(res, 'Administrator access removed.'); } catch (err) { console.error('[ADMIN][DEMOTE ADMIN]', err); return R.error(res, 'Could not update user role.', 500); } }; // ─── BULK BAN ───────────────────────────────────────────────────────────────── exports.bulkBanUsers = async (req, res) => { try { const { ids, reason, ban_type, expires_at } = 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 ban your own account.', 400); if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400); if (!['temporary', 'permanent'].includes(ban_type)) return R.error(res, 'Invalid ban type.', 400); if (ban_type === 'temporary' && !expires_at) return R.error(res, 'Expiry date is required for temporary bans.', 400); if (ban_type === 'temporary' && new Date(expires_at) <= new Date()) return R.error(res, 'Expiry date must be in the future.', 400); const users = await mdl_Users.findAll({ where: { user_id: ids } }); if (!users.length) return R.error(res, 'No users found.', 404); const unbannedUsers = users.filter((u) => !u.is_banned); if (!unbannedUsers.length) return R.error(res, 'All selected users are already banned.', 400); const targetIds = unbannedUsers.map((u) => u.user_id); const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null; const now = new Date(); await sequelize.transaction(async (t) => { await mdl_UserBans.bulkCreate( targetIds.map((uid) => ({ user_id: uid, banned_by: req.user.user_id, reason: reason.trim(), ban_type, banned_at: now, expires_at: banExpiresAt, })), { transaction: t } ); await mdl_Users.update( { is_banned: true, ban_expires_at: banExpiresAt }, { where: { user_id: targetIds }, transaction: t } ); await mdl_UserSessions.update( { is_active: false, logout_info: { date: now.toISOString(), forced_by: req.user.user_id } }, { where: { user_id: targetIds }, transaction: t } ); await trustedDevice.revokeAllForUser(targetIds); }); const dateStr = fmtDate(new Date()); unbannedUsers.forEach((u) => { sendEmail({ to: u.email, type: 'BANNED', data: { name: u.personal_info?.name?.full_name ?? 'User', email: u.email, date: dateStr, reason: reason.trim(), ban_type, }, }).catch((err) => console.error('[ADMIN][BULK BAN] Email failed:', u.email, err)); }); return R.success(res, `${targetIds.length} user(s) banned successfully.`, { banned_ids: targetIds, skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))), }); } catch (err) { console.error('[ADMIN][BULK BAN USERS]', err); return R.error(res, 'Could not ban users.', 500); } }; // ─── GET USER BAN HISTORY ───────────────────────────────────────────────────── exports.getUserBans = async (req, res) => { try { const { id } = req.params; const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] }); if (!user) return R.error(res, 'User not found.', 404); const bans = await mdl_UserBans.findAll({ where: { user_id: id }, include: [ { model: mdl_Users, as: 'banner', attributes: ['user_id', 'email', 'personal_info'] }, { model: mdl_Users, as: 'lifter', attributes: ['user_id', 'email', 'personal_info'] }, ], order: [['banned_at', 'DESC']], }); return R.success(res, 'Ban history retrieved.', bans); } catch (err) { console.error('[ADMIN][GET USER BANS]', err); return R.error(res, 'Could not retrieve ban history.', 500); } }; // ─── BULK UNBAN ─────────────────────────────────────────────────────────────── exports.bulkUnbanUsers = async (req, res) => { try { const { ids, lift_reason } = 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 } }); if (!users.length) return R.error(res, 'No users found.', 404); const bannedUsers = users.filter((u) => u.is_banned); if (!bannedUsers.length) return R.error(res, 'None of the selected users are currently banned.', 400); const targetIds = bannedUsers.map((u) => u.user_id); const now = new Date(); await sequelize.transaction(async (t) => { await mdl_UserBans.update( { is_lifted: true, lifted_at: now, lifted_by: req.user.user_id, lift_reason: lift_reason?.trim() || null, }, { where: { user_id: targetIds, is_lifted: false }, transaction: t } ); await mdl_Users.update( { is_banned: false, ban_expires_at: null }, { where: { user_id: targetIds }, transaction: t } ); }); logActivity(req.user.user_id, 'bulk_unban_users', { entityType: 'user', details: { unban_ids: targetIds }, }); const dateStr = fmtDate(now); bannedUsers.forEach((u) => { sendEmail({ to: u.email, type: 'BAN_LIFTED', data: { name: u.personal_info?.name?.full_name ?? 'User', email: u.email, date: dateStr, }, }).catch((err) => console.error('[ADMIN][BULK UNBAN] Email failed:', u.email, err)); }); return R.success(res, `${targetIds.length} user(s) unbanned successfully.`, { unbanned_ids: targetIds, skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))), }); } catch (err) { console.error('[ADMIN][BULK UNBAN USERS]', err); return R.error(res, 'Could not unban users.', 500); } };