Source

infohub/User.js

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