mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -7,21 +7,22 @@
|
|||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
***********************************************************************************************************************************************************************
|
***********************************************************************************************************************************************************************
|
||||||
* Change History:
|
* Change History:
|
||||||
* DATE AUTHOR LOG DESCRIPTION
|
* DATE AUTHOR LOG DESCRIPTION
|
||||||
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
|
* Oct 06,2025 rgrgogu 001 Initial creation - STAR Phase 1
|
||||||
* May 23, 2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper
|
* 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 sequelize = require('../../config/db.config');
|
||||||
const { Op, Sequelize } = require('sequelize');
|
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 { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
|
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { paginate } = require('../../utils/paginate.util');
|
const { paginate } = require('../../utils/paginate.util');
|
||||||
const { getFieldValues } = require('../../utils/fieldValues.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 { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../utils/defaultGroup.util');
|
const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../utils/defaultGroup.util');
|
||||||
@@ -32,26 +33,60 @@ const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../uti
|
|||||||
* e.g. "SALES-A3F1", "ONBOARD-Q1-9C2D"
|
* e.g. "SALES-A3F1", "ONBOARD-Q1-9C2D"
|
||||||
* Retries up to 5 times in the unlikely event of a collision.
|
* 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: <SLUG>-<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 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++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const suffix = Math.random().toString(16).slice(2, 6).toUpperCase();
|
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 });
|
const exists = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false });
|
||||||
if (!exists) return code;
|
if (!exists) return code;
|
||||||
}
|
}
|
||||||
throw new Error('Could not generate a unique group code after 5 attempts.');
|
throw new Error('Could not generate a unique group code after 5 attempts.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Exports for unit testing ──────────────────────────────────────────────
|
||||||
|
exports.__test__ = { buildSlugGroupCode, generateGroupCode };
|
||||||
|
|
||||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||||
exports.getGroups = async (req, res) => {
|
exports.getGroups = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await paginate(mdl_UserGroups, req, {
|
const result = await paginate(mdl_UserGroups, req, {
|
||||||
excludeAttributes: groupExclude,
|
excludeAttributes: groupExclude,
|
||||||
jsonbSchemas: groupSchemas,
|
jsonbSchemas: groupSchemas,
|
||||||
computedAttributes: groupComputed,
|
computedAttributes: groupComputed,
|
||||||
context: 'list',
|
context: 'list',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, 'Groups retrieved.', result);
|
return R.success(res, 'Groups retrieved.', result);
|
||||||
@@ -69,16 +104,16 @@ exports.getGroup = async (req, res) => {
|
|||||||
|
|
||||||
const members = await paginate(mdl_Users, req, {
|
const members = await paginate(mdl_Users, req, {
|
||||||
excludeAttributes: usersExclude,
|
excludeAttributes: usersExclude,
|
||||||
jsonbSchemas: usersSchemas,
|
jsonbSchemas: usersSchemas,
|
||||||
jsonbColumn: 'personal_info',
|
jsonbColumn: 'personal_info',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||||
context: 'list',
|
context: 'list',
|
||||||
findOptions: {
|
findOptions: {
|
||||||
include: [{
|
include: [{
|
||||||
model: mdl_UserGroupMembers,
|
model: mdl_UserGroupMembers,
|
||||||
where: { group_id: req.params.gid },
|
where: { group_id: req.params.gid },
|
||||||
attributes: [],
|
attributes: [],
|
||||||
required: true,
|
required: true,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -109,7 +144,7 @@ exports.createGroup = async (req, res) => {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
group_code: code,
|
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 } });
|
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;
|
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 (description !== undefined) group.description = description;
|
||||||
|
|
||||||
if (group_code !== undefined) {
|
if (group_code !== undefined) {
|
||||||
const code = group_code.toUpperCase().trim();
|
const code = group_code.toUpperCase().trim();
|
||||||
const duplicate = await mdl_UserGroups.findOne({
|
const duplicate = await mdl_UserGroups.findOne({
|
||||||
where: { group_code: code, group_id: { [Op.ne]: group.group_id } },
|
where: { group_code: code, group_id: { [Op.ne]: group.group_id } },
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
@@ -156,7 +191,7 @@ exports.updateGroup = async (req, res) => {
|
|||||||
exports.deactivateGroup = async (req, res) => {
|
exports.deactivateGroup = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
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);
|
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.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) => {
|
exports.restoreGroup = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
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);
|
if (group.is_active) return R.error(res, 'Group is already active.', 400);
|
||||||
|
|
||||||
await group.restore();
|
await group.restore();
|
||||||
@@ -195,7 +230,7 @@ exports.bulkDeactivateGroups = async (req, res) => {
|
|||||||
if (!Array.isArray(ids) || !ids.length)
|
if (!Array.isArray(ids) || !ids.length)
|
||||||
return R.error(res, 'No group IDs provided.', 400);
|
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);
|
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||||
|
|
||||||
const activeGroups = groups.filter((g) => g.is_active && !g.deletedAt);
|
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 } });
|
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.`, {
|
return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, {
|
||||||
deactivated_ids: activeIds,
|
deactivated_ids: activeIds,
|
||||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err);
|
console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err);
|
||||||
@@ -228,8 +263,8 @@ exports.bulkRestoreGroups = async (req, res) => {
|
|||||||
if (!Array.isArray(ids) || !ids.length)
|
if (!Array.isArray(ids) || !ids.length)
|
||||||
return R.error(res, 'No group IDs provided.', 400);
|
return R.error(res, 'No group IDs provided.', 400);
|
||||||
|
|
||||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||||
|
|
||||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||||
if (!deletedGroups.length)
|
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 } });
|
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.`, {
|
return R.success(res, `${deletedIds.length} group(s) restored successfully.`, {
|
||||||
restored_ids: deletedIds,
|
restored_ids: deletedIds,
|
||||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][BULK RESTORE GROUPS]', err);
|
console.error('[ADMIN][BULK RESTORE GROUPS]', err);
|
||||||
@@ -258,7 +293,7 @@ exports.bulkRestoreGroups = async (req, res) => {
|
|||||||
exports.permanentlyDeleteGroup = async (req, res) => {
|
exports.permanentlyDeleteGroup = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
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);
|
if (!group.deletedAt) return R.error(res, 'Group must be deactivated before it can be permanently deleted.', 400);
|
||||||
|
|
||||||
await group.destroy({ force: true });
|
await group.destroy({ force: true });
|
||||||
@@ -278,8 +313,8 @@ exports.bulkPermanentlyDeleteGroups = async (req, res) => {
|
|||||||
if (!Array.isArray(ids) || !ids.length)
|
if (!Array.isArray(ids) || !ids.length)
|
||||||
return R.error(res, 'No group IDs provided.', 400);
|
return R.error(res, 'No group IDs provided.', 400);
|
||||||
|
|
||||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||||
|
|
||||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||||
if (!deletedGroups.length)
|
if (!deletedGroups.length)
|
||||||
@@ -304,14 +339,14 @@ exports.bulkPermanentlyDeleteGroups = async (req, res) => {
|
|||||||
exports.getArchivedGroups = async (req, res) => {
|
exports.getArchivedGroups = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await paginate(mdl_UserGroups, req, {
|
const result = await paginate(mdl_UserGroups, req, {
|
||||||
excludeAttributes: groupExclude,
|
excludeAttributes: groupExclude,
|
||||||
jsonbSchemas: groupSchemas,
|
jsonbSchemas: groupSchemas,
|
||||||
computedAttributes: groupComputed,
|
computedAttributes: groupComputed,
|
||||||
context: 'archived',
|
context: 'archived',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||||
findOptions: {
|
findOptions: {
|
||||||
paranoid: false,
|
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 ─────────────────────────────────────────────────────────────
|
// ─── FIELD VALUES ─────────────────────────────────────────────────────────────
|
||||||
exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, 'GROUP', {
|
exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, 'GROUP', {
|
||||||
blockedFields: ['deletedAt'],
|
blockedFields: ['deletedAt'],
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── MEMBERSHIP ───────────────────────────────────────────────────────────────
|
// ─── MEMBERSHIP ───────────────────────────────────────────────────────────────
|
||||||
@@ -334,12 +369,12 @@ exports.getUsersNotInGroup = async (req, res) => {
|
|||||||
const { gid: group_id } = req.params;
|
const { gid: group_id } = req.params;
|
||||||
|
|
||||||
// Exclude users already in THIS group
|
// 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 memberIds = members.map((m) => m.user_id);
|
||||||
|
|
||||||
const users = await mdl_Users.findAll({
|
const users = await mdl_Users.findAll({
|
||||||
where: {
|
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
|
acc_type: 'user', // exclude staff/admin — only regular users can be added to a group
|
||||||
},
|
},
|
||||||
attributes: [
|
attributes: [
|
||||||
@@ -374,9 +409,9 @@ exports.getUsersInGroup = async (req, res) => {
|
|||||||
|
|
||||||
const group = await mdl_UserGroups.findByPk(group_id, {
|
const group = await mdl_UserGroups.findByPk(group_id, {
|
||||||
include: [{
|
include: [{
|
||||||
model: mdl_Users,
|
model: mdl_Users,
|
||||||
as: 'members',
|
as: 'members',
|
||||||
through: { attributes: [] },
|
through: { attributes: [] },
|
||||||
attributes: [
|
attributes: [
|
||||||
'user_id',
|
'user_id',
|
||||||
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||||
@@ -395,14 +430,14 @@ exports.getUsersInGroup = async (req, res) => {
|
|||||||
exports.addUserToGroup = async (req, res) => {
|
exports.addUserToGroup = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { gid: group_id } = req.params;
|
const { gid: group_id } = req.params;
|
||||||
const { user_ids } = req.body;
|
const { user_ids } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(user_ids) || !user_ids.length)
|
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||||
return R.error(res, 'No users provided.', 400);
|
return R.error(res, 'No users provided.', 400);
|
||||||
|
|
||||||
const existingUsers = await mdl_Users.findAll({ where: { user_id: user_ids }, attributes: ['user_id'] });
|
const existingUsers = await mdl_Users.findAll({ where: { user_id: user_ids }, attributes: ['user_id'] });
|
||||||
const existingIds = existingUsers.map((u) => u.user_id);
|
const existingIds = existingUsers.map((u) => u.user_id);
|
||||||
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||||
|
|
||||||
if (notFound.length)
|
if (notFound.length)
|
||||||
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
||||||
@@ -430,7 +465,7 @@ exports.addUserToGroup = async (req, res) => {
|
|||||||
exports.removeUserFromGroup = async (req, res) => {
|
exports.removeUserFromGroup = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { gid: group_id } = req.params;
|
const { gid: group_id } = req.params;
|
||||||
const { user_ids } = req.body;
|
const { user_ids } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(user_ids) || !user_ids.length)
|
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||||
return R.error(res, 'No users provided.', 400);
|
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'],
|
where: { user_id: user_ids, group_id }, attributes: ['user_id'],
|
||||||
});
|
});
|
||||||
const existingIds = existingMembers.map((m) => m.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)
|
if (notFound.length)
|
||||||
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,8 +131,8 @@ describe('createOrder()', () => {
|
|||||||
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
|
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
|
||||||
|
|
||||||
const [, body] = axios.post.mock.calls[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.return_url).toBe('https://app.new-starr.test/subscriptions/checkout');
|
||||||
expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/plans/checkout?cancelled=true');
|
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 () => {
|
test('honors explicit return/cancel urls when provided', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function DashboardSection({
|
|||||||
iconMap = {},
|
iconMap = {},
|
||||||
linkMap = {},
|
linkMap = {},
|
||||||
chartLinkMap = {},
|
chartLinkMap = {},
|
||||||
|
chartHeight = 240,
|
||||||
className = "",
|
className = "",
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +58,8 @@ export function DashboardSection({
|
|||||||
label={b.label}
|
label={b.label}
|
||||||
data={b.data}
|
data={b.data}
|
||||||
onBarClick={chartLinkMap[b.key]}
|
onBarClick={chartLinkMap[b.key]}
|
||||||
|
height={b.height ?? chartHeight}
|
||||||
|
yAxisWidth={b.yAxisWidth}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PieBreakdown
|
<PieBreakdown
|
||||||
@@ -64,6 +67,7 @@ export function DashboardSection({
|
|||||||
label={b.label}
|
label={b.label}
|
||||||
data={b.data}
|
data={b.data}
|
||||||
onSliceClick={chartLinkMap[b.key]}
|
onSliceClick={chartLinkMap[b.key]}
|
||||||
|
height={b.height ?? chartHeight}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function TableDashboard({
|
|||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
statMap = {},
|
statMap = {},
|
||||||
tableRefsRef, // ← remove activeFilters prop entirely
|
tableRefsRef, // ← remove activeFilters prop entirely
|
||||||
|
chartHeight = 240,
|
||||||
className = "",
|
className = "",
|
||||||
}) {
|
}) {
|
||||||
// ─── Always read live from ref ────────────────────────────────────────────
|
// ─── Always read live from ref ────────────────────────────────────────────
|
||||||
@@ -124,6 +125,7 @@ export function TableDashboard({
|
|||||||
label={b.label}
|
label={b.label}
|
||||||
data={b.data}
|
data={b.data}
|
||||||
onBarClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
|
onBarClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
|
||||||
|
height={b.height ?? chartHeight}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PieBreakdown
|
<PieBreakdown
|
||||||
@@ -131,6 +133,7 @@ export function TableDashboard({
|
|||||||
label={b.label}
|
label={b.label}
|
||||||
data={b.data}
|
data={b.data}
|
||||||
onSliceClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
|
onSliceClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
|
||||||
|
height={b.height ?? chartHeight}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export default function UsersTable() {
|
|||||||
breakdowns={dashboardBreakdowns}
|
breakdowns={dashboardBreakdowns}
|
||||||
statMap={USER_STAT_MAP}
|
statMap={USER_STAT_MAP}
|
||||||
tableRefsRef={tableRefsRef}
|
tableRefsRef={tableRefsRef}
|
||||||
|
chartHeight={320}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function ViewUser() {
|
|||||||
fetchUserAchievements(userId);
|
fetchUserAchievements(userId);
|
||||||
fetchUserActivity(userId, { page: 1, limit: 10 });
|
fetchUserActivity(userId, { page: 1, limit: 10 });
|
||||||
fetchUserBans(userId);
|
fetchUserBans(userId);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const loadActivityPage = (p) => {
|
const loadActivityPage = (p) => {
|
||||||
|
|||||||
+1
-1
@@ -9,6 +9,6 @@
|
|||||||
"test": "pnpm --filter api test"
|
"test": "pnpm --filter api test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user