Files
starr-philproperties/apps/api/controllers/admin/users.controller.js
T

939 lines
37 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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);
}
};