| Current Path : /home/deltalab/PMS/ims-connector/logic/shopify/ |
| Current File : //home/deltalab/PMS/ims-connector/logic/shopify/product.js |
/**
* Shopify Product adapter
*
* This component maps the INDACO products into Shopify
*/
const axios = require("axios"); // TODO: substitute with graphql
const { gql, GraphQLClient } = require('graphql-request');
// RESOURCES -----------------------------------
const { productBaseModel, productInventoryModel } = require('./../../models/mongoose/product');
const { ProductDetails } = require("./types/product.type");
const { ProductVariant } = require("./types/product.type");
// INITIALIZATION ----------------------------------
const SHOPIFY_HEADER = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Shopify-Access-Token': process.env.SHOPIFY_PASSWORD
}
// Prepare the graphql client used to contact Shopify
const shopifyClient = new GraphQLClient(process.env.SHOPIFY_STORE_URL, { headers: SHOPIFY_HEADER });
/**
* Update the given variant
* @param {ProductVariant} productVariant
* @returns the changed product variant
*/
async function updateVariant(productVariant) {
await axios({
url: process.env.SHOPIFY_STORE_URL,
method: 'post',
data: {
query: `
mutation productVariantUpdate($input:ProductVariantInput!){
productVariantUpdate(input:$input) {
product {
id,
title
},
productVariant {
id,
title,
sku
}
}
}
`,
variables: {
input: productVariant,
}
},
headers: SHOPIFY_HEADER,
}).then((result) => {
data = result;
});
return data;
};
/**
* Create a variant
* @param {*} input
* @returns
*/
async function createVariant(input) {
await axios({
url: process.env.SHOPIFY_STORE_URL,
method: 'post',
data: {
query: `
mutation productVariantCreate($input:ProductVariantInput!){
productVariantCreate(input:$input) {
product {
id
title
}
productVariant {
id
title
sku
}
}
}
`,
variables: {
input: input,
}
},
headers: SHOPIFY_HEADER,
}).then((result) => {
data = result;
});
return data;
};
async function getProductRawData(productId) {
const response = await axios({
url: process.env.SHOPIFY_STORE_URL,
method: 'post',
data: {
query: `
query ($id: ID!) {
product (id: $id) {
id
title
description
descriptionHtml
totalInventory
variants(first: 1) {
edges {
node {
id
sku
barcode
price
weight
weightUnit
inventoryItem {
requiresShipping
inventoryLevels(first:1){
edges{
node{
id
available
}
}
}
}
inventoryQuantity
}
}
}
}
}
`,
variables: {
id: productId,
}
},
headers: SHOPIFY_HEADER,
});
// Sanity check
if (!response.data.data || !response.data.data.product) {
console.log(`no such product ${productId}`);
return null;
}
const productData = response.data.data.product;
return productData;
}
/**
* Retrieve a single product details
* @param {*} productId the imsgid to be retrieved
* @returns
*/
async function getProduct(productId) {
const response = await getProductRawData(productId);
// Prepare the object to be returned
const product = new productBaseModel();
populateProduct(product, response.data.data.product);
return product;
};
/**
* Update title and description for the specified product.
* Also collect important product data for further updates
* @param {*} input the new product to use to update the details
* @return the ProductDetails of the given product
*/
async function updateProductDetails(input) {
// Prepare the query
const updateProdQuery = gql`
mutation productUpdate($input: ProductInput!) {
productUpdate(input: $input) {
product {
id
totalInventory
variants(first:1){
edges{
node{
id,
inventoryItem{
inventoryLevels(first:1){
edges{
node{
id
available
}
}
}
}
}
}
}
media (first: 10) {
edges {
node {
... on MediaImage {
id
}
}
}
}
}
}
}`;
// Prepare the data to be sent
//const updatedProductStub = new ProductInputSchema(productId, title, description);
const prodUpdateArgs = {
input: {
id: input.imsgid,
title: input.title,
descriptionHtml: input.description,
variants: {
sku: input.sku,
requiresShipping: input.requiresShipping
}
}
}
// Executing the update product query
const response = await shopifyClient.request(updateProdQuery, prodUpdateArgs);
// Collect data from response
const product = response.productUpdate.product;
if (!product) {
throw new Error(`no such product ${productId}`);
}
const variant = product.variants.edges[0].node;
const inventoryLevel = variant.inventoryItem.inventoryLevels.edges[0].node;
// Prepare the object to be returned
const productDetails = new ProductDetails(product.id);
productDetails.variantId = variant.id;
productDetails.inventoryLevelId = inventoryLevel.id;
productDetails.inventoryLevelAvailable = inventoryLevel.available;
return productDetails;
}
/**
* Change the inventory level specified of the given amount
* @param {*} inventoryLevelId the imsgid reference for the inventory level of a product
* @param {*} availableDelta the number of items added or removed
* @return the new quantity for the given inventory
*/
async function inventoryAdjustQuantity(inventoryLevelId, availableDelta) {
const inventoryAdjustQuantityQuery = gql`
mutation inventoryupdate($input: InventoryAdjustQuantityInput!) {
inventoryAdjustQuantity(input: $input){
inventoryLevel {
available
}
}
}`;
const inventoryAdjustQuantityArgs = {
input: {
inventoryLevelId: inventoryLevelId,
availableDelta: availableDelta,
}
}
const response = await shopifyClient.request(inventoryAdjustQuantityQuery, inventoryAdjustQuantityArgs);
return response.inventoryAdjustQuantity.inventoryLevel.available;
}
/**
* Change the status of a product
* @param {*} productId imsgid of the product to change
* @param {*} status either ACTIVE or something else WTF RTFM
* @return the new status or null if error
*/
async function productChangeStatus(productId, status) {
// Update the entire product status after the variant update
const updateStatusQuery = gql`
mutation productChangeStatus($productId: ID!, $status: ProductStatus!) {
productChangeStatus(productId: $productId, status: $status) {
product {
id
status
}
}
}`;
const statusUpdateArgs = {
productId: productId,
status: status,
}
// Send the status update to shopify to activate the updated product
const response = await shopifyClient.request(updateStatusQuery, statusUpdateArgs);
// Sanity check
if (!response.productChangeStatus.product) {
return null;
}
return response.productChangeStatus.product.status;
}
/**
* Update the shopify version of the given product
* @param {productBaseModel} _input
* @returns the updated product imsgid
*/
async function updateProduct(_input) {
// Make sure the input is a product base model!
const input = new productBaseModel(_input);
// Update the title and description and retrieve data
//const productDetails = await updateProductDetails(input.imsgid, input.title, input.description);
const productDetails = await updateProductDetails(input);
if (!productDetails) {
console.log(`Could not update the product details ${input.imsgid}`);
return null;
}
if (_input.trackInventory) {
const availableDelta = input.quantity - productDetails.inventoryLevelAvailable - input.bookedQuantity; //subtract open quantity from inventory adjustments
console.log(`product ${input.imsgid} availability delta ${input.quantity} - ${productDetails.inventoryLevelAvailable}`);
// Only update if the quantity has changed
if (availableDelta && availableDelta !== 0) {
const quantity = await inventoryAdjustQuantity(productDetails.inventoryLevelId, availableDelta);
if (!quantity) {
console.log(`Could not update the product quantity ${input.imsgid}`);
return null;
}
}
}
// Create the product variant object from the complete product
const updatedVariant = new ProductVariant(productDetails.variantId);
updatedVariant.barcode = input.barcode;
updatedVariant.sku = input.sku;
updatedVariant.price = input.price;
updatedVariant.compareAtPrice = input.showMsrp ? input.msrp : input.originalPrice;
if(updatedVariant.compareAtPrice <= updatedVariant.price) {
updatedVariant.compareAtPrice = null;
}
updatedVariant.weight = input.weight;
updatedVariant.weightUnit = input.weightUnit;
updatedVariant.inventoryManagement = input.trackInventory ? 'SHOPIFY' : 'NOT_MANAGED';
// Update the variant reserved fields
const variant = await updateVariant(updatedVariant);
if (!variant) {
console.log(`Could not update the product variant ${input.imsgid}`);
return null;
}
// TODO: check response for errors
// Force the product to active status
const active = await productChangeStatus(input.imsgid, "ACTIVE");
if (!active) {
console.log(`Could not change product status ${input.imsgid}`);
return null;
}
return input.imsgid;
};
/**
* Create a product using the duplication tecnique
* @param {pruductBaseModel} input
* @returns the created product imsgid
*/
async function createProduct(input) {
// Duplication query
// The productId is fixed because is based off a known duplication prototype
const duplicateProdQuery = gql`
mutation productDuplicate($newTitle: String!, $productId: ID!) {
productDuplicate(newTitle: $newTitle, productId: $productId) {
newProduct {
id,
handle
}
}
}`;
// Prepare the arguments to set the new title and active status
const arguments = {
newTitle: input.title,
productId: process.env.DUPLICATION_PROTOTYPE_ID
};
// Perform the duplication
const response = await shopifyClient.request(duplicateProdQuery, arguments);
if (!response.productDuplicate) {
// TODO: errors?
console.log(`could not duplicate product ${process.env.DUPLICATION_PROTOTYPE_ID}`);
return null;
}
// Update the newly duplicated product with the supplied product
input.imsgid = response.productDuplicate.newProduct.id;
input.handle = response.productDuplicate.newProduct.handle;
const productId = await updateProduct(input);
console.log(`created product: ${productId}`);
return productId;
};
async function deleteProductMedia(productId, imsgids) {
// productDeleteMedia(
// productId:"gid://shopify/Product/6734676787376",
// mediaIds: []
// ) {
// deletedMediaIds
// }
console.log(`Deleting media ${imsgids} from ${productId}`);
const result = await axios({
url: process.env.SHOPIFY_STORE_URL,
method: 'post',
data: {
query: `
mutation productDeleteMedia ($productId : ID!, $mediaIds: [ID!]!) {
productDeleteMedia(
productId: $productId,
mediaIds: $mediaIds)
{
deletedMediaIds
}
}
`,
variables: {
productId: productId,
mediaIds: imsgids,
}
},
headers: SHOPIFY_HEADER,
});
const productDeleteMedia = result.data.data.productDeleteMedia;
console.log(`Product ${productId} media ${imsgids} deleted`);
return productDeleteMedia.deletedMediaIds;
}
/**
* Removes the product in Shopify based on the given imsgid
* @param {*} productId the product's imsgid
* @returns the deleted product id
*/
async function deleteProduct(productId) {
const result = await axios({
url: process.env.SHOPIFY_STORE_URL,
method: 'post',
data: {
query: `
mutation productDelete($input : ProductDeleteInput!){
productDelete(input: $input) {
deletedProductId
}
}
`,
variables: {
input: {
id: productId
}
}
},
headers: SHOPIFY_HEADER,
});
const productDelete = result.data.data.productDelete;
if (!productDelete.deletedProductId) {
console.log(`Could not delete product ${productId}`);
// returning something to prevent crazy shit
return "";
}
console.log(`Product ${productId} deleted`);
return productDelete.deletedProductId;
};
/**
* Perform the actual file upload to the given url destination
* @param {*} url where to upload
* @param {*} data what to upload
* @param {*} fileType the type of data being uploaded (eg: image/jpeg)
* @returns
*/
async function uploadFile(url, data, fileType) {
const options = {
headers: {
'content_type': fileType,
'x-aws-acl': 'private',
'Content-Type': fileType
}
};
const putResult = await axios.put(url, data, options);
return putResult.status;
}
/**
*
* @param {*} productId imsgid of the product to be associated with the media
* @param {*} name the name of media file
* @param {*} data source data to be uploaded
* @param {*} mimeType the file type
* @returns the uploaded resource url
*/
async function uploadProductMedia(productId, name, data, mimeType) {
const buf = Buffer.from(data, 'base64');
const size = new String(buf.length);
console.log(`uploading media ${name} (${size} Bytes)`);
// 1. allocate space for the picture on shopify
// stagedUploadsCreate
const result = await axios({
url: process.env.SHOPIFY_STORE_URL,
method: 'post',
data: {
query: `
mutation stagedUploadsCreate($input : [StagedUploadInput!]!){
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
userErrors {
field
message
}
}
}
`,
variables: {
input: [{
filename: name,
mimeType: mimeType,
resource: "IMAGE",
fileSize: size,
}]
}
},
headers: SHOPIFY_HEADER,
});
if (!result.data.data) {
console.log(result.data.errors);
return null;
}
const stagedTarget = result.data.data.stagedUploadsCreate.stagedTargets[0];
// 2. perform the REST call to actually upload the asset
const uploadStatus = await uploadFile(stagedTarget.url, buf, mimeType);
console.log(`uploading ${name}: ${uploadStatus}`);
// 3. associate the media to the product
// productCreateMedia
const createMediaResponse = await axios({
url: process.env.SHOPIFY_STORE_URL,
method: 'post',
data: {
query: `
mutation productCreateMedia(
$productId: ID!,
$media: [CreateMediaInput!]!
){
productCreateMedia(productId: $productId, media: $media) {
media {
alt
mediaContentType
status
mediaErrors{
details
message
}
... on MediaImage {
id
}
}
product {
id
}
}
}
`,
variables: {
productId: productId,
media: [{
originalSource: stagedTarget.resourceUrl,
alt: "IMS uploaded",
mediaContentType: "IMAGE"
}]
}
},
headers: SHOPIFY_HEADER,
});
// sanity check
if (!createMediaResponse.data.data) {
console.log(createMediaResponse.data.errors);
return null;
}
const productCreateMedia = createMediaResponse.data.data.productCreateMedia;
// check if there is at least one product media
if (productCreateMedia.media.length > 0) {
// assuming only one media was created
const media = productCreateMedia.media[0];
// return the media id as imsgid of the image
return media.id;
}
// unfortunately no id could be retrieved, try again
return null;
}
// PRIVATE FUNCTIONS ==========================
/**
* Populate the given product with the specified shopify data
* @param {productBaseModel} product to populate
* @param {*} data shopify response data
*/
function populateProduct(product, data) {
// Sanity check
if (!data) return product;
// Details
product.imsgid = data.id;
product.title = data.title;
product.description = data.descriptionHtml ? data.descriptionHtml : data.description;
// cannot set the quantity from IMS as the warehouses are managed locally
product.inventoryLevels = [];
const inventoryLevel = new productInventoryModel();
inventoryLevel.amount = data.totalInventory;
product.inventoryLevels.push(inventoryLevel);
// Assuming there is at lesat one variant
if (data.variants && data.variants.edges && data.variants.edges.length > 0) {
const variant = data.variants.edges[0].node;
product.sku = variant.sku;
product.barcode = variant.barcode;
product.price = variant.price;
product.requiresShipping = variant.inventoryItem.requiresShipping;
// TODO: add size mapping
// Transforming weight to grams based on the wieght unit
switch (variant.weightUnit) {
case 'KILOGRAMS':
product.weight = variant.weight * 1000;
break;
case 'POUNDS':
product.weight = variant.weight * 453.592;
break;
case 'OUNCES':
product.weight = variant.weight * 28.3495;
break;
case 'GRAMS':
default:
product.weight = variant.weight;
break;
}
} else {
console.log(`cannot properly populate product: no variants in ${data}`);
}
return product;
}
/**
* Retrieve the ims product media
* @param {string} imsgid
*/
async function getProductMedia(imsgid) {
const productQuery = gql`
query ($id: ID!) {
product (id: $id) {
media(first: 10) {
edges {
node {
... on MediaImage {
id
}
}
}
}
}
}`;
const arguments = {
id: imsgid,
};
// Send request to IMS connector using the ad-hoc client
const response = await shopifyClient.request(productQuery, arguments);
// FIXME: 10 media limit is hardcoded in the query
if (!response.product) return null;
const media = [];
for (const edge of response.product.media.edges) {
media.push(edge.node.id);
}
return media;
}
// EXPORTS ====================================
module.exports = {
createProduct,
deleteProduct,
deleteProductMedia,
getProduct,
getProductRawData,
getProductMedia,
updateProduct,
uploadProductMedia,
inventoryAdjustQuantity,
}