Source

infohub/UserRole.js

// $Id: UserRole.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 role domain classes and services.
 *  - Types and services to manage roles and entitlements.
 */

/**
 * @classdesc Role aggregate (info, avatar, permissions, users).
 * @category Usermanagement
 */
export class UserRole {
  constructor(userRole = /** @type {UserRole} */ ({})) {
    this.info = userRole.info || {};
    this.avatar = userRole.avatar || ""; // Base64
    this.permissions = userRole.permissions || {}; // RolePermissions enum
    this.users = userRole.users || []; // string[]
  }

  /**
   * Convert the role to a JSON object.
   * @returns {object}
   */
  toJSON() {
    return Object.fromEntries(
      Object.entries(this).filter(([_, v]) => typeof v !== "function")
    );
  }

  /**
   * Set the user role from a JSON object.
   * @param {object} userRole - The JSON object
   * @returns {void}
   */
  setFromJSON(userRole = /** @type {object} */ ({})) {
    this.info = userRole.info || {};
    this.avatar = userRole.avatar || ""; // Base64
    this.permissions = userRole.permissions || {}; // RolePermissions enum
    this.users = userRole.users || []; // string[]
  }

  /**
   * Create a user role from a JSON object.
   * @param {object} userRole - The JSON object
   * @returns {UserRole}
   */
  static createFromJSON(userRole = /** @type {object} */ ({})) {
    const obj = new UserRole();
    obj.setFromJSON(userRole);
    return obj;
  }
  /**
   * Set the avatar of a role.
   * @param {string} avatar - The avatar base64 string
   * @returns {void}
   */
  setAvatar(avatar) {
    this.avatar = avatar;
  }
  /**
   * Get the avatar of a role.
   * @returns {string} - The avatar base64 string
   */
  getAvatar() {
    return this.avatar;
  }
}

/**
 * @classdesc Service layer for CRUD operations on roles.
 * @ingroup user_roles
 * @category Usermanagement
 */
export class UserRoleServices {
  /**
   * @since 09.2025, aab
   * @param {cnContext} ctx - Context instance
   */
  #cache = cacheStore;
  constructor(ctx = /** @type {cnContext} */ ({})) {
    this._ctx = ctx;
  }

  /**
   * Create a new role.
   * @param {object} [data] - Role payload
   * @param {string} [data.name] - The name
   * @param {string} [data.description] - The description
   * @param {object} [data.permissions] - The permissions
   * @returns {Promise<ApiResponse>}
   */
  async createRole(data) {
    // validate the input
    if (!data?.name) {
      return new ApiResponse(400, null);
    }

    let url = "";
    try {
      // create the role via api
      url = `${this._ctx.getServiceUrl(
        cnContext.SERVICE.USR
      )}/${this._ctx.getScope()}/userroles`;
      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}|role:`) ||
            key.startsWith(`scope:${scope}|roles:`)
        );
        return new ApiResponse(200, res.data);
      } else {
        return new ApiResponse(res.status, res.data || null);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * Update an existing role.
   * @param {string} roleid
   * @param {object} [data] - Role payload
   * @param {string} [data.name] - The name
   * @param {string} [data.description] - The description
   * @param {object} [data.permissions] - The permissions
   *
   * @returns {Promise<ApiResponse>}
   */
  async updateRole(roleid, data) {
    let url = "";
    try {
      // get the role data from the api
      url = `${this._ctx.getServiceUrl(
        cnContext.SERVICE.USR
      )}/${this._ctx.getScope()}/userroles/${roleid}`;
      // make the api call
      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}|role:${roleid}` ||
            key.startsWith(`scope:${scope}|roles:`)
        );
        return new ApiResponse(200, res.data);
      } else {
        return new ApiResponse(res.status, res.data);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * Delete a role.
   * @param {string} roleid
   * @returns {Promise<ApiResponse>}
   */
  async deleteRole(roleid) {
    let url = "";
    try {
      // get the role data from the api
      url = `${this._ctx.getServiceUrl(
        cnContext.SERVICE.USR
      )}/${this._ctx.getScope()}/userroles/${roleid}`;
      // make the api call
      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}|role:${roleid}` ||
            key.startsWith(`scope:${scope}|roles:`)
        );
        return new ApiResponse(200, res.data);
      } else {
        return new ApiResponse(res.status, res.data);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * Add users to a role.
   * @param {string} roleid
   * @param {object} [data] - Payload
   * @param {string} [data.parentid] - The parent ID
   * @param {string[]} [data.userids] - The user IDs
   * @returns {Promise<ApiResponse>}
   */

  async addUsersToRole(roleid, data) {
    // validate the input
    if (!roleid || !data?.userids || data?.userids?.length === 0) {
      return new ApiResponse(400, null);
    }

    let url = "";
    try {
      // get the role data from the api
      url = `${this._ctx.getServiceUrl(
        cnContext.SERVICE.USR
      )}/${this._ctx.getScope()}/userroles/${roleid}/users`;
      // make the api call
      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}|role:${roleid}` ||
            key.startsWith(`scope:${scope}|roles:`)
        );
        return new ApiResponse(200, res.data);
      } else {
        return new ApiResponse(res.status, res.data);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * Remove users from a role.
   * @param {string} roleid
   * @param {string[]} userids
   * @returns {Promise<ApiResponse>}
   */
  async removeUsersFromRole(roleid, userids) {
    // validate the input
    if (!roleid || !userids || userids.length === 0) {
      return new ApiResponse(400, null);
    }

    let url = "";
    try {
      // get the role data from the api
      url = `${this._ctx.getServiceUrl(
        cnContext.SERVICE.USR
      )}/${this._ctx.getScope()}/userroles/${roleid}/users`;
      // make the api call
      const res = await RestServices.makeApiCall(
        this._ctx.getToken(),
        RestServices.METHODS.DELETE,
        url,
        userids
      );
      if (res.status < 300) {
        const scope = this._ctx.getScope?.() || "";
        this.#cache?.markNeedUpdateWhere?.(
          (key) =>
            key === `scope:${scope}|role:${roleid}` ||
            key.startsWith(`scope:${scope}|roles:`)
        );
        return new ApiResponse(200, res.data);
      } else {
        return new ApiResponse(res.status, res.data);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * Update a role avatar (base64 string).
   * @param {string} roleid
   * @param {string} avatar - Base64 encoded image
   * @returns {Promise<ApiResponse>}
   */
  async updateRoleAvatar(roleid, avatar) {
    let url = "";
    try {
      // get the role avatar from the api
      url = `${this._ctx.getServiceUrl(
        cnContext.SERVICE.USR
      )}/${this._ctx.getScope()}/userroles/${roleid}/avatar`;
      // make the api call
      const res = await RestServices.makeApiCall(
        this._ctx.getToken(),
        RestServices.METHODS.PUT,
        url,
        avatar,
        "text/plain"
      );
      if (res.status < 300) {
        const key = `role:avatar:${roleid}`;
        this.#cache?.delete?.(key);
        this.#cache?.set?.(key, res.data, {
          tags: { type: "role", part: "avatar", roleid },
        });
        return new ApiResponse(200, res.data);
      } else {
        return new ApiResponse(res.status, res.data);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * Fetch a role avatar (base64 string).
   * @param {string} roleid
   * @returns {Promise<ApiResponse>} - The response object
   */
  async getRoleAvatar(roleid) {
    // validate the input
    const key = `role:avatar:${roleid}`;
    // get the avatar from the cache
    const avatar = this.#cache?.get?.(key);
    // if the avatar is found, return it
    if (avatar && avatar !== "") {
      return new ApiResponse(200, avatar);
    }

    let url = "";
    try {
      // get the role avatar from the api
      url = `${this._ctx.getServiceUrl(
        cnContext.SERVICE.USR
      )}/${this._ctx.getScope()}/userroles/${roleid}/avatar`;
      // make the api call
      const res = await RestServices.makeApiCall(
        this._ctx.getToken(),
        RestServices.METHODS.GET,
        url
      );
      if (res.status < 300) {
        // cache the avatar
        this.#cache?.set?.(key, res.data, {
          tags: { type: "role", part: "avatar", roleid },
        });
        // return the avatar
        return new ApiResponse(200, res.data);
      } else {
        return new ApiResponse(res.status, res.data);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * Fetch a single role by ID.
   * @param {string} roleid
   * @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 getRole(roleid, query) {
    let url = "";

    // validate the input
    if (!roleid) {
      return new ApiResponse(400, null);
    }

    // build the query string
    const queryString = this.#buildQueryString(query);
    url = `${this._ctx.getServiceUrl(
      cnContext.SERVICE.USR
    )}/${this._ctx.getScope()}/userroles/${roleid}`;

    const scope = this._ctx.getScope?.() || "";
    const key = `scope:${scope}|role:${roleid}|q:${queryString}`;
    const cached = this.#cache?.getEntry?.(key);
    if (cached && cached.needUpdate === false) {
      return new ApiResponse(200, cached.value);
    }

    try {
      // get the role data from the api
      // make the api call
      const res = await RestServices.makeApiCall(
        this._ctx.getToken(),
        RestServices.METHODS.GET,
        url + queryString
      );
      if (res.status < 300) {
        // process the data (guard null)
        const list = res.data ? [res.data] : [];
        const processedData = this.#processUserRoleObject(list);
        this.#cache?.set?.(key, processedData, {
          tags: { type: "role", kind: "single", roleid, scope },
        });
        // return the data
        return new ApiResponse(200, processedData[0]);
      } else {
        return new ApiResponse(res.status, res.data);
      }
    } catch (error) {
      return new ApiResponse(500, null);
    }
  }

  /**
   * List all roles (optionally filtered).
   * @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 getAllRoles(query) {
    let url = "";
    // build the query string
    const queryString = this.#buildQueryString(query);
    // build the url
    url = `${this._ctx.getServiceUrl(
      cnContext.SERVICE.USR
    )}/${this._ctx.getScope()}/userroles`;
    // build the key
    const scope = this._ctx.getScope?.() || "";
    const key = `scope:${scope}|roles:all|q:${queryString}`;
    // get the cached entry
    const cachedEntry = this.#cache?.getEntry?.(key);
    // if the cached entry 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 + queryString
      );
      if (res.status < 300) {
        // process the data (guard null)
        const list = Array.isArray(res.data) ? res.data : [];
        const processedData = this.#processUserRoleObject(list);
        // cache the data
        this.#cache?.set?.(key, processedData, {
          tags: { type: "role", kind: "list", scope },
        });
        // return the data
        return new ApiResponse(200, processedData);
      } else {
        return new ApiResponse(res.status, res.data || []);
      }
    } catch (error) {
      // return the error
      return new ApiResponse(500, []);
    }
  }

  /**
   * List roles by IDs (or all), optionally attaching avatars.
   * @param {string[]} roleList - List of role IDs
   * @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 roles
   * @returns {Promise<ApiResponse>} - The response object
   */
  async getRoles(roleList = [], query, withAvatar = false) {
    let rolesList = /** @type {UserRole[]} */ ([]);
    // get the roles from the api
    try {
      if (roleList.length > 0) {
        // get the roles from the api
        const roleRes = await Promise.all(
          roleList?.map(async (roleid) => {
            const res = await this.getRole(roleid, query);
            return res.status < 300 && res.data ? [res.data] : [];
          })
        );
        // flatten the roles
        const flattenedRoles = roleRes.flat();
        rolesList.push(...flattenedRoles);
      }
      // if the roles are not found, get the roles from the api
      if (rolesList.length === 0) {
        const rolesRes = await this.getAllRoles(query);
        rolesList.push(...(rolesRes.data || []));
      }
      // get the avatar of the roles if withAvatar is true
      if (withAvatar) {
        // get the avatar of the roles
        const avatarList = await Promise.all(
          rolesList.map(async (role) => {
            // @ts-ignore
            const res = await this.getRoleAvatar(role?.info?.id);
            return res.status < 300 ? res.data : null;
          })
        );
        // add the avatar to the roles
        rolesList.forEach((role, index) => {
          role.avatar = avatarList[index] || "";
        });
      }
      return new ApiResponse(200, rolesList || []);
    } catch (error) {
      return new ApiResponse(500, []);
    }
  }

  /**
   * query string builder
   * @param {object} [query] - Query object containing filter, members, etc.
   * @param {string} [query.filter] - The filter
   * @param {string} [query.members] - The members
   * @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 = "?";
    // Handle filter parameter
    if (query["filter"]) {
      queryString += c + "filter=" + encodeURIComponent(query["filter"]);
      c = "&";
    }
    // Handle members parameter - controls what data is returned
    if (query["members"]) {
      let membersString = query["members"];
      // Ensure defaultMember is included if specified and not already present
      if (defaultMember && !membersString.includes(defaultMember)) {
        membersString = membersString + "," + defaultMember;
      }
      queryString += c + "members=" + encodeURIComponent(membersString);
      c = "&";
    } else if (defaultMember) {
      // Apply default member if not specified
      queryString += c + "members=" + encodeURIComponent(defaultMember);
      c = "&";
    }
    return queryString;
  }

  /**
   * Process the user role object
   * @param {Object[]} Objects - Array of user role objects
   * @returns {UserRole[]} - Array of UserRole objects
   */
  #processUserRoleObject(Objects = []) {
    let rolesList = [];
    for (const object of Objects) {
      const roleObject = UserRole.createFromJSON(object);
      rolesList.push(roleObject);
    }
    return rolesList;
  }
}

// ESM file: no CommonJS export