// $Id: UserGroup.js 8636 2025-09-09 14:21:04Z amr.abdou $
// @ts-check
"use strict";
import { RestServices } from "../tools/RestServices.js";
import { cnContext } from "./cnContext.js";
import { cacheStore } from "./cache.internal.js";
import { ApiResponse } from "../tools/ApiResponse.js";
/**
* @fileoverview
* - User group domain classes and services.
* - Types and services to manage groups and memberships.
*/
/**
* @classdesc Group aggregate (info, avatar, permissions, users, roles).
* @category Usermanagement
* @property {Object} info
* @property {string} avatar
* @property {Object} permissions
* @property {Object[]} userrelations
* @property {Object[]} invitations
* @property {string[]} users
* @property {Object.<string, any>} roles
*/
export class UserGroup {
constructor(userGroup = /** @type {UserGroup} */ ({})) {
this.info = userGroup.info || {};
this.avatar = userGroup.avatar || ""; // Base64
this.permissions = userGroup.permissions || {}; // GroupPermissions enum
this.userrelations = userGroup.userrelations || []; // UserRelation[]
this.invitations = userGroup.invitations || []; // Invitation[]
this.users = userGroup.users || []; // string[]
this.roles = userGroup.roles || {}; // Record<string, TRoleInfo>
}
/**
* Set the user group from a JSON object.
* @param {UserGroup} userGroup
*/
setFromJSON(userGroup = /** @type {UserGroup} */ ({})) {
this.info = userGroup.info || {};
this.avatar = userGroup.avatar || ""; // Base64
this.permissions = userGroup.permissions || {}; // GroupPermissions enum
this.userrelations = userGroup.userrelations || []; // UserRelation[]
this.invitations = userGroup.invitations || []; // Invitation[]
this.users = userGroup.users || []; // string[]
this.roles = userGroup.roles || {}; // Record<string, TRoleInfo>
}
/**
* Create a user group from a JSON object.
* @param {UserGroup} userGroup
* @returns {UserGroup}
*/
static createFromJSON(userGroup = /** @type {UserGroup} */ ({})) {
const obj = new UserGroup();
obj.setFromJSON(userGroup);
return obj;
}
/**
* Convert the user group to a JSON object.
* @returns {object}
*/
toJSON() {
return Object.fromEntries(
Object.entries(this).filter(([_, v]) => typeof v !== "function")
);
}
/**
* Set the avatar of the user group.
* @param {string} avatar - The avatar base64 string
* @returns {void}
*/
setAvatar(avatar) {
this.avatar = avatar;
}
/**
* Get the avatar of the user group.
* @returns {string} - The avatar base64 string
*/
getAvatar() {
return this.avatar;
}
}
/**
* @classdesc Service layer for CRUD operations on user groups.
* @category Usermanagement
*/
export class UserGroupServices {
#cache = cacheStore;
/**
* @since 09.2025, aab
* @param {cnContext} ctx - Context instance
*/
constructor(ctx = /** @type {cnContext} */ ({})) {
this._ctx = ctx;
}
/**
* Create a new user group.
* @param {string} projCatId - The project or catalogue ID
* @param {string} type - The type of group (project or catalogue)
* @param {object} [data] - Group payload
* @param {string} [data.name] - The name of the group
* @param {string} [data.description] - The description of the group
* @param {object} [data.permissions] - The permissions of the group
* @param {string[]} [data.users] - The users of the group
* @param {string[]} [data.roles] - The roles of the group
* @param {string[]} [data.userrelations] - The user relations of the group
* @param {string[]} [data.invitations] - The invitations of the group
* @returns {Promise<ApiResponse>}
*/
async createGroup(projCatId, type, data) {
if (!data) {
return new ApiResponse(400, null);
}
if (!data.name || !type) {
return new ApiResponse(400, null);
}
let url = "";
try {
if (!projCatId) {
// Global group.
url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups`;
} else if (type === "project") {
// Project group.
url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/projects/${projCatId}/usergroups`;
} else if (type === "catalogue") {
// Catalogue group.
url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/catalogues/${projCatId}/usergroups`;
} else {
return new ApiResponse(400, null);
}
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.POST,
url,
data
);
if (res.status < 300) {
const scope = this._ctx.getScope?.() || "";
this.#cache?.deleteWhere?.(
(key) =>
key.startsWith(`scope:${scope}|group:`) ||
key.startsWith(`scope:${scope}|groups:`)
);
return new ApiResponse(200, res.data);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Update an existing user group.
* @param {string} groupid - The group ID
* @param {object} [data] - Group payload
* @param {string} [data.name] - The name of the group
* @param {string} [data.description] - The description of the group
* @param {object} [data.permissions] - The permissions of the group
* @param {string[]} [data.users] - The users of the group
* @param {string[]} [data.roles] - The roles of the group
* @param {string[]} [data.userrelations] - The user relations of the group
* @param {string[]} [data.invitations] - The invitations of the group
*
* @returns {Promise<ApiResponse>}
*/
async updateGroup(groupid, data) {
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}`;
try {
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.PATCH,
url,
data
);
if (res.status < 300) {
const scope = this._ctx.getScope?.() || "";
this.#cache?.markNeedUpdateWhere?.(
(key) =>
key === `scope:${scope}|group:${groupid}` ||
key.startsWith(`scope:${scope}|groups:`)
);
return new ApiResponse(200, res.data);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Delete a user group.
* @param {string} groupid - The group ID
* @returns {Promise<ApiResponse>}
*/
async deleteGroup(groupid) {
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}`;
try {
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.DELETE,
url
);
if (res.status < 300) {
const scope = this._ctx.getScope?.() || "";
this.#cache?.deleteWhere?.(
(key) =>
key === `scope:${scope}|group:${groupid}` ||
key.startsWith(`scope:${scope}|groups:`)
);
return new ApiResponse(200, res.data);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Add a user to a group.
* @param {string} groupid - The group ID
* @param {object} [data] - Payload
* @param {string} [data.parentid] - The parent ID
* @param {object[]} [data.userrelations] - The user relations of the group
* @param {string} [data.userrelations[].userid] - The user ID
* @param {string[]} [data.userrelations[].roles] - The roles of the user
* @param {string} [data.userrelations[].companyid] - The company ID
* @param {object} [data.userrelations[].userconfig] - The user config
*
* @returns {Promise<ApiResponse>}
*/
async addUserToGroup(groupid, data) {
if (!data) {
return new ApiResponse(400, null);
}
if (!data.userrelations) {
return new ApiResponse(400, null);
}
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}/userrelations`;
try {
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.POST,
url,
data
);
if (res.status < 300) {
const scope = this._ctx.getScope?.() || "";
this.#cache?.markNeedUpdateWhere?.(
(key) =>
key === `scope:${scope}|group:${groupid}` ||
key.startsWith(`scope:${scope}|groups:`)
);
return new ApiResponse(200, res.data);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Update a user's membership in a group.
* @param {string} userrelationId - The user relation ID
* @param {object} [data] - Payload
* @param {string} [data.userid] - The user ID
* @param {string[]} [data.roles] - The roles of the user
* @param {string} [data.companyid] - The company ID
* @param {object} [data.userconfig] - The user config
*
* @returns {Promise<ApiResponse>} - The response object
*/
async updateUserInGroup(userrelationId, data) {
if (!data || !userrelationId) {
return new ApiResponse(400, null);
}
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/userrelations/${userrelationId}`;
try {
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.PATCH,
url,
data
);
if (res.status < 300) {
const scope = this._ctx.getScope?.() || "";
const payload = /** @type {{ id: (unknown|undefined) }} */ (
res.data ?? {}
);
const gid = typeof payload.id === "string" ? payload.id : "";
this.#cache?.markNeedUpdateWhere?.(
(key) =>
key === `scope:${scope}|group:${gid}` ||
key.startsWith(`scope:${scope}|groups:`)
);
return new ApiResponse(200, res.data);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Remove a user from a group.
* @param {string} groupid - The group ID
* @param {object} [data] - Payload
* @param {string} [data.userid] - The user ID
* @param {string} [data.roleid] - The role ID
* @param {string} [data.companyid] - The company ID
*
* @returns {Promise<ApiResponse>} - The response object
*/
async removeUserFromGroup(groupid, data) {
if (!data) {
return new ApiResponse(400, null);
}
if (!data.userid && !data.roleid && !data.companyid) {
return new ApiResponse(400, null);
}
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}/userrelations`;
try {
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.DELETE,
url,
data
);
if (res.status < 300) {
const scope = this._ctx.getScope?.() || "";
this.#cache?.markNeedUpdateWhere?.(
(key) =>
key === `scope:${scope}|group:${groupid}` ||
key.startsWith(`scope:${scope}|groups:`)
);
return new ApiResponse(200, res.data);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Invite a user to a group (creates a pending invitation).
* @param {string} groupid - The group ID
* @param {object} [data] - Payload
* @param {string} [data.parentid] - The parent ID
* @param {string} [data.message] - The message
* @param {string} [data.subject] - The subject
* @param {object[]} [data.userrelations] - The user relations of the group
* @param {string} [data.userrelations[].userid] - The user ID
* @param {string[]} [data.userrelations[].roles] - The roles of the user
* @param {string} [data.userrelations[].companyid] - The company ID
* @param {object} [data.userrelations[].userconfig] - The user config
*
* @returns {Promise<ApiResponse>} - The response object
*/
async inviteUserToGroup(groupid, data) {
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}/invitations`;
try {
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.POST,
url,
data
);
if (res.status < 300) {
const scope = this._ctx.getScope?.() || "";
this.#cache?.markNeedUpdateWhere?.((key) =>
key.startsWith(`scope:${scope}|groups:`)
);
return new ApiResponse(200, res.data);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Fetch a single group by ID.
* @param {string} groupid - The group ID
* @param {object} [query] - The query parameters
* @param {string} [query.filter] - The filter
* @param {string} [query.members] - The members
* @returns {Promise<ApiResponse>} - The response object
*/
async getGroupById(groupid, query) {
const queryString = this.#buildQueryString(query);
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}${queryString}`;
const scope = this._ctx.getScope?.() || "";
// get the cached data
// build the key
const key = `scope:${scope}|group:${groupid}|q:${queryString}`;
const cached = this.#cache?.getEntry?.(key);
// if the cached data is not stale, return it
if (cached && cached.needUpdate === false) {
// return the cached data
return new ApiResponse(200, cached.value);
}
try {
// make the api call
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.GET,
url
);
if (res.status < 300) {
// create a new UserGroup object
const processedData = this.#processUserGroupObject([res.data]);
// cache the processed data
this.#cache?.set?.(key, processedData, {
tags: { type: "group", kind: "single", groupid, scope },
});
// return the processed data
return new ApiResponse(200, processedData[0]);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* List groups in a project.
* @param {string} projectid - The project ID
* @param {object} [query] - The query parameters
* @param {string} [query.filter] - The filter
* @param {string} [query.members] - The members
* @returns {Promise<ApiResponse>}
*/
async getGroupsInProject(projectid, query) {
if (!projectid) {
return new ApiResponse(400, []);
}
const queryString = this.#buildQueryString(query);
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/projects/${projectid}/usergroups${queryString}`;
const scope = this._ctx.getScope?.() || "";
// build the key
const key = `scope:${scope}|groups:project:${projectid}|q:${queryString}`;
// get the cached data
const cachedEntry = this.#cache?.getEntry?.(key);
// if the cached data is not stale, return it
if (cachedEntry && cachedEntry.needUpdate === false) {
return new ApiResponse(200, cachedEntry.value);
}
try {
// make the api call
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.GET,
url
);
if (res.status < 300) {
// process the data
const list = Array.isArray(res.data) ? res.data : [];
if (list.length > 0) {
const processedData = this.#processUserGroupObject(list);
// cache the processed data
this.#cache?.set?.(key, processedData, {
tags: { type: "group", kind: "list", scope, projectid },
});
// return the processed data
return new ApiResponse(200, processedData);
}
return new ApiResponse(200, null);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* List groups in a catalogue.
* @param {string} catalogueid - The catalogue ID
* @param {object} [query] - The query parameters
* @param {string} [query.filter] - The filter
* @param {string} [query.members] - The members
* @returns {Promise<ApiResponse>}
*/
async getGroupsInCatalogue(catalogueid, query) {
if (!catalogueid) {
return new ApiResponse(400, null);
}
const queryString = this.#buildQueryString(query);
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/catalogues/${catalogueid}/usergroups${queryString}`;
const scope = this._ctx.getScope?.() || "";
// build the key
const key = `scope:${scope}|groups:catalogue:${catalogueid}|q:${queryString}`;
// get the cached data
const cachedEntry = this.#cache?.getEntry?.(key);
// if the cached data is not stale, return it
if (cachedEntry && cachedEntry.needUpdate === false) {
return new ApiResponse(200, cachedEntry.value ?? null);
}
try {
// make the api call
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.GET,
url
);
if (res.status < 300) {
// process the data
const list = Array.isArray(res.data) ? res.data : [];
if (list.length > 0) {
const processedData = this.#processUserGroupObject(list);
// cache the processed data
this.#cache?.set?.(key, processedData, {
tags: { type: "group", kind: "list", scope, catalogueid },
});
// return the processed data
return new ApiResponse(200, processedData ?? null);
} else {
// return an empty array
return new ApiResponse(200, null);
}
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* List global (scope‑wide) groups.
* @param {object} [query] - The query parameters
* @param {string} [query.filter] - The filter
* @param {string} [query.members] - The members
* @returns {Promise<ApiResponse>}
*/
async getGlobalGroups(query) {
const queryString = this.#buildQueryString(query);
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups${queryString}`;
const scope = this._ctx.getScope?.() || "";
// build the key
const key = `scope:${scope}|groups:global|q:${queryString}`;
// get the cached data
const cachedEntry = this.#cache?.getEntry?.(key);
// if the cached data is not stale, return it
if (cachedEntry && cachedEntry.needUpdate === false) {
return new ApiResponse(200, cachedEntry.value);
}
try {
// make the api call
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.GET,
url
);
if (res.status < 300) {
// process the data
const list = Array.isArray(res.data) ? res.data : [];
if (list.length > 0) {
const processedData = this.#processUserGroupObject(list);
// cache the processed data
this.#cache?.set?.(key, processedData, {
tags: { type: "group", kind: "list", scope, global: true },
});
// return the processed data
return new ApiResponse(200, processedData);
}
// return an empty array
return new ApiResponse(200, null);
} else {
return new ApiResponse(res.status, null);
}
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* List groups across projects, catalogues, and/or global scope.
* @param {string[]} projectList - The project ids
* @param {string[]} catalogueList - The catalogue ids
* @param {boolean} globalGroups - Whether to include global user groups
* @param {object} [query] - The query parameters
* @param {string} [query.filter] - The filter
* @param {string} [query.members] - The members
* @param {boolean} withAvatar - Whether to include the avatar of the groups
* @returns {Promise<ApiResponse>} - The response object
*/
async getGroups(
projectList = [],
catalogueList = [],
globalGroups = false,
query,
withAvatar = false
) {
if (
projectList.length === 0 &&
catalogueList.length === 0 &&
!globalGroups
) {
return new ApiResponse(400, []);
}
let groupList = [];
try {
if (projectList.length > 0) {
// get the groups in the projects
const projectGroups = await Promise.all(
projectList.map(async (projectId) => {
const res = await this.getGroupsInProject(projectId, query);
return res.status < 300 ? res.data : null;
})
);
// if the groups are found, add them to the list
if (projectGroups.length > 0) {
if (projectGroups.flat().length > 0) {
groupList.push(...projectGroups.flat());
}
}
}
if (catalogueList.length > 0) {
// get the groups in the catalogues
const catalogueGroups = await Promise.all(
catalogueList.map(async (catalogueId) => {
const res = await this.getGroupsInCatalogue(catalogueId, query);
return res.status < 300 ? res.data : null;
})
);
// if the groups are found, add them to the list
if (catalogueGroups.length > 0) {
if (catalogueGroups.flat().length > 0) {
groupList.push(...catalogueGroups.flat());
}
}
}
if (globalGroups) {
// get the global groups
const res = await this.getGlobalGroups(query);
if (res.status < 300) {
const list = Array.isArray(res.data) ? res.data : [];
if (list.length > 0) {
groupList.push(...list);
}
}
}
// if the avatar is requested, get the avatar for each group
if (withAvatar) {
const avatarList = await Promise.all(
groupList.map(async (group) => {
// @ts-ignore
const res = await this.getGroupAvatar(group.info?.id);
return res.status < 300 ? res.data : "";
})
);
// add the avatar to the group
groupList.forEach((group, index) => {
group.avatar = avatarList[index];
});
}
// return the list of groups
return new ApiResponse(200, groupList);
} catch (error) {
return new ApiResponse(500, null);
}
}
/**
* Fetch a group's avatar (base64 string).
* @param {string} groupid - The group ID
* @returns {Promise<ApiResponse>} - The response object
*/
async getGroupAvatar(groupid) {
const key = `group:avatar:${groupid}`;
const avatar = this.#cache?.get?.(key);
if (avatar && avatar !== "") return new ApiResponse(200, avatar);
// Get the avatar from the server.
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}/avatar`;
try {
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.GET,
url
);
if (res.status < 300) {
this.#cache?.set?.(key, res.data, {
tags: { type: "group", part: "avatar", groupid },
});
return new ApiResponse(200, res.data || "");
} else {
return new ApiResponse(res.status, "");
}
} catch (error) {
return new ApiResponse(500, "");
}
}
/**
* Update a group's avatar (base64 string).
* @param {string} groupid - The group ID
* @param {string} avatar - The avatar base64 string
* @returns {Promise<ApiResponse>} - The response object
*/
async updateGroupAvatar(groupid, avatar) {
const url = `${this._ctx.getServiceUrl(
cnContext.SERVICE.USR
)}/${this._ctx.getScope()}/usergroups/${groupid}/avatar`;
try {
// make the api call
const res = await RestServices.makeApiCall(
this._ctx.getToken(),
RestServices.METHODS.PUT,
url,
avatar
);
if (res.status < 300) {
// delete the cached data
const key = `group:avatar:${groupid}`;
this.#cache?.delete?.(key);
// cache the new data
this.#cache?.set?.(key, res.data, {
tags: { type: "group", part: "avatar", groupid },
});
// return the new data
return new ApiResponse(200, res.data || "");
} else {
// return the error
return new ApiResponse(res.status, "");
}
} catch (error) {
// return the error
return new ApiResponse(500, "");
}
}
/** \cond DOXYGEN_EXCLUDE */
/**
* Query string builder.
* @param {object} [query] - Query object containing filter, members, etc.
* @param {string} defaultMember - Default member value if not specified
* @returns {string} - Properly formatted query string
*/
#buildQueryString(query, defaultMember = "info") {
let queryString = "";
let c = "?";
if (query["filter"]) {
queryString += c + "filter=" + encodeURIComponent(query["filter"]);
c = "&";
}
if (query["members"]) {
let membersString = query["members"];
if (defaultMember && !membersString.includes(defaultMember)) {
membersString = membersString + "," + defaultMember;
}
queryString += c + "members=" + encodeURIComponent(membersString);
c = "&";
} else if (defaultMember) {
queryString += c + "members=" + encodeURIComponent(defaultMember);
c = "&";
}
return queryString;
}
/**
* Process user group objects.
* @param {Object[]} objects - The objects to process
* @returns {UserGroup[]} - The processed objects
*/
#processUserGroupObject(objects = []) {
let groupsList = [];
for (const object of objects) {
groupsList.push(UserGroup.createFromJSON(object));
}
return groupsList;
}
/** \endcond */
}
Source