Your IP : 216.73.217.95


Current Path : /home/deltalab/PMS/ims-connector/logic/shopify/
Upload File :
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,
}