express-handlers.js

/* eslint-disable max-len,camelcase */
'use strict';
/**
 * Node.js Express application request handlers.
 *
 * @module express-handlers
 * @requires axios
 * @requires @google-cloud/error-reporting
 * @requires @google-cloud/firestore
 */
require('./tracer')('product-enrichment');

const axios = require('axios');
const {Firestore} = require('@google-cloud/firestore');
const firestore = new Firestore();
const path = require('path');
const common = require(path.join(__dirname, 'common'));
const fetch = require('node-fetch');

/**
 * Bynder webhook.
 *
 * Request, Response and Next are Callback functions.  You can provide multiple callback functions that behave
 * just like middleware, except that these callbacks can invoke next('route') to bypass the remaining route
 * callback(s). You can use this mechanism to impose pre-conditions on a route, then pass control to subsequent
 * routes if there is no reason to proceed with the current route.
 *
 * @name bynderWebhook
 * @function
 * @memberof express-handlers:bynderWebhook
 * @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).
 *  @async
 */
async function bynderWebhook(req, res) {
  common.logInfo(`app.post ${common.bynderWebhookPath} req.body: ${JSON.stringify(req.body)} - BEGIN`);
  const messageType = req.get('x-amz-sns-message-type');
  const messageId = req.get('x-amz-sns-message-id');
  const baseComponent = `${common.backendServiceName}.post.${common.bynderWebhookPath}`;
  // Add log correlation to nest all log messages beneath request log in Log Viewer.
  const globalLogFields = await common.getGlobalLogFields(req);

  // Verify messageType is set.
  if (!messageType) {
    const msg = `msg: ${common.errMsgBynderMessageTypeMissing} req.headers: ${JSON.stringify(req.headers)}`;
    common.logAppError(msg, baseComponent, globalLogFields);
    return res.status(400).send(common.errMsgBynderMessageTypeMissing);
  }

  const component = `${baseComponent}.messageType.${messageType}.messageId.${messageId}`;
  const {Message, Signature, Subject, SubscribeURL} = req.body;
  const messageObj = messageType === 'Notification' ? JSON.parse(Message) : {media: {property_ProductSKU: []}};
  const {media, media_id: id} = messageObj ? messageObj : {};
  // let {name, description, copyright, brandId, tags, datePublished, archive, limited, isPublic} = media;
  const {property_ProductSKU: productIds} = media ? media : {};
  const msg = `messageId: ${messageId}, messageType: ${messageType}, productIds: ${productIds}, Signature: ${Signature}, Subject: ${Subject}, SubscribeURL: ${SubscribeURL}`;
  common.logAppInfo(msg, component, globalLogFields);
  await common.writeEndpointRequestCountTimeSeriesDataPoint(req, 'bynderWebHook', component, globalLogFields, `${productIds}`, Subject);

  try {
    if (messageType === 'SubscriptionConfirmation') {
      if (!SubscribeURL) {
        common.logAppError(common.errMsgBynderSubscribeURLMissing, baseComponent, globalLogFields);
        return res.status(400).send(common.errMsgBynderSubscribeURLMissing);
      }
      return axios
        .get(SubscribeURL)
        .then(function (response) {
          common.logAppInfo(response.data, component, globalLogFields);
          return res.status(200).send(`${messageType} success`);
        })
        .catch(function (err) {
          common.logAppError(`${msg} error: ${err.stack}`, component, globalLogFields);
          return res.status(400).send(common.errMsgBynderSubscriptionConfirmationInvalid);
        });
    }
    if (messageType === 'Notification') {
      common.logAppInfo(JSON.stringify(messageObj, null, 2), component, globalLogFields);
      if (Subject !== 'asset_bank.media.create') {
        // log invalid request to stderr when Notification Subject does not equal asset_bank.media.create.
        common.logAppError(common.errMsgBynderWebHookNotificationSubjectInvalid, component, globalLogFields);
        return res.status(400).send(common.errMsgBynderWebHookNotificationSubjectInvalid);
      }
      if (!productIds) {
        // log invalid request to stderr when Notification message does not contain Message.media.property_ProductSKU.
        common.logAppError(common.errMsgBynderWebHookNotificationInvalid, component, globalLogFields);
        return res.status(400).send(common.errMsgBynderWebHookNotificationInvalid);
      }
      // 'yeti.com', 'www.yeti.com'
      const salesOrg = '1100';
      // Domestic
      const distributionChannel = '10';
      const {fulfilledCount, params, rejections} = await common.assetMetaProperties(id, productIds, salesOrg, distributionChannel, globalLogFields);
      if (fulfilledCount === 0) {
        const message = `firestore rejections: ${rejections.toString()}`;
        common.logAppError(message, component, globalLogFields);
        return res.status(400).send(common.errMsgFirestoreQueryInvalid);
      }
      // ensure API Call does not leave any open handles
      await process.nextTick(() => {});
      const url = `${common.bynderBaseUrl}/v4/media/`;
      return await fetch(url, {
        method: 'POST',
        headers: {
          Accept: 'application/json',
          Authorization: `Bearer ${common.bynderPermanentToken}`,
          'Content-Type': 'application/x-www-form-urlencoded',
          'User-Agent': `${common.name}/${common.version}`,
        },
        body: new URLSearchParams(params),
      })
        .then((data) => {
          // const message = `bynder.editMeda.response:`;
          // common.logAppInfo(`${message} ${JSON.stringify(data, null, 2)}`, component, globalLogFields);
          return res.status(200).send(`${messageType} success`);
        })
        .catch((err) => {
          common.logAppError(
            `${common.errMsgBynderEditMediaFailed}; fulfilledCount = ${fulfilledCount}; params = ${params}; rejections = ${rejections.join(',')};`,
            component,
            globalLogFields
          );
          common.logAppError(`${common.errMsgBynderEditMediaFailed}; err = ${JSON.stringify(err, null, 2)}`, component, globalLogFields);
          return res.status(500).send(common.errMsgBynderEditMediaFailed);
        });
    }
    if (messageType === 'UnsubscribeConfirmation') {
      common.logAppInfo(`${messageType} - ${msg} Message: ${Message}`, component, globalLogFields);
      return res.status(200).send(`${messageType} success`);
    }
    // noinspection ExceptionCaughtLocallyJS
    throw new Error('invalid x-amz-sns-message-type message type header');
  } catch (err) {
    common.logAppError(err.message, component, globalLogFields);
    return res.status(400).send(err.message);
  }
}

/**
 * Get product details from Google Firestore.
 *
 * @name getProduct
 * @function
 * @memberof express-handlers:getProduct
 * @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).
 *  @async
 */
async function getProduct(req, res) {
  const {key, ...queryStringWithoutKey} = req.query;
  common.logInfo(`app.get ${common.productPath} queryStringWithoutKey: ${JSON.stringify(queryStringWithoutKey)} - BEGIN`);
  const baseComponent = `${common.backendServiceName}.get.${common.productPath}`;
  // Add log correlation to nest all log messages beneath request log in Log Viewer.
  const globalLogFields = await common.getGlobalLogFields(req);

  try {
    const requestApiKey = req.get('X-API-Key') || key;
    const requestHeaders = JSON.stringify(req.headers);
    // Verify request API key is supplied as an X-API-Key Header or a query string argument.
    if (!requestApiKey) {
      const msg = `msg: ${common.errMsgApiKeyMissing} req.headers: ${requestHeaders}`;
      common.logAppError(msg, baseComponent, globalLogFields);
      return res.status(401).send(common.errMsgApiKeyMissing);
    }

    // Verify request API key value matches the pre-shared API key.
    if (requestApiKey !== common.apiKey) {
      const msg = `msg: ${common.errMsgApiKeyInvalid} req.headers: ${requestHeaders}`;
      common.logAppError(msg, baseComponent, globalLogFields);
      return res.status(401).send(common.errMsgApiKeyInvalid);
    }

    // noinspection JSUnresolvedVariable
    if (!req.query.product_id) {
      common.logAppError(common.errMsgQueryProductIdMissing, baseComponent, globalLogFields);
      return res.status(400).send(common.errMsgQueryProductIdMissing);
    }

    // noinspection JSUnresolvedVariable
    if (!req.query.site_id) {
      common.logAppError(common.errMsgQuerySiteIdMissing, baseComponent, globalLogFields);
      return res.status(400).send(common.errMsgQuerySiteIdMissing);
    }

    // noinspection JSUnresolvedVariable
    if (!common.siteIdArray.includes(req.query.site_id)) {
      common.logAppError(common.errMsgQuerySiteIdInvalid, baseComponent, globalLogFields);
      return res.status(400).send(common.errMsgQuerySiteIdInvalid);
    }

    // noinspection JSUnresolvedVariable
    const productIds = Array.isArray(req.query.product_id) ? req.query.product_id : [req.query.product_id];
    // noinspection JSUnresolvedVariable
    const siteId = req.query.site_id.toString();
    const salesOrg = ['yeti.com', 'www.yeti.com'].includes(siteId) ? '1100' : '1500';
    // Domestic
    const distributionChannel = '10';
    const component = `${baseComponent}.sales_org.${salesOrg}.distribution_channel.${distributionChannel}.productIds.${productIds.join(',')}`;
    const subject = req.get('User-Agent');
    await common.writeEndpointRequestCountTimeSeriesDataPoint(req, 'getProduct', component, globalLogFields, productIds, subject);
    common.logAppInfo(`productIds: ${productIds.join(',')}`, component, globalLogFields);
    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) => {
          // common.logInfo('document.get() response:');
          // common.logInfo(JSON.stringify(response, null, 2));
          const product = common.firestoreConvertResponse(documentPath, response, 'product', 'id', component, globalLogFields);
          // common.logInfo('product:');
          // common.logInfo(JSON.stringify(product, null, 2));
          return Object.keys(product).length > 0 ? {product, product_id: p} : {product_id: p};
        })
        .catch((err) => {
          common.logAppError(err, component, globalLogFields);
          throw err;
        });
    });

    // Promise.allSettled - wait for array of all promises to resolve or reject.
    await Promise.allSettled(promises)
      .then((results) => {
        // common.logInfo('Promise.allSettled results:');
        // common.logInfo(JSON.stringify(results, null, 2));
        const response = {};
        common.logAppInfo(`Firestore Response length: ${results.length}`, component, globalLogFields);
        // common.logInfo(JSON.stringify(results, null, 2));
        results.forEach((result) => {
          const {found, reason, status, value} = result;
          if (status === 'rejected') {
            common.logAppInfo(`firestore.rejected.reason: ${reason}`, component, globalLogFields);
          } else if (found === false) {
            common.logAppInfo(`firestore product not found: ${value.product_id}`, component, globalLogFields);
          } else {
            // parse meta properties when firestore document get promise fulfills and returns data (value is not undefined)
            const {product, product_id} = value;
            if (product) {
              // include result in response when firestore promise fulfills and returns data (value is not undefined)
              response[product_id] = product;
            } else {
              common.logAppInfo(`invalid product_id: ${product_id}`, component, globalLogFields);
            }
          }
        });
        return res.status(200).send(JSON.stringify(response));
      })
      .catch((err) => {
        /**
       * error response for a JSON request:
       * {
            "error": {
              "code": "integer",
              "message": "string",
              "status": "string"
            }
          }
       */
        // pass errors to Express to create Google Stackdriver Error Report
        const message = `req.query: ${JSON.stringify(req.query)}, code: ${err.code}, message: ${err.message}, errorStack: ${err.stack}`;
        common.logAppError(message, component, globalLogFields);
        return res.status(500).send(message);
      });
  } catch (err) {
    const message = `req.query: ${JSON.stringify(req.query)} error: ${err.stack}`;
    common.logAppError(message, baseComponent, globalLogFields);
    return res.status(500).send(message);
  }
}

// noinspection JSUnusedGlobalSymbols
module.exports = {
  bynderWebhook,
  getProduct,
};