/*********************************************************************************************************************************************************************** * File Name: user_groups.controller.js (admin) * Type of Program: Controller * Description: Admin-level user group management — CRUD + membership. * * Author: rgrgogu * Date Created: Oct. 6, 2025 *********************************************************************************************************************************************************************** * Change History: * DATE AUTHOR LOG DESCRIPTION * Oct 06,2025 rgrgogu 001 Initial creation - STAR Phase 1 * May 23,2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper * Sept 24,2026 Kenneth Obsequio 003 Limit group_code format for long text. ***********************************************************************************************************************************************************************/ const sequelize = require('../../config/db.config'); const { Op, Sequelize } = require('sequelize'); const mdl_Users = require('../../models/users/users.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { getFieldValues } = require('../../utils/fieldValues.util'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes'); const logActivity = require('../../utils/logActivity.util'); const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../utils/defaultGroup.util'); // ─── Helper — generate a unique group code ──────────────────────────────────── /** * Generates a unique group_code in the format: -<4-char hex> * e.g. "SALES-A3F1", "ONBOARD-Q1-9C2D" * Retries up to 5 times in the unlikely event of a collision. */ const LIMITWORDS = new Set(['OF', 'THE', 'AND', 'FOR', 'TO', 'IN', 'A', 'AN']); const buildSlugGroupCode = (name, maxLen = 4) => { const words = name .toUpperCase() .trim() .split(/\s+/) .map(w => w.replace(/[^A-Z0-9]/g, '')) .filter(w => w.length > 0 && !LIMITWORDS.has(w)); if (words.length === 0) return 'GROUP'.slice(0, maxLen); // ensure fallback also respects cap // Single word -> just truncate it (e.g. "Sales" -> "SALE") if (words.length === 1) { return words[0].slice(0, maxLen); } // Multiple words -> take first letter of each const acronym = words.map(w => w[0]).join(''); // Guard against 1-letter acronyms (e.g. two 1-word-after-filtering edge cases) return acronym.length >= 2 ? acronym.slice(0, maxLen) : words[0].slice(0, maxLen); } /** * Generates a unique group_code in the format: -<4-char hex> * e.g. "GA-F2CA", "SALES-9C2D" * Retries up to 5 times in the unlikely event of a collision. */ const generateGroupCode = async (name) => { const slug = buildSlugGroupCode(name); for (let i = 0; i < 5; i++) { const suffix = Math.random().toString(16).slice(2, 6).toUpperCase(); const code = `${slug}-${suffix}`; const exists = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false }); if (!exists) return code; } throw new Error('Could not generate a unique group code after 5 attempts.'); }; // ─── Exports for unit testing ────────────────────────────────────────────── exports.__test__ = { buildSlugGroupCode, generateGroupCode }; // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getGroups = async (req, res) => { try { const result = await paginate(mdl_UserGroups, req, { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed, context: 'list', auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, }); return R.success(res, 'Groups retrieved.', result); } catch (err) { console.error('[ADMIN][GET GROUPS]', err); return R.error(res, 'Could not retrieve groups.', 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── 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' }, context: 'list', 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); } }; // ─── CREATE ─────────────────────────────────────────────────────────────────── exports.createGroup = async (req, res) => { try { const { name, description, group_code } = req.body; if (!name) return R.error(res, 'Group name is required.', 400); // Use provided group_code (uppercased via model hook), otherwise auto-generate const code = group_code ? group_code.toUpperCase().trim() : await generateGroupCode(name); // Check uniqueness explicitly so we return a clear error message const duplicate = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false }); if (duplicate) return R.error(res, `Group code "${code}" is already in use.`, 409); const group = await mdl_UserGroups.create({ name, description, group_code: code, createdBy: req.user.user_id, }); logActivity(req.user.user_id, 'create_group', { entityType: 'group', entityId: group.group_id, details: { name: group.name, group_code: group.group_code } }); 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); } }; // ─── UPDATE ─────────────────────────────────────────────────────────────────── exports.updateGroup = async (req, res) => { try { const group = await mdl_UserGroups.findByPk(req.params.gid); if (!group) return R.error(res, 'Group not found.', 404); const { name, description, group_code } = req.body; if (name !== undefined) group.name = name; if (description !== undefined) group.description = description; if (group_code !== undefined) { const code = group_code.toUpperCase().trim(); const duplicate = await mdl_UserGroups.findOne({ where: { group_code: code, group_id: { [Op.ne]: group.group_id } }, paranoid: false, }); if (duplicate) return R.error(res, `Group code "${code}" is already in use.`, 409); group.group_code = code; } group.updatedBy = req.user.user_id; await group.save(); logActivity(req.user.user_id, 'update_group', { entityType: 'group', entityId: group.group_id }); return R.success(res, 'Group updated.', group); } catch (err) { console.error('[ADMIN][UPDATE GROUP]', err); return R.error(res, 'Could not update group.', 500); } }; // ─── DEACTIVATE ─────────────────────────────────────────────────────────────── 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(); logActivity(req.user.user_id, 'deactivate_group', { entityType: 'group', entityId: group.group_id }); return R.success(res, 'Group deactivated.'); } catch (err) { console.error('[ADMIN][DEACTIVATE GROUP]', err); return R.error(res, 'Could not deactivate group.', 500); } }; // ─── RESTORE ────────────────────────────────────────────────────────────────── exports.restoreGroup = async (req, res) => { try { const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false }); 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 }); logActivity(req.user.user_id, 'restore_group', { entityType: 'group', entityId: group.group_id }); return R.success(res, 'Group restored.'); } catch (err) { console.error('[ADMIN][RESTORE GROUP]', err); return R.error(res, 'Could not restore group.', 500); } }; // ─── BULK DEACTIVATE ────────────────────────────────────────────────────────── exports.bulkDeactivateGroups = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No group IDs provided.', 400); const groups = await mdl_UserGroups.findAll({ where: { group_id: ids } }); if (!groups.length) return R.error(res, 'No groups found.', 404); const activeGroups = groups.filter((g) => g.is_active && !g.deletedAt); if (!activeGroups.length) return R.error(res, 'All selected groups are already deactivated.', 400); const activeIds = activeGroups.map((g) => g.group_id); await mdl_UserGroups.update( { is_active: false, deletedBy: req.user.user_id }, { where: { group_id: activeIds } } ); await mdl_UserGroups.destroy({ where: { group_id: activeIds } }); logActivity(req.user.user_id, 'bulk_deactivate_groups', { entityType: 'group', details: { ids: activeIds, count: activeIds.length } }); return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, { deactivated_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err); return R.error(res, 'Could not deactivate groups.', 500); } }; // ─── BULK RESTORE ───────────────────────────────────────────────────────────── exports.bulkRestoreGroups = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No group IDs provided.', 400); const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false }); if (!groups.length) return R.error(res, 'No groups found.', 404); const deletedGroups = groups.filter((g) => g.deletedAt); if (!deletedGroups.length) return R.error(res, 'All selected groups are already active.', 400); const deletedIds = deletedGroups.map((g) => g.group_id); await mdl_UserGroups.restore({ where: { group_id: deletedIds } }); await mdl_UserGroups.update( { is_active: true, updatedBy: req.user.user_id, deletedBy: null }, { where: { group_id: deletedIds }, paranoid: false } ); logActivity(req.user.user_id, 'bulk_restore_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } }); return R.success(res, `${deletedIds.length} group(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK RESTORE GROUPS]', err); return R.error(res, 'Could not restore groups.', 500); } }; // ─── PERMANENT DELETE ───────────────────────────────────────────────────────── exports.permanentlyDeleteGroup = async (req, res) => { try { const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false }); if (!group) return R.error(res, 'Group not found.', 404); if (!group.deletedAt) return R.error(res, 'Group must be deactivated before it can be permanently deleted.', 400); await group.destroy({ force: true }); logActivity(req.user.user_id, 'permanently_delete_group', { entityType: 'group', entityId: group.group_id }); return R.success(res, 'Group permanently deleted.'); } catch (err) { console.error('[ADMIN][PERMANENT DELETE GROUP]', err); return R.error(res, 'Could not permanently delete group.', 500); } }; // ─── BULK PERMANENT DELETE ──────────────────────────────────────────────────── exports.bulkPermanentlyDeleteGroups = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No group IDs provided.', 400); const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false }); if (!groups.length) return R.error(res, 'No groups found.', 404); const deletedGroups = groups.filter((g) => g.deletedAt); if (!deletedGroups.length) return R.error(res, 'All selected groups must be deactivated before they can be permanently deleted.', 400); const deletedIds = deletedGroups.map((g) => g.group_id); await mdl_UserGroups.destroy({ where: { group_id: deletedIds }, force: true }); logActivity(req.user.user_id, 'bulk_permanently_delete_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } }); return R.success(res, `${deletedIds.length} group(s) permanently deleted.`, { deleted_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK PERMANENT DELETE GROUPS]', err); return R.error(res, 'Could not permanently delete groups.', 500); } }; // ─── ARCHIVED ───────────────────────────────────────────────────────────────── exports.getArchivedGroups = async (req, res) => { try { const result = await paginate(mdl_UserGroups, req, { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed, context: 'archived', auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null }, is_active: false }, }, }); return R.success(res, 'Archived groups retrieved.', result); } catch (err) { console.error('[ADMIN][GET ARCHIVED GROUPS]', err); return R.error(res, 'Could not retrieve archived groups.', 500); } }; // ─── FIELD VALUES ───────────────────────────────────────────────────────────── exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, 'GROUP', { blockedFields: ['deletedAt'], paranoid: false, }); // ─── MEMBERSHIP ─────────────────────────────────────────────────────────────── exports.getUsersNotInGroup = async (req, res) => { try { const { gid: group_id } = req.params; // Exclude users already in THIS group 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] }, acc_type: 'user', // exclude staff/admin — only regular users can be added to a group }, attributes: [ 'user_id', [Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'], // All active groups this user belongs to (excluding NOGRP), comma-separated [ Sequelize.literal(`( SELECT STRING_AGG(ug.name || ' (' || ug.group_code || ')', ', ' ORDER BY ug.name) FROM user_group_members ugm JOIN user_groups ug ON ug.group_id = ugm.group_id WHERE ugm.user_id = "User".user_id AND ugm."deletedAt" IS NULL AND ug."deletedAt" IS NULL AND ug.group_code != 'NOGRP' )`), 'current_group', ], ], }); 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); } }; 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) return R.error(res, 'No users provided.', 400); const existingUsers = await mdl_Users.findAll({ where: { user_id: user_ids }, attributes: ['user_id'] }); const existingIds = existingUsers.map((u) => u.user_id); const notFound = user_ids.filter((id) => !existingIds.includes(id)); if (notFound.length) return R.error(res, `Users not found: ${notFound.join(', ')}`, 404); 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 } ); await mdl_UserGroupMembers.bulkCreate( user_ids.map((user_id) => ({ user_id, group_id, createdBy: req.user.user_id })), { ignoreDuplicates: true } ); await dropDefaultGroupMembership(user_ids, group_id, { updatedBy: req.user.user_id }); logActivity(req.user.user_id, 'add_user_to_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } }); 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) 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 existingIds = existingMembers.map((m) => m.user_id); const notFound = user_ids.filter((id) => !existingIds.includes(id)); if (notFound.length) 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 } }); await reconcileDefaultGroup(user_ids, { createdBy: req.user.user_id }); logActivity(req.user.user_id, 'remove_user_from_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } }); 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); } };