Skip to content

Instantly share code, notes, and snippets.

@snorrees
Created March 15, 2023 14:33
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save snorrees/1ca7c3191d62ede6b9b5d0a1822d7103 to your computer and use it in GitHub Desktop.
Save snorrees/1ca7c3191d62ede6b9b5d0a1822d7103 to your computer and use it in GitHub Desktop.
Sanity Connect custom handler. See requirements.md
import type {SanityClient} from '@sanity/client'
import {v5 as uuidv5} from 'uuid'
import {buildCollectionDocumentId, commitCollectionDocument} from './sanityOps'
import type {ShopifyDocumentCollection} from './storageTypes'
import {SHOPIFY_COLLECTION_DOCUMENT_TYPE, UUID_NAMESPACE_COLLECTIONS} from './constants'
import {DataSinkCollection} from './requestTypes'
import {idFromGid} from './requestHelpers'
export async function handleCollectionUpdate(
client: SanityClient,
collection: DataSinkCollection,
): Promise<{
collectionDocument: ShopifyDocumentCollection
}> {
const {handle, id, image, rules, sortOrder} =
collection
const sortOrderUpper = (sortOrder?.toUpperCase() || 'unknown').replace('-', '_')
const shopifyId = idFromGid(collection.id)
const collectionDocument: ShopifyDocumentCollection = {
_id: buildCollectionDocumentId(shopifyId), // Shopify product ID
_type: SHOPIFY_COLLECTION_DOCUMENT_TYPE,
store: {
...collection,
id: shopifyId,
gid: id,
isDeleted: false,
imageUrl: image?.src,
rules: rules?.map((rule) => ({
_key: uuidv5(
`shopify-collection-${collection.id}-${rule.column}-${rule.condition}-${rule.relation}`,
UUID_NAMESPACE_COLLECTIONS
),
_type: 'object',
column: rule.column?.toUpperCase() as Uppercase<string>,
condition: rule.condition,
relation: rule.relation?.toUpperCase() as Uppercase<string>,
})),
sortOrder: sortOrderUpper,
slug: {
_type: 'slug',
current: handle,
}
},
}
await commitCollectionDocument(client, collectionDocument)
return {collectionDocument}
}
export const SHOPIFY_PRODUCT_DOCUMENT_TYPE = 'product'
export const SHOPIFY_PRODUCT_VARIANT_DOCUMENT_TYPE = 'productVariant'
export const SHOPIFY_COLLECTION_DOCUMENT_TYPE = 'collection'
export const UUID_NAMESPACE_PRODUCT_VARIANT = '86350fb0-1fc5-4863-9071-2088151b41f2'
export const UUID_NAMESPACE_COLLECTIONS = 'cea57930-b309-47f4-9b06-a50d2ea39fee'
import {createClient} from "@sanity/client";
import {RequestBody} from './requestTypes'
import {handleProductUpdate} from './productUpdate'
import {deleteCollectionDocuments, deleteProductDocuments} from './sanityOps'
import {handleCollectionUpdate} from './collectionUpdate'
const sanityClient = createClient({
apiVersion: "2023-01-01",
dataset: process.env.SANITY_DATASET,
projectId: process.env.SANITY_PROJECT_ID,
token: process.env.SANITY_ADMIN_AUTH_TOKEN,
useCdn: false,
});
export async function handle(body: RequestBody) {
if (['create', 'update'].includes(body.action) && 'products' in body) {
for (const product of body.products) {
await handleProductUpdate(sanityClient, product)
}
}
else if (body.action === 'delete' && 'productIds' in body) {
for (const productId of body.productIds) {
await deleteProductDocuments(sanityClient, productId)
}
}
else if (['create', 'update'].includes(body.action) && 'collections' in body) {
for (const collection of body.collections) {
await handleCollectionUpdate(sanityClient, collection)
}
}
else if (body.action === 'delete' && 'collectionIds' in body) {
for (const collectionId of body.collectionIds) {
await deleteCollectionDocuments(sanityClient, collectionId)
}
}
}
import type {SanityClient} from '@sanity/client'
import {v5 as uuidv5} from 'uuid'
import {buildProductDocumentId, buildProductVariantDocumentId, commitProductDocuments} from './sanityOps'
import type {ShopifyDocumentProduct, ShopifyDocumentProductVariant} from './storageTypes'
import {
SHOPIFY_PRODUCT_DOCUMENT_TYPE,
SHOPIFY_PRODUCT_VARIANT_DOCUMENT_TYPE,
UUID_NAMESPACE_PRODUCT_VARIANT
} from './constants'
import {DataSinkProduct} from './requestTypes'
import {idFromGid} from './requestHelpers'
export async function handleProductUpdate(
client: SanityClient,
product: DataSinkProduct,
): Promise<{
productDocument: ShopifyDocumentProduct
productVariantsDocuments: ShopifyDocumentProductVariant[]
}> {
const {
handle,
id,
images,
status,
priceRange
} = product
const variants = product.variants || []
const firstImage = images?.[0]
const shopifyProductId = idFromGid(id)
const productVariantsDocuments = variants.map<ShopifyDocumentProductVariant>((variant) => {
const variantId = idFromGid(variant.id)
return ({
_id: buildProductVariantDocumentId(variantId),
_type: SHOPIFY_PRODUCT_VARIANT_DOCUMENT_TYPE,
store: {
...variant,
id: variantId,
gid: `gid://shopify/ProductVariant/${variant.id}`,
isDeleted: false,
option1: variant.selectedOptions[0]?.value,
option2: variant.selectedOptions[1]?.value,
option3: variant.selectedOptions[2]?.value,
previewImageUrl: variant.image?.src,
price: Number(variant.price),
compareAtPrice: variant.compareAtPrice ?? 0,
productGid: variant.product.id,
productId: idFromGid(variant.product.id),
sku: variant.sku,
status,
updatedAt: variant.updatedAt,
inventory: {
management: (variant.inventoryManagement || 'not_managed').toUpperCase(),
policy: (variant.inventoryPolicy || '').toUpperCase(),
quantity: variant.inventoryQuantity ?? 0,
isAvailable: variant.inventoryQuantity !== null && variant.inventoryQuantity > 0
}
}
})
})
const options: ShopifyDocumentProduct['store']['options'] =
product.options.map((option) => ({
_type: 'option',
_key: option.id,
name: option.name,
values: option.values ?? [],
})) || []
// We assign _key values of product option name and values since they're guaranteed unique in Shopify
const productDocument: ShopifyDocumentProduct = {
_id: buildProductDocumentId(shopifyProductId), // Shopify product ID
_type: SHOPIFY_PRODUCT_DOCUMENT_TYPE,
store: {
...product,
id: shopifyProductId,
gid: id,
isDeleted: false,
...(firstImage
? {
previewImageUrl: firstImage.src,
}
: {}),
priceRange,
slug: {
_type: 'slug',
current: handle,
},
options,
variants: productVariantsDocuments.map((variant) => {
return {
_key: uuidv5(variant._id, UUID_NAMESPACE_PRODUCT_VARIANT),
_type: 'reference',
_ref: variant._id,
_weak: true,
}
}),
},
}
await commitProductDocuments(client, productDocument, productVariantsDocuments)
return {productDocument, productVariantsDocuments}
}
export function idFromGid(gid: string): number {
const parts = gid.split('/')
const id = Number(parts[parts.length -1])
return isNaN(id) ? 0 : id
}
export interface ProductChanged {
action: 'sync' | 'create' | 'update'
products: DataSinkProduct[]
}
export interface ProductDeleted {
action: 'delete'
productIds: number[]
}
export interface CollectionChanged {
action: 'sync' | 'create' | 'update'
collections: DataSinkCollection[]
}
export interface CollectionDeleted {
action: 'delete'
collectionIds: number[]
}
export type RequestBody =
| ProductChanged
| ProductDeleted
| CollectionChanged
| CollectionDeleted
export interface DataSinkCollection {
id: `gid://shopify/Collection/${string}`
createdAt: string
handle: string
descriptionHtml: string
image?: DataSinkCollectionImage
rules?: {
column: string
condition: string
relation: string
}[]
disjunctive?: boolean
sortOrder: string
title: string
updatedAt: string
}
export interface DataSinkProduct {
id: `gid://shopify/Product/${string}`
title: string
description: string
descriptionHtml: string
featuredImage?: DataSinkProductImage
handle: string
images: DataSinkProductImage[]
options: DataSinkProductOption[]
priceRange: DataSinkProductPriceRange
productType: string
tags: string[]
variants: DataSinkProductVariant[]
vendor: string
status: 'active' | 'archived' | 'draft' | 'unknown'
publishedAt: string
createdAt: string
updatedAt: string
}
export interface DataSinkProductImage {
id: `gid://shopify/ProductImage/${string}`
altText?: string
height?: number
width?: number
src: string
}
export interface DataSinkProductOption {
id: `gid://shopify/ProductOption/${string}`
name: string
position: number
values: string[]
}
export interface DataSinkProductPriceRange {
minVariantPrice?: number
maxVariantPrice?: number
}
export interface DataSinkProductVariant {
id: `gid://shopify/ProductVariant/${string}`
title: string
compareAtPrice?: number
barcode?: string
inventoryPolicy: string
inventoryQuantity: number
inventoryManagement: string
position: number
requiresShipping: boolean
fulfillmentService: string
sku: string
taxable: boolean
weight: number
weightUnit: string
price: string
createdAt: string
updatedAt: string
image?: DataSinkProductImage
product: {
id: `gid://shopify/Product/${string}`
status: 'active' | 'archived' | 'draft' | 'unknown'
}
selectedOptions: {
name: string
value: string
}[]
}
export interface DataSinkCollectionImage {
altText: string
height?: number
width?: number
src: string
}

The following files have these dependencies (+ TypeScript):

npm i @sanity/client uuid groq 

Invoke handle(body) in handleRequest.ts, where body is the request body of your endpoint.

For Next.js that might look like

import {handle} from './handleRequest'

export default async function handler(req, res) {
  // Next.js will automatically parse `req.body` with requests of `content-type: application/json`,
  // so manually parsing with `JSON.parse` is unnecessary.
  const { body, method } = req;

  // Ignore non-POST requests
  if (method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  await handle(body)

  res.status(200).json({ message: "OK" });
}
import {IdentifiedSanityDocumentStub, SanityClient, Transaction} from '@sanity/client'
import groq from 'groq'
import {ShopifyDocumentCollection, ShopifyDocumentProduct, ShopifyDocumentProductVariant} from './storageTypes'
import {SHOPIFY_PRODUCT_VARIANT_DOCUMENT_TYPE} from './constants'
export async function hasDraft(
client: SanityClient,
document: IdentifiedSanityDocumentStub
): Promise<boolean> {
const draftId = `drafts.${document._id}`
const draft = await client.getDocument(draftId)
return draft !== undefined
}
export async function hasDrafts(
client: SanityClient,
documents: IdentifiedSanityDocumentStub[]
): Promise<Record<string, boolean>> {
const draftIds = documents.map((document) => `drafts.${document._id}`)
const drafts = await client.fetch<string[]>(groq`*[_id in $draftIds]._id`, {draftIds})
return documents.reduce<Record<string, boolean>>((acc, current) => {
acc[current._id] = drafts.includes(`drafts.${current._id}`)
return acc
}, {})
}
const deleteProductVariants = async (
client: SanityClient,
transaction: Transaction,
productDocument: ShopifyDocumentProduct,
productVariantsDocuments: ShopifyDocumentProductVariant[]
): Promise<void> => {
const productVariantIds = productVariantsDocuments.map(({_id}) => _id)
const deletedProductVariantIds = await client.fetch<string[]>(
groq`*[
_type == "${SHOPIFY_PRODUCT_VARIANT_DOCUMENT_TYPE}"
&& store.productId == $productId
&& !(_id in $productVariantIds)
]._id`,
{
productId: productDocument.store.id,
productVariantIds,
}
)
deletedProductVariantIds.forEach((deletedProductVariantId) => {
transaction.patch(deletedProductVariantId, (patch) => patch.set({'store.isDeleted': true}))
})
}
export async function deleteProductDocuments(client: SanityClient, id: number) {
// Fetch all product variant documents with matching Shopify Product ID
const productVariants: string[] = await client.fetch(
`*[
_type == "${SHOPIFY_PRODUCT_VARIANT_DOCUMENT_TYPE}"
&& store.productId == $id
]._id`,
{id}
)
const documentId = buildProductDocumentId(id)
const draftDocumentId = `drafts.${documentId}`
// Check for draft
const draft = await client.getDocument(draftDocumentId)
const transaction = client.transaction()
// Mark product as deleted
transaction.patch(documentId, (patch) => patch.set({'store.isDeleted': true}))
if (draft) {
transaction.patch(draftDocumentId, (patch) => patch.set({'store.isDeleted': true}))
}
// Mark all product variants as deleted
productVariants.forEach((productVariantDocumentId) =>
transaction.patch(productVariantDocumentId, (patch) => patch.set({'store.isDeleted': true}))
)
await transaction.commit()
}
export const createProductDocument = (
client: SanityClient,
transaction: Transaction,
document: ShopifyDocumentProduct,
draftExists: boolean
) => {
const publishedId = document._id
// Create new product if none found
transaction.createIfNotExists(document).patch(publishedId, (patch) => {
return patch.set({store: document.store})
})
// Patch existing draft (if present)
if (draftExists) {
const draftId = `drafts.${document._id}`
transaction.patch(draftId, (patch) => {
return patch.set({store: document.store})
})
}
}
export const createProductVariantDocument = (
client: SanityClient,
transaction: Transaction,
document: ShopifyDocumentProductVariant,
draftExists: boolean
) => {
const publishedId = document._id
// Create document if it doesn't exist, otherwise patch with existing content
transaction.createIfNotExists(document).patch(publishedId, (patch) => patch.set(document))
if (draftExists) {
const draftId = `drafts.${document._id}`
const documentDraft = Object.assign({}, document, {
_id: draftId,
})
transaction.patch(draftId, (patch) => patch.set(documentDraft))
}
}
export async function commitProductDocuments(
client: SanityClient,
productDocument: ShopifyDocumentProduct,
productVariantsDocuments: ShopifyDocumentProductVariant[]
) {
const transaction = client.transaction()
const drafts = await hasDrafts(client, [productDocument, ...productVariantsDocuments])
// Create product and merge options
createProductDocument(client, transaction, productDocument, drafts[productDocument._id])
// Mark the non existing product variants as deleted
await deleteProductVariants(client, transaction, productDocument, productVariantsDocuments)
// Create / update product variants
for (const productVariantsDocument of productVariantsDocuments) {
createProductVariantDocument(
client,
transaction,
productVariantsDocument,
drafts[productVariantsDocument._id]
)
}
await transaction.commit()
}
export const createCollectionDocument = async (
client: SanityClient,
transaction: Transaction,
collectionDocument: ShopifyDocumentCollection,
draftExists: boolean
// eslint-disable-next-line require-await
) => {
transaction
.createIfNotExists(collectionDocument)
.patch(collectionDocument._id, (patch) => patch.set(collectionDocument))
const draftId = `drafts.${collectionDocument._id}`
if (draftExists) {
const documentDraft = Object.assign({}, collectionDocument, {
_id: draftId,
})
transaction.patch(draftId, (patch) => patch.set(documentDraft))
}
}
export async function commitCollectionDocument(
client: SanityClient,
collectionDocument: ShopifyDocumentCollection
) {
const transaction = client.transaction()
const drafts = await hasDrafts(client, [collectionDocument])
// Create product and merge options
await createCollectionDocument(
client,
transaction,
collectionDocument,
drafts[collectionDocument._id]
)
await transaction.commit()
}
export async function deleteCollectionDocuments(client: SanityClient, id: number) {
const documentId = buildCollectionDocumentId(id)
const draftDocumentId = `drafts.${documentId}`
// Check for draft
const draft = await client.getDocument(draftDocumentId)
const transaction = client.transaction()
// Mark product as deleted
transaction.patch(documentId, (patch) => patch.set({'store.isDeleted': true}))
if (draft) {
transaction.patch(draftDocumentId, (patch) => patch.set({'store.isDeleted': true}))
}
await transaction.commit()
}
export function buildProductDocumentId(id: number): ShopifyDocumentProduct['_id'] {
return `shopifyProduct-${id}`
}
export function buildProductVariantDocumentId(id: number): ShopifyDocumentProductVariant['_id'] {
return `shopifyProductVariant-${id}`
}
export function buildCollectionDocumentId(id: number): ShopifyDocumentCollection['_id'] {
return `shopifyCollection-${id}`
}
export type VariantPriceRange = {
minVariantPrice?: number
maxVariantPrice?: number
}
export type ShopifyDocumentCollection = {
_id: `shopifyCollection-${string}` // Shopify product ID
_type: 'collection'
store: {
id: number
gid: `gid://shopify/Collection/${string}`
createdAt: string
isDeleted: boolean
descriptionHtml: string
imageUrl?: string
rules?: {
_key: string
_type: 'object'
column: Uppercase<string>
condition: string
relation: Uppercase<string>
}[]
disjunctive?: boolean
slug: {
_type: 'slug'
current: string
}
sortOrder: string
title: string
updatedAt?: string
}
}
export type ShopifyDocumentProductVariant = {
_id: `shopifyProductVariant-${string}` // Shopify product ID
_type: 'productVariant'
store: {
id: number
gid: `gid://shopify/ProductVariant/${string}`
compareAtPrice: number
createdAt: string
isDeleted: boolean
option1: string
option2: string
option3: string
previewImageUrl?: string
price: number
productGid: `gid://shopify/Product/${string}`
productId: number
sku: string
status: 'active' | 'archived' | 'draft' | 'unknown'
title: string
updatedAt?: string
inventory: {
policy: string
quantity?: number
management: string
isAvailable?: boolean
}
}
}
export type ShopifyDocumentProduct = {
_id: `shopifyProduct-${string}` // Shopify product ID
_type: 'product'
store: {
id: number
gid: `gid://shopify/Product/${string}`
priceRange: VariantPriceRange
productType: string
slug: {_type: string; current: string}
status: 'active' | 'archived' | 'draft' | 'unknown'
tags: string[]
title: string
updatedAt?: string
previewImageUrl?: string
createdAt: string
isDeleted: boolean
variants?: {_key: string; _type: string; _ref: string; _weak: boolean}[]
options: {
_type: string
_key: string
name: string
values: string[]
}[]
vendor: string
descriptionHtml: string
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment