From bf48c9546718657015bac4a33eccc8d2da1c8fb6 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Sat, 23 May 2026 14:03:38 +0800 Subject: [PATCH] adjusted --- controllers/admin/user_groups.controller.js | 117 +++++++++++++------- controllers/admin/users.controller.js | 31 +++++- controllers/auth.controller.js | 56 +++++++--- models/users/user_groups.mdl.js | 46 +++++--- models/users/users.attributes.js | 12 +- 5 files changed, 175 insertions(+), 87 deletions(-) diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 11d0948..9431d44 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -5,6 +5,11 @@ * * Author: rgrgogu * Date Created: Oct. 6, 2025 + *********************************************************************************************************************************************************************** + * Change History: + * DATE AUTHOR LOG DESCRIPTION + * Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1 + * May 23, 2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper ***********************************************************************************************************************************************************************/ const sequelize = require('../../config/db.config'); const { Op, Sequelize } = require('sequelize'); @@ -12,24 +17,38 @@ 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 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 auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; +// ─── 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 generateGroupCode = async (name) => { + const slug = name.toUpperCase().trim().replace(/\s+/g, '-').replace(/[^A-Z0-9\-]/g, '').slice(0, 20); + 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.'); +}; // ─── GET ALL ────────────────────────────────────────────────────────────────── - exports.getGroups = async (req, res) => { try { const result = await paginate(mdl_UserGroups, req, { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed, - context: "list", + context: 'list', auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, }); @@ -41,7 +60,6 @@ exports.getGroups = async (req, res) => { }; // ─── GET ONE ────────────────────────────────────────────────────────────────── - exports.getGroup = async (req, res) => { try { const group = await mdl_UserGroups.findByPk(req.params.gid); @@ -52,13 +70,13 @@ exports.getGroup = async (req, res) => { jsonbSchemas: usersSchemas, jsonbColumn: 'personal_info', auditOptions: { mdl_Users, parentAlias: 'User' }, - context: "list", + context: 'list', findOptions: { include: [{ - model: mdl_UserGroupMembers, - where: { group_id: req.params.gid }, + model: mdl_UserGroupMembers, + where: { group_id: req.params.gid }, attributes: [], - required: true, + required: true, }], }, }); @@ -71,14 +89,25 @@ exports.getGroup = async (req, res) => { }; // ─── CREATE ─────────────────────────────────────────────────────────────────── - exports.createGroup = async (req, res) => { try { - const { name, description } = req.body; + 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, createdBy: req.user.user_id, + name, + description, + group_code: code, + createdBy: req.user.user_id, }); return R.success(res, 'Group created.', group, 201); @@ -89,15 +118,26 @@ exports.createGroup = async (req, res) => { }; // ─── 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 } = req.body; + 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(); @@ -109,11 +149,10 @@ exports.updateGroup = async (req, res) => { }; // ─── 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) 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 }); @@ -127,13 +166,10 @@ exports.deactivateGroup = async (req, res) => { }; // ─── 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); + 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(); @@ -147,14 +183,13 @@ exports.restoreGroup = async (req, res) => { }; // ─── 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 } }); + 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); @@ -180,15 +215,14 @@ exports.bulkDeactivateGroups = async (req, res) => { }; // ─── 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 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) @@ -213,14 +247,13 @@ exports.bulkRestoreGroups = async (req, res) => { }; // ─── ARCHIVED ───────────────────────────────────────────────────────────────── - exports.getArchivedGroups = async (req, res) => { try { const result = await paginate(mdl_UserGroups, req, { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed, - context: "archived", + context: 'archived', auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, findOptions: { paranoid: false, @@ -236,14 +269,12 @@ exports.getArchivedGroups = async (req, res) => { }; // ─── FIELD VALUES ───────────────────────────────────────────────────────────── - -exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, "GROUP", { - blockedFields: ["deletedAt"], +exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, 'GROUP', { + blockedFields: ['deletedAt'], paranoid: false, }); // ─── MEMBERSHIP ─────────────────────────────────────────────────────────────── - exports.getUsersNotInGroup = async (req, res) => { try { const { gid: group_id } = req.params; @@ -252,7 +283,7 @@ exports.getUsersNotInGroup = async (req, res) => { const memberIds = members.map((m) => m.user_id); const users = await mdl_Users.findAll({ - where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } }, + where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } }, attributes: [ 'user_id', [Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'], @@ -272,9 +303,9 @@ exports.getUsersInGroup = async (req, res) => { const group = await mdl_UserGroups.findByPk(group_id, { include: [{ - model: mdl_Users, - as: 'members', - through: { attributes: [] }, + model: mdl_Users, + as: 'members', + through: { attributes: [] }, attributes: [ 'user_id', [Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'], @@ -293,14 +324,14 @@ exports.getUsersInGroup = async (req, res) => { exports.addUserToGroup = async (req, res) => { try { const { gid: group_id } = req.params; - const { user_ids } = req.body; + 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)); + 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); @@ -325,7 +356,7 @@ exports.addUserToGroup = async (req, res) => { exports.removeUserFromGroup = async (req, res) => { try { const { gid: group_id } = req.params; - const { user_ids } = req.body; + const { user_ids } = req.body; if (!Array.isArray(user_ids) || !user_ids.length) return R.error(res, 'No users provided.', 400); diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 89351e7..599f1a9 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -21,7 +21,7 @@ const { paginate } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); const { getFieldValues } = require("../../utils/fieldValues.util"); -const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); +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']; @@ -34,8 +34,18 @@ exports.getUsers = async (req, res) => { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, jsonbColumn: 'personal_info', + computedAttributes: userComputed, context: "list", auditOptions: { mdl_Users, parentAlias: 'User' }, + findOptions: { + 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); @@ -312,17 +322,26 @@ exports.getUserFieldValues = getFieldValues(mdl_Users, "USER", { // ─── ARCHIVED ───────────────────────────────────────────────────────────────── +// ─── GET ARCHIVED USERS ─────────────────────────────────────────────────────── exports.getArchivedUsers = async (req, res) => { try { const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - context: "archived", - auditOptions: { mdl_Users, parentAlias: 'User' }, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + computedAttributes: userComputed, + context: 'archived', + auditOptions: { mdl_Users, parentAlias: 'User' }, findOptions: { paranoid: false, - where: { deletedAt: { [Op.ne]: null }, is_active: false }, + where: { deletedAt: { [Op.ne]: null }, is_active: false }, + include: [{ + model: mdl_UserGroups, + as: 'groups', + through: { attributes: [] }, + attributes: ['group_id', 'name', 'group_code'], + required: false, + }], }, }); diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index ba5bbde..8617f3f 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -28,6 +28,7 @@ const bcrypt = require('bcryptjs'); const sequelize = require('../config/db.config') 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 { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util'); const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); const sendEmail = require('../services/email.service'); @@ -53,32 +54,53 @@ const safeUser = (user, extraExclude = []) => { // ─── Register ────────────────────────────────────────────────────────────────── exports.register = async (req, res) => { const transaction = await sequelize.transaction(); - + try { - const { email, password } = req.body; - + const { email, password, personal_info, group_code } = req.body; + + // ── Duplicate check ─────────────────────────────────────────────────────── const existing = await mdl_Users.findOne({ where: { email } }); if (existing) return R.error(res, 'Email is already registered.', 409); - + + // ── Validate group_code if provided ─────────────────────────────────────── + let group = null; + if (group_code) { + group = await mdl_UserGroups.findOne({ + where: { group_code: group_code.toUpperCase().trim(), is_active: true }, + }); + if (!group) return R.error(res, 'Invalid or inactive group code.', 400); + } + + // ── Create user ─────────────────────────────────────────────────────────── const hashed = await bcrypt.hash(password, 12); - const otp = generateOTP(); - + const otp = generateOTP(); + const user = await mdl_Users.create({ email, - password: hashed, - otp_code: otp, + password: hashed, + otp_code: otp, otp_expires_at: getOTPExpiry(), - is_active: true, - is_verified: false, - reg_type: 'system', - acc_type: 'user', - createdBy: null, + is_active: true, + is_verified: false, + reg_type: 'system', + acc_type: 'user', + personal_info: personal_info ?? null, + createdBy: null, }, { transaction }); - - await sendEmail({ to: email, type: "OTP", data: { otp } }); - + + // ── Enroll into group ───────────────────────────────────────────────────── + if (group) { + await mdl_UserGroupMembers.create({ + group_id: group.group_id, + user_id: user.user_id, + createdBy: null, + }, { transaction }); + } + + await sendEmail({ to: email, type: 'OTP', data: { otp } }); + await transaction.commit(); - + return R.success(res, 'Registration successful. Please check your email for the OTP.', { email: user.email, }, 201); diff --git a/models/users/user_groups.mdl.js b/models/users/user_groups.mdl.js index dfcd438..d365fe1 100644 --- a/models/users/user_groups.mdl.js +++ b/models/users/user_groups.mdl.js @@ -7,6 +7,11 @@ * Author: rgrgogu * Date Created: Oct. 6, 2025 *********************************************************************************************************************************************************************** + * Change History: + * DATE AUTHOR LOG DESCRIPTION + * Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1 + * May 23, 2026 rgrgogu 002 Added group_code — unique invite code for self-registration + *********************************************************************************************************************************************************************** * HOW TO USE: * const { mdl_UserGroups, mdl_UserGroupMembers } = require('./models/user_groups.mdl'); ***********************************************************************************************************************************************************************/ @@ -16,10 +21,17 @@ const mdl_Users = require('./users.mdl'); // ─── UserGroups ──────────────────────────────────────────────────────────────── const mdl_UserGroups = sequelize.define('UserGroup', { - group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Group ID", order: 1, hidden: true }, - name: { type: DataTypes.STRING(50), allowNull: false, label: "Group Name", order: 2 }, - description: { type: DataTypes.TEXT, label: "Description", order: 3, hidden: true }, - is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 4 }, + group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Group ID", order: 1, hidden: true }, + name: { type: DataTypes.STRING(50), allowNull: false, label: "Group Name", order: 2 }, + /** + * group_code — unique, human-readable invite code distributed to users. + * Used in the self-registration URL: /register?group_code= + * Automatically uppercased via a beforeValidate hook below. + * Example: "SALES-2025", "ONBOARD-Q1" + */ + group_code: { type: DataTypes.STRING(50), allowNull: true, unique: true, label: "Group Code", order: 3 }, + description:{ type: DataTypes.TEXT, label: "Description", order: 4, hidden: true }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 5 }, // ── Audit trails ──────────────────────────────────────────────────────────── createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, @@ -28,14 +40,20 @@ const mdl_UserGroups = sequelize.define('UserGroup', { }, { tableName: 'user_groups', timestamps: true, - paranoid: true, + paranoid: true, + hooks: { + // Always store group_code in uppercase to make lookups case-insensitive + beforeValidate: (group) => { + if (group.group_code) group.group_code = group.group_code.toUpperCase().trim(); + }, + }, }); // ─── Junction: UserGroupMembers ──────────────────────────────────────────────── const mdl_UserGroupMembers = sequelize.define('UserGroupMember', { - group_id: { type: DataTypes.BIGINT, primaryKey: true }, - user_id: { type: DataTypes.BIGINT, primaryKey: true }, - joined_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + group_id: { type: DataTypes.BIGINT, primaryKey: true }, + user_id: { type: DataTypes.BIGINT, primaryKey: true }, + joined_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, // ── Audit trails ──────────────────────────────────────────────────────────── createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, @@ -44,12 +62,12 @@ const mdl_UserGroupMembers = sequelize.define('UserGroupMember', { }, { tableName: 'user_group_members', timestamps: true, - paranoid: true, + paranoid: true, }); // ─── Associations ────────────────────────────────────────────────────────────── -mdl_Users.belongsToMany(mdl_UserGroups, { through: mdl_UserGroupMembers, foreignKey: 'user_id', otherKey: 'group_id', as: 'groups' }); -mdl_UserGroups.belongsToMany(mdl_Users, { through: mdl_UserGroupMembers, foreignKey: 'group_id', otherKey: 'user_id', as: 'members', }); +mdl_Users.belongsToMany(mdl_UserGroups, { through: mdl_UserGroupMembers, foreignKey: 'user_id', otherKey: 'group_id', as: 'groups' }); +mdl_UserGroups.belongsToMany(mdl_Users, { through: mdl_UserGroupMembers, foreignKey: 'group_id', otherKey: 'user_id', as: 'members' }); mdl_UserGroups.hasMany(mdl_UserGroupMembers, { foreignKey: 'group_id' }); mdl_UserGroupMembers.belongsTo(mdl_UserGroups, { foreignKey: 'group_id' }); @@ -57,14 +75,12 @@ mdl_UserGroupMembers.belongsTo(mdl_UserGroups, { foreignKey: 'group_id' }); mdl_Users.hasMany(mdl_UserGroupMembers, { foreignKey: 'user_id' }); mdl_UserGroupMembers.belongsTo(mdl_Users, { foreignKey: 'user_id' }); -// models/users/user_groups.mdl.js — add at the bottom mdl_UserGroups.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' }); mdl_UserGroups.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' }); -mdl_UserGroups.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' }); +mdl_UserGroups.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' }); -// Self-referencing associations for audit fields mdl_Users.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' }); mdl_Users.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' }); -mdl_Users.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' }); +mdl_Users.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' }); module.exports = { mdl_UserGroups, mdl_UserGroupMembers }; \ No newline at end of file diff --git a/models/users/users.attributes.js b/models/users/users.attributes.js index 9a7d2eb..ed93882 100644 --- a/models/users/users.attributes.js +++ b/models/users/users.attributes.js @@ -47,12 +47,12 @@ const userExclude = [ ]; const computedAttributes = [ - // { - // key: "memberCount", - // label: "Members", - // type: "number", - // order: 99, // adjust order as needed - // }, + { + key: "groups", + label: "Groups", + type: "array", // tells the paginator this is a pre-joined association array + order: 5, + }, ]; module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes }; \ No newline at end of file