Source

infohub/User.js

// $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;
  }
}