Source: src/js/models/notifications/ObjectNotification.js

define(["backbone", "dataoneNotifications"], (
  Backbone,
  DataONENotifications,
) => {
  "use strict";

  const ERROR_CODES = {
    CLIENT_UNAVAILABLE: "CLIENT_UNAVAILABLE",
    FEATURE_DISABLED: "FEATURE_DISABLED",
    MISSING_EMAIL: "MISSING_EMAIL",
    PID_REQUIRED: "PID_REQUIRED",
    RESOURCE_TYPES_UNSUPPORTED: "RESOURCE_TYPES_UNSUPPORTED",
    SERVICE_URL_REQUIRED: "SERVICE_URL_REQUIRED",
    SIGN_IN_REQUIRED: "SIGN_IN_REQUIRED",
    STILL_LOADING: "STILL_LOADING",
  };

  const GET_USER_MODEL = () => {
    if (MetacatUI.appUserModel) {
      return MetacatUI.appUserModel;
    }
    throw new Error("User model is not available.");
  };

  const GET_APP_MODEL = () => {
    if (MetacatUI.appModel) {
      return MetacatUI.appModel;
    }
    throw new Error("App model is not available.");
  };

  /**
   * @class ObjectNotification
   * @classdesc Model for loading and saving notification subscriptions for a
   * single DataONE object identifier.
   * @classcategory Models
   * @augments Backbone.Model
   * @since 2.37.0
   */
  const ObjectNotification = Backbone.Model.extend(
    /** @lends ObjectNotification.prototype */ {
      /**
       * Default model attributes.
       * @returns {object} Model defaults.
       */
      defaults() {
        return {
          apiVersion: undefined,
          client: null,
          loadedSubscriptions: false,
          loadingSubscriptions: false,
          metadataModel: null,
          pid: null,
          prefixUrl: null,
          resourceTypeOptions: [],
          resourceTypes: null,
          savedResourceTypes: [],
          savingSubscriptions: false,
          title: null,
        };
      },

      /**
       * @param {object} [_attrs] Initial model attributes.
       * @param {object} [options] Model options.
       * @param {Backbone.Model} [options.appModel] App model override.
       * @param {Backbone.Model} [options.userModel] User model override.
       * @param {Function} [options.NotificationClient] NotificationClient
       * constructor override.
       */
      initialize(_attrs = {}, options = {}) {
        this.appModel = options.appModel || null;
        this.userModel = options.userModel || null;
        this.NotificationClient = options.NotificationClient || null;
        this.loadRequestId = 0;
        this.saveRequestId = 0;

        this.refreshResourceTypes();

        this.stopListening();
        this.listenTo(
          this.getAppModel(),
          "change:notificationServiceUrl change:notificationServiceApiVersion change:notificationServiceResourceTypes change:enableNotificationService",
          this.resetClient,
        );
        this.listenTo(
          this.getUserModel(),
          "change:loggedIn change:email",
          this.resetClient,
        );
      },

      /**
       * Fetch the current subscriptions for this PID.
       * @returns {Promise<string[]>} Saved resource type names.
       */
      async loadSubscriptions() {
        const setupError = this.getSetupError();
        if (setupError) {
          this.set({
            loadedSubscriptions: false,
            loadingSubscriptions: false,
          });
          throw new Error(setupError);
        }

        const requestId = this.loadRequestId + 1;
        this.loadRequestId = requestId;
        this.set({
          loadedSubscriptions: false,
          loadingSubscriptions: true,
        });

        try {
          const client = await this.ensureClient();
          const resourceTypes = await client.getResourceTypesByPid({
            pid: this.get("pid"),
          });

          if (requestId !== this.loadRequestId) {
            return this.get("savedResourceTypes").slice();
          }

          const savedResourceTypes = this.setSavedResourceTypes(
            resourceTypes || [],
          );
          this.set({ loadedSubscriptions: true });
          this.trigger("subscriptions:loaded", {
            pid: this.get("pid"),
            resourceTypes: savedResourceTypes.slice(),
          });
          return savedResourceTypes;
        } finally {
          if (requestId === this.loadRequestId) {
            this.set({ loadingSubscriptions: false });
          }
        }
      },

      /**
       * Save selected resource types by subscribing and unsubscribing as needed.
       * @param {string[]} selectedResourceTypes Selected resource type names.
       * @returns {Promise.<{changed: boolean, resourceTypes: Array.<string>, stale: (boolean|undefined)}>}
       * Save result.
       */
      async saveSubscriptions(selectedResourceTypes) {
        const setupError = this.getSetupError();
        if (setupError) {
          throw new Error(setupError);
        }

        if (!this.get("loadedSubscriptions")) {
          throw new Error(ERROR_CODES.STILL_LOADING);
        }

        const selected = this.filterSupportedResourceTypes(
          selectedResourceTypes || [],
        );
        const changes = this.getSubscriptionChanges(selected);

        if (!changes.toSubscribe.length && !changes.toUnsubscribe.length) {
          return {
            changed: false,
            resourceTypes: this.get("savedResourceTypes").slice(),
          };
        }

        const requestId = this.saveRequestId + 1;
        this.saveRequestId = requestId;
        const staleResult = () => ({
          changed: false,
          resourceTypes: this.get("savedResourceTypes").slice(),
          stale: true,
        });
        this.set({ savingSubscriptions: true });

        try {
          const client = await this.ensureClient();
          const subscribeRequests = changes.toSubscribe.map((resourceType) =>
            client.subscribe({ pid: this.get("pid"), resourceType }),
          );
          const unsubscribeRequests = changes.toUnsubscribe.map(
            (resourceType) =>
              client.unsubscribe({ pid: this.get("pid"), resourceType }),
          );

          await Promise.all([...subscribeRequests, ...unsubscribeRequests]);

          if (requestId !== this.saveRequestId) {
            return staleResult();
          }

          const savedResourceTypes = this.setSavedResourceTypes(selected);
          const payload = {
            pid: this.get("pid"),
            resourceTypes: savedResourceTypes.slice(),
          };
          this.trigger("subscriptions:saved", payload);
          return {
            changed: true,
            resourceTypes: savedResourceTypes,
          };
        } catch (error) {
          if (requestId !== this.saveRequestId) {
            return staleResult();
          }
          throw error;
        } finally {
          if (requestId === this.saveRequestId) {
            this.set({ savingSubscriptions: false });
          }
        }
      },

      /**
       * Ensure a NotificationClient instance is available.
       * @returns {Promise<object>} NotificationClient instance.
       */
      async ensureClient() {
        if (this.get("client")) {
          return this.get("client");
        }

        const NotificationClient =
          this.NotificationClient || DataONENotifications?.NotificationClient;

        if (!NotificationClient) {
          throw new Error(ERROR_CODES.CLIENT_UNAVAILABLE);
        }

        const setupError = this.getSetupError();
        if (setupError) {
          throw new Error(setupError);
        }

        const viewModel = this;
        const client = new NotificationClient({
          prefixUrl: this.getPrefixUrl(),
          apiVersion: this.getApiVersion(),
          resourceTypes: this.getResourceTypeNames(),
          getToken: () => viewModel.getUserModel().getTokenPromise(),
          validatePID: (pid) => viewModel.validatePid(pid),
        });

        this.set({ client });
        return client;
      },

      /** Reset the cached NotificationClient and loaded state */
      resetClient() {
        this.loadRequestId += 1;
        this.saveRequestId += 1;
        this.set({
          client: null,
          loadedSubscriptions: false,
          loadingSubscriptions: false,
          savedResourceTypes: [],
          savingSubscriptions: false,
        });
        this.refreshResourceTypes();
        this.trigger("notifications:reset");
      },

      /**
       * Return a setup error code that blocks loading or saving.
       * @returns {string|null} Error code or null.
       */
      getSetupError() {
        const appModel = this.getAppModel();
        const featureEnabled = !!appModel.get("enableNotificationService");
        if (!featureEnabled) {
          return ERROR_CODES.FEATURE_DISABLED;
        }

        if (!this.getUserModel().get("loggedIn")) {
          return ERROR_CODES.SIGN_IN_REQUIRED;
        }

        if (!this.getEmailAddress()) {
          return ERROR_CODES.MISSING_EMAIL;
        }

        if (!this.validatePid(this.get("pid"))) {
          return ERROR_CODES.PID_REQUIRED;
        }

        if (!this.getPrefixUrl()) {
          return ERROR_CODES.SERVICE_URL_REQUIRED;
        }

        if (!this.getResourceTypes().length) {
          return ERROR_CODES.RESOURCE_TYPES_UNSUPPORTED;
        }

        return null;
      },

      /**
       * Retrieve and store configured resource types.
       * @returns {Array.<{type: string, label: string, description: string}>}
       * Resource type display options.
       */
      refreshResourceTypes() {
        const resourceTypeOptions = this.getResourceTypes();
        this.set({ resourceTypeOptions });
        return resourceTypeOptions;
      },

      /**
       * Retrieve configured resource types.
       * @returns {Array.<{type: string, label: string, description: string}>}
       * Resource type display options.
       */
      getResourceTypes() {
        const override = this.get("resourceTypes");
        const configured = this.getAppModel().get(
          "notificationServiceResourceTypes",
        );
        const types = override || configured || [];

        return Array.isArray(types) ? types.slice() : [];
      },

      /**
       * Get configured resource type names.
       * @returns {string[]} Resource type names.
       */
      getResourceTypeNames() {
        return this.getResourceTypes().map((option) => option.type);
      },

      /**
       * Filter resource type names to supported values in configured order.
       * @param {string[]} resourceTypes Resource type names.
       * @returns {string[]} Supported resource type names.
       */
      filterSupportedResourceTypes(resourceTypes) {
        const subscribedTypeKeys = new Set(
          Array.isArray(resourceTypes) ? resourceTypes : [],
        );

        return this.getResourceTypes()
          .filter((option) => subscribedTypeKeys.has(option.type))
          .map((option) => option.type);
      },

      /**
       * Set the last saved subscription state.
       * @param {string[]} resourceTypes Resource type names returned by the
       * service.
       * @returns {string[]} Supported saved resource type names.
       */
      setSavedResourceTypes(resourceTypes) {
        const savedResourceTypes =
          this.filterSupportedResourceTypes(resourceTypes);
        this.set({ savedResourceTypes });
        return savedResourceTypes;
      },

      /**
       * Diff selected types against the last saved types.
       * @param {string[]} selectedResourceTypes Current selected resource
       * types.
       * @returns {{toSubscribe: string[], toUnsubscribe: string[]}} Resource
       * type changes.
       */
      getSubscriptionChanges(selectedResourceTypes) {
        const selected = this.filterSupportedResourceTypes(
          selectedResourceTypes || [],
        );
        const selectedKeys = new Set(selected);
        const savedResourceTypes = this.get("savedResourceTypes") || [];
        const savedKeys = new Set(savedResourceTypes);

        return {
          toSubscribe: selected.filter((type) => !savedKeys.has(type)),
          toUnsubscribe: savedResourceTypes.filter(
            (type) => !selectedKeys.has(type),
          ),
        };
      },

      /**
       * Retrieve the Notification Service base URL.
       * @returns {string|null} URL or null.
       */
      getPrefixUrl() {
        return (
          this.get("prefixUrl") ||
          this.getAppModel().get("notificationServiceUrl") ||
          null
        );
      },

      /**
       * Retrieve the Notification Service API version.
       * @returns {string|false|undefined} Configured API version.
       */
      getApiVersion() {
        if (this.get("apiVersion") !== undefined) {
          return this.get("apiVersion");
        }
        return this.getAppModel().get("notificationServiceApiVersion");
      },

      /**
       * Get a dataset title from attributes, model, or PID.
       * @returns {string} Dataset title.
       */
      getDatasetTitle() {
        if (this.get("title")) return this.asString(this.get("title"));

        const metadataModel = this.get("metadataModel");
        if (metadataModel && typeof metadataModel.get === "function") {
          const title =
            metadataModel.get("title") ||
            metadataModel.get("name") ||
            metadataModel.get("id");
          const text = this.asString(Array.isArray(title) ? title[0] : title);
          if (text) return text;
        }

        return this.asString(this.get("pid"));
      },

      /**
       * Get the email address the notification service will use.
       * @returns {string} Email address.
       */
      getEmailAddress() {
        return this.asString(this.getUserModel().get("email"));
      },

      /**
       * Get the user's account settings URL.
       * @returns {string} Account settings URL.
       */
      getAccountSettingsUrl() {
        const username = this.asString(this.getUserModel().get("username"));
        const profilePath = username ? `profile/${username}` : "my-profile";

        return `${MetacatUI.root}/${profilePath}/s=settings/s=account`;
      },

      /**
       * Validate that a PID has been provided.
       * @param {string} pid PID to validate.
       * @returns {boolean} True when the PID is non-empty.
       */
      validatePid(pid) {
        return typeof pid === "string" && pid.trim().length > 0;
      },

      /**
       * Convert any value to a string.
       * @param {*} value Value to convert.
       * @returns {string} String value.
       */
      asString(value) {
        if (value === null || value === undefined) return "";
        return String(value).trim();
      },

      /**
       * Get the configured AppModel.
       * @returns {Backbone.Model} App model.
       */
      getAppModel() {
        return this.appModel || GET_APP_MODEL();
      },

      /**
       * Get the configured user model.
       * @returns {Backbone.Model} User model.
       */
      getUserModel() {
        return this.userModel || GET_USER_MODEL();
      },
    },
  );

  ObjectNotification.ERROR_CODES = ERROR_CODES;

  return ObjectNotification;
});