Source

src/context.ts

/*!
 * Convert JS SDK
 * Version 1.0.0
 * Copyright(c) 2020 Convert Insights, Inc
 * License Apache-2.0
 */
import {ContextInterface, PreviewInput} from './interfaces/context';
import {EventManagerInterface} from '@convertcom/js-sdk-event';
import {ExperienceManagerInterface} from '@convertcom/js-sdk-experience';
import {FeatureManagerInterface} from './interfaces/feature-manager';
import {LogManagerInterface} from '@convertcom/js-sdk-logger';
import {DataManagerInterface} from '@convertcom/js-sdk-data';

import {
  Config,
  BucketedFeature,
  BucketedVariation,
  BucketingAttributes,
  ConversionAttributes,
  VisitorSegments,
  SegmentsAttributes,
  Entity,
  ConfigExperience,
  ExperienceVariationConfig,
  StoreData
} from '@convertcom/js-sdk-types';

import {
  BucketingError,
  ERROR_MESSAGES,
  EntityType,
  RuleError,
  SystemEvents
} from '@convertcom/js-sdk-enums';
import {objectDeepMerge, objectNotEmpty} from '@convertcom/js-sdk-utils';
import {SegmentsManagerInterface} from '@convertcom/js-sdk-segments';
import {ApiManagerInterface} from '@convertcom/js-sdk-api';

/**
 * Provides visitor context
 * @category Main
 * @constructor
 * @implements {ContextInterface}
 */
export class Context implements ContextInterface {
  private _eventManager: EventManagerInterface;
  private _experienceManager: ExperienceManagerInterface;
  private _featureManager: FeatureManagerInterface;
  private _dataManager: DataManagerInterface;
  private _segmentsManager: SegmentsManagerInterface;
  private _apiManager: ApiManagerInterface;
  private _loggerManager: LogManagerInterface;
  private _config: Config;
  private _visitorId: string;
  private _visitorProperties: Record<string, any>;
  private _environment: string;
  /**
   * qs-02: preview state for this Context instance only. Never shared across
   * Context instances on the same SDK instance (isolation, AC6). `null` means
   * this context runs normally.
   */
  private _preview: {
    experienceId: string;
    variationId: string;
    experience: ConfigExperience;
    experienceKey: string;
  } | null = null;

  /**
   * @param {Config} config
   * @param {Object} dependencies
   * @param {ApiManagerInterface} dependencies.apiManager
   * @param {EventManagerInterface} dependencies.eventManager
   * @param {ExperienceManagerInterface} dependencies.experienceManager
   * @param {FeatureManagerInterface} dependencies.featureManager
   * @param {DataManagerInterface} dependencies.dataManager
   * @param {ApiManagerInterface} dependencies.apiManager
   * @param {LogManagerInterface} dependencies.loggerManager
   */
  constructor(
    config: Config,
    visitorId: string,
    {
      eventManager,
      experienceManager,
      featureManager,
      segmentsManager,
      dataManager,
      apiManager,
      loggerManager
    }: {
      eventManager: EventManagerInterface;
      experienceManager: ExperienceManagerInterface;
      featureManager: FeatureManagerInterface;
      segmentsManager: SegmentsManagerInterface;
      dataManager: DataManagerInterface;
      apiManager: ApiManagerInterface;
      loggerManager?: LogManagerInterface;
    },
    visitorProperties?: Record<string, any>
  ) {
    this._environment = config?.environment;
    this._visitorId = visitorId;

    this._config = config;
    this._eventManager = eventManager;
    this._experienceManager = experienceManager;
    this._featureManager = featureManager;
    this._dataManager = dataManager;
    this._segmentsManager = segmentsManager;
    this._apiManager = apiManager;
    this._loggerManager = loggerManager;

    if (objectNotEmpty(visitorProperties)) {
      const {properties} =
        this._dataManager.filterReportSegments(visitorProperties);
      if (properties) this._visitorProperties = properties;
      segmentsManager.putSegments(visitorId, visitorProperties);
    }
  }

  /**
   * qs-02: put this Context into preview mode for a single experience/variation
   * pair. Once set:
   *  - `runExperience(experienceKey)` for the matching `experienceKey` returns
   *    the forced preview decision directly (bypassing audiences/locations/
   *    bucketing) and fires no `SystemEvents.BUCKETING` event.
   *  - Every other `run*` call is suppressed from tracking/persisting
   *    (`enableTracking: false`, `enableStorage: false`) and fires no
   *    `SystemEvents.BUCKETING` event, nor `LOCATION_ACTIVATED`/
   *    `LOCATION_DEACTIVATED` events (location/audience matching still runs).
   *  - `trackConversion()` is a full no-op (zero-trace, AC5).
   * Preview state is local to THIS Context instance only (AC6 isolation) and
   * never mutates shared `DataManager` config.
   * Inert (leaves `_preview` unset) when `experienceId`/`variationId` are
   * missing, when the experience cannot be resolved (neither via shared
   * config nor via the `?exp=` low-cache fetch), or when the variation is not
   * present on the resolved experience (AC7).
   * @param {Object} input
   * @param {string} input.experienceId
   * @param {string} input.variationId
   * @return {Promise<void>}
   */
  async setPreview(input: PreviewInput): Promise<void> {
    const experienceId = input?.experienceId;
    const variationId = input?.variationId;
    if (!experienceId || !variationId) {
      this._loggerManager?.warn?.(
        'Context.setPreview()',
        ERROR_MESSAGES.PREVIEW_INPUT_REQUIRED
      );
      return;
    }

    let experience = this._dataManager.getEntityById(
      experienceId,
      EntityType.EXPERIENCE
    ) as ConfigExperience;

    if (!experience) {
      try {
        const config =
          await this._apiManager.getConfigByExperience(experienceId);
        experience = (config?.experiences || []).find(
          (candidate) => String(candidate?.id) === String(experienceId)
        ) as ConfigExperience;
      } catch (error) {
        this._loggerManager?.warn?.(
          'Context.setPreview()',
          ERROR_MESSAGES.PREVIEW_EXPERIENCE_NOT_FOUND,
          error
        );
        this._preview = null;
        return;
      }
    }

    if (!experience) {
      this._loggerManager?.warn?.(
        'Context.setPreview()',
        ERROR_MESSAGES.PREVIEW_EXPERIENCE_NOT_FOUND
      );
      this._preview = null;
      return;
    }

    const decision = this._dataManager.getPreviewDecision(
      experience,
      variationId
    );
    if (!decision) {
      this._loggerManager?.warn?.(
        'Context.setPreview()',
        ERROR_MESSAGES.PREVIEW_VARIATION_NOT_FOUND
      );
      this._preview = null;
      return;
    }

    this._preview = {
      experienceId,
      variationId,
      experience,
      experienceKey: experience.key
    };
  }

  /**
   * Get variation from specific experience
   * @param {string} experienceKey An experience's key that should be activated
   * @param {BucketingAttributes=} attributes An object that specifies attributes for the visitor
   * @param {Record<any, any>=} attributes.locationProperties An object of key-value pairs that are used for location matching
   * @param {Record<any, any>=} attributes.visitorProperties An object of key-value pairs that are used for audience targeting
   * @param {boolean=} attributes.updateVisitorProperties Decide whether to update visitor properties upon bucketing
   * @param {string=} attributes.environment Overwrite the environment
   * @return {BucketedVariation | RuleError | BucketingError}
   */
  runExperience(
    experienceKey: string,
    attributes?: BucketingAttributes
  ): BucketedVariation | RuleError | BucketingError {
    if (!this._visitorId) {
      this._loggerManager?.error?.(
        'Context.runExperience()',
        ERROR_MESSAGES.VISITOR_ID_REQUIRED
      );
      return;
    }
    // qs-02: force the preview decision for the previewed experience -- bypass
    // audiences/locations/bucketing entirely and never fire BUCKETING (AC3, AC5).
    if (this._preview && experienceKey === this._preview.experienceKey) {
      return this._dataManager.getPreviewDecision(
        this._preview.experience,
        this._preview.variationId
      );
    }
    const visitorProperties = this.getVisitorProperties(
      attributes?.visitorProperties
    );
    const bucketedVariation = this._experienceManager.selectVariation(
      this._visitorId,
      experienceKey,
      {
        ...attributes,
        visitorProperties, // represents audiences
        environment: attributes?.environment || this._environment,
        // qs-02: while previewing, every OTHER experience still decides but is
        // fully suppressed from tracking/persisting and LOCATION events
        // (zero-trace, AC5).
        ...(this._preview
          ? {enableTracking: false, enableStorage: false, suppressEvents: true}
          : {})
      }
    );
    if (Object.values(RuleError).includes(bucketedVariation as RuleError))
      return bucketedVariation as RuleError;
    if (
      Object.values(BucketingError).includes(
        bucketedVariation as BucketingError
      )
    )
      return bucketedVariation as BucketingError;
    if (bucketedVariation) {
      if (!this._preview) {
        this._eventManager.fire(
          SystemEvents.BUCKETING,
          {
            visitorId: this._visitorId,
            experienceKey,
            variationKey: (bucketedVariation as BucketedVariation).key
          },
          null,
          true
        );
      }
    }
    return bucketedVariation as BucketedVariation;
  }

  /**
   * Get variations across all experiences
   * @param {BucketingAttributes=} attributes An object that specifies attributes for the visitor
   * @param {string=} attributes.locationProperties An object of key-value pairs that are used for location matching
   * @param {Record<any, any>=} attributes.visitorProperties An object of key-value pairs that are used for audience targeting
   * @param {boolean=} attributes.updateVisitorProperties Decide whether to update visitor properties upon bucketing
   * @param {string=} attributes.environment Overwrite the environment
   * @return {Array<BucketedVariatio | RuleError | BucketingError>}
   */
  runExperiences(
    attributes?: BucketingAttributes
  ): Array<BucketedVariation | RuleError | BucketingError> {
    if (!this._visitorId) {
      this._loggerManager?.error?.(
        'Context.runExperiences()',
        ERROR_MESSAGES.VISITOR_ID_REQUIRED
      );
      return;
    }
    const visitorProperties = this.getVisitorProperties(
      attributes?.visitorProperties
    );
    const bucketedVariations = this._experienceManager.selectVariations(
      this._visitorId,
      {
        ...attributes,
        visitorProperties, // represents audiences
        environment: attributes?.environment || this._environment,
        // qs-02: suppress tracking/persisting and LOCATION events while
        // previewing (AC5).
        ...(this._preview
          ? {enableTracking: false, enableStorage: false, suppressEvents: true}
          : {})
      }
    );
    // Return rule errors if present
    const matchedRuleErrors = bucketedVariations.filter((match) =>
      Object.values(RuleError).includes(match as RuleError)
    );
    if (matchedRuleErrors.length) return matchedRuleErrors as Array<RuleError>;
    // Return bucketing errors if present
    const matchedBucketingErrors = bucketedVariations.filter((match) =>
      Object.values(BucketingError).includes(match as BucketingError)
    );
    if (matchedBucketingErrors.length)
      return matchedBucketingErrors as Array<BucketingError>;

    if (!this._preview) {
      (bucketedVariations as Array<BucketedVariation>).forEach(
        ({experienceKey, key}) => {
          this._eventManager.fire(
            SystemEvents.BUCKETING,
            {
              visitorId: this._visitorId,
              experienceKey,
              variationKey: key
            },
            null,
            true
          );
        }
      );
      return bucketedVariations as Array<BucketedVariation>;
    }
    // qs-02: preview forcing is API-agnostic -- the previewed experience must
    // return its forced variation from run-all too, not only from
    // runExperience(key) (parity with the PHP/Android/Python/iOS SDKs).
    // Replace the previewed experience's normally-bucketed entry with the
    // forced decision, or append it when the previewed experience is a draft
    // absent from the run-all set (mirrors runExperience's getPreviewDecision
    // path). No BUCKETING event fires and nothing persists -- zero-trace (AC5)
    // is already guaranteed by the enableTracking/enableStorage:false forwarded
    // into selectVariations above.
    const preview = this._preview;
    const forced = this._dataManager.getPreviewDecision(
      preview.experience,
      preview.variationId
    );
    if (!forced) return bucketedVariations as Array<BucketedVariation>;
    const forcedRest = (bucketedVariations as Array<BucketedVariation>).filter(
      (variation) => variation.experienceKey !== preview.experienceKey
    );
    return [...forcedRest, forced];
  }

  /**
   * Get feature and its status
   * @param {string} key A feature key
   * @param {BucketingAttributes=} attributes An object that specifies attributes for the visitor
   * @param {string=} attributes.locationProperties An object of key-value pairs that are used for location matching
   * @param {Record<any, any>=} attributes.visitorProperties An object of key-value pairs that are used for audience targeting
   * @param {boolean=} attributes.updateVisitorProperties Decide whether to update visitor properties upon bucketing
   * @param {string=} attributes.environment Overwrite the environment
   * @param {boolean=} attributes.typeCasting Control automatic type conversion to the variable's defined type. Does not do any JSON validation. Defaults to `true`
   * @param {Array<string>=} attributes.experienceKeys Use only specific experiences
   * @return {BucketedFeature | RuleError | Array<BucketedFeature | RuleError>}
   */
  runFeature(
    key: string,
    attributes?: BucketingAttributes
  ): BucketedFeature | RuleError | Array<BucketedFeature | RuleError> {
    if (!this._visitorId) {
      this._loggerManager?.error?.(
        'Context.runFeature()',
        ERROR_MESSAGES.VISITOR_ID_REQUIRED
      );
      return;
    }
    const visitorProperties = this.getVisitorProperties(
      attributes?.visitorProperties
    );
    const bucketedFeature = this._featureManager.runFeature(
      this._visitorId,
      key,
      {
        visitorProperties,
        locationProperties: attributes?.locationProperties,
        updateVisitorProperties: attributes?.updateVisitorProperties,
        typeCasting: Object.prototype.hasOwnProperty.call(
          attributes || {},
          'typeCasting'
        )
          ? attributes.typeCasting
          : true,
        environment: attributes?.environment || this._environment,
        // qs-02: suppress tracking/persisting and LOCATION events while
        // previewing (AC5).
        ...(this._preview
          ? {enableTracking: false, enableStorage: false, suppressEvents: true}
          : {})
      },
      attributes?.experienceKeys
    );
    if (Array.isArray(bucketedFeature)) {
      // Return rule errors if present
      const matchedErrors = bucketedFeature.filter((match) =>
        Object.values(RuleError).includes(match as RuleError)
      );
      if (matchedErrors.length) return matchedErrors as Array<RuleError>;

      if (!this._preview) {
        (bucketedFeature as Array<BucketedFeature>).forEach(
          ({experienceKey, status}) => {
            this._eventManager.fire(
              SystemEvents.BUCKETING,
              {
                visitorId: this._visitorId,
                experienceKey,
                featureKey: key,
                status
              },
              null,
              true
            );
          }
        );
      }
    } else {
      if (Object.values(RuleError).includes(bucketedFeature as RuleError))
        return bucketedFeature as RuleError;

      if (bucketedFeature && !this._preview) {
        this._eventManager.fire(
          SystemEvents.BUCKETING,
          {
            visitorId: this._visitorId,
            experienceKey: (bucketedFeature as BucketedFeature).experienceKey,
            featureKey: key,
            status: (bucketedFeature as BucketedFeature).status
          },
          null,
          true
        );
      }
    }
    return bucketedFeature as BucketedFeature;
  }

  /**
   * Get features and their statuses
   * @param {BucketingAttributes=} attributes An object that specifies attributes for the visitor
   * @param {string=} attributes.locationProperties An object of key-value pairs that are used for location matching
   * @param {Record<any, any>=} attributes.visitorProperties An object of key-value pairs that are used for audience targeting
   * @param {boolean=} attributes.updateVisitorProperties Decide whether to update visitor properties upon bucketing
   * @param {string=} attributes.environment Overwrite the environment
   * @param {boolean=} attributes.typeCasting Control automatic type conversion to the variable's defined type. Does not do any JSON validation. Defaults to `true`
   * @return {Array<BucketedFeature | RuleError>}
   */
  runFeatures(
    attributes?: BucketingAttributes
  ): Array<BucketedFeature | RuleError> {
    if (!this._visitorId) {
      this._loggerManager?.error?.(
        'Context.runFeatures()',
        ERROR_MESSAGES.VISITOR_ID_REQUIRED
      );
      return;
    }
    const visitorProperties = this.getVisitorProperties(
      attributes?.visitorProperties
    );
    const bucketedFeatures = this._featureManager.runFeatures(this._visitorId, {
      visitorProperties,
      locationProperties: attributes?.locationProperties,
      updateVisitorProperties: attributes?.updateVisitorProperties,
      typeCasting: Object.prototype.hasOwnProperty.call(
        attributes || {},
        'typeCasting'
      )
        ? attributes.typeCasting
        : true,
      environment: attributes?.environment || this._environment,
      // qs-02: suppress tracking/persisting and LOCATION events while
      // previewing (AC5).
      ...(this._preview
        ? {enableTracking: false, enableStorage: false, suppressEvents: true}
        : {})
    });
    // Return rule errors if present
    const matchedErrors = bucketedFeatures.filter((match) =>
      Object.values(RuleError).includes(match as RuleError)
    );
    if (matchedErrors.length) return matchedErrors as Array<RuleError>;

    if (!this._preview) {
      (bucketedFeatures as Array<BucketedFeature>).forEach(
        ({experienceKey, key, status}) => {
          this._eventManager.fire(
            SystemEvents.BUCKETING,
            {
              visitorId: this._visitorId,
              experienceKey,
              featureKey: key,
              status
            },
            null,
            true
          );
        }
      );
    }
    return bucketedFeatures as Array<BucketedFeature>;
  }

  /**
   * Trigger Conversion
   * @param {string} goalKey A goal key
   * @param {ConversionAttributes=} attributes An object that specifies attributes for the visitor
   * @param {Record<string, any>=} attributes.ruleData An object of key-value pairs that are used for goal matching
   * @param {Array<GoalData>=} attributes.conversionData An array of key-value pairs that are used for transaction data
   * @param {Record<ConversionSettingKey, number | string | boolean>} attributes.conversionSetting An object of key-value pairs that are used for tracking settings
   * @return {RuleError}
   */
  trackConversion(
    goalKey: string,
    attributes?: ConversionAttributes
  ): RuleError {
    if (!this._visitorId) {
      this._loggerManager?.error?.(
        'Context.trackConversion()',
        ERROR_MESSAGES.VISITOR_ID_REQUIRED
      );
      return;
    }
    // qs-02: zero-trace -- a previewing context never converts (AC5). Return
    // before calling dataManager.convert() so no store write / enqueue can occur.
    if (this._preview) {
      this._loggerManager?.trace?.(
        'Context.trackConversion()',
        'Skipped: Context is in preview mode',
        {goalKey}
      );
      return;
    }

    const goalRule = attributes?.ruleData;
    const goalData = attributes?.conversionData;
    if (goalData) {
      if (!Array.isArray(goalData)) {
        this._loggerManager?.error?.(
          'Context.trackConversion()',
          ERROR_MESSAGES.GOAL_DATA_NOT_VALID
        );
        return;
      }
    }

    const segments = this._segmentsManager.getSegments(this._visitorId);
    const triggred = this._dataManager.convert(
      this._visitorId,
      goalKey,
      goalRule,
      goalData,
      segments,
      attributes?.conversionSetting
    );
    if (Object.values(RuleError).includes(triggred as RuleError))
      return triggred as RuleError;
    if (triggred) {
      this._eventManager.fire(
        SystemEvents.CONVERSION,
        {
          visitorId: this._visitorId,
          goalKey
        },
        null,
        true
      );
    }

    return;
  }

  /**
   * Set default segments for reports
   * @param {VisitorSegments} segments A segment key
   */
  setDefaultSegments(segments: VisitorSegments): void {
    // qs-02: preview contexts must leave zero trace -- suppress the
    // persistence write while preserving normal-context behavior exactly.
    this._segmentsManager.putSegments(
      this._visitorId,
      segments,
      !this._preview
    );
  }

  /**
   * To be deprecated
   */
  setCustomSegments(
    segmentKeys: string[],
    attributes?: SegmentsAttributes
  ): RuleError {
    return this.runCustomSegments(segmentKeys, attributes);
  }

  /**
   * Match Custom segments
   * @param {Array<string>} segmentKeys A list of segment keys
   * @param {SegmentsAttributes=} attributes An object that specifies attributes for the visitor
   * @param {Record<string, any>=} attributes.ruleData An object of key-value pairs that are used for segments matching
   * @return {RuleError}
   */
  runCustomSegments(
    segmentKeys: Array<string>,
    attributes?: SegmentsAttributes
  ): RuleError {
    if (!this._visitorId) {
      this._loggerManager?.error?.(
        'Context.runCustomSegments()',
        ERROR_MESSAGES.VISITOR_ID_REQUIRED
      );
      return;
    }
    const segmentsRule = this.getVisitorProperties(attributes?.ruleData);
    // qs-02: preview contexts must leave zero trace -- suppress the
    // persistence write while preserving normal-context matching behavior.
    const error = this._segmentsManager.selectCustomSegments(
      this._visitorId,
      segmentKeys,
      segmentsRule,
      !this._preview
    );
    if (error) return error as RuleError;

    return;
  }

  /**
   * Update visitor properties in memory
   * @param {string} visitorId
   * @param {Record<string, any>} visitorProperties
   */
  updateVisitorProperties(
    visitorId: string,
    visitorProperties: Record<string, any>
  ): void {
    // qs-02: preview contexts must leave zero trace -- skip the persistence
    // write entirely (DataManager.putData() has no per-call storage gate).
    if (this._preview) return;
    this._dataManager.putData(visitorId, {segments: visitorProperties});
  }

  /**
   * get Config Entity
   * @param {string} key
   * @param {EntityType} entityType
   * @return {Entity}
   */
  getConfigEntity(key: string, entityType: EntityType): Entity {
    if (entityType === EntityType.VARIATION) {
      const experiences = this._dataManager.getEntitiesList(
        EntityType.EXPERIENCE
      ) as Array<ConfigExperience>;
      for (const {key: experienceKey} of experiences) {
        const variation = this._dataManager.getSubItem(
          'experiences',
          experienceKey,
          'variations',
          key,
          'key',
          'key'
        ) as ExperienceVariationConfig;
        if (variation) {
          return variation;
        }
      }
    }
    return this._dataManager.getEntity(key, entityType);
  }

  /**
   * get Config Entity by string
   * @param {string} id
   * @param {EntityType} entityType
   * @return {Entity}
   */
  getConfigEntityById(id: string, entityType: EntityType): Entity {
    if (entityType === EntityType.VARIATION) {
      const experiences = this._dataManager.getEntitiesList(
        EntityType.EXPERIENCE
      ) as Array<ConfigExperience>;
      for (const {id: experienceId} of experiences) {
        const variation = this._dataManager.getSubItem(
          'experiences',
          experienceId,
          'variations',
          id,
          'id',
          'id'
        ) as ExperienceVariationConfig;
        if (variation) {
          return variation;
        }
      }
    }
    return this._dataManager.getEntityById(id, entityType);
  }

  /**
   * Get visitor data
   * @returns {StoreData}
   */
  getVisitorData(): StoreData {
    return this._dataManager.getData(this._visitorId) || {};
  }

  /**
   * Send pending API/DataStore queues to server
   * @param {string=} reason
   * @return {Promise<any>}
   */
  releaseQueues(reason?: string): Promise<any> {
    if (this._dataManager.dataStoreManager)
      this._dataManager.dataStoreManager.releaseQueue(reason);
    return this._apiManager.releaseQueue(reason);
  }

  /**
   * Get visitor properties
   * @param {Record<string, any>=} attributes An object of key-value pairs that are used for audience targeting
   * @return {Record<string, any>}
   */
  private getVisitorProperties(
    attributes?: Record<string, any>
  ): Record<string, any> {
    const {segments} = this._dataManager.getData(this._visitorId) || {};
    const visitorProperties = attributes
      ? objectDeepMerge(this._visitorProperties || {}, attributes)
      : this._visitorProperties;
    return objectDeepMerge(segments || {}, visitorProperties || {});
  }
}