// $Id: User.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 { cnObject } from "./cnObject.js";
import { cacheStore } from "./cache.internal.js";
import { ApiResponse } from "../tools/ApiResponse.js";
/**
* @fileoverview
*
* - User: aggregate that holds contact, groups and roles
* - UserServices: service layer that fetches/assembles user aggregates
*/
/**
* @category Usermanagement
*
* @classdesc Contact information record for a user.
* @extends cnObject
*
* @property {string} userid Unique user identifier
* @property {string} object_id Object id
* @property {string} object_type Object type ( `res_user##CONTACT`)
* @property {string} shortid Short id
* @property {string} createdon Creation date
* @property {string} updatedon Last update date
* @property {string} createdby Creator
* @property {string} updatedby Last updater
*
*
*/
export class Contact extends cnObject {
constructor(contact = /** @type {Contact} */ ({})) {
super();
this.userid = contact.object_id || contact.userid || "";
this.object_id = contact.object_id || "";
this.object_type = contact.object_type || "";
this.shortid = contact.shortid || "";
this.createdon = contact.createdon || "";
this.updatedon = contact.updatedon || "";
this.createdby = contact.createdby || "";
this.updatedby = contact.updatedby || "";
}
/**
* Fill an existing contact with new data.
* @param {Contact } [contact={}] - The contact data to update with
* @returns {void}
*/
setFromJSON(contact = /** @type {Contact} */ ({})) {
this.userid = contact.userid || "";
this.object_id = contact.object_id || "";
this.object_type = contact.object_type || "";
this.shortid = contact.shortid || "";
this.createdon = contact.createdon || "";
this.updatedon = contact.updatedon || "";
this.createdby = contact.createdby || "";
this.updatedby = contact.updatedby || "";
}
/**
* 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} */ ({})) {
const obj = new Contact();
obj.setFromJSON(contact);
return obj;
}
/**
* Convert the contact info to a plain object.
* @returns {object} Plain object representation of the contact
*/
toJSON() {
return Object.fromEntries(
Object.entries(this).filter(([_, v]) => typeof v !== "function")
);
}
}
/**
* User Class
*/
/**
* @classdesc User aggregate (avatar, name, contact, groups, roles).
* @category Usermanagement
* @property {string} avatar Base64 avatar image
* @property {string} name Display name
* @property {Contact} info Contact information
* @property {object} userGroups Group ids
* @property {string[]} userRoles Role ids
*
*/
export class User {
constructor(user = /** @type {User} */ ({})) {
this.avatar = user.avatar || ""; // IDP, base64
this.name = user.name || ""; // IDP
this.info = user.info ?? Contact.createFromJSON(user.info);
this.userGroups = user.userGroups ?? {};
this.userRoles = user.userRoles || []; // [roleid1, roleid2, roleid3]
}
/**
* Set the user info from a JSON object
* @param {User} [user={}]
* @returns {void}
*/
setFromJSON(user = /** @type {User} */ ({})) {
this.avatar = user.avatar || ""; // IDP, base64
this.name = user.name || ""; // IDP
this.info = user.info ?? Contact.createFromJSON(user.info);
this.userGroups = user.userGroups ?? {};
this.userRoles = user.userRoles || [];
}
/**
* Convert the user info to a JSON object
* @returns {object}
*/
toJSON() {
return Object.fromEntries(
Object.entries(this).filter(([_, v]) => typeof v !== "function")
);
}
/**
* Create a User object from a JSON object
* @param {User} [user={}]
* @returns {User}
*/
static createFromJSON(user = /** @type {User} */ ({})) {
const obj = new User();
obj.setFromJSON(user);
return obj;
}
/**
* Set the avatar of a user locally.
* @param {string} avatar - The avatar base64 string
* @returns {void}
*/
setAvatar(avatar) {
this.avatar = avatar;
}
/**
* Get the avatar of a user locally.
* @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 {cnContext} ctx - Context instance
*/
constructor(ctx = /** @type {cnContext} */ ({})) {
this._ctx = ctx;
}
#cache = cacheStore;
/**
* Internal: fetch contact object for a user from Infohub.
* @param {string} userid - The user ID
* @returns {Promise<ApiResponse>}
*/
async #getContactFromInfoHub(userid) {
if (!userid) {
return new ApiResponse(400, null);
}
// Cache lookup.
const scope = this._ctx.getScope?.() || "";
const key = `scope:${scope}|user:contact:${userid}`;
const entry = this.#cache?.getEntry ? this.#cache.getEntry(key) : undefined;
if (entry && entry.needUpdate === false) {
return new ApiResponse(200, entry.value);
}
// If not cached, fetch from API.
try {
const filter = `$object_id ~eq~ '${userid}' $AND $object_type ~eq~ 'res_user##CONTACT'`;
const members = "info";
let url = `${this._ctx.getServiceUrl(
cnContext.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
);
if (response.status < 300) {
const contactInfo = Contact.createFromJSON(
response.getData()?.[0]?.info || {}
);
// set cache
this.#cache?.set?.(key, contactInfo, {
needUpdate: false,
tags: { type: "user", part: "contact", userid, scope },
});
return new ApiResponse(200, contactInfo);
}
return new ApiResponse(500, null);
} catch (error) {
return new ApiResponse(500, 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<ApiResponse>}
*/
async #getGroupsOfUser(userid, globalGroups = false, project, catalogue) {
// Validate input.
if (!userid) {
return new ApiResponse(400, {
globalgroups: [],
projectGroups: {},
catalogueGroups: {},
});
}
// Cache lookup.
const scope = this._ctx.getScope?.() || "";
const key = `scope:${scope}|user:groups:${userid}:${project || ""}:${
catalogue || ""
}:${globalGroups ? "global" : "local"}`;
const entry = this.#cache?.getEntry ? this.#cache.getEntry(key) : undefined;
if (entry && entry.needUpdate === false) {
return new ApiResponse(200, entry.value);
}
try {
// Prepare API calls.
const apiCalls = {};
if (globalGroups) {
const url = `${this._ctx.getServiceUrl(
cnContext.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(
cnContext.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(
cnContext.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
);
}
});
}
}
});
// Cache.
this.#cache?.set?.(key, userGroupsData, {
needUpdate: false,
tags: {
type: "user",
part: "groups",
userid,
project: project || null,
catalogue: catalogue || null,
globalGroups,
scope,
},
});
return new ApiResponse(200, userGroupsData);
} catch (error) {
return new ApiResponse(500, {
globalgroups: [],
projectGroups: {},
catalogueGroups: {},
});
}
}
/**
* Internal: fetch role ids for a user.
* @param {string} userid - The user ID
* @returns {Promise<ApiResponse>}
*/
async #getRolesOfUser(userid) {
// Validate input.
if (!userid) {
return new ApiResponse(400, []);
}
// Cache lookup.
const scope = this._ctx.getScope?.() || "";
const key = `scope:${scope}|user:roles:${userid}`;
const entry = this.#cache?.getEntry ? this.#cache.getEntry(key) : undefined;
if (entry && entry.needUpdate === false) {
return new ApiResponse(200, entry.value);
}
try {
const url = `${this._ctx.getServiceUrl(
cnContext.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);
}
}
// Cache.
this.#cache?.set?.(key, rolesArray, {
needUpdate: false,
tags: { type: "user", part: "roles", userid, scope },
});
return new ApiResponse(200, rolesArray);
}
return new ApiResponse(200, []);
} catch (error) {
return new ApiResponse(500, []);
}
}
/**
* Get avatar of a user
* @param {string} userid - The user ID
*
* @returns {Promise<ApiResponse>}
*
*/
// Note: requires an IDP token; may not work with the Infohub token.
async getUserAvatar(userid) {
if (!userid) {
return new ApiResponse(400, "");
}
// Cache lookup.
const key = `user:avatar:${userid}`;
const avatarCached = this.#cache?.get?.(key);
if (avatarCached && avatarCached !== "") {
return new ApiResponse(200, avatarCached);
}
try {
const url = `${this._ctx.getServiceUrl(
cnContext.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 : "";
this.#cache?.set?.(key, avatar, {
tags: { type: "user", part: "avatar", userid },
});
return new ApiResponse(200, avatar);
}
return new ApiResponse(500, "");
} catch (error) {
return new ApiResponse(500, "");
}
}
/** \cond DOXYGEN_EXCLUDE */
/**
* Internal: fetch display name for a user.
* @param {string} userid - The user ID
* @returns {Promise<ApiResponse>}
*/
// Note: requires an IDP token; may not work with the Infohub token.
async #getUserInfoFromIdp(userid) {
if (!userid) {
return new ApiResponse(400, "");
}
// Cache lookup.
const key = `user:name:${userid}`;
const cached = this.#cache?.get?.(key);
if (cached && cached !== "") {
return new ApiResponse(200, cached);
}
// If not cached, fetch from API.
try {
const url = `${this._ctx.getServiceUrl(
cnContext.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 : "";
// Cache.
this.#cache?.set?.(key, name, {
tags: { type: "user", part: "name", userid },
});
return new ApiResponse(200, name);
}
return new ApiResponse(500, "");
} catch (error) {
return new ApiResponse(500, "");
}
}
/** \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()
: "";
// Get user data for each user ID.
const usersData = await Promise.all(
userids.map(async (userid) => {
// 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") {
userData.info = hubres.value.data || null;
}
}
return new User(/** @type {object} */ (userData));
})
);
return new ApiResponse(200, usersData);
} 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