// $Id: User.js 8990 2025-10-13 14:21:09Z amr.abdou $
// @ts-check
'use strict';
import { RestServices } from '../tools/RestServices.js';
import { Context } from './Context.js';
import { cnObject } from '../gom/cnObject.js';
import { ApiResponse } from '../tools/ApiResponse.js';
import { LicenceServices } from './Licence.js';
import { ScopeServices } from './Scope.js';
/**
* @category Usermanagement
* @classdesc Contact information record for a user.
* @extends cnObject
*
*/
export class Contact extends cnObject {
constructor(contact = /** @type {Contact} */ ({})) {
super();
const node = /** @type {any} */ (contact) || {};
this.setFromJson(node && typeof node === 'object' && 'info' in node ? node : { info: node });
}
/**
* Fill an existing contact with new data.
* @param {Contact } [contact={}] - The contact data to update with
* @returns {void}
*/
setFromJSON(contact = /** @type {Contact} */ ({})) {
const node = /** @type {any} */ (contact) || {};
this.setFromJson(node && typeof node === 'object' && 'info' in node ? node : { info: node });
}
/**
* Create a new contact from saved data.
* @static
* @param {Contact} [contact={}] - The contact data to create from
* @see {@link Contact} - See Contact class for all available properties
* @returns {Contact} A new Contact instance
*/
static createFromJSON(contact = /** @type {Contact} */ ({})) {
return new Contact(contact);
}
/**
* Convert the contact info to a plain object.
* @returns {object} Plain object representation of the contact
*/
toJSON() {
// Expose common members in plain JSON for transport
try {
return this.getAsJson(['info', 'properties']) || {};
} catch {
return {};
}
}
}
/**
* User Class
*/
/**
* @classdesc User aggregate (avatar, name, contact, groups, roles).
* @category Usermanagement
* @property {Licence[]} licences Licences
* @property {string} avatar Avatar
* @property {string} name Name
* @property {Contact} contact Contact
* @property {string[]} userGroups User groups
* @property {string[]} userRoles User roles
* @property {Scope[]} scopes Scopes
* @property {Licence[]} licences Licences
*
*/
export class User {
/**
* Create a user from a JSON object
* @param {object} [data={}] - The user data
*/
constructor(data = /** @type {object} */ ({})) {
Object.assign(this, data);
}
/**
* Set the user data
* @param {object} data - The user data
* @returns {void}
*/
setData(data = /** @type {object} */ ({})) {
Object.assign(this, data);
}
/**
* Get the user data
* @returns {object} - The user data
*/
getData() {
return Object.fromEntries(Object.entries(this).filter(([_, v]) => typeof v !== 'function'));
}
/**
* Set the user from a JSON object
* @param {object} user - The JSON object
* @returns {void}
*/
setFromJSON(user = /** @type {object} */ ({})) {
Object.assign(this, user);
}
/**
* Convert the user to a JSON object
* @returns {object} - The JSON object
*/
toJSON() {
return Object.fromEntries(Object.entries(this).filter(([_, v]) => typeof v !== 'function'));
}
/**
* Create a user from a JSON object
* @param {object} user - The JSON object
* @returns {User} - The user
*/
static createFromJSON(user = /** @type {object} */ ({})) {
return new User(user);
}
/**
* Set the avatar of a user
* @param {string} avatar - The avatar base64 string
* @returns {void}
*/
setAvatar(avatar) {
this.avatar = avatar;
}
/**
* Get the avatar of a user
* @returns {string} - The avatar base64 string
*/
getAvatar() {
return this.avatar || '';
}
}
/**
* @classdesc Data access/service layer for building user aggregates.
* @category Usermanagement
*/
export class UserServices {
/**
* @since 09.2025, aab
* @param {Context} ctx - Context instance
*/
constructor(ctx = /** @type {Context} */ ({})) {
this._ctx = ctx;
}
// #cache = cacheStore;
/**
* Internal: fetch contact object for a user from Infohub.
* @param {string} userid - The user ID
* @returns {Promise<{status: number, data: any}>}
*/
async #getContactFromInfoHub(userid) {
if (!userid) {
return { status: 400, data: null };
}
// If not cached, fetch from API.
try {
const filter = `$object_id ~eq~ '${userid}' $AND $object_type ~eq~ 'res_user##CONTACT'`;
const members = 'info, properties';
let url = `${this._ctx.getServiceUrl(Context.SERVICE.HUB)}/${this._ctx.getScope()}/objects`;
url += this.#buildQueryString({ filter, members });
// Call API.
const response = await RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
// console.log("response", response.getData()?.[0]?.info);
if (response.status < 300) {
// console.log(response.getData());
const node = (Array.isArray(response.getData()) ? response.getData() : [])[0] || {};
const contactInfo = Contact.createFromJSON(node);
return { status: 200, data: contactInfo };
}
return { status: 500, data: null };
} catch (error) {
return { status: 500, data: null };
}
}
/**
* Internal: fetch group ids for a user.
* @param {string} userid - The user ID
* @param {boolean} globalGroups - Whether to include global groups
* @param {string=} project - The project ID
* @param {string=} catalogue - The catalogue ID
* @returns {Promise<{status: number, data: any}>}
*/
async #getGroupsOfUser(userid, globalGroups = false, project, catalogue) {
// Validate input.
if (!userid) {
return {
status: 400,
data: {
globalgroups: [],
projectGroups: {},
catalogueGroups: {},
},
};
}
try {
// Prepare API calls.
const apiCalls = {};
if (globalGroups) {
const url = `${this._ctx.getServiceUrl(
Context.SERVICE.USR,
)}/${this._ctx.getScope()}/usergroups?filter=$userid ~eq~ '${userid}'&members=info`;
apiCalls.global = RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
}
if (typeof project === 'string' && project.trim()) {
const url = `${this._ctx.getServiceUrl(
Context.SERVICE.USR,
)}/${this._ctx.getScope()}/projects/${project}/usergroups?filter=$userid ~eq~ '${userid}'&members=info`;
apiCalls.project = RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
}
if (typeof catalogue === 'string' && catalogue.trim()) {
const url = `${this._ctx.getServiceUrl(
Context.SERVICE.USR,
)}/${this._ctx.getScope()}/catalogues/${catalogue}/usergroups?filter=$userid ~eq~ '${userid}'&members=info`;
apiCalls.catalogue = RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
}
// Execute all API calls.
const results = await Promise.allSettled(Object.values(apiCalls));
const keys = Object.keys(apiCalls);
// Initialize result structure.
const userGroupsData = {
globalgroups: /** @type {string[]} */ ([]),
projectGroups: /** @type {Object.<string, string[]>} */ ({}),
catalogueGroups: /** @type {Object.<string, string[]>} */ ({}),
};
// Process results.
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.status < 300) {
const data = result.value.data;
const type = keys[index];
if (type === 'global') {
// Extract group IDs for global groups.
data.forEach((group) => {
if (group.info?.id) {
userGroupsData.globalgroups.push(group?.info?.id);
}
});
} else if (type === 'project') {
// Extract group IDs for project groups.
const projectId = /** @type {string} */ (project);
if (!userGroupsData.projectGroups[projectId]) {
userGroupsData.projectGroups[projectId] = [];
}
data.forEach((group) => {
if (group.info?.id) {
userGroupsData.projectGroups[projectId].push(group?.info?.id);
}
});
} else if (type === 'catalogue') {
// Extract group IDs for catalogue groups.
const catalogueId = /** @type {string} */ (catalogue);
if (!userGroupsData.catalogueGroups[catalogueId]) {
userGroupsData.catalogueGroups[catalogueId] = [];
}
data.forEach((group) => {
if (group.info?.id) {
userGroupsData.catalogueGroups[catalogueId].push(group?.info?.id);
}
});
}
}
});
return { status: 200, data: userGroupsData };
} catch (error) {
return {
status: 500,
data: {
globalgroups: [],
projectGroups: {},
catalogueGroups: {},
},
};
}
}
/**
*
* Get projects of the currently logged in user. <br>
* The function uses the token stored in the context object.
*
*
* @since 10.2025, aab
* @param {object} [query] - Query object
* @param {string[]} [query.members=['info']]
* @param {string} [query.filter='']
* @returns {Promise<ApiResponse>} ApiResponse with list of json objects :
* @example:
*[
* {
* "info": {
* "created": "2025-09-30T08:41:06.953000",
* "createdby": "alexander.rensch@colneo.email",
* "depth": 0,
* "name": "testProject AR",
* "number": "test123",
* "object_id": "test123",
* "object_name": "testProject AR",
* "object_type": "all_project",
* "shortid": "1TYRUZ3Z_Ho=",
* "type": "project",
* "updated": "2025-09-30T08:41:06.969000",
* "updatedby": "alexander.rensch@colneo.email"
* }
* }
* }
*]
*
*/
async getProjectsOfCurrentUser( query = {}) {
const scope = this._ctx.getScope();
if (!scope || scope === '' ){
return new ApiResponse( 400, null, "scope is required");
}
if ( query && !query.members ) {
query.members = ['info'];
}
if (query && query.filter === '') {
delete query.filter;
}
try {
let q = '';
if (query && Object.keys(query).length > 0) {
const urlParams = new URLSearchParams();
for (const key of Object.keys(query)) {
urlParams.set(key, query[key]);
}
q = `?${urlParams.toString()}`;
}
const url = `${this._ctx.getServiceUrl(Context.SERVICE.HUB)}/${scope}/projects${q}`;
const resp = await RestServices.makeApiCall( this._ctx.getToken(), RestServices.METHODS.GET, url );
return resp.status < 300 ? new ApiResponse(200, resp.data) : new ApiResponse(resp.status, null, resp.message);
} catch (error) {
return new ApiResponse(500, null, error.message);
}
}
/**
* Get licences of current user
* current user will be determined by context
* @returns {Promise<ApiResponse>} User
*/
async getCurrentUserLicences() {
const userid = this._ctx.getUserId();
if (!userid) {
return new ApiResponse(400, null);
}
const licenceServices = new LicenceServices(this._ctx);
const licences = await licenceServices.getLicencesOfUser(userid);
const list = Array.isArray(licences.getData()) ? licences.getData() : [];
return licences.status < 300 ? new ApiResponse(200, list) : new ApiResponse(licences.status, null, licences.message);
}
/**
* Internal: fetch role ids for a user.
* Get scopes of current user will be determined by context
* @returns {Promise<{status: number, data: any}>}
*/
async getCurrentUserScopes() {
const userid = this._ctx.getUserId();
if (!userid) {
return { status: 400, data: [] };
}
const scopeServices = new ScopeServices(this._ctx);
const scopes = await scopeServices.getScopesOfUser(userid);
const user = new User({ scopes: [scopes.getData() || []] });
return scopes.status < 300 ? new ApiResponse(200, user) : new ApiResponse(scopes.status, null, scopes.message);
}
/**
* @param {string} userid - The user ID
* @returns {Promise<{status: number, data: any}>}
*/
async #getRolesOfUser(userid) {
// Validate input.
if (!userid) {
return { status: 400, data: [] };
}
try {
const url = `${this._ctx.getServiceUrl(
Context.SERVICE.USR,
)}/${this._ctx.getScope()}/userroles?filter=$userid ~eq~ '${userid}'&members=info`;
const response = await RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
// Process roles to match userRoles format [roleid1, roleid2].
if (response.status < 300 && response.data) {
const rolesArray = [];
const list = Array.isArray(response.data) ? response.data : [];
for (const role of list) {
if (role?.info?.id) {
rolesArray.push(role.info.id);
}
}
return { status: 200, data: rolesArray };
}
return { status: 200, data: [] };
} catch (error) {
return { status: 500, data: [] };
}
}
/**
*
* Get groups in which user 'userid' is contained.
*
* @param {string} userid
* @param {*} projectList
* @param {*} catalogueList
* @param {*} globalGroups
* @returns {Promise<ApiResponse>}
* @example
* {
* 'status' : 200
* 'data' : {
* 'projectgroups' : [],
* 'cataloguegroups' : [],
* 'globalgroups' : []
* }
* }
*/
async getGroupsOfUser( userid , projectList = [], catalogueList = [], globalGroups = false) {
if (!userid || (projectList.length === 0 && catalogueList.length === 0 && !globalGroups)) {
return new ApiResponse(400, []);
}
const query = new URLSearchParams({
members: 'info',
filter: `$userid ~eq~ '${userid}'`
}).toString();
// build the urls
const baseUrl = `${this._ctx.getServiceUrl(Context.SERVICE.USR)}/${this._ctx.getScope()}`;
const urls = [
...projectList.map(id => `${baseUrl}/projects/${id}/usergroups?${query}`),
...catalogueList.map(id => `${baseUrl}/catalogues/${id}/usergroups?${query}`),
...(globalGroups ? [`${baseUrl}/usergroups?${query}`] : [])
];
// extract the groups from the results
const extractGroups = (result) => {
if (result.status === 'fulfilled') {
const res = result.value;
if (res.getStatus() < 300 && Array.isArray(res.getData())) {
return res.getData();
}
}
return [];
};
// execute the api calls and get the results
const results = await Promise.allSettled(
urls.map(url => RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url))
);
// get the results
const projectEnd = projectList.length;
const catalogueEnd = projectEnd + catalogueList.length;
// extract the groups from the results
const projectgroups = results.slice(0, projectEnd).flatMap(extractGroups);
const cataloguegroups = results.slice(projectEnd, catalogueEnd).flatMap(extractGroups);
const globalgroups = results.slice(catalogueEnd).flatMap(extractGroups);
return new ApiResponse(200, { projectgroups, cataloguegroups, globalgroups });
}
/**
* Get avatar of a user
* @param {string} userid - The user ID
*
* @returns {Promise<{status: number, data: any}>}
*
*/
async getUserAvatar(userid) {
if (!userid) {
return { status: 400, data: '' };
}
try {
const url = `${this._ctx.getServiceUrl(Context.SERVICE.IDP)}/users/${userid}/avatar`;
const res = await RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
if (res.status < 300) {
const avatar = typeof res.data === 'string' ? res.data : '';
return { status: 200, data: avatar };
}
return { status: 500, data: '' };
} catch (error) {
return { status: 500, data: '' };
}
}
/**
* Get current user's data from IDP#
* including last chosen role
* @returns {Promise<ApiResponse>}
*/
async getCurrentUserData() {
const userid = this._ctx.getUserId();
if (!userid) {
return new ApiResponse(400, null);
}
try {
const url = `${this._ctx.getServiceUrl(Context.SERVICE.IDP)}/users/${userid}`;
const res = await RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
return res.getStatus() < 300 ? new ApiResponse(200, res.getData()) : new ApiResponse(res.getStatus(), null, res.getMessage());
} catch (error) {
return new ApiResponse(500, null, error.message);
}
}
/**
* Update current user's data to IDP
* @param {object} data - The user data
* @param {string} [data.name] - The name
* @param {string} [data.roleid] - The role ID
* @returns {Promise<ApiResponse>}
*/
async updateCurrentUserData(data) {
const userid = this._ctx.getUserId();
if (!userid) {
return new ApiResponse(400, null, 'User ID is required');
}
if (!data) {
return new ApiResponse(400, null, 'User data is required');
}
try {
const url = `${this._ctx.getServiceUrl(Context.SERVICE.IDP)}/users/${userid}`;
const res = await RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.PATCH, url, data);
return res.status < 300 ? new ApiResponse(200, res.data) : new ApiResponse(res.status, null, res.message);
} catch (error) {
return new ApiResponse(500, null, error.message);
}
}
/** \cond DOXYGEN_EXCLUDE */
/**
* Internal: fetch display name for a user.
* @param {string} userid - The user ID
* @returns {Promise<{status: number, data: any}>}
*/
async #getUserInfoFromIdp(userid) {
if (!userid) {
return { status: 400, data: '' };
}
try {
const url = `${this._ctx.getServiceUrl(Context.SERVICE.IDP)}/users/${userid}`;
const res = await RestServices.makeApiCall(this._ctx.getToken(), RestServices.METHODS.GET, url);
if (res.status < 300) {
const payload = /** @type {{ name: (unknown|undefined) }} */ (res.data ?? {});
const name = typeof payload.name === 'string' ? payload.name : '';
return { status: 200, data: name };
}
return { status: 500, data: '' };
} catch (error) {
return { status: 500, data: '' };
}
}
/** \endcond */
/**
* Build user aggregates, optionally including groups, roles, and contact info.
* @param {string[]} userids - Array of user IDs
* @param {string} [projectid] - Project ID
* @param {string} [catalogueid] - Catalogue ID
* @param {boolean} [globalGroups=false] - Include global groups
* @param {boolean} [userRoles=false] - Include user roles
* @param {boolean} [withInfo=false] - Include user contact info
* @returns {Promise<ApiResponse>} - The response object
*/
async getUsers(userids = [], projectid, catalogueid, globalGroups = false, userRoles = false, withInfo = false) {
// Validate input.
if (!userids || userids.length === 0) {
return new ApiResponse(400, []);
}
try {
// Sanitize optional IDs to avoid empty path segments.
const projectIdClean = Array.isArray(projectid) ? '' : typeof projectid === 'string' ? projectid.trim() : '';
const catalogueIdClean = Array.isArray(catalogueid) ? '' : typeof catalogueid === 'string' ? catalogueid.trim() : '';
// Build user promises and settle to avoid failing all on single error.
const userPromises = userids.map(async (userid) => {
try {
// Get basic user info - no redundant ID field.
let userData = {};
// Get user's groups.
const groupsResult = await this.#getGroupsOfUser(
userid,
globalGroups,
projectIdClean || undefined,
catalogueIdClean || undefined,
);
if (groupsResult.status === 200) {
userData.userGroups = groupsResult.data || [];
}
// Get user's roles.
if (userRoles) {
const rolesResult = await this.#getRolesOfUser(userid);
if (rolesResult.status === 200) {
userData.userRoles = rolesResult.data || [];
}
}
// Optionally get user's avatar, display name, and contact.
if (withInfo) {
const [idpres, hubres, avatarres] = await Promise.allSettled([
this.#getUserInfoFromIdp(userid),
this.#getContactFromInfoHub(userid),
this.getUserAvatar(userid),
]);
if (avatarres.status === 'fulfilled') {
userData.avatar = avatarres.value.data || '';
}
if (idpres.status === 'fulfilled') {
userData.name = idpres.value.data || '';
}
if (hubres.status === 'fulfilled') {
const contactObj = /** @type {any} */ (hubres.value.data);
userData.contact =
contactObj && typeof contactObj.getAsJson === 'function'
? contactObj.getAsJson(['info', 'properties'])
: contactObj || null;
}
}
return new User(/** @type {object} */(userData));
} catch (e) {
// Propagate failure for this user only
throw e;
}
});
const settled = await Promise.allSettled(userPromises);
const usersData = settled.filter((r) => r.status === 'fulfilled').map((r) => /** @type {any} */(r).value);
const failedCount = settled.filter((r) => r.status === 'rejected').length;
const message = failedCount ? `${failedCount} of ${userids.length} user(s) failed` : null;
return new ApiResponse(200, usersData, message);
} catch (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 = /** @type {object} */ ({}), 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;
}
}
Source