common.js

/* eslint-disable camelcase */
// noinspection JSValidateJSDoc

'use strict';
/**
 * Node.js Express application common shared variables and functions.
 *
 * @module common
 * @requires @google-cloud/firestore
 * @requires @google-cloud/error-reporting
 * @requires debug
 * @requires gax
 */
// const Bynder = require('@bynder/bynder-js-sdk');
const {debug} = require('debug');
const {ErrorReporting} = require('@google-cloud/error-reporting');
const {Firestore} = require('@google-cloud/firestore');
const firestore = new Firestore();
const fs = require('fs');
const gax = require('google-gax');
// node-fetch from v3 is an ESM-only module
const fetch = require('node-fetch');
// you can use the async import() function from CommonJS to load node-fetch asynchronously
// const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
const path = require('path');
const moment = require('moment');
const monitoring = require('@google-cloud/monitoring');
const metricServiceClient = new monitoring.MetricServiceClient();
const wait = (ms) => new Promise((res) => setTimeout(res, ms));
const {v4: uuidv4} = require('uuid');

/**
 * Encapsulates the overridable settings for a particular API call.
 *
 * ``CallOptions`` is an optional arg for all GAX API calls.  It is used to
 * configure the settings of a specific API call.
 *
 * When provided, its values override the GAX service defaults for that
 * particular call.
 *
 * Typically, the API clients will accept this as the second to the last
 * argument. See the examples below.
 * @typedef {object} CallOptions
 * @property {number} timeout - The client-side timeout for API calls.
 * @property {object} retry - determines whether and how to retry
 *   on transient errors. When set to null, the call will not retry.
 * @property {boolean} autoPaginate - If set to false and the call is
 *   configured for paged iteration, page unrolling is not performed, instead
 *   the callback will be called with the response object.
 * @property {object} pageToken - If set and the call is configured for
 *   paged iteration, paged iteration is not performed and requested with this
 *   pageToken.
 * @property {number} maxResults - If set and the call is configured for
 *   paged iteration, the call will stop when the number of response elements
 *   reaches to the specified size. By default, it will unroll the page to
 *   the end of the list.
 * @property {boolean} isBundling - If set to false and the call is configured
 *   for bundling, bundling is not performed.
 * @property {BackoffSettings} longrunning - BackoffSettings used for polling.
 * @example
 * // suppress bundling for bundled method.
 * api.bundlingMethod(
 *     param, {optParam: aValue, isBundling: false}, function(err, response) {
 *   // handle response.
 * });
 * @example
 * // suppress streaming for page-streaming method.
 * api.pageStreamingMethod(
 *     param, {optParam: aValue, autoPaginate: false}, function(err, page) {
 *   // not returning a stream, but callback is called with the paged response.
 * });
 */

/**
 * Configure call CallSettings object.
 * @param {object} settings - An object containing parameter settings.
 * @param {number} settings.timeout - The client-side timeout for API calls.
 *   This parameter is ignored for retrying calls.
 * @param {object} settings.retry - The configuration for retrying upon
 *   transient error. If set to null, this call will not retry.
 * @param {boolean} settings.autoPaginate - If there is no `pageDescriptor`,
 *   this attribute has no meaning. Otherwise, determines whether a page
 * streamed response should make the page structure transparent to the user by
 *   flattening the repeated field in the returned generator.
 * @param {number} settings.pageToken - If there is no `pageDescriptor`,
 *   this attribute has no meaning. Otherwise, determines the page token used
 * in the page streaming request.
 * @param {object} settings.otherArgs - Additional arguments to be passed to
 *   the API calls.
 * @param {Function=} settings.promise - A constructor for a promise that
 * implements the ES6 specification of promise. If not provided, native
 * promises will be used.
 */
const gaxOptions = new gax.CallSettings();

/**
 * Per-call configurable settings for retrying upon transient failure.
 *
 * @param {number[]} retryCodes - a list of Google API canonical error codes
 *   upon which a retry should be attempted.
 * @param {BackoffSettings} backoffSettings - configures the retry
 *   exponential backoff algorithm.
 * @return {object} A new RetryOptions object.
 *
 */
gaxOptions.retry = gax.createRetryOptions(
  [gax.Status.DEADLINE_EXCEEDED, gax.Status.UNAVAILABLE],
  /**
   * Parameters to the exponential backoff algorithm for retrying.
   * @typedef {object} BackoffSettings
   * @property {number} initialRetryDelayMillis - the initial delay time,
   *   in milliseconds, between the completion of the first failed request and the
   *   initiation of the first retrying request.
   * @property {number} retryDelayMultiplier - the multiplier by which to
   *   increase the delay time between the completion of failed requests, and the
   *   initiation of the subsequent retrying request.
   * @property {number} maxRetryDelayMillis - the maximum delay time, in
   *   milliseconds, between requests. When this value is reached,
   *   ``retryDelayMultiplier`` will no longer be used to increase delay time.
   * @property {number} initialRpcTimeoutMillis - the initial timeout parameter
   *   to the request.
   * @property {number} rpcTimeoutMultiplier - the multiplier by which to
   *   increase the timeout parameter between failed requests.
   * @property {number} maxRpcTimeoutMillis - the maximum timeout parameter, in
   *   milliseconds, for a request. When this value is reached,
   *   ``rpcTimeoutMultiplier`` will no longer be used to increase the timeout.
   * @property {number} totalTimeoutMillis - the total time, in milliseconds,
   *   starting from when the initial request is sent, after which an error will
   *   be returned, regardless of the retrying attempts made meanwhile.
   */
  gax.createBackoffSettings(100, 1.2, 1000, 300, 1.3, 3000, 30000)
);
// wait 30 seconds before timing out
gaxOptions.timeout = 30000;

/**
 * Control stderr logging using DEBUG command line flag.
 * set DEBUG=app:error on the command line to enable stderr logging.
 * @example
 *    DEBUG=app:error node index.js
 *    DEBUG=app:* node index.js
 *
 * when DEBUG=app:error command line flag is not set stderr logging is disabled.
 * @example node index.js
 * @name logError
 * @function
 * @memberof module:common
 * @inner
 */
const logError = debug('app:error');

/**
 * Control stderr logging using DEBUG command line flag.
 * set DEBUG=app:log on the command line to enable stderr logging.
 * @example
 *    DEBUG=app:error node index.js
 *    DEBUG=app:* node index.js
 *
 * when DEBUG=app:log command line flag is not set stderr logging is disabled.
 * @example node index.js
 * @name logInfo
 * @function
 * @memberof module:common
 * @inner
 */
const logInfo = debug('app:log');

/**
 * Gax retry options.
 *
 * Request configuration options, outlined here:
 * @see https://googleapis.github.io/gax-nodejs/global.html#CallOptions
 * @type {{gaxOptions: CallSettings}}
 */
const options = {
  gaxOptions: gaxOptions,
};

/**
 * Common constant values and functions.
 *
 * @type {object}
 */
const commonObjects = {
  /**
   * API Key used to verify request.
   */
  apiKey: process.env.API_KEY,

  /**
   * True/False flag, when True app error logging is enabled.
   *
   * @const {boolean}
   */
  appErrorLoggingEnabled: debug.enabled('app:error'),

  /**
   * Get properties for one or more products from Google Firestore and return an object
   * populated with Bynder Asset MetaProperties.
   * @param {string} id - Bynder Asset media id.
   * @param {string[]} productIds - Array of one or more Product id(s).
   * @param {string} salesOrg - YETI Sales Organization, valid values: yeti.com, www.yeti.com, yeti.ca, www.yeti.ca
   * @param {string} distributionChannel - YETI Distribution Channel, valid value: 10 which is Domestic
   * @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
   *  beneath the request log in Log Viewer.
   * @return {Promise<*>}
   */
  assetMetaProperties: (id, productIds, salesOrg, distributionChannel, globalLogFields) => {
    const component = `${process.env.BACKEND_SERVICE_NAME}.common.assetMetaProperties`;
    const promises = productIds.map(async (p) => {
      // Obtain a document reference.
      const documentPath = `sales_org/${salesOrg}/distribution_channel/${distributionChannel}/product/${p}`;
      const document = firestore.doc(documentPath);
      return await document
        .get()
        .then((response) => {
          const product = commonObjects.firestoreConvertResponse(documentPath, response, 'product', 'id', component, globalLogFields);
          return Object.keys(product).length > 0 ? {product, product_id: p} : {product_id: p};
        })
        .catch((err) => {
          commonObjects.logAppError(err, component, globalLogFields);
          // logError(JSON.stringify(err, null, 2));
          throw err;
        });
    });

    // Promise.allSettled - wait for array of all promises to resolve or reject.
    return Promise.allSettled(promises)
      .then((results) => {
        const productColor = [];
        const productName = [];
        const category = [];
        const categoryType = [];
        const masterSKU = [];
        const rejections = [];
        let fulfilledCount = 0;
        commonObjects.logAppInfo(`Firestore Response length: ${results.length}`, component, globalLogFields);
        // iterate firestore results populating arrays of product attribute values
        results.forEach((result) => {
          // commonObjects.logInfo('assetMetaProperties.Promise.allSettled:');
          // commonObjects.logInfo(JSON.stringify(result, null, 2));
          const {reason, status, value} = result;
          if (status === 'rejected') {
            commonObjects.logAppInfo(`firestore.rejected.reason: ${reason}`, component, globalLogFields);
          } else if (value.product.found === false) {
            commonObjects.logAppInfo(`firestore.product.not.found: ${value.product_id}`, component, globalLogFields);
            rejections.push(`firestore.product.not.found: ${value.product_id}`);
          } else {
            const {product, product_id} = value;
            // parse metaproperties when firestore promise fulfills and returns data (value is not undefined)
            if (product) {
              fulfilledCount++;
              const {category_type, color, master_sku, product_category, product_description} = product;
              if (product_category !== '(not set)') category.push(product_category);
              if (category_type !== '(not set)') categoryType.push(category_type);
              if (master_sku !== '(not set)') masterSKU.push(master_sku);
              if (color !== '(not set)') productColor.push(color);
              const name = product_description.split(color || '').join('');
              if (name !== '(not set)') productName.push(name);
            } else {
              commonObjects.logAppInfo(`invalid product_id: ${product_id}`, component, globalLogFields);
              rejections.push(`invalid product_id: ${product_id}`);
            }
          }
        });
        // join product attribute values into csv strings
        commonObjects.logAppInfo(
          `Firestore query results
              fulfilledCount: ${fulfilledCount}
              rejectedCount: ${rejections.length}
              category: ${category.join(',')}
              categoryType: ${categoryType.join(',')}
              color: ${productColor.join(',')}
              masterSKU: ${masterSKU.join(',')}
              name: ${productName.join(',')}
              productIds: ${productIds.join(',')}`,
          component,
          globalLogFields
        );
        // if all the firestore request were rejected ( fulfilledCount = 0 ), do not return any metaproperties.
        const params =
          fulfilledCount === 0
            ? {id}
            : {
                id,
                // [`metaproperty.${process.env.BYNDER_PRODUCT_COLOR_ID}`]: productColor.join(','),
                // [`metaproperty.${process.env.BYNDER_PRODUCT_NAME_ID}`]: productName.join(','),
                [`metaproperty.${process.env.BYNDER_PRODUCT_CATEGORY_ID}`]: category.join(','),
                [`metaproperty.${process.env.BYNDER_PRODUCT_SUB_CATEGORY_ID}`]: categoryType.join(','),
                [`metaproperty.${process.env.SAP_MASTER_SKU}`]: masterSKU.join(','),
              };
        // commonObjects.logAppInfo(`bynder.asset.assetMetaProperties:`, component, globalLogFields);
        // commonObjects.logAppInfo(JSON.stringify(params, null, 2), component, globalLogFields);
        return {fulfilledCount, params, rejections};
      })
      .catch((err) => err);
  },

  /**
   * Returns JSON base64 encoded X-Endpoint-API-UserInfo.
   *
   * @name authInfoHandler
   * @function
   * @memberof module:common
   * @inner
   * @param {object} req - The req object represents the HTTP request and has properties for the request query string,
   *  parameters, body, HTTP headers, and so on. In this documentation and by convention, the object is always
   *  referred to as req (and the HTTP response is res) but its actual name is determined by the parameters to the
   *  callback function in which you are working.
   * @param {object} res - The res object represents the HTTP response that an Express app sends when it gets an
   *  HTTP request. In this documentation and by convention, the object is always referred to as res
   *  (and the HTTP request is req) but its actual name is determined by the parameters to the callback function in
   *  which you are working.
   *  The res object is an enhanced version of Node’s own response object and supports all
   *  @see [built-in fields and methods](https://nodejs.org/api/http.html#http_class_http_serverresponse).
   */
  authInfoHandler: (req, res) => {
    let authUser;
    let statusCode = 200;
    const encodedInfo = req.get('X-Endpoint-API-UserInfo');
    if (encodedInfo) {
      try {
        authUser = JSON.parse(Buffer.from(encodedInfo, 'base64').toString());
      } catch (e) {
        statusCode = 400;
        authUser = {id: 'anonymous', err: 'X-Endpoint-API-UserInfo header invalid JSON.'};
      }
    } else {
      authUser = {id: 'anonymous'};
    }
    res.status(statusCode).json(authUser).end();
  },

  /**
   * Backend Node.js Express application service domain.
   *
   * @const {string}
   */
  backendServiceDomain: process.env.BACKEND_SERVICE_DOMAIN,

  /**
   * Backend Node.js Express application service name.
   *
   * @const {string}
   */
  backendServiceName: process.env.BACKEND_SERVICE_NAME,

  /**
   * Express application API version base path.
   *
   * @const {string}
   */
  basePath: process.env.BASE_PATH,

  /**
   * Bynder API base URL.
   *
   * @const {string}
   */
  bynderBaseUrl: 'https://assets.yeti.com/api',

  /**
   * Bynder API permanent token.
   *
   * @const {string}
   */
  bynderPermanentToken: process.env.BYNDER_SECRET,

  /**
   * Product Enrichment API POST Bynder Application Webhook Endpoint Path.
   *
   * @const {string}
   */
  bynderWebhookPath: `${process.env.BASE_PATH}/bynder-webhook`,

  /**
   * Time in ms (via setTimeout) after which the value will be removed from the cache.
   * 1 hour (1 minute = 60 seconds = 60 × 1000 milliseconds = 60,000 ms)
   *
   * @const {number}
   */
  cacheDuration: 60000 * 2,

  /**
   * Generic async function used to retry a function until the supplied max depth.
   *
   * @name callWithRetry
   * @function
   * @memberof module:common
   * @inner
   * @param {function} fn - Name of the async function to execute.
   * @param {number} depth - max number of times to retry the supplied async function.
   */
  callWithRetry: async (fn, depth = 0) => {
    try {
      return await fn();
    } catch (err) {
      if (depth > 7) {
        throw err;
      }
      // 408, 502, 503 and 504
      if (err.code === 403) {
        // Bynder API request limit / quota exceeded, Bynder allows
        // 4500 requests in any five-minute time frame from a single IP address.
        // Request must wait for 5+ minutes before being retried.
        // see: https://bynder.docs.apiary.io/#introduction/limit-on-requests
        // 300000 ms = 5 min
        // 306000 ms = 5.1 min
        await wait(300000 ** depth * 10);
      } else {
        await wait(2 ** depth * 10);
      }

      return commonObjects.callWithRetry(fn, depth + 1);
    }
  },

  /**
   * Product Registration API code coverage GET endpoint path.
   *
   * @const {string}
   */
  coveragePath: `${process.env.BASE_PATH}/coverage/lcov-report`,

  /**
   * Product Registration API documentation GET endpoint path.
   *
   * @const {string}
   */
  docsPath: `${process.env.BASE_PATH}/docs`,

  /**
   * Response error message returned when request X-API-Key header value is invalid.
   *
   * @const {string}
   */
  errMsgApiKeyInvalid: 'Unauthorized: Invalid X-API-Key header or key query string argument value.',

  /**
   * Response error message returned when request X-API-Key header is not set.
   *
   * @const {string}
   */
  errMsgApiKeyMissing: 'Unauthorized: Missing required X-API-Key header and missing key query string argument.',

  /**
   * Stderr message returned when Bynder API editMedia method fails.
   *
   * @const {string}
   */
  errMsgBynderEditMediaFailed: 'error: Bynder API editMedia failed.',

  /**
   * Stderr message returned when Bynder API getMediaInfo fails.
   *
   * @const {string}
   */
  errMsgBynderGetMediaInfoFailed: 'error: Bynder API getMediaInfo failed.',

  /**
   * Stderr message returned when Bynder API GET Meta Properties fails.
   *
   * @const {string}
   */
  errMsgBynderGetMetaPropertiesFailed: 'error: Bynder API getMetaproperties failed.',

  /**
   * Stderr message returned when a Bynder Webhook SubscriptionConfirmation message is invalid.
   *
   * @const {string}
   */
  errMsgBynderSubscriptionConfirmationInvalid: 'error: SubscriptionConfirmation message invalid.',

  /**
   * Stderr message returned when a Bynder Webhook SubscriptionConfirmation message is missing required SubscribeURL.
   *
   * @const {string}
   */
  errMsgBynderSubscribeURLMissing: 'error: SubscriptionConfirmation message missing required SubscribeURL.',

  /**
   * Stderr message returned when a Bynder Webhook request is missing required messageType header x-amz-sns-message-type.
   *
   * @const {string}
   */
  errMsgBynderMessageTypeMissing: 'error: Missing required messageType header x-amz-sns-message-type.',

  /**
   * Stderr message returned when a Bynder Webhook Notification message failed to process.
   *
   * @const {string}
   */
  errMsgBynderNotificationFailed: 'error: Bynder Webhook Notification failed to process.',

  /**
   * Stderr message returned when CLI command syntax is missing one or more required parameters.
   *
   * @const {string}
   */
  errMsgBynderWebHookNotificationInvalid: 'error: missing required media.property_ProductSKU argument.',

  /**
   * Stderr message returned when SNS Notification Subject does not equal asset_bank.media.create.
   *
   * @const {string}
   */
  errMsgBynderWebHookNotificationSubjectInvalid: 'error: invalid SNS Notification Subject, valid values: asset_bank.media.create.',

  /**
   * Stderr message returned when Bynder Webhook Firestore request does not match any products.
   *
   * @const {string}
   */
  errMsgBynderWebhookProductsInvalid: 'error: Bynder Webhook invalid media.property_ProductSKU, no product matches found.',

  /**
   * Stderr message returned when CLI command syntax is missing one or more required parameters.
   *
   * @const {string}
   */
  errMsgCliCommandInvalid: "error: missing required argument, either 'key' or 'data' must be supplied.",

  /**
   * Stderr message returned when auth createJwt CLI command syntax is missing one or more required parameters.
   *
   * @const {string}
   */
  errMsgCliCreateJwtCommandInvalid: "error: missing required argument, 'path' must be supplied as a parameter" + ' or set as GOOGLE_APPLICATION_CREDENTIALS environment variable.',

  /**
   * Stderr message returned when auth createTokenId CLI command syntax is missing one or more required parameters.
   *
   * @const {string}
   */
  errMsgCliCreateTokenIdCommandInvalid:
    "error: missing required argument, 'url' must be supplied as a parameter" + ' or set as BACKEND_SERVICE_DOMAIN|FRONTEND_SERVICE_DOMAIN environment variable.',

  /**
   * Response error message returned when invalid Firestore request is rejected.
   *
   * @const {string}
   */
  errMsgFirestoreQueryInvalid: 'Bad Request: Invalid Firestore request.',

  /**
   * Response error message returned when request query string is missing required product_id parameter.
   *
   * @const {string}
   */
  errMsgQueryProductIdMissing: 'Bad Request: Invalid request query string, product_id parameter is required.',

  /**
   * Response error message returned when request query string site_id parameter value is invalid.
   *
   * @const {string}
   */
  errMsgQuerySiteIdInvalid:
    'Bad Request: Invalid request query string, site_id parameter value is invalid.  site_id must one of' + ' be yeti.ca, yeti.com, www.yeti.ca or www.yeti.com.',

  /**
   * Response error message returned when request query string is missing required site_id parameter.
   *
   * @const {string}
   */
  errMsgQuerySiteIdMissing: 'Bad Request: Invalid request query string, site_id parameter is required.',

  /**
   * ErrorReporting instance used to submit Google Error Reporting issues.
   */
  errors: new ErrorReporting({
    projectId: process.env.GCP_PROJECT_ID,
    // Specifies when errors are reported to the Error Reporting Console.
    // production (default): Only report errors if the NODE_ENV environment variable is set to "production".
    // always: Always report errors regardless of the value of NODE_ENV.
    // never: Never report errors regardless of the value of NODE_ENV.
    reportMode: 'production',
    // Catch and Report Application-wide Uncaught Errors
    reportUnhandledRejections: true,
    // Determines the logging level internal to the library; levels range 0-5
    // where 0 indicates no logs, 1 errors, 2 warnings ... 5 all logs should be reported.
    logLevel: 1,
    serviceContext: {
      service: `${process.env.BACKEND_SERVICE_NAME}`,
      version: `${process.env.GIT_RELEASE_TAG}`,
    },
  }),

  /**
   * Converts a Firestore response payload to clean JSON object.
   *
   * @name firestoreConvertResponse
   * @function
   * @memberof module:common
   * @inner
   * @param {string} documentPath - Firestore document path for the document.get() operation.
   * @param {object} response - Parsed Firestore javascript object.
   * @param {string} resource - Resource name (aka table name) of the object - so for example 'orders' or 'customers'.
   * @param {string} keyName - Name of the primary key of the resource - so for example 'orderId' or 'customerId'.
   * @param {string} component - Name of the component the custom metric is for.  Component is used to group together all the log entries related to a request.
   * @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request beneath the request log in Log Viewer.
   * @return {object} Converted resource object.
   */
  firestoreConvertResponse: (documentPath, response, resource, keyName, component, globalLogFields) => {
    const responseObject = {};
    responseObject[resource] = [];

    try {
      if (response) {
        const convertedRecord = {};
        // see: [projects.databases.document.get](https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/get)
        // handle document:get document not found (aka missing)
        // commonObjects.logInfo('firestoreConvertResponse:');
        // commonObjects.logInfo(JSON.stringify(response, null, 2));
        if (Object.prototype.hasOwnProperty.call(response, '_fieldsProto') && response._fieldsProto !== undefined) {
          convertedRecord.found = true;
          convertedRecord[keyName] = documentPath.split('/').slice(-1)[0];
          const record = response._fieldsProto;
          Object.keys(record).forEach((key) => {
            if (!['_createTime', '_readTime', '_ref', '_serializer', '_updateTime'].includes(key)) {
              const value = commonObjects.firestoreGetValue(record[key]);
              // commonObjects.logInfo(`record: key = '${key}', value = '${value}'`);
              // commonObjects.logInfo(JSON.stringify(record, null, 2));
              if (value !== undefined) {
                convertedRecord[key] = value;
              }
            }
          });
        } else {
          convertedRecord[keyName] = documentPath;
          convertedRecord.found = false;
        }
        responseObject[resource].push(convertedRecord);
      }
    } catch (err) {
      commonObjects.logAppError(err, component, globalLogFields);
    }

    return responseObject[resource][0];
  },

  /**
   * Retrieves a Firestore value from different data type objects (String, Boolean, Geo, Timestamp)
   *
   * @name firestoreGetValue
   * @function
   * @memberof module:common
   * @inner
   * @param {object} node - The Firestore input object.
   * @return {object} - The string value of hte data type object.
   */
  firestoreGetValue: (node) => {
    // commonObjects.logInfo('firestoreGetValue.node:');
    // commonObjects.logInfo(JSON.stringify(node, null, 2));
    let result;

    if (node && node.arrayValue) {
      const arrayValues = [];
      const values = node.arrayValue.values;
      values.forEach((valueObj) => {
        const fields = commonObjects.firestoreGetValue(values[valueObj]);
        arrayValues.push(fields);
      });
      result = arrayValues;
    } else if (node && Object.prototype.hasOwnProperty.call(node, 'booleanValue')) {
      result = node.booleanValue;
    } else if (node && node.doubleValue) {
      result = node.doubleValue;
    } else if (node && node.geoPointValue) {
      result = node.geoPointValue.latitude + ', ' + node.geoPointValue.longitude;
    } else if (node && node.integerValue) {
      result = node.integerValue;
    } else if (node && node.mapValue) {
      const mapValues = {};
      if (node.mapValue.fields) {
        const mapValueFields = node.mapValue.fields;
        Object.keys(mapValueFields).forEach((key) => {
          const value = commonObjects.firestoreGetValue(mapValueFields[key]);
          if (value !== undefined) {
            mapValues[key] = value;
          }
        });
      }
      result = mapValues;
    } else if (node && node.stringValue) {
      result = node.stringValue;
    } else if (node && node.timestampValue) {
      // extract the seconds and nanos values from your Firestore timestamp object
      const {seconds, nanos} = node.timestampValue;
      // combine the seconds and nanos values into a single timestamp in milliseconds
      const milliseconds = seconds * 1000 + nanos / 1e6;
      // use Moment.js to convert the timestamp to a date
      return moment(milliseconds).format();
    }
    return result;
  },

  /**
   * Frontend Node.js Express application service domain.
   *
   * @const {string}
   */
  frontendServiceDomain: process.env.FRONTEND_SERVICE_DOMAIN,

  /**
   * Add log correlation to nest all log messages beneath request log in Log Viewer.
   *
   * @name getGlobalLogFields
   * @function
   * @memberof module:common
   * @inner
   * @param {object} req - The req object represents the HTTP request and has properties for the request query string,
   *  parameters, body, HTTP headers, and so on. In this documentation and by convention, the object is always
   *  referred to as req (and the HTTP response is res) but its actual name is determined by the parameters to the
   *  callback function in which you are working.
   */
  getGlobalLogFields: async (req) => {
    let globalLogFields;
    const cloudTraceContext = req.get('x-cloud-trace-context');
    if (cloudTraceContext) {
      logInfo(`traceHeader: ${cloudTraceContext}`);
      const [trace] = cloudTraceContext.split('/');
      globalLogFields = {
        'logging.googleapis.com/trace': `projects/${process.env.GCP_PROJECT_ID}/traces/${trace}`,
      };
    } else {
      globalLogFields = {};
    }
    return globalLogFields;
  },

  /**
   * Return Bynder media asset object.
   *
   * @name getMediaInfo
   * @function
   * @memberof module:common
   * @inner
   * @async
   * @param {string} id - Bynder asset unique identifier.
   * @param {string} component - Name of the component the custom metric is for.  Component is used to group together
   *  all the log entries related to a request.
   * @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
   *  beneath the request log in Log Viewer.
   * @return {Promise<*>}
   */
  getMediaInfo: async (id, component, globalLogFields) => {
    // noinspection JSCheckFunctionSignatures
    // const bynder = new Bynder(bynderOptions);
    // // ensure Bynder API Call does not leave any open handles
    // await process.nextTick(() => {});
    // // .send('GET', `v4/media/${id}/`, common.bynderOptions)
    // // noinspection JSUnresolvedFunction
    // return await bynder
    //   .getMediaInfo({id})
    //   .then((data) => data)
    //   .catch((err) => err);
    // ensure fetch API Call does not leave any open handles
    await process.nextTick(() => {});
    const url = `${commonObjects.bynderBaseUrl}/v4/media/${id}/`;
    return await fetch(url, {
      method: 'GET',
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${commonObjects.bynderPermanentToken}`,
        'User-Agent': `${commonObjects.name}/${commonObjects.version}`,
      },
    })
      .then((res) => res.json())
      .catch((err) => {
        commonObjects.logAppError(err, component, globalLogFields);
      });
  },

  /**
   * Return all Bynder metaproperties.
   *
   * @name getMetaproperties
   * @function
   * @memberof module:common
   * @inner
   * @async
   * @param {string} component - Name of the component the custom metric is for.  Component is used to group together
   *  all the log entries related to a request.
   * @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
   *  beneath the request log in Log Viewer.
   * @return {Promise<*>}
   */
  getMetaproperties: async (component, globalLogFields) => {
    // noinspection JSCheckFunctionSignatures
    // const bynder = new Bynder(bynderOptions);
    // ensure Bynder API Call does not leave any open handles
    // await process.nextTick(() => {});
    // .send('GET', `v4/metaproperties/`, options)
    // noinspection JSUnresolvedFunction
    // return bynder
    //   .getMetaproperties()
    //   .then((data) => data)
    //   .catch((err) => err);
    // ensure fetch API Call does not leave any open handles
    await process.nextTick(() => {});
    const url = `${commonObjects.bynderBaseUrl}/v4/metaproperties/`;
    return await fetch(url, {
      method: 'GET',
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${commonObjects.bynderPermanentToken}`,
        'User-Agent': `${commonObjects.name}/${commonObjects.version}`,
      },
    })
      .then((res) => res.json())
      .catch((err) => {
        commonObjects.logAppError(err, component, globalLogFields);
      });
  },

  /**
   * In production, creates JSON structured Google Stackdriver log ERROR entry.
   * Log viewer accesses 'component' as 'jsonPayload.component'.
   * Otherwise, logs message to stderr.
   *
   * @name logAppError
   * @function
   * @memberof module:common
   * @inner
   * @param {string} message - Error message description.
   * @param {string} component - Name of the component that generated the error.
   * @param {object} globalLogFields - Global logging.googleapis.com/trace object.
   */
  logAppError: (message, component, globalLogFields) => {
    const entry = Object.assign({severity: 'ERROR', message: message, component: component}, globalLogFields);
    if (process.env.NODE_ENV === 'production') {
      // Create JSON structured Google Stackdriver log entry in production.
      // Log viewer accesses 'component' as 'jsonPayload.component'.
      console.error(JSON.stringify(entry));
    } else {
      // Otherwise, log message to stderr.
      logError(message);
    }
    if (debug.enabled('app:error')) {
      console.trace(component);
    } else {
      logError(JSON.stringify(entry));
    }
  },

  /**
   * In production, creates JSON structured Google Stackdriver log INFO entry.
   * Log viewer accesses 'component' as 'jsonPayload.component'.
   * Otherwise, logs message to stderr.
   *
   * @name logAppInfo
   * @function
   * @memberof module:common
   * @inner
   * @param {string} message - Info message description.
   * @param {string} component - Name of the component that log message is for.
   * @param {object} globalLogFields - Global logging.googleapis.com/trace object.
   */
  logAppInfo: (message, component, globalLogFields) => {
    const entry = Object.assign({severity: 'INFO', message: message, component: component}, globalLogFields);
    if (process.env.NODE_ENV === 'production') {
      console.info(JSON.stringify(entry));
    } else {
      // Otherwise, log message to stderr.
      logInfo(message);
    }
  },

  /**
   * Control stderr logging using DEBUG command line flag.
   * set DEBUG=app:error on the command line to enable stderr logging.
   * @example
   *    DEBUG=app:error node index.js
   *    DEBUG=app:* node index.js
   *
   * when DEBUG=app:error command line flag is not set stderr logging is disabled.
   * @example node index.js
   * @name logError
   * @function
   * @memberof module:common
   * @inner
   */
  logError,

  /**
   * Control stdout logging using DEBUG command line flag.
   * set DEBUG=app:log on the command line to enable stdout logging.
   * @example
   *    DEBUG=app:log node index.js
   *    DEBUG=app:* node index.js
   *
   * when DEBUG=app:log command line flag is not set stdout logging is disabled.
   * @example node index.js
   * @name logInfo
   * @function
   * @memberof module:common
   * @inner
   */
  logInfo,

  /**
   * Application name.
   *
   * @const {string}
   */
  name: process.env.GIT_PROJECT_NAME,

  /**
   * Child process stderr, stdout and exit event listener.
   * @name onExit
   * @function
   * @memberof module:common
   * @inner
   * @param {object} childProcess - The child process object to listen to events for.
   * @return {Promise<*>}
   */
  onExit: (childProcess) => {
    return new Promise((resolve, reject) => {
      childProcess.stderr.once('data', (data) => reject(data));
      childProcess.stdout.once('data', (data) => resolve(data));
      childProcess.once('exit', (code, signal) => {
        if (code === 0) {
          resolve(undefined);
        } else {
          reject(new Error(`Exit with error code: ${code}, signal: ${signal}`));
        }
      });
      childProcess.once('error', (err) => {
        reject(err);
      });
    });
  },

  /**
   * Google Cloud Firestore Node.js Client options.
   *
   * @const {object}
   */
  options,

  /**
   * Port the Express application listens on.
   *
   * @const {string}
   */
  port: process.env.PORT || 8080,

  /**
   * Product Enrichment API GET Product endpoint path.
   *
   * @const {string}
   */
  productPath: `${process.env.BASE_PATH}/product`,

  /**
   * Google Cloud Platform project id.
   *
   * @const {string}
   */
  projectId: process.env.GCP_PROJECT_ID,

  /**
   * Array of one or more supported website id(s).
   *
   * @const {array}
   */
  siteIdArray: ['yeti.ca', 'yeti.com', 'www.yeti.ca', 'www.yeti.com'],

  /**
   * Unique identifier for a Bynder asset uploaded for testing.
   */
  testBynderAssetId: '06D7138F-FCD3-41CF-A344680A52A99264',

  /**
   * Node.js Express application and CLI version.
   *
   * @const {string}
   */
  version: process.env.GIT_RELEASE_TAG,
  /**
   * Write custom metric time series data to track request count by endpoint and requestor IP.
   *
   * @name writeEndpointRequestCountTimeSeriesDataPoint
   * @function
   * @memberof module:common
   * @inner
   * @param {object} req - The req object represents the HTTP request and has properties for the request query string,
   *  parameters, body, HTTP headers, and so on. In this documentation and by convention, the object is always
   *  referred to as req (and the HTTP response is res) but its actual name is determined by the parameters to the
   *  callback function in which you are working.
   * @param {string} endpoint - Name of the API Endpoint.
   * @param {string} component - Name of the component the custom metric is for.  Component is used to group together
   *  all the log entries related to a request.
   * @param {object} globalLogFields - Object used to add log correlation which nest all log messages for a request
   *  beneath the request log in Log Viewer.
   * @param {string} productIds - One or more product ids the request is for.
   * @param {string} subject - SNS Notification subject or GET request header.
   */
  writeEndpointRequestCountTimeSeriesDataPoint: async (req, endpoint, component, globalLogFields, productIds, subject) => {
    try {
      const metricType = 'custom.googleapis.com/endpoints/request_count';
      commonObjects.logAppInfo(`Begin writing ${metricType} time series metric data.`, component, globalLogFields);
      const projectId = process.env.GCP_PROJECT_ID;
      const requestorIp = req.headers['x-envoy-external-address'] || req.headers['x-forwarded-for'] || req.ip;
      const dataPoint = {
        interval: {
          endTime: {
            seconds: Date.now() / 1000,
          },
        },
        value: {
          int64Value: 1,
        },
      };

      // noinspection JSCheckFunctionSignatures
      const timeSeriesData = {
        metric: {
          type: metricType,
          // label key naming convention
          //  - Use lower-case letters (a-z), digits (0-9), and underscores (_).
          //  - You must start label keys with a letter or underscore.
          //  - The maximum length of a label key is 100 characters.
          //  - Each key must be unique within the metric type.
          //  - You can have no more than 30 labels per metric type.
          // see: https://cloud.google.com/monitoring/api/v3/naming-conventions#naming-types-and-labels
          labels: {
            component,
            endpoint,
            product_ids: productIds,
            requestor_ip: requestorIp,
            subject: subject.substring(0, 1023),
          },
        },
        resource: {
          type: 'generic_task',
          labels: {
            project_id: projectId,
            location: 'us-central1',
            namespace: process.env.FRONTEND_SERVICE_DOMAIN,
            job: endpoint,
            task_id: uuidv4(),
          },
        },
        points: [dataPoint],
      };

      const request = {
        name: metricServiceClient.projectPath(projectId),
        timeSeries: [timeSeriesData],
      };

      // Writes time series data
      await metricServiceClient.createTimeSeries(request);
      commonObjects.logAppInfo(`Done writing ${metricType} time series metric data.`, component, globalLogFields);
    } catch (err) {
      commonObjects.logAppError(err, component, globalLogFields);
    }
  },

  /**
   * Write JSON data to file system.
   * @name writeFile
   * @function
   * @memberof module:common
   * @inner
   * @param {string} filePath - File systems path for the file to create.
   * @param {object} data - JSON object to write to a file.
   */
  writeFile: (filePath, data) => {
    // path.resolve - resolves a sequence of paths or path segments into an absolute path.
    const fp = path.resolve(filePath);
    logInfo(`writing data to: ${fp}`);
    fs.writeFile(fp, JSON.stringify(data, null, 2), (err) => {
      if (err) {
        logError(err);
        return err;
      }
    });
  },
};

module.exports = commonObjects;