diff --git a/apps/api/controllers/admin/user_groups.controller.js b/apps/api/controllers/admin/user_groups.controller.js index d0fc068..7de7134 100644 --- a/apps/api/controllers/admin/user_groups.controller.js +++ b/apps/api/controllers/admin/user_groups.controller.js @@ -7,21 +7,22 @@ * 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 + * 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_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 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: 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'); @@ -32,26 +33,60 @@ const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../uti * 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 = name.toUpperCase().trim().replace(/\s+/g, '-').replace(/[^A-Z0-9\-]/g, '').slice(0, 20); + 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 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, + excludeAttributes: groupExclude, + jsonbSchemas: groupSchemas, computedAttributes: groupComputed, - context: 'list', - auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, + context: 'list', + auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, }); return R.success(res, 'Groups retrieved.', result); @@ -69,16 +104,16 @@ exports.getGroup = async (req, res) => { const members = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, - context: 'list', + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + auditOptions: { mdl_Users, parentAlias: 'User' }, + 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, }], }, }); @@ -109,7 +144,7 @@ exports.createGroup = async (req, res) => { name, description, group_code: code, - createdBy: req.user.user_id, + 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 } }); @@ -128,11 +163,11 @@ exports.updateGroup = async (req, res) => { const { name, description, group_code } = req.body; - if (name !== undefined) group.name = name; + if (name !== undefined) group.name = name; if (description !== undefined) group.description = description; if (group_code !== undefined) { - const code = group_code.toUpperCase().trim(); + 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, @@ -156,7 +191,7 @@ exports.updateGroup = async (req, res) => { 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 }); @@ -174,7 +209,7 @@ exports.deactivateGroup = async (req, res) => { 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) return R.error(res, 'Group not found.', 404); if (group.is_active) return R.error(res, 'Group is already active.', 400); await group.restore(); @@ -195,7 +230,7 @@ exports.bulkDeactivateGroups = async (req, res) => { 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); @@ -213,7 +248,7 @@ exports.bulkDeactivateGroups = async (req, res) => { 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)), + skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err); @@ -228,8 +263,8 @@ exports.bulkRestoreGroups = async (req, res) => { 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) @@ -246,7 +281,7 @@ exports.bulkRestoreGroups = async (req, res) => { 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)), + skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK RESTORE GROUPS]', err); @@ -258,7 +293,7 @@ exports.bulkRestoreGroups = async (req, res) => { 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) 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 }); @@ -278,8 +313,8 @@ exports.bulkPermanentlyDeleteGroups = async (req, res) => { 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) @@ -304,14 +339,14 @@ exports.bulkPermanentlyDeleteGroups = async (req, res) => { exports.getArchivedGroups = async (req, res) => { try { const result = await paginate(mdl_UserGroups, req, { - excludeAttributes: groupExclude, - jsonbSchemas: groupSchemas, + excludeAttributes: groupExclude, + jsonbSchemas: groupSchemas, computedAttributes: groupComputed, - context: 'archived', - auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, + context: 'archived', + auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, findOptions: { paranoid: false, - where: { deletedAt: { [Op.ne]: null }, is_active: false }, + where: { deletedAt: { [Op.ne]: null }, is_active: false }, }, }); @@ -325,7 +360,7 @@ exports.getArchivedGroups = async (req, res) => { // ─── FIELD VALUES ───────────────────────────────────────────────────────────── exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, 'GROUP', { blockedFields: ['deletedAt'], - paranoid: false, + paranoid: false, }); // ─── MEMBERSHIP ─────────────────────────────────────────────────────────────── @@ -334,12 +369,12 @@ exports.getUsersNotInGroup = async (req, res) => { 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 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] }, + user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] }, acc_type: 'user', // exclude staff/admin — only regular users can be added to a group }, attributes: [ @@ -374,9 +409,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'], @@ -395,14 +430,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 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); @@ -430,7 +465,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); @@ -439,7 +474,7 @@ exports.removeUserFromGroup = async (req, res) => { 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)); + const notFound = user_ids.filter((id) => !existingIds.includes(id)); if (notFound.length) return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404); diff --git a/apps/api/tests/controllers/user_groups.controller.test.js b/apps/api/tests/controllers/user_groups.controller.test.js new file mode 100644 index 0000000..5dfff14 --- /dev/null +++ b/apps/api/tests/controllers/user_groups.controller.test.js @@ -0,0 +1,100 @@ +// tests/controllers/user_groups.controller.test.js + +jest.mock('../../models/users/user_groups.mdl', () => ({ + mdl_UserGroups: { findOne: jest.fn() }, + mdl_UserGroupMembers: {}, +})); + +const { mdl_UserGroups } = require('../../models/users/user_groups.mdl'); +const { __test__ } = require('../../controllers/admin/user_groups.controller'); +const { buildSlugGroupCode, generateGroupCode } = __test__; + +describe('buildSlugGroupCode', () => { + test('multi-word name -> acronym from first letters', () => { + const result = buildSlugGroupCode('Group of Auditors'); + expect(result).toBe('GA'); + }); + + test('filters out stopwords before building acronym', () => { + const result = buildSlugGroupCode('The Sales and Marketing Team'); + expect(result).toBe('SMT'); + }); + + test('single word -> truncated as-is', () => { + const result = buildSlugGroupCode('Sales'); + expect(result).toBe('SALE'); + }); + + test('respects maxLen', () => { + const result = buildSlugGroupCode('Internal Audit Team Extended', 3); + expect(result).toBe('IAT'); + }); + + test('strips non-alphanumeric characters per word', () => { + const result = buildSlugGroupCode('R&D Ops'); + expect(result).toBe('RO'); + }); + + test('falls back to GROUP when name is only stopwords/empty after filtering', () => { + const result = buildSlugGroupCode('The Of And'); + expect(result).toBe('GROU'); + }); + + test('falls back to first word when acronym would be 1 letter', () => { + const result = buildSlugGroupCode('A Ops'); + expect(result).toBe('OPS'); + }); + + test('is case-insensitive on input', () => { + const result = buildSlugGroupCode('group of auditors'); + expect(result).toBe('GA'); + }); +}); + +describe('generateGroupCode', () => { + beforeEach(() => { + mdl_UserGroups.findOne.mockReset(); + }); + + test('returns SLUG-XXXX when code is unique on first try', async () => { + mdl_UserGroups.findOne.mockResolvedValueOnce(null); + + const code = await generateGroupCode('Group of Auditors'); + console.log(`[TEST][GROUP CODE] Generated unique code: "${code}" (1 attempt)`); + + expect(code).toMatch(/^GA-[0-9A-F]{4}$/); + expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(1); + }); + + test('retries on collision until a unique code is found', async () => { + mdl_UserGroups.findOne + .mockResolvedValueOnce({ group_code: 'GA-AAAA' }) + .mockResolvedValueOnce({ group_code: 'GA-BBBB' }) + .mockResolvedValueOnce(null); + + const code = await generateGroupCode('Group of Auditors'); + + expect(code).toMatch(/^GA-[0-9A-F]{4}$/); + expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(3); + }); + + test('throws after 5 failed attempts', async () => { + mdl_UserGroups.findOne.mockResolvedValue({ group_code: 'GA-AAAA' }); + + await expect(generateGroupCode('Group of Auditors')).rejects.toThrow( + 'Could not generate a unique group code after 5 attempts.' + ); + expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(5); + }); + + test('generated code stays within max slug length', async () => { + mdl_UserGroups.findOne.mockResolvedValueOnce(null); + + const code = await generateGroupCode('Internal Audit Team For The Whole Organization Wide'); + const [slug] = code.split('-'); + console.log(`[TEST][GROUP CODE] Long name -> "${code}" (slug length: ${slug.length})`); + + expect(slug.length).toBeLessThanOrEqual(4); // 4, not 8 + expect(code.length).toBeLessThanOrEqual(9); // total: XXXX-XXXX + }); +}); \ No newline at end of file diff --git a/apps/api/tests/providers/paypal.provider.test.js b/apps/api/tests/providers/paypal.provider.test.js index 300426f..66e86a8 100644 --- a/apps/api/tests/providers/paypal.provider.test.js +++ b/apps/api/tests/providers/paypal.provider.test.js @@ -131,8 +131,8 @@ describe('createOrder()', () => { await provider.createOrder({ amount: 5, referenceId: 'ref-1' }); const [, body] = axios.post.mock.calls[1]; - expect(body.application_context.return_url).toBe('https://app.new-starr.test/plans/checkout'); - expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/plans/checkout?cancelled=true'); + expect(body.application_context.return_url).toBe('https://app.new-starr.test/subscriptions/checkout'); + expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/subscriptions/checkout?cancelled=true'); }); test('honors explicit return/cancel urls when provided', async () => { diff --git a/apps/web/index.html b/apps/web/index.html index 42e235c..d88c000 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,7 @@ + ) : ( ) )} diff --git a/apps/web/src/components/generic/Dashboard/TableDashboard.jsx b/apps/web/src/components/generic/Dashboard/TableDashboard.jsx index 2ec8c77..6b8685e 100644 --- a/apps/web/src/components/generic/Dashboard/TableDashboard.jsx +++ b/apps/web/src/components/generic/Dashboard/TableDashboard.jsx @@ -41,6 +41,7 @@ export function TableDashboard({ breakdowns = [], statMap = {}, tableRefsRef, // ← remove activeFilters prop entirely + chartHeight = 240, className = "", }) { // ─── Always read live from ref ──────────────────────────────────────────── @@ -124,6 +125,7 @@ export function TableDashboard({ label={b.label} data={b.data} onBarClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined} + height={b.height ?? chartHeight} /> ) : ( handleChartClick(b, entry) : undefined} + height={b.height ?? chartHeight} /> ) )} diff --git a/apps/web/src/modules/admin/components/users/UserTable.jsx b/apps/web/src/modules/admin/components/users/UserTable.jsx index 2489da8..e72990d 100644 --- a/apps/web/src/modules/admin/components/users/UserTable.jsx +++ b/apps/web/src/modules/admin/components/users/UserTable.jsx @@ -155,6 +155,7 @@ export default function UsersTable() { breakdowns={dashboardBreakdowns} statMap={USER_STAT_MAP} tableRefsRef={tableRefsRef} + chartHeight={320} /> )} diff --git a/apps/web/src/modules/admin/pages/users/ViewUser.jsx b/apps/web/src/modules/admin/pages/users/ViewUser.jsx index f351a94..e898846 100644 --- a/apps/web/src/modules/admin/pages/users/ViewUser.jsx +++ b/apps/web/src/modules/admin/pages/users/ViewUser.jsx @@ -50,6 +50,7 @@ export default function ViewUser() { fetchUserAchievements(userId); fetchUserActivity(userId, { page: 1, limit: 10 }); fetchUserBans(userId); + window.scrollTo(0, 0); }, [userId]); const loadActivityPage = (p) => { diff --git a/package.json b/package.json index 78190d5..78852ff 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,6 @@ "test": "pnpm --filter api test" }, "devDependencies": { - "concurrently": "^9.2.1" + "concurrently": "^9.2.4" } }