fix: source shopify handle duplicate unique values and more efficient updates (#1154)

* fix: added functionality to handle duplicate sku, ean, upc, and barcodes on product variants

* handle unique variant values

* save

* clear ignored keys in redis

* fixed plugin to handle duplicates and use last build time to make queries more effective

* v1.1.1-canary.0

* removed console log

* v1.1.1-canary.1

* reduce amounts of calls to shopify api

* v1.1.1-canary.2

* added README and support for SmartCollections

* v1.1.1-canary.4

* v1.1.1-canary.5

* v1.1.1-canary.5

* fix dropped products in smart collections

* cleanup

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
This commit is contained in:
Kasper Fabricius Kristensen
2022-03-10 13:10:57 +01:00
committed by GitHub
co-authored by Sebastian Rindom
parent 218b20b26d
commit eb8e247e7d
7 changed files with 572 additions and 83 deletions
@@ -1,7 +1,7 @@
import { BaseService } from "medusa-interfaces"
import { pager } from "../utils/pager"
import { createClient } from "../utils/create-client"
import { DataType } from "@shopify/shopify-api"
import { BaseService } from "medusa-interfaces"
import { createClient } from "../utils/create-client"
import { pager } from "../utils/pager"
class ShopifyClientService extends BaseService {
// eslint-disable-next-line no-empty-pattern
@@ -23,7 +23,7 @@ class ShopifyClientService extends BaseService {
}
delete(params) {
return this.client_.post(params)
return this.client_.delete(params)
}
post(params) {
@@ -3,7 +3,14 @@ import { removeIndex } from "../utils/remove-index"
class ShopifyCollectionService extends BaseService {
constructor(
{ manager, shopifyProductService, productCollectionService },
{
manager,
shopifyProductService,
productCollectionService,
productService,
storeService,
productRepository,
},
options
) {
super()
@@ -16,6 +23,13 @@ class ShopifyCollectionService extends BaseService {
this.productService_ = shopifyProductService
/** @private @const {ProductCollectionService} */
this.collectionService_ = productCollectionService
/** @private @const {StoreService} */
this.storeService_ = storeService
/** @private @const {ProductService} */
this.medusaProductService_ = productService
/** @private @const {Product} */
this.productRepository_ = productRepository
}
withTransaction(transactionManager) {
@@ -28,6 +42,9 @@ class ShopifyCollectionService extends BaseService {
options: this.options,
shopifyProductService: this.productService_,
productCollectionService: this.collectionService_,
storeService: this.storeService_,
productService: this.medusaProductService_,
productRepository: this.productRepository_,
})
cloned.transactionManager_ = transactionManager
@@ -42,10 +59,10 @@ class ShopifyCollectionService extends BaseService {
* @param {Object[]} products
* @return {Promise}
*/
async createWithProducts(collects, collections, products) {
async createCustomCollections(collects, collections, products) {
return this.atomicPhase_(async (manager) => {
const normalizedCollections = collections.map((c) =>
this.normalizeCollection_(c)
this.normalizeCustomCollection_(c)
)
const result = []
@@ -61,25 +78,71 @@ class ShopifyCollectionService extends BaseService {
.create(nc)
}
const productIds = collects.reduce((productIds, c) => {
if (c.collection_id === collection.metadata.sh_id) {
productIds.push(c.product_id)
}
return productIds
}, [])
const productIds = this.getCustomCollectionProducts_(
collection.metadata.sh_id,
collects,
products
)
const reducedProducts = products.reduce((reducedProducts, p) => {
if (productIds.includes(p.id)) {
reducedProducts.push(p)
removeIndex(products, p)
}
return reducedProducts
}, [])
await this.addProductsToCollection(collection.id, productIds)
for (const product of reducedProducts) {
await this.productService_
result.push(collection)
}
return result
})
}
async createSmartCollections(collections, products) {
return this.atomicPhase_(async (manager) => {
if (!collections) {
return Promise.resolve()
}
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const ids = products.map((p) => p.id)
const completeProducts = await productRepo.findWithRelations(
["variants", "tags", "type"],
ids
)
const defaultCurrency = await this.storeService_
.retrieve()
.then((store) => {
return store.default_currency_code
})
.catch((_) => undefined)
const normalizedCollections = collections.map((c) =>
this.normalizeSmartCollection_(c)
)
const result = []
for (const nc of normalizedCollections) {
let collection = await this.collectionService_
.retrieveByHandle(nc.collection.handle)
.catch((_) => undefined)
if (!collection) {
collection = await this.collectionService_
.withTransaction(manager)
.create(product, collection.id)
.create(nc.collection)
}
const validProducts = this.getValidProducts_(
nc.rules,
completeProducts,
nc.disjunctive ?? false,
defaultCurrency
)
if (validProducts.length) {
const productIds = validProducts.map((p) => p.id)
await this.addProductsToCollection(collection.id, productIds)
}
result.push(collection)
@@ -89,7 +152,222 @@ class ShopifyCollectionService extends BaseService {
})
}
normalizeCollection_(shopifyCollection) {
async addProductsToCollection(collectionId, productIds) {
return this.atomicPhase_(async (manager) => {
const result = await this.collectionService_
.withTransaction(manager)
.addProducts(collectionId, productIds)
return result
})
}
getCustomCollectionProducts_(shCollectionId, collects, products) {
const medusaProductIds = products.reduce((prev, curr) => {
if (curr.external_id) {
prev[curr.external_id] = curr.id
}
return prev
}, {})
const productIds = collects.reduce((productIds, c) => {
if (c.collection_id === shCollectionId) {
productIds.push(`${c.product_id}`)
}
return productIds
}, [])
const productIdsToAdd = Object.keys(medusaProductIds).map((shopifyId) => {
if (productIds.includes(shopifyId)) {
const medusaId = medusaProductIds[shopifyId]
delete medusaProductIds[shopifyId]
return medusaId
}
})
// remove added products from the array
for (const id of productIdsToAdd) {
const productToRemove = products.find((p) => p.id === id)
if (productToRemove) {
removeIndex(products, productToRemove)
}
}
return productIdsToAdd
}
getValidProducts_(rules, products, disjunctive, defaultCurrency) {
const validProducts = []
for (const product of products) {
const results = rules.map((r) =>
this.testRule_(r, product, defaultCurrency)
)
if (disjunctive && !results.includes(false)) {
validProducts.push(product)
} else if (!disjunctive && results.includes(true)) {
validProducts.push(product)
}
}
// remove valid products from the array
for (const validProduct of validProducts) {
removeIndex(products, validProduct)
}
return validProducts
}
testRule_(rule, product, defaultCurrency = undefined) {
const { column, relation, condition } = rule
if (column === "title") {
return this.testTextRelation_(product.title, relation, condition)
}
if (column === "type") {
return this.testTextRelation_(product.type.value, relation, condition)
}
if (column === "vendor") {
if (product.metadata?.vendor) {
return this.testTextRelation_(
product.metadata?.vendor,
relation,
condition
)
}
return false
}
if (column === "variant_title") {
if (product.variants?.length) {
const anyMatch = product.variants.some((variant) => {
return this.testTextRelation_(variant.title, relation, condition)
})
return anyMatch
}
return false
}
if (column === "tag") {
if (product.tags) {
const anyMatch = product.tags.some((tag) =>
this.testTextRelation_(tag.value, relation, condition)
)
return anyMatch
}
return false
}
if (column === "variant_inventory") {
if (product.variants?.length) {
const anyMatch = product.variants.some((variant) =>
this.testNumberRelation_(
variant.inventory_quantity,
relation,
condition
)
)
return anyMatch
}
return false
}
if (column === "variant_price") {
if (product.variants?.length && defaultCurrency) {
const prices = []
for (const variant of product.variants) {
if (variant.prices) {
for (const price of variant.prices) {
if (price.currency_code === defaultCurrency) {
prices.push(price.amount)
}
}
}
}
const anyMatch = prices.some((price) => {
return this.testNumberRelation_(price, relation, condition)
})
return anyMatch
}
return false
}
if (column === "variant_weight") {
if (product.variants?.length) {
const anyMatch = product.variants.some((variant) =>
this.testNumberRelation_(variant.weight, relation, condition)
)
return anyMatch
}
return false
}
// If we get here, it means the column is variant_compare_at_price which we don't support until we extend MoneyAmount
return true
}
testTextRelation_(text, relation, condition) {
if (relation === "contains") {
return text.includes(condition)
}
if (relation === "equals") {
return text === condition
}
if (relation === "not_equals") {
return text !== condition
}
if (relation === "starts_with") {
return text.startsWith(condition)
}
if (relation === "ends_with") {
return text.endsWith(condition)
}
return false
}
testNumberRelation_(number, relation, condition) {
if (relation === "greater_than") {
return number > condition
}
if (relation === "less_than") {
return number < condition
}
if (relation === "equals") {
return number === condition
}
if (relation === "not_equals") {
return number !== condition
}
return false
}
normalizeCustomCollection_(shopifyCollection) {
return {
title: shopifyCollection.title,
handle: shopifyCollection.handle,
@@ -99,6 +377,21 @@ class ShopifyCollectionService extends BaseService {
},
}
}
normalizeSmartCollection_(smartCollection) {
return {
collection: {
title: smartCollection.title,
handle: smartCollection.handle,
metadata: {
sh_id: smartCollection.id,
sh_body: smartCollection.body_html,
},
},
rules: smartCollection.rules,
disjunctive: smartCollection.disjunctive,
}
}
}
export default ShopifyCollectionService
@@ -1,9 +1,10 @@
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import _ from "lodash"
import { parsePrice } from "../utils/parse-price"
import { INCLUDE_PRESENTMENT_PRICES } from "../utils/const"
import axios from "axios"
import isEmpty from "lodash/isEmpty"
import omit from "lodash/omit"
import random from "lodash/random"
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { parsePrice } from "../utils/parse-price"
class ShopifyProductService extends BaseService {
constructor(
@@ -62,7 +63,7 @@ class ShopifyProductService extends BaseService {
* @param {string} collectionId optional
* @return {Product} the created product
*/
async create(data, collectionId) {
async create(data) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(data.id, "product.created")
if (ignore) {
@@ -71,14 +72,16 @@ class ShopifyProductService extends BaseService {
const existingProduct = await this.productService_
.withTransaction(manager)
.retrieveByExternalId(data.id)
.retrieveByExternalId(data.id, {
relations: ["variants", "options"],
})
.catch((_) => undefined)
if (existingProduct) {
return await this.update(data)
return await this.update(existingProduct, data)
}
const normalizedProduct = this.normalizeProduct_(data, collectionId)
const normalizedProduct = this.normalizeProduct_(data)
normalizedProduct.profile_id = await this.getShippingProfile_(
normalizedProduct.is_giftcard
)
@@ -95,7 +98,8 @@ class ShopifyProductService extends BaseService {
this.addVariantOptions_(v, product.options)
)
for (const variant of variants) {
for (let variant of variants) {
variant = await this.ensureVariantUnique_(variant)
await this.productVariantService_
.withTransaction(manager)
.create(product.id, variant)
@@ -108,45 +112,24 @@ class ShopifyProductService extends BaseService {
})
}
async update(data) {
async update(existing, shopifyUpdate) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(data.id, "product.updated")
const ignore = await this.redis_.shouldIgnore(
shopifyUpdate.id,
"product.updated"
)
if (ignore) {
return
}
let existing = await this.productService_
.retrieveByExternalId(data.id, {
relations: ["variants", "options"],
})
.catch((_) => undefined)
if (!existing) {
return await this.create(data)
}
/**
* Variants received from webhook do not include
* presentment prices. Therefore, we fetch them
* separately, and add to the data object.
*/
const { variants } = await this.shopify_
.get({
path: `products/${data.id}`,
extraHeaders: INCLUDE_PRESENTMENT_PRICES,
})
.then((res) => {
return res.body.product
})
data.variants = variants || []
const normalized = this.normalizeProduct_(data)
const normalized = this.normalizeProduct_(shopifyUpdate)
existing = await this.addProductOptions_(existing, normalized.options)
await this.updateVariants_(existing, normalized.variants)
await this.deleteVariants_(existing, normalized.variants)
delete normalized.variants
delete normalized.options
const update = {}
@@ -156,8 +139,8 @@ class ShopifyProductService extends BaseService {
}
}
if (!_.isEmpty(update)) {
await this.redis_.addIgnore(data.id, "product.updated")
if (!isEmpty(update)) {
await this.redis_.addIgnore(shopifyUpdate.id, "product.updated")
return await this.productService_
.withTransaction(manager)
.update(existing.id, update)
@@ -344,8 +327,12 @@ class ShopifyProductService extends BaseService {
}
variant = this.addVariantOptions_(variant, options)
const match = variants.find((v) => v.sku === variant.sku)
const match = variants.find(
(v) => v.metadata.sh_id === variant.metadata.sh_id
)
if (match) {
variant = this.removeUniqueConstraint_(variant)
await this.productVariantService_
.withTransaction(manager)
.update(match.id, variant)
@@ -370,7 +357,9 @@ class ShopifyProductService extends BaseService {
continue
}
const match = updateVariants.find((v) => v.sku === variant.sku)
const match = updateVariants.find(
(v) => v.metadata.sh_id === variant.metadata.sh_id
)
if (!match) {
await this.productVariantService_
.withTransaction(manager)
@@ -396,7 +385,11 @@ class ShopifyProductService extends BaseService {
for (const option of updateOptions) {
const match = options.find((o) => o.title === option.title)
if (!match) {
if (match) {
await this.productService_
.withTransaction(manager)
.updateOption(product.id, match.id, { title: option.title })
} else if (!match) {
await this.productService_
.withTransaction(manager)
.addOption(id, option.title)
@@ -406,6 +399,7 @@ class ShopifyProductService extends BaseService {
const result = await this.productService_.retrieve(id, {
relations: ["variants", "options"],
})
return result
})
}
@@ -428,7 +422,7 @@ class ShopifyProductService extends BaseService {
* @param {string} collectionId optional
* @return {object} normalized object
*/
normalizeProduct_(product, collectionId) {
normalizeProduct_(product) {
return {
title: product.title,
handle: product.handle,
@@ -446,9 +440,11 @@ class ShopifyProductService extends BaseService {
tags: product.tags.split(",").map((tag) => this.normalizeTag_(tag)) || [],
images: product.images.map((img) => img.src) || [],
thumbnail: product.image?.src || null,
collection_id: collectionId || null,
external_id: product.id,
status: "proposed",
metadata: {
vendor: product.vendor,
},
}
}
@@ -548,6 +544,52 @@ class ShopifyProductService extends BaseService {
value: tag,
}
}
handleDuplicateConstraint_(uniqueVal) {
return `DUP-${random(100, 999)}-${uniqueVal}`
}
async testUnique_(uniqueVal, type) {
// Test if the unique value has already been added, if it was then pass the value onto the duplicate handler and return the new value
const exists = await this.redis_.getUniqueValue(uniqueVal, type)
if (exists) {
const dupValue = this.handleDuplicateConstraint_(uniqueVal)
await this.redis_.addUniqueValue(dupValue, type)
return dupValue
}
// If it doesn't exist, we return the value
await this.redis_.addUniqueValue(uniqueVal, type)
return uniqueVal
}
async ensureVariantUnique_(variant) {
let { sku, ean, upc, barcode } = variant
if (sku) {
sku = await this.testUnique_(sku, "SKU")
}
if (ean) {
ean = await this.testUnique_(ean, "EAN")
}
if (upc) {
upc = await this.testUnique_(upc, "UPC")
}
if (barcode) {
barcode = await this.testUnique_(barcode, "BARCODE")
}
return { ...variant, sku, ean, upc, barcode }
}
removeUniqueConstraint_(update) {
const payload = omit(update, ["sku", "ean", "upc", "barcode"])
return payload
}
}
export default ShopifyProductService
@@ -1,3 +1,4 @@
// shopify-redis
import { BaseService } from "medusa-interfaces"
import { IGNORE_THRESHOLD } from "../utils/const"
@@ -12,7 +13,7 @@ class shopifyRedisService extends BaseService {
}
async addIgnore(id, side) {
const key = `${id}_ignore_${side}`
const key = `sh_${id}_ignore_${side}`
return await this.redis_.set(
key,
1,
@@ -22,7 +23,17 @@ class shopifyRedisService extends BaseService {
}
async shouldIgnore(id, action) {
const key = `${id}_ignore_${action}`
const key = `sh_${id}_ignore_${action}`
return await this.redis_.get(key)
}
async addUniqueValue(uniqueVal, type) {
const key = `sh_${uniqueVal}_${type}`
return await this.redis_.set(key, 1, "EX", 60 * 5)
}
async getUniqueValue(uniqueVal, type) {
const key = `sh_${uniqueVal}_${type}`
return await this.redis_.get(key)
}
}
@@ -6,6 +6,7 @@ class ShopifyService extends BaseService {
{
manager,
shippingProfileService,
storeService,
shopifyProductService,
shopifyCollectionService,
shopifyClientService,
@@ -26,6 +27,8 @@ class ShopifyService extends BaseService {
this.collectionService_ = shopifyCollectionService
/** @private @const {ShopifyRestClient} */
this.client_ = shopifyClientService
/** @private @const {StoreService} */
this.store_ = storeService
}
withTransaction(transactionManager) {
@@ -40,6 +43,8 @@ class ShopifyService extends BaseService {
shopifyClientService: this.client_,
shopifyProductService: this.productService_,
shopifyCollectionService: this.collectionService_,
shopifyBuildService: this.buildService_,
storeService: this.store_,
})
cloned.transactionManager_ = transactionManager
@@ -49,30 +54,80 @@ class ShopifyService extends BaseService {
async importShopify() {
return this.atomicPhase_(async (manager) => {
const updatedSinceQuery = await this.getAndUpdateBuildTime_()
await this.shippingProfileService_.createDefault()
await this.shippingProfileService_.createGiftCardDefault()
const products = await this.client_.list(
"products",
INCLUDE_PRESENTMENT_PRICES
INCLUDE_PRESENTMENT_PRICES,
updatedSinceQuery
)
const customCollections = await this.client_.list(
"custom_collections",
null,
updatedSinceQuery
)
const smartCollections = await this.client_.list(
"smart_collections",
null,
updatedSinceQuery
)
const collects = await this.client_.list(
"collects",
null,
updatedSinceQuery
)
const resolvedProducts = await Promise.all(
products.map(async (product) => {
return await this.productService_
.withTransaction(manager)
.create(product)
})
)
const customCollections = await this.client_.list("custom_collections")
const smartCollections = await this.client_.list("smart_collections")
const collects = await this.client_.list("collects")
await this.collectionService_
.withTransaction(manager)
.createWithProducts(
collects,
[...customCollections, ...smartCollections],
products
)
.createCustomCollections(collects, customCollections, resolvedProducts)
for (const product of products) {
await this.productService_.withTransaction(manager).create(product)
}
await this.collectionService_
.withTransaction(manager)
.createSmartCollections(smartCollections, resolvedProducts)
})
}
async getAndUpdateBuildTime_() {
let buildtime = null
const store = await this.store_.retrieve()
if (!store) {
return {}
}
if (store.metadata?.source_shopify_bt) {
buildtime = store.metadata.source_shopify_bt
}
const payload = {
metadata: {
source_shopify_bt: new Date().toISOString(),
},
}
await this.store_.update(payload)
if (!buildtime) {
return {}
}
return {
updated_at_min: buildtime,
}
}
}
export default ShopifyService