ndimatteo / HULL

๐Ÿ’€ Headless Shopify Starter โ€“ย powered by Next.js + Sanity.io
https://hull.dev
MIT License
1.36k stars 167 forks source link

Sync seems inconsistent #131

Closed SidNewman closed 7 months ago

SidNewman commented 8 months ago

Hi there,

I've got a headless build which is awesome, its been live for over a year now ๐Ÿš€

Recently the products syncs seems to have stopped working / they only sometimes work which is quite odd.

Ive found that creating a product from scratch syncs fine (80% of the time), but updating any of the products just doesn't work. Or I can get it to work on some updates, however i'm not sure what fields are triggering the update if that makes sense.

Ive had a look at my server logs and it looks like i'm getting a 422 error - just not sure what to do with it:

Error: Request failed with status code 422

The set up is exactly the same, with my live url web-hooks added, ive also tried changing the Shopify API version but no luck.

Any pointers? ๐Ÿคž The deletion endpoint is fine too

Heres my shopify-product-update.js file for reference:

/* eslint-disable no-unused-vars */

import axios from 'axios'
import sanityClient from '@sanity/client'
import crypto from 'crypto'
import { nanoid } from 'nanoid'

const getRawBody = require('raw-body')
const jsondiffpatch = require('jsondiffpatch')

// Sanity
// Setup Sanity connection
const sanity = sanityClient({
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  token: process.env.SANITY_API_TOKEN,
  apiVersion: '2022-08-30',
  useCdn: false,
})

// Turn off default NextJS bodyParser, so we can run our own middleware
export const config = {
  api: {
    bodyParser: false,
  },
}

// Custom Middleware to parse Shopify's webhook payload
const runMiddleware = (req, res, fn) => {
  new Promise((resolve) => {
    if (!req.body) {
      let buffer = ''
      req.on('data', (chunk) => {
        buffer += chunk
      })

      req.on('end', () => {
        resolve()
        req.body = JSON.parse(Buffer.from(buffer).toString())
      })
    }
  })
}

export default async function send(req, res) {
  // bail if it's not a post request or it's missing an ID
  if (req.method !== 'POST') {
    console.log('must be a POST request with a product ID')
    return res
      .status(200)
      .json({ error: 'must be a POST request with a product ID' })
  }

  /*  ------------------------------ */
  /*  1. Run our middleware
  /*  2. check webhook integrity
  /*  ------------------------------ */

  // run our middleware to extract the "raw" body for matching the Shopify Integrity Key
  await runMiddleware(req, res)
  const rawBody = await getRawBody(req)

  // get request integrity header
  const hmac = req.headers['x-shopify-hmac-sha256']
  const generatedHash = await crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_INTEGRITY)
    .update(rawBody, 'utf8', 'hex')
    .digest('base64')

  // bail if shopify integrity doesn't match
  if (hmac !== generatedHash) {
    console.log('not verified from Shopify')
    return res.status(200).json({ error: 'not verified from Shopify' })
  }

  // extract shopify data
  const {
    body: { status, id, title, description, handle, options, variants, tags },
  } = req

  console.log('here be the tags: ', tags)

  console.log('here be the body: ', req)

  /*  ------------------------------ */
  /*  Construct our product objects
  /*  ------------------------------ */

  // Define product document
  const product = {
    _type: 'product',
    _id: `product-${id}`,
  }

  // Define product options if there are more than one variant
  const productOptions =
    variants.length > 1
      ? options.map((option) => ({
          _key: option.id,
          _type: 'productOption',
          name: option.name,
          values: option.values,
          position: option.position,
        }))
      : []

  // Define product fields
  const productFields = {
    wasDeleted: false,
    isDraft: status === 'draft' ? true : false,
    productTitle: title,
    productID: id,
    description: description,
    slug: { current: handle },
    price: variants[0].price * 100,
    comparePrice: variants[0].compare_at_price * 100,
    sku: variants[0].sku || '',
    tags: tags,
    inStock: variants.some(
      (v) => v.inventory_quantity > 0 || v.inventory_policy === 'continue'
    ),
    lowStock:
      variants.reduce((a, b) => a + (b.inventory_quantity || 0), 0) <= 10,
    options: productOptions,
  }

  // Define productVariant documents
  const productVariants = variants
    .sort((a, b) => (a.id > b.id ? 1 : -1))
    .map((variant) => ({
      _type: 'productVariant',
      _id: `productVariant-${variant.id}`,
    }))

  // Define productVariant fields
  const productVariantFields = variants
    .sort((a, b) => (a.id > b.id ? 1 : -1))
    .map((variant) => ({
      isDraft: status === 'draft' ? true : false,
      wasDeleted: false,
      productTitle: title,
      productID: id,
      variantTitle: variant.title,
      variantID: variant.id,
      price: variant.price * 100,
      comparePrice: variant.compare_at_price * 100,
      sku: variant.sku || '',
      inStock:
        variant.inventory_quantity > 0 ||
        variant.inventory_policy === 'continue',
      lowStock: variant.inventory_quantity <= 5,
      options:
        variants.length > 1
          ? options.map((option) => ({
              _key: option.id,
              _type: 'productOptionValue',
              name: option.name,
              value: variant[`option${option.position}`],
              position: option.position,
            }))
          : [],
    }))

  // construct our comparative product object
  const productCompare = {
    ...product,
    ...productFields,
    ...{
      variants: productVariants.map((variant, key) => ({
        ...variant,
        ...productVariantFields[key],
      })),
    },
  }

  /*  ------------------------------ */
  /*  Check for previous sync
  /*  ------------------------------ */

  // Setup our Shopify connection
  const shopifyConfig = {
    'Content-Type': 'application/json',
    'X-Shopify-Access-Token': process.env.SHOPIFY_API_PASSWORD,
  }

  // Fetch the metafields for this product
  const shopifyProduct = await axios({
    url: `https://${process.env.SHOPIFY_STORE_ID}.myshopify.com/admin/api/2022-10/products/${id}/metafields.json`,
    method: 'GET',
    headers: shopifyConfig,
  })

  // See if our metafield exists
  const previousSync = shopifyProduct.data?.metafields.find(
    (mf) => mf.key === 'product_sync'
  )

  // Metafield found
  if (previousSync) {
    console.log('Comparing previous sync...')

    // Differences found
    if (jsondiffpatch.diff(JSON.parse(previousSync.value), productCompare)) {
      console.log('discrepancy found...')

      // update our shopify metafield with the new data before continuing sync with Sanity
      axios({
        url: `https://${process.env.SHOPIFY_STORE_ID}.myshopify.com/admin/api/2022-10/products/${id}/metafields/${previousSync.id}.json`,
        method: 'PUT',
        headers: shopifyConfig,
        data: {
          metafield: {
            id: previousSync.id,
            value: JSON.stringify(productCompare),
            value_type: 'string',
          },
        },
      })

      // No changes found
    } else {
      console.log('no difference, sync complete!')
      return res
        .status(200)
        .json({ error: 'nothing to sync, product up-to-date' })
    }
    // No metafield created yet, let's do that
  } else {
    console.log('Metafield not found, create new')
    axios({
      url: `https://${process.env.SHOPIFY_STORE_ID}.myshopify.com/admin/api/2022-10/products/${id}/metafields.json`,
      method: 'POST',
      headers: shopifyConfig,
      data: {
        metafield: {
          namespace: 'sanity',
          key: 'product_sync',
          value: JSON.stringify(productCompare),
          value_type: 'string',
        },
      },
    })
  }

  /*  ------------------------------ */
  /*  Begin Sanity Product Sync
  /*  ------------------------------ */

  console.log('product sync starting...')

  let stx = sanity.transaction()

  // create product if doesn't exist
  stx = stx.createIfNotExists(product)

  // unset options field first, to avoid patch set issues
  stx = stx.patch(`product-${id}`, (patch) => patch.unset(['options']))

  // patch (update) product document with core shopify data
  stx = stx.patch(`product-${id}`, (patch) => patch.set(productFields))

  // patch (update) title & slug if none has been set
  stx = stx.patch(`product-${id}`, (patch) =>
    patch.setIfMissing({ title: title })
  )

  // create variant if doesn't exist & patch (update) variant with core shopify data
  productVariants.forEach((variant, i) => {
    stx = stx.createIfNotExists(variant)
    stx = stx.patch(variant._id, (patch) => patch.set(productVariantFields[i]))
    stx = stx.patch(variant._id, (patch) =>
      patch.setIfMissing({ title: productVariantFields[i].variantTitle })
    )
  })

  // grab current variants
  const currentVariants = await sanity.fetch(
    `*[_type == "productVariant" && productID == ${id}]{
      _id
    }`
  )

  // mark deleted variants
  currentVariants.forEach((cv) => {
    const active = productVariants.some((v) => v._id === cv._id)
    if (!active) {
      stx = stx.patch(cv._id, (patch) => patch.set({ wasDeleted: true }))
    }
  })
  const result = await stx.commit()

  console.log('sync complete!')

  res.statusCode = 200
  res.json(JSON.stringify(result))
}

TIA

ndimatteo commented 7 months ago

Hey there @SidNewman sorry for the delay (busy end of the year over here)!

This definitely sounds odd. A 422 error is not something we return anywhere in the product-update API route, which tells me maybe something is malformed coming from Shopify when the webhook fires? Without more details it's hard to understand what a solution could be.

Unfortunately, I won't be working on updating HULL until the new year, and I don't have plans to continue to maintain a custom sync function in the next release.

Instead I'll be opting to use the Sanity Connect app alongside the new hydrogen-react library.

Going to close this for now, but feel free to follow-up if more details come to light about the issue you're experiencing! ๐Ÿค˜