/* 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,
};