feat: medusa-source-shopify loader (#563)

* added statuses to product + unit test for updating status

* add update to product model

* added integration tests

* added integration test to validate that updating status to null results in invalid_data error

* removed comment

* update GET /store/products integration test

* fixed unit test with IdMap

* init plugin

* changed dbehaviour on invalid status input on admin list products

* mprices

* updated migration to add status = published on all existing products + added integration test on GET /admin/products when status null is provided

* merged product status

* init ShopifyService

* made requested changes to migration and GET /store/products

* fixed test

* made requested changes to migration

* push progress on source plugin

* add webhook product/create handler

* fixed normalization of variant weight

* removed weight func

* work on events

* finished product hooks (error on new variant needs to be fixed)

* fixed order status

* create fulfillments

* update fulfillment on cancel

* refactored services, handle returns though medusa, helper methods

* order updates

* removed dist

* update gitignore

* emit cahnges to product

* added redis ignore check to prevent update loops

* fixed product-variant.deleted event

* fix more events

* fix test

* fix: order taxes

* added refund with no items

* fixes to hooks

* fixed handling refunds and returns issued from Shopify

* added unit tests to ShopifyProductService and ShopifyCollectionService

* linting fix

* prepared loader PR

* fix: jsDocs

* fix: pager

* fix: build output and babelrc

* chore: linting

* fix: address type

* fix: migration clean up

* fix: update snapshots with ext_ids

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
This commit is contained in:
Kasper Fabricius Kristensen
2021-12-08 10:09:21 +01:00
committed by GitHub
parent f2ba4018fc
commit 577bcc23d4
46 changed files with 7857 additions and 61 deletions

View File

@@ -27,6 +27,7 @@ Object {
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product",
"height": null,
"hs_code": null,
@@ -53,6 +54,7 @@ Object {
"deleted_at": null,
"description": "test-product-description1",
"discountable": true,
"external_id": null,
"handle": "test-product1",
"height": null,
"hs_code": null,
@@ -86,6 +88,7 @@ Object {
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product_filtering_1",
"height": null,
"hs_code": null,
@@ -112,6 +115,7 @@ Object {
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product_filtering_3",
"height": null,
"hs_code": null,
@@ -145,6 +149,7 @@ Object {
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product_filtering_2",
"height": null,
"hs_code": null,

View File

@@ -17,6 +17,7 @@ Array [
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product",
"height": null,
"hs_code": null,
@@ -240,6 +241,7 @@ Array [
"deleted_at": null,
"description": "test-product-description1",
"discountable": true,
"external_id": null,
"handle": "test-product1",
"height": null,
"hs_code": null,
@@ -387,6 +389,7 @@ Array [
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product_filtering_3",
"height": null,
"hs_code": null,
@@ -419,6 +422,7 @@ Array [
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product_filtering_1",
"height": null,
"hs_code": null,
@@ -451,6 +455,7 @@ Array [
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product_filtering_2",
"height": null,
"hs_code": null,
@@ -488,6 +493,7 @@ Array [
"deleted_at": null,
"description": "test-giftcard-description",
"discountable": false,
"external_id": null,
"handle": "test-giftcard",
"height": null,
"hs_code": null,

View File

@@ -17,6 +17,7 @@ Object {
"deleted_at": null,
"description": "test-product-description",
"discountable": true,
"external_id": null,
"handle": "test-product",
"height": null,
"hs_code": null,

View File

@@ -47,6 +47,7 @@ Object {
"deleted_at": null,
"description": null,
"discountable": true,
"external_id": null,
"handle": null,
"height": null,
"hs_code": null,
@@ -119,6 +120,7 @@ Object {
"display_id": 1,
"draft_order_id": null,
"email": "test@email.com",
"external_id": null,
"fulfillment_status": "fulfilled",
"id": "test-order",
"idempotency_key": null,
@@ -218,6 +220,7 @@ Object {
"deleted_at": null,
"description": null,
"discountable": true,
"external_id": null,
"handle": null,
"height": null,
"hs_code": null,
@@ -290,6 +293,7 @@ Object {
"display_id": Any<Number>,
"draft_order_id": null,
"email": "test@email.com",
"external_id": null,
"fulfillment_status": "fulfilled",
"id": "test-order",
"idempotency_key": null,

View File

@@ -9,6 +9,7 @@ Joi.address = () => {
Joi.object().keys({
first_name: Joi.string().required(),
last_name: Joi.string().required(),
company: Joi.string().optional(),
address_1: Joi.string().required(),
address_2: Joi.string()
.allow(null, "")

View File

@@ -0,0 +1,14 @@
{
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-instanceof",
"@babel/plugin-transform-classes"
],
"presets": ["@babel/preset-env"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}

View File

@@ -0,0 +1,16 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
!jest.config.js
/dist
/api
/services
/utils
/subscribers
/loaders

View File

@@ -0,0 +1,7 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock

View File

View File

@@ -0,0 +1,50 @@
{
"name": "medusa-source-shopify",
"version": "1.0.0-dev-1634210430972",
"description": "Source plugin that allows users to import products from a Shopify store",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/medusa-source-shopify"
},
"author": "Kasper Fabrcius Kristensen <kasper@medusa-commerce.com>",
"license": "MIT",
"scripts": {
"build": "babel src -d . --ignore **/__tests__,**/__mocks__",
"prepare": "cross-env NODE_ENV=production npm run build",
"watch": "babel -w src --out-dir . --ignore **/__tests__,**/__mocks__",
"test": "jest"
},
"peerDependencies": {
"medusa-interfaces": "1.x",
"typeorm": "0.x"
},
"dependencies": {
"@babel/plugin-transform-classes": "^7.15.4",
"@shopify/shopify-api": "^1.4.1",
"axios": "^0.21.4",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"ioredis": "^4.27.9",
"lodash": "^4.17.21",
"medusa-core-utils": "^1.1.22",
"medusa-interfaces": "^1.1.24",
"medusa-test-utils": "^1.1.26"
},
"devDependencies": {
"@babel/cli": "^7.15.4",
"@babel/core": "^7.15.5",
"@babel/node": "^7.15.4",
"@babel/plugin-proposal-class-properties": "^7.14.5",
"@babel/plugin-transform-instanceof": "^7.14.5",
"@babel/plugin-transform-runtime": "^7.15.0",
"@babel/preset-env": "^7.15.6",
"@babel/register": "^7.15.3",
"@babel/runtime": "^7.15.4",
"client-sessions": "^0.8.0",
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"jest": "^27.2.0"
}
}

View File

@@ -0,0 +1,8 @@
export default async (container, options) => {
try {
const shopifyService = container.resolve("shopifyService")
await shopifyService.importShopify()
} catch (err) {
console.log(err)
}
}

View File

@@ -0,0 +1,14 @@
export const ProductCollectionServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: `col_${data.metadata.sh_id}`,
...data,
})
}),
retrieveByHandle: jest.fn().mockImplementation((handle) => {
return Promise.resolve(undefined)
}),
}

View File

@@ -0,0 +1,32 @@
import { medusaProducts } from "./test-products"
export const ProductServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((data) => {
if (data.handle === "ipod-nano") {
return Promise.resolve(medusaProducts.ipod)
}
}),
update: jest.fn().mockImplementation((_id, _update) => {
return Promise.resolve(medusaProducts.ipod)
}),
retrieveByExternalId: jest.fn().mockImplementation((id) => {
if (id === "shopify_ipod") {
return Promise.resolve(medusaProducts.ipod)
}
if (id === "shopify_deleted") {
return Promise.resolve(medusaProducts.ipod)
}
return Promise.resolve(undefined)
}),
retrieve: jest.fn().mockImplementation((_id, _config) => {
if (_id === "prod_ipod") {
return Promise.resolve(medusaProducts.ipod)
}
}),
addOption: jest.fn().mockImplementation((_id, _title) => {
return Promise.resolve()
}),
}

View File

@@ -0,0 +1,17 @@
export const ProductVariantServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((_data) => {
return Promise.resolve({})
}),
update: jest.fn().mockImplementation((_data) => {
return Promise.resolve()
}),
delete: jest.fn().mockImplementation((_data) => {
return Promise.resolve()
}),
addOption: jest.fn().mockImplementation((id, title) => {
return Promise.resolve()
}),
}

View File

@@ -0,0 +1,8 @@
export const ShippingProfileServiceMock = {
retrieveGiftCardDefault: jest.fn().mockImplementation((_data) => {
return Promise.resolve({ id: "gift_card_profile" })
}),
retrieveDefault: jest.fn().mockImplementation((_data) => {
return Promise.resolve({ id: "default_shipping_profile" })
}),
}

View File

@@ -0,0 +1,34 @@
import { shopifyProducts } from "./test-products"
export const ShopifyClientServiceMock = {
get: jest.fn().mockImplementation((params) => {
if (params.path === "products/ipod") {
return Promise.resolve(shopifyProducts.ipod)
}
if (params.path === "products/new_ipod") {
return Promise.resolve(shopifyProducts.new_ipod)
}
if (params.path === "products/shopify_ipod") {
return Promise.resolve({
body: {
product: shopifyProducts.ipod_update,
},
})
}
if (params.path === "products/shopify_deleted") {
return Promise.resolve({
body: {
product: {
...shopifyProducts.ipod,
variants: shopifyProducts.ipod.variants.slice(1, -1),
},
},
})
}
}),
list: jest.fn().mockImplementation((path, _headers, _query) => {
if (path === "products") {
return Promise.resolve([shopifyProducts.ipod])
}
}),
}

View File

@@ -0,0 +1,10 @@
import { medusaProducts } from "./test-products"
export const ShopifyProductServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((_data) => {
return Promise.resolve(medusaProducts.ipod)
}),
}

View File

@@ -0,0 +1,8 @@
export const ShopifyRedisServiceMock = {
addIgnore: jest.fn().mockImplementation((_id, _event) => {
return Promise.resolve()
}),
shouldIgnore: jest.fn().mockImplementation((_id, _event) => {
return false
}),
}

View File

@@ -0,0 +1,577 @@
export const medusaProducts = {
ipod: {
id: "prod_ipod",
title: "IPod Nano - 8GB",
subtitle: null,
description:
"<p>It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.</p>",
handle: "ipod-nano",
is_giftcard: false,
status: "proposed",
images: [
{
id: "img_01FHZ9ZFC4SP5ME8T18SW47R7S",
url:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "img_01FHZ9ZFC4SK54GE072FHMQ5XT",
url:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1633120966",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "img_01FHZ9ZFC44EKQPVWK76HBZT0S",
url:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
thumbnail:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
options: [
{
id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
title: "Color",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
variants: [
{
id: "variant_01FHZ9ZFED5MEX3V4A7H5V2A0C",
title: "Pink",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFEPN276FGACR08ZBZ2M",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFED5MEX3V4A7H5V2A0C",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008PINK",
barcode: "1234_pink",
ean: null,
upc: "1234_pink",
inventory_quantity: 10,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFEDMCJME1BR4X8H89MG",
value: "Pink",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFED5MEX3V4A7H5V2A0C",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 808950810,
},
},
{
id: "variant_01FHZ9ZFFA2KPA4JJSKF07VGWH",
title: "Red",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFFFX31Z9GZ8ZHQ5KQ2P",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFFA2KPA4JJSKF07VGWH",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008RED",
barcode: "1234_red",
ean: null,
upc: "1234_red",
inventory_quantity: 20,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFFASNAD8JSWS85Y4ZM0",
value: "Red",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFFA2KPA4JJSKF07VGWH",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 49148385,
},
},
{
id: "variant_01FHZ9ZFG2F54TZMAWV0RJCQ5D",
title: "Green",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFG6QPPCG4XPX8JG0HGW",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFG2F54TZMAWV0RJCQ5D",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008GREEN",
barcode: "1234_green",
ean: null,
upc: "1234_green",
inventory_quantity: 30,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFG2K7GG7B79VVZW6AKN",
value: "Green",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFG2F54TZMAWV0RJCQ5D",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 39072856,
},
},
{
id: "variant_01FHZ9ZFGN4GZN3P6JEB39C9G8",
title: "Black",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFGVNSHF4YRANBJSVN11",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFGN4GZN3P6JEB39C9G8",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008BLACK",
barcode: "1234_black",
ean: null,
upc: "1234_black",
inventory_quantity: 40,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFGNJXHVHFERQHR1AXHX",
value: "Black",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFGN4GZN3P6JEB39C9G8",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 457924702,
},
},
],
profile_id: "sp_01FHZ9YRZT5DD4HTKJG2T38WXD",
weight: null,
length: null,
height: null,
width: null,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
collection_id: null,
collection: null,
type_id: null,
type: null,
tags: [
{
id: "ptag_01FHZ9ZFBVN7EK9VZ2EC0T2Y1A",
value: "Emotive",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "ptag_01FHZ9ZFBY01226XFYXP0R3EEK",
value: " Flash Memory",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "ptag_01FHZ9ZFC0N8S7G85F2TZXKSAM",
value: " MP3",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "ptag_01FHZ9ZFC2K6V0A43S398DC2Q2",
value: " Music",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
discountable: true,
external_id: "shopify_ipod",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
}
const shopifyIpod = {
id: "shopify_ipod",
title: "IPod Nano - 8GB",
body_html:
"<p>It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.</p>",
vendor: "Apple",
product_type: "Cult Products",
created_at: "2021-10-01T16:42:46-04:00",
handle: "ipod-nano",
updated_at: "2021-10-01T16:42:46-04:00",
published_at: "2007-12-31T19:00:00-05:00",
template_suffix: null,
status: "active",
published_scope: "web",
tags: "Emotive, Flash Memory, MP3, Music",
admin_graphql_api_id: "gid://shopify/Product/632910392",
variants: [
{
id: 808950810,
product_id: 632910392,
title: "Pink",
price: "199.00",
sku: "IPOD2008PINK",
position: 1,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Pink",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_pink",
grams: 567,
image_id: 562641783,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 808950810,
inventory_quantity: 10,
old_inventory_quantity: 10,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/808950810",
},
{
id: 49148385,
product_id: 632910392,
title: "Red",
price: "199.00",
sku: "IPOD2008RED",
position: 2,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Red",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_red",
grams: 567,
image_id: null,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 49148385,
inventory_quantity: 20,
old_inventory_quantity: 20,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/49148385",
},
{
id: 39072856,
product_id: 632910392,
title: "Green",
price: "199.00",
sku: "IPOD2008GREEN",
position: 3,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Green",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_green",
grams: 567,
image_id: null,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 39072856,
inventory_quantity: 30,
old_inventory_quantity: 30,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/39072856",
},
{
id: 457924702,
product_id: 632910392,
title: "Black",
price: "199.00",
sku: "IPOD2008BLACK",
position: 4,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Black",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_black",
grams: 567,
image_id: null,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 457924702,
inventory_quantity: 40,
old_inventory_quantity: 40,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/457924702",
},
],
options: [
{
id: 594680422,
product_id: 632910392,
name: "Color",
position: 1,
values: ["Pink", "Red", "Green", "Black"],
},
],
images: [
{
id: 850703190,
product_id: 632910392,
position: 1,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
variant_ids: [],
admin_graphql_api_id: "gid://shopify/ProductImage/850703190",
},
{
id: 562641783,
product_id: 632910392,
position: 2,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1633120966",
variant_ids: [808950810],
admin_graphql_api_id: "gid://shopify/ProductImage/562641783",
},
{
id: 378407906,
product_id: 632910392,
position: 3,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
variant_ids: [],
admin_graphql_api_id: "gid://shopify/ProductImage/378407906",
},
],
image: {
id: 850703190,
product_id: 632910392,
position: 1,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
variant_ids: [],
admin_graphql_api_id: "gid://shopify/ProductImage/850703190",
},
}
export const shopifyProducts = {
ipod: shopifyIpod,
new_ipod: { ...shopifyIpod, id: "shopify_new_ipod" },
ipod_update: {
...shopifyIpod,
title: "IPod Nano",
options: [
...shopifyIpod.options,
{
id: 594680423,
product_id: 632910392,
name: "Memory",
position: 2,
values: ["8GB", "16GB"],
},
],
variants: [
{ ...shopifyIpod.variants[0], title: "Pink / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[1], title: "Red / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[2], title: "Green / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[3], title: "Black / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[0], title: "Pink / 16GB", option2: "16GB" },
{ ...shopifyIpod.variants[1], title: "Red / 16GB", option2: "16GB" },
{ ...shopifyIpod.variants[2], title: "Green / 16GB", option2: "16GB" },
{ ...shopifyIpod.variants[3], title: "Black / 16GB", option2: "16GB" },
],
},
}

View File

@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShopifyCollectionService create normalizes a collection from Shopify 1`] = `
Object {
"handle": "spring",
"metadata": Object {
"sh_body": "spring collection",
"sh_id": "spring",
},
"title": "Spring",
}
`;

View File

@@ -0,0 +1,157 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShopifyProductService normalizeProduct_ succesfully normalizes a product from Shopify 1`] = `
Object {
"collection_id": null,
"description": "<p>It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.</p>",
"external_id": "shopify_ipod",
"handle": "ipod-nano",
"images": Array [
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1633120966",
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
],
"is_giftcard": false,
"options": Array [
Object {
"title": "Color",
"values": Array [
Object {
"value": "Pink",
},
Object {
"value": "Red",
},
Object {
"value": "Green",
},
Object {
"value": "Black",
},
],
},
],
"product_type": Object {
"value": "Cult Products",
},
"status": "proposed",
"tags": Array [
Object {
"value": "Emotive",
},
Object {
"value": " Flash Memory",
},
Object {
"value": " MP3",
},
Object {
"value": " Music",
},
],
"thumbnail": "https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
"title": "IPod Nano - 8GB",
"variants": Array [
Object {
"allow_backorder": true,
"barcode": "1234_pink",
"inventory_quantity": 10,
"manage_inventory": true,
"metadata": Object {
"sh_id": 808950810,
},
"options": Array [
Object {
"value": "Pink",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008PINK",
"title": "Pink",
"upc": "1234_pink",
"variant_rank": 1,
"weight": 567,
},
Object {
"allow_backorder": true,
"barcode": "1234_red",
"inventory_quantity": 20,
"manage_inventory": true,
"metadata": Object {
"sh_id": 49148385,
},
"options": Array [
Object {
"value": "Red",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008RED",
"title": "Red",
"upc": "1234_red",
"variant_rank": 2,
"weight": 567,
},
Object {
"allow_backorder": true,
"barcode": "1234_green",
"inventory_quantity": 30,
"manage_inventory": true,
"metadata": Object {
"sh_id": 39072856,
},
"options": Array [
Object {
"value": "Green",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008GREEN",
"title": "Green",
"upc": "1234_green",
"variant_rank": 3,
"weight": 567,
},
Object {
"allow_backorder": true,
"barcode": "1234_black",
"inventory_quantity": 40,
"manage_inventory": true,
"metadata": Object {
"sh_id": 457924702,
},
"options": Array [
Object {
"value": "Black",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008BLACK",
"title": "Black",
"upc": "1234_black",
"variant_rank": 4,
"weight": 567,
},
],
}
`;

View File

@@ -0,0 +1,80 @@
import { MockManager } from "medusa-test-utils"
import ShopifyCollectionService from "../shopify-collection"
import { ProductCollectionServiceMock } from "../__mocks__/product-collection"
import { ShopifyProductServiceMock } from "../__mocks__/shopify-product"
import { shopifyProducts } from "../__mocks__/test-products"
describe("ShopifyCollectionService", () => {
describe("create", () => {
const shopifyCollectionService = new ShopifyCollectionService({
manager: MockManager,
shopifyProductService: ShopifyProductServiceMock,
productCollectionService: ProductCollectionServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("creates a collection with a product", async () => {
const collects = [
{
collection_id: "spring",
created_at: "2018-04-25T13:51:12-04:00",
id: 841564295,
position: 2,
product_id: "shopify_ipod",
sort_value: "0000000002",
updated_at: "2018-04-25T13:51:12-04:00",
},
]
const collections = [
{
id: "spring",
body_html: "spring collection",
title: "Spring",
handle: "spring",
},
]
const products = [shopifyProducts.ipod]
const results = await shopifyCollectionService.createWithProducts(
collects,
collections,
products
)
expect(
ProductCollectionServiceMock.retrieveByHandle
).toHaveBeenCalledTimes(1)
expect(ProductCollectionServiceMock.create).toHaveBeenCalledTimes(1)
expect(ShopifyProductServiceMock.create).toHaveBeenCalledTimes(1)
expect(results).toEqual([
{
id: "col_spring",
title: "Spring",
handle: "spring",
metadata: {
sh_id: "spring",
sh_body: "spring collection",
},
},
])
})
it("normalizes a collection from Shopify", () => {
const shopifyCollection = {
id: "spring",
body_html: "spring collection",
title: "Spring",
handle: "spring",
}
const normalized = shopifyCollectionService.normalizeCollection_(
shopifyCollection
)
expect(normalized).toMatchSnapshot()
})
})
})

View File

@@ -0,0 +1,116 @@
import { MockManager } from "medusa-test-utils"
import ShopifyProductService from "../shopify-product"
import { ProductServiceMock } from "../__mocks__/product-service"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
import { ShopifyClientServiceMock } from "../__mocks__/shopify-client"
import { ShopifyRedisServiceMock } from "../__mocks__/shopify-redis"
import { medusaProducts, shopifyProducts } from "../__mocks__/test-products"
describe("ShopifyProductService", () => {
describe("normalizeProduct_", () => {
const shopifyProductService = new ShopifyProductService({
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("succesfully normalizes a product from Shopify", async () => {
const data = await ShopifyClientServiceMock.get({ path: "products/ipod" })
const normalized = shopifyProductService.normalizeProduct_(data)
expect(normalized).toMatchSnapshot()
})
})
describe("create", () => {
const shopifyProductService = new ShopifyProductService({
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
productService: ProductServiceMock,
shopifyRedisService: ShopifyRedisServiceMock,
shippingProfileService: ShippingProfileServiceMock,
productVariantService: ProductVariantServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("succesfully creates a product from Shopify", async () => {
const data = shopifyProducts.new_ipod
const product = await shopifyProductService.create(data)
expect(ShopifyRedisServiceMock.shouldIgnore).toHaveBeenCalledTimes(1)
expect(ShopifyRedisServiceMock.addIgnore).toHaveBeenCalledTimes(1)
expect(ShippingProfileServiceMock.retrieveDefault).toHaveBeenCalledTimes(
1
)
expect(ProductServiceMock.create).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(4)
expect(product).toEqual(
expect.objectContaining({
id: "prod_ipod",
})
)
})
})
describe("update", () => {
const shopifyProductService = new ShopifyProductService({
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
productService: ProductServiceMock,
shopifyRedisService: ShopifyRedisServiceMock,
shippingProfileService: ShippingProfileServiceMock,
productVariantService: ProductVariantServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("updates a product and adds 4 new variants", async () => {
const data = shopifyProducts.ipod_update
jest
.spyOn(shopifyProductService, "addProductOptions_")
.mockImplementation(() => ({
...medusaProducts.ipod,
options: [
...medusaProducts.ipod.options,
{
id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX4",
title: "Memory",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
}))
await shopifyProductService.update(data)
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(8)
expect(ProductServiceMock.update).toHaveBeenCalledTimes(1)
expect(ShopifyClientServiceMock.get).toHaveBeenCalledTimes(1)
})
it("updates a product and deletes 2 existing variants", async () => {
const data = { ...shopifyProducts.ipod, id: "shopify_deleted" }
data.variants = data.variants.slice(1, -1)
await shopifyProductService.update(data)
expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(2)
expect(ProductServiceMock.update).toHaveBeenCalledTimes(1)
expect(ShopifyClientServiceMock.get).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,42 @@
import { BaseService } from "medusa-interfaces"
import { pager } from "../utils/pager"
import { createClient } from "../utils/create-client"
import { DataType } from "@shopify/shopify-api"
class ShopifyClientService extends BaseService {
// eslint-disable-next-line no-empty-pattern
constructor({}, options) {
super()
this.options = options
/** @private @const {ShopifyRestClient} */
this.client_ = createClient(this.options)
}
get(params) {
return this.client_.get(params)
}
async list(path, extraHeaders = null, extraQuery = {}) {
return await pager(this.client_, path, extraHeaders, extraQuery)
}
delete(params) {
return this.client_.post(params)
}
post(params) {
return this.client_.post({
path: params.path,
body: params.body,
type: DataType.JSON,
})
}
put(params) {
return this.client_.post(params)
}
}
export default ShopifyClientService

View File

@@ -0,0 +1,104 @@
import { BaseService } from "medusa-interfaces"
import { removeIndex } from "../utils/remove-index"
class ShopifyCollectionService extends BaseService {
constructor(
{ manager, shopifyProductService, productCollectionService },
options
) {
super()
this.options = options
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ShopifyProductService} */
this.productService_ = shopifyProductService
/** @private @const {ProductCollectionService} */
this.collectionService_ = productCollectionService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ShopifyCollectionService({
manager: transactionManager,
options: this.options,
shopifyProductService: this.productService_,
productCollectionService: this.collectionService_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
*
* @param {Object[]} collects
* @param {Object[]} collections
* @param {Object[]} products
* @return {Promise}
*/
async createWithProducts(collects, collections, products) {
return this.atomicPhase_(async (manager) => {
const normalizedCollections = collections.map((c) =>
this.normalizeCollection_(c)
)
const result = []
for (const nc of normalizedCollections) {
let collection = await this.collectionService_
.retrieveByHandle(nc.handle)
.catch((_) => undefined)
if (!collection) {
collection = await this.collectionService_
.withTransaction(manager)
.create(nc)
}
const productIds = collects.reduce((productIds, c) => {
if (c.collection_id === collection.metadata.sh_id) {
productIds.push(c.product_id)
}
return productIds
}, [])
const reducedProducts = products.reduce((reducedProducts, p) => {
if (productIds.includes(p.id)) {
reducedProducts.push(p)
removeIndex(products, p)
}
return reducedProducts
}, [])
for (const product of reducedProducts) {
await this.productService_
.withTransaction(manager)
.create(product, collection.id)
}
result.push(collection)
}
return result
})
}
normalizeCollection_(shopifyCollection) {
return {
title: shopifyCollection.title,
handle: shopifyCollection.handle,
metadata: {
sh_id: shopifyCollection.id,
sh_body: shopifyCollection.body_html,
},
}
}
}
export default ShopifyCollectionService

View File

@@ -0,0 +1,553 @@
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"
class ShopifyProductService extends BaseService {
constructor(
{
manager,
productService,
productVariantService,
shippingProfileService,
shopifyClientService,
shopifyRedisService,
},
options
) {
super()
this.options = options
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ProductService} */
this.productService_ = productService
/** @private @const {ProductVariantService} */
this.productVariantService_ = productVariantService
/** @private @const {ShippingProfileService} */
this.shippingProfileService_ = shippingProfileService
/** @private @const {ShopifyRestClient} */
this.shopify_ = shopifyClientService
this.redis_ = shopifyRedisService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ShopifyProductService({
manager: transactionManager,
options: this.options,
shippingProfileService: this.shippingProfileService_,
productVariantService: this.productVariantService_,
productService: this.productService_,
shopifyClientService: this.shopify_,
shopifyRedisService: this.redis_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Creates a product based on an event in Shopify.
* Also adds the product to a collection if a collection id is provided
* @param {object} data
* @param {string} collectionId optional
* @return {Product} the created product
*/
async create(data, collectionId) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(data.id, "product.created")
if (ignore) {
return
}
const existingProduct = await this.productService_
.withTransaction(manager)
.retrieveByExternalId(data.id)
.catch((_) => undefined)
if (existingProduct) {
return await this.update(data)
}
const normalizedProduct = this.normalizeProduct_(data, collectionId)
normalizedProduct.profile_id = await this.getShippingProfile_(
normalizedProduct.is_giftcard
)
let variants = normalizedProduct.variants
delete normalizedProduct.variants
const product = await this.productService_
.withTransaction(manager)
.create(normalizedProduct)
if (variants) {
variants = variants.map((v) =>
this.addVariantOptions_(v, product.options)
)
for (const variant of variants) {
await this.productVariantService_
.withTransaction(manager)
.create(product.id, variant)
}
}
await this.redis_.addIgnore(data.id, "product.created")
return product
})
}
async update(data) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(data.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)
existing = await this.addProductOptions_(existing, normalized.options)
await this.updateVariants_(existing, normalized.variants)
await this.deleteVariants_(existing, normalized.variants)
delete normalized.variants
const update = {}
for (const key of Object.keys(normalized)) {
if (normalized[key] !== existing[key]) {
update[key] = normalized[key]
}
}
if (!_.isEmpty(update)) {
await this.redis_.addIgnore(data.id, "product.updated")
return await this.productService_
.withTransaction(manager)
.update(existing.id, update)
}
return Promise.resolve()
})
}
/**
* Deletes a product based on an event in Shopify
* @param {string} id
* @return {Promise}
*/
async delete(id) {
return this.atomicPhase_(async (manager) => {
const product = await this.productService_.retrieveByExternalId(id)
return await this.productService_
.withTransaction(manager)
.delete(product.id)
})
}
async shopifyProductUpdate(id, fields) {
const product = await this.productService_.retrieve(id, {
relations: ["tags", "type"],
})
// Event was not emitted by update
if (!fields) {
return
}
const update = {
product: {
id: product.external_id,
},
}
if (fields.includes("title")) {
update.product.title = product.title
}
if (fields.includes("tags")) {
const values = product.tags.map((t) => t.value)
update.product.tags = values.join(",")
}
if (fields.includes("description")) {
update.product.body_html = product.description
}
if (fields.includes("handle")) {
update.product.handle = product.handle
}
if (fields.includes("type")) {
update.product.type = product.type?.value
}
await axios
.put(
`https://${this.options.domain}.myshopify.com/admin/api/2021-10/products/${product.external_id}.json`,
update,
{
headers: {
"X-Shopify-Access-Token": this.options.password,
},
}
)
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An error occured while attempting to issue a product update to Shopify: ${err.message}`
)
})
await this.redis_.addIgnore(product.external_id, "product.updated")
}
async shopifyVariantUpdate(id, fields) {
const variant = await this.productVariantService_.retrieve(id, {
relations: ["prices", "product"],
})
// Event was not emitted by update
if (!fields) {
return
}
const update = {
variant: {
id: variant.metadata.sh_id,
},
}
if (fields.includes("title")) {
update.variant.title = variant.title
}
if (fields.includes("allow_backorder")) {
update.variant.inventory_police = variant.allow_backorder
? "continue"
: "deny"
}
if (fields.includes("prices")) {
update.variant.price = variant.prices[0].amount / 100
}
if (fields.includes("weight")) {
update.variant.grams = variant.weight
}
await axios
.put(
`https://${this.options.domain}.myshopify.com/admin/api/2021-10/variants/${variant.metadata.sh_id}.json`,
update,
{
headers: {
"X-Shopify-Access-Token": this.options.password,
},
}
)
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An error occured while attempting to issue a product update to Shopify: ${err.message}`
)
})
await this.redis_.addIgnore(
variant.metadata.sh_id,
"product-variant.updated"
)
}
async shopifyVariantDelete(productId, metadata) {
const product = await this.productService_.retrieve(productId)
await axios
.delete(
`https://${this.options.domain}.myshopify.com/admin/api/2021-10/products/${product.external_id}/variants/${metadata.sh_id}.json`,
{
headers: {
"X-Shopify-Access-Token": this.options.password,
},
}
)
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An error occured while attempting to issue a product variant delete to Shopify: ${err.message}`
)
})
await this.redis_.addIgnore(metadata.sh_id, "product-variant.deleted")
}
async updateCollectionId(productId, collectionId) {
return this.atomicPhase_(async (manager) => {
return await this.productService_
.withTransaction(manager)
.update(productId, { collection_id: collectionId })
})
}
async updateVariants_(product, updateVariants) {
return this.atomicPhase_(async (manager) => {
const { id, variants, options } = product
for (let variant of updateVariants) {
const ignore =
(await this.redis_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.updated"
)) ||
(await this.redis_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.created"
))
if (ignore) {
continue
}
variant = this.addVariantOptions_(variant, options)
const match = variants.find((v) => v.sku === variant.sku)
if (match) {
await this.productVariantService_
.withTransaction(manager)
.update(match.id, variant)
} else {
await this.productVariantService_
.withTransaction(manager)
.create(id, variant)
}
}
})
}
async deleteVariants_(product, updateVariants) {
return this.atomicPhase_(async (manager) => {
const { variants } = product
for (const variant of variants) {
const ignore = await this.redis_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.deleted"
)
if (ignore) {
continue
}
const match = updateVariants.find((v) => v.sku === variant.sku)
if (!match) {
await this.productVariantService_
.withTransaction(manager)
.delete(variant.id)
}
}
})
}
addVariantOptions_(variant, productOptions) {
const options = productOptions.map((o, i) => ({
option_id: o.id,
...variant.options[i],
}))
variant.options = options
return variant
}
async addProductOptions_(product, updateOptions) {
return this.atomicPhase_(async (manager) => {
const { id, options } = product
for (const option of updateOptions) {
const match = options.find((o) => o.title === option.title)
if (!match) {
await this.productService_
.withTransaction(manager)
.addOption(id, option.title)
}
}
const result = await this.productService_.retrieve(id, {
relations: ["variants", "options"],
})
return result
})
}
async getShippingProfile_(isGiftCard) {
let shippingProfile
if (isGiftCard) {
shippingProfile =
await this.shippingProfileService_.retrieveGiftCardDefault()
} else {
shippingProfile = await this.shippingProfileService_.retrieveDefault()
}
return shippingProfile
}
/**
* Normalizes a product, with a possible optional collection id
* @param {object} product
* @param {string} collectionId optional
* @return {object} normalized object
*/
normalizeProduct_(product, collectionId) {
return {
title: product.title,
handle: product.handle,
description: product.body_html,
product_type: {
value: product.product_type,
},
is_giftcard: product.product_type === "Gift Cards",
options:
product.options.map((option) => this.normalizeProductOption_(option)) ||
[],
variants:
product.variants.map((variant) => this.normalizeVariant_(variant)) ||
[],
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",
}
}
/**
* Normalizes a product option
* @param {object} option
* @return {object} normalized ProductOption
*/
normalizeProductOption_(option) {
return {
title: option.name,
values: option.values.map((v) => {
return { value: v }
}),
}
}
/**
* Normalizes a product variant
* @param {object} variant
* @return {object} normalized variant
*/
normalizeVariant_(variant) {
return {
title: variant.title,
prices: this.normalizePrices_(variant.presentment_prices),
sku: variant.sku || null,
barcode: variant.barcode || null,
upc: variant.barcode || null,
inventory_quantity: variant.inventory_quantity,
variant_rank: variant.position,
allow_backorder: variant.inventory_policy === "continue",
manage_inventory: variant.inventory_management === "shopify",
weight: variant.grams,
options: this.normalizeVariantOptions_(
variant.option1,
variant.option2,
variant.option3
),
metadata: {
sh_id: variant.id,
},
}
}
/**
* Normalizes an array of presentment prices
* @param {array} presentmentPrices
* @return {Object[]} array of normalized prices
*/
normalizePrices_(presentmentPrices) {
return presentmentPrices.map((p) => {
return {
amount: parsePrice(p.price.amount),
currency_code: p.price.currency_code.toLowerCase(),
}
})
}
/**
* Normalizes the three possble variant options
* @param {string} option1
* @param {string} option2
* @param {string} option3
* @return {Object[]} normalized variant options
*/
normalizeVariantOptions_(option1, option2, option3) {
const opts = []
if (option1) {
opts.push({
value: option1,
})
}
if (option2) {
opts.push({
value: option2,
})
}
if (option3) {
opts.push({
value: option3,
})
}
return opts
}
/**
* Normalizes a tag
* @param {string} tag
* @return {Object} normalized Tag
*/
normalizeTag_(tag) {
return {
value: tag,
}
}
}
export default ShopifyProductService

View File

@@ -0,0 +1,30 @@
import { BaseService } from "medusa-interfaces"
import { IGNORE_THRESHOLD } from "../utils/const"
class shopifyRedisService extends BaseService {
constructor({ redisClient }, options) {
super()
this.options_ = options
/** @private @const {RedisClient} */
this.redis_ = redisClient
}
async addIgnore(id, side) {
const key = `${id}_ignore_${side}`
return await this.redis_.set(
key,
1,
"EX",
this.options_.ignore_threshold || IGNORE_THRESHOLD
)
}
async shouldIgnore(id, action) {
const key = `${id}_ignore_${action}`
return await this.redis_.get(key)
}
}
export default shopifyRedisService

View File

@@ -0,0 +1,78 @@
import { BaseService } from "medusa-interfaces"
import { INCLUDE_PRESENTMENT_PRICES } from "../utils/const"
class ShopifyService extends BaseService {
constructor(
{
manager,
shippingProfileService,
shopifyProductService,
shopifyCollectionService,
shopifyClientService,
},
options
) {
super()
this.options = options
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ShippingProfileService} */
this.shippingProfileService_ = shippingProfileService
/** @private @const {ShopifyProductService} */
this.productService_ = shopifyProductService
/** @private @const {ShopifyCollectionService} */
this.collectionService_ = shopifyCollectionService
/** @private @const {ShopifyRestClient} */
this.client_ = shopifyClientService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ShopifyService({
manager: transactionManager,
options: this.options,
shippingProfileService: this.shippingProfileService_,
shopifyClientService: this.client_,
shopifyProductService: this.productService_,
shopifyCollectionService: this.collectionService_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
async importShopify() {
return this.atomicPhase_(async (manager) => {
await this.shippingProfileService_.createDefault()
await this.shippingProfileService_.createGiftCardDefault()
const products = await this.client_.list(
"products",
INCLUDE_PRESENTMENT_PRICES
)
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
)
for (const product of products) {
await this.productService_.withTransaction(manager).create(product)
}
})
}
}
export default ShopifyService

View File

@@ -0,0 +1,13 @@
export function buildQuery(query) {
let path = ""
if (query) {
const queryString = Object.entries(query).map(([key, value]) => {
return `${key}=${value}`
})
path = `?${queryString.join("&")}`
}
return path
}

View File

@@ -0,0 +1,5 @@
export const INCLUDE_PRESENTMENT_PRICES = {
"X-Shopify-Api-Features": "include-presentment-prices",
}
export const IGNORE_THRESHOLD = 2

View File

@@ -0,0 +1,7 @@
import Shopify from "@shopify/shopify-api"
export const createClient = (options) => {
const { domain, password } = options
return new Shopify.Clients.Rest(`${domain}.myshopify.com`, password)
}

View File

@@ -0,0 +1,46 @@
export async function pager(
client,
path,
extraHeaders = null,
extraQuery = {}
) {
let objects = []
let nextPage = null
let hasNext = true
while (hasNext) {
const params = {
path,
query: { page_info: nextPage },
}
if (extraHeaders) {
Object.assign(params, { extraHeaders: extraHeaders })
}
if (extraQuery) {
Object.assign(params.query, extraQuery)
}
if (!params.query.page_info) {
delete params.query.page_info
}
const response = await client.get(params)
objects = [...objects, ...response.body[path]]
const link = response.headers.get("link")
const match =
/(?:page_info=)(?<page_info>[a-zA-Z0-9]+)(?:>; rel="next")/.exec(link)
if (match?.groups) {
nextPage = match.groups["page_info"]
hasNext = true
} else {
hasNext = false
}
}
return objects
}

View File

@@ -0,0 +1,3 @@
export function parsePrice(price) {
return parseInt(Number(price).toFixed(2) * 100)
}

View File

@@ -0,0 +1,4 @@
export function removeIndex(arr, obj) {
const index = arr.indexOf(obj)
arr.splice(index, 1)
}

File diff suppressed because it is too large Load Diff

View File

@@ -60,12 +60,21 @@
jsesc "^2.5.1"
source-map "^0.5.0"
"@babel/helper-annotate-as-pure@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61"
integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==
"@babel/generator@^7.15.4":
version "7.15.8"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.8.tgz#fa56be6b596952ceb231048cf84ee499a19c0cd1"
integrity sha512-ECmAKstXbp1cvpTTZciZCgfOt6iN64lR0d+euv3UZisU5awfRawOvg07Utn/qBGuH4bRIEZKrA/4LzZyXhZr8g==
dependencies:
"@babel/types" "^7.14.5"
"@babel/types" "^7.15.6"
jsesc "^2.5.1"
source-map "^0.5.0"
"@babel/helper-annotate-as-pure@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.15.4.tgz#3d0e43b00c5e49fdb6c57e421601a7a658d5f835"
integrity sha512-QwrtdNvUNsPCj2lfNQacsGSQvGX8ee1ttrBrcozUP2Sv/jylewBP/8QFe6ZkBsC8T/GYWonNAWJV4aRR9AL2DA==
dependencies:
"@babel/types" "^7.15.4"
"@babel/helper-compilation-targets@^7.14.5":
version "7.14.5"
@@ -77,17 +86,17 @@
browserslist "^4.16.6"
semver "^6.3.0"
"@babel/helper-create-class-features-plugin@^7.14.6":
version "7.14.8"
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.14.8.tgz#a6f8c3de208b1e5629424a9a63567f56501955fc"
integrity sha512-bpYvH8zJBWzeqi1o+co8qOrw+EXzQ/0c74gVmY205AWXy9nifHrOg77y+1zwxX5lXE7Icq4sPlSQ4O2kWBrteQ==
"@babel/helper-create-class-features-plugin@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.15.4.tgz#7f977c17bd12a5fba363cb19bea090394bf37d2e"
integrity sha512-7ZmzFi+DwJx6A7mHRwbuucEYpyBwmh2Ca0RvI6z2+WLZYCqV0JOaLb+u0zbtmDicebgKBZgqbYfLaKNqSgv5Pw==
dependencies:
"@babel/helper-annotate-as-pure" "^7.14.5"
"@babel/helper-function-name" "^7.14.5"
"@babel/helper-member-expression-to-functions" "^7.14.7"
"@babel/helper-optimise-call-expression" "^7.14.5"
"@babel/helper-replace-supers" "^7.14.5"
"@babel/helper-split-export-declaration" "^7.14.5"
"@babel/helper-annotate-as-pure" "^7.15.4"
"@babel/helper-function-name" "^7.15.4"
"@babel/helper-member-expression-to-functions" "^7.15.4"
"@babel/helper-optimise-call-expression" "^7.15.4"
"@babel/helper-replace-supers" "^7.15.4"
"@babel/helper-split-export-declaration" "^7.15.4"
"@babel/helper-function-name@^7.14.5":
version "7.14.5"
@@ -98,6 +107,15 @@
"@babel/template" "^7.14.5"
"@babel/types" "^7.14.5"
"@babel/helper-function-name@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.15.4.tgz#845744dafc4381a4a5fb6afa6c3d36f98a787ebc"
integrity sha512-Z91cOMM4DseLIGOnog+Z8OI6YseR9bua+HpvLAQ2XayUGU+neTtX+97caALaLdyu53I/fjhbeCnWnRH1O3jFOw==
dependencies:
"@babel/helper-get-function-arity" "^7.15.4"
"@babel/template" "^7.15.4"
"@babel/types" "^7.15.4"
"@babel/helper-get-function-arity@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815"
@@ -105,6 +123,13 @@
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-get-function-arity@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.15.4.tgz#098818934a137fce78b536a3e015864be1e2879b"
integrity sha512-1/AlxSF92CmGZzHnC515hm4SirTxtpDnLEJ0UyEMgTMZN+6bxXKg04dKhiRx5Enel+SUA1G1t5Ed/yQia0efrA==
dependencies:
"@babel/types" "^7.15.4"
"@babel/helper-hoist-variables@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d"
@@ -112,13 +137,27 @@
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-member-expression-to-functions@^7.14.5", "@babel/helper-member-expression-to-functions@^7.14.7":
"@babel/helper-hoist-variables@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.15.4.tgz#09993a3259c0e918f99d104261dfdfc033f178df"
integrity sha512-VTy085egb3jUGVK9ycIxQiPbquesq0HUQ+tPO0uv5mPEBZipk+5FkRKiWq5apuyTE9FUrjENB0rCf8y+n+UuhA==
dependencies:
"@babel/types" "^7.15.4"
"@babel/helper-member-expression-to-functions@^7.14.5":
version "7.14.7"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.7.tgz#97e56244beb94211fe277bd818e3a329c66f7970"
integrity sha512-TMUt4xKxJn6ccjcOW7c4hlwyJArizskAhoSTOCkA0uZ+KghIaci0Qg9R043kUMWI9mtQfgny+NQ5QATnZ+paaA==
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-member-expression-to-functions@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.15.4.tgz#bfd34dc9bba9824a4658b0317ec2fd571a51e6ef"
integrity sha512-cokOMkxC/BTyNP1AlY25HuBWM32iCEsLPI4BHDpJCHHm1FU2E7dKWWIXJgQgSFiu4lp8q3bL1BIKwqkSUviqtA==
dependencies:
"@babel/types" "^7.15.4"
"@babel/helper-module-imports@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
@@ -147,6 +186,13 @@
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-optimise-call-expression@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.15.4.tgz#f310a5121a3b9cc52d9ab19122bd729822dee171"
integrity sha512-E/z9rfbAOt1vDW1DR7k4SzhzotVV5+qMciWV6LaG1g4jeFrkDlJedjtV4h0i4Q/ITnUu+Pk08M7fczsB9GXBDw==
dependencies:
"@babel/types" "^7.15.4"
"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
@@ -162,6 +208,16 @@
"@babel/traverse" "^7.14.5"
"@babel/types" "^7.14.5"
"@babel/helper-replace-supers@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.15.4.tgz#52a8ab26ba918c7f6dee28628b07071ac7b7347a"
integrity sha512-/ztT6khaXF37MS47fufrKvIsiQkx1LBRvSJNzRqmbyeZnTwU9qBxXYLaaT/6KaxfKhjs2Wy8kG8ZdsFUuWBjzw==
dependencies:
"@babel/helper-member-expression-to-functions" "^7.15.4"
"@babel/helper-optimise-call-expression" "^7.15.4"
"@babel/traverse" "^7.15.4"
"@babel/types" "^7.15.4"
"@babel/helper-simple-access@^7.14.8":
version "7.14.8"
resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz#82e1fec0644a7e775c74d305f212c39f8fe73924"
@@ -176,11 +232,23 @@
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-split-export-declaration@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.15.4.tgz#aecab92dcdbef6a10aa3b62ab204b085f776e257"
integrity sha512-HsFqhLDZ08DxCpBdEVtKmywj6PQbwnF6HHybur0MAnkAKnlS6uHkwnmRIkElB2Owpfb4xL4NwDmDLFubueDXsw==
dependencies:
"@babel/types" "^7.15.4"
"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.8":
version "7.14.8"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.8.tgz#32be33a756f29e278a0d644fa08a2c9e0f88a34c"
integrity sha512-ZGy6/XQjllhYQrNw/3zfWRwZCTVSiBLZ9DHVZxn9n2gip/7ab8mv2TWlKPIBk26RwedCBoWdjLmn+t9na2Gcow==
"@babel/helper-validator-identifier@^7.14.9":
version "7.15.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389"
integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==
"@babel/helper-validator-option@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3"
@@ -209,6 +277,11 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.14.8.tgz#66fd41666b2d7b840bd5ace7f7416d5ac60208d4"
integrity sha512-syoCQFOoo/fzkWDeM0dLEZi5xqurb5vuyzwIMNZRNun+N/9A4cUZeQaE7dTrB8jGaKuJRBtEOajtnmw0I5hvvA==
"@babel/parser@^7.15.4":
version "7.15.8"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.8.tgz#7bacdcbe71bdc3ff936d510c15dcea7cf0b99016"
integrity sha512-BRYa3wcQnjS/nqI8Ac94pYYpJfojHVvVXJ97+IDCImX4Jc8W8Xv1+47enbruk+q1etOpsQNwnfFcNGw+gtPGxA==
"@babel/plugin-syntax-async-generators@^7.8.4":
version "7.8.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
@@ -293,23 +366,23 @@
dependencies:
"@babel/helper-plugin-utils" "^7.14.5"
"@babel/plugin-transform-typescript@^7.14.5":
version "7.14.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.14.6.tgz#6e9c2d98da2507ebe0a883b100cde3c7279df36c"
integrity sha512-XlTdBq7Awr4FYIzqhmYY80WN0V0azF74DMPyFqVHBvf81ZUgc4X7ZOpx6O8eLDK6iM5cCQzeyJw0ynTaefixRA==
"@babel/plugin-transform-typescript@^7.15.0":
version "7.15.8"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.15.8.tgz#ff0e6a47de9b2d58652123ab5a879b2ff20665d8"
integrity sha512-ZXIkJpbaf6/EsmjeTbiJN/yMxWPFWvlr7sEG1P95Xb4S4IBcrf2n7s/fItIhsAmOf8oSh3VJPDppO6ExfAfKRQ==
dependencies:
"@babel/helper-create-class-features-plugin" "^7.14.6"
"@babel/helper-create-class-features-plugin" "^7.15.4"
"@babel/helper-plugin-utils" "^7.14.5"
"@babel/plugin-syntax-typescript" "^7.14.5"
"@babel/preset-typescript@^7.13.0":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.14.5.tgz#aa98de119cf9852b79511f19e7f44a2d379bcce0"
integrity sha512-u4zO6CdbRKbS9TypMqrlGH7sd2TAJppZwn3c/ZRLeO/wGsbddxgbPDUZVNrie3JWYLQ9vpineKlsrWFvO6Pwkw==
"@babel/preset-typescript@^7.15.0":
version "7.15.0"
resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.15.0.tgz#e8fca638a1a0f64f14e1119f7fe4500277840945"
integrity sha512-lt0Y/8V3y06Wq/8H/u0WakrqciZ7Fz7mwPDHWUJAXlABL5hiUG42BNlRXiELNjeWjO5rWmnNKlx+yzJvxezHow==
dependencies:
"@babel/helper-plugin-utils" "^7.14.5"
"@babel/helper-validator-option" "^7.14.5"
"@babel/plugin-transform-typescript" "^7.14.5"
"@babel/plugin-transform-typescript" "^7.15.0"
"@babel/template@^7.14.5", "@babel/template@^7.3.3":
version "7.14.5"
@@ -320,6 +393,15 @@
"@babel/parser" "^7.14.5"
"@babel/types" "^7.14.5"
"@babel/template@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.15.4.tgz#51898d35dcf3faa670c4ee6afcfd517ee139f194"
integrity sha512-UgBAfEa1oGuYgDIPM2G+aHa4Nlo9Lh6mGD2bDBGMTbYnc38vulXPuC1MGjYILIEmlwl6Rd+BPR9ee3gm20CBtg==
dependencies:
"@babel/code-frame" "^7.14.5"
"@babel/parser" "^7.15.4"
"@babel/types" "^7.15.4"
"@babel/traverse@^7.1.0", "@babel/traverse@^7.14.5", "@babel/traverse@^7.14.8":
version "7.14.8"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.14.8.tgz#c0253f02677c5de1a8ff9df6b0aacbec7da1a8ce"
@@ -335,6 +417,21 @@
debug "^4.1.0"
globals "^11.1.0"
"@babel/traverse@^7.15.4":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.4.tgz#ff8510367a144bfbff552d9e18e28f3e2889c22d"
integrity sha512-W6lQD8l4rUbQR/vYgSuCAE75ADyyQvOpFVsvPPdkhf6lATXAsQIG9YdtOcu8BB1dZ0LKu+Zo3c1wEcbKeuhdlA==
dependencies:
"@babel/code-frame" "^7.14.5"
"@babel/generator" "^7.15.4"
"@babel/helper-function-name" "^7.15.4"
"@babel/helper-hoist-variables" "^7.15.4"
"@babel/helper-split-export-declaration" "^7.15.4"
"@babel/parser" "^7.15.4"
"@babel/types" "^7.15.4"
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.3.0", "@babel/types@^7.3.3":
version "7.14.8"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.8.tgz#38109de8fcadc06415fbd9b74df0065d4d41c728"
@@ -343,6 +440,14 @@
"@babel/helper-validator-identifier" "^7.14.8"
to-fast-properties "^2.0.0"
"@babel/types@^7.15.4", "@babel/types@^7.15.6":
version "7.15.6"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.6.tgz#99abdc48218b2881c058dd0a7ab05b99c9be758f"
integrity sha512-BPU+7QhqNjmWyDO0/vitH/CuhpV8ZmK1wpKva8nuyNF5MJfuRNWMc+hc14+u9xT93kvykMdncrJT19h74uB1Ig==
dependencies:
"@babel/helper-validator-identifier" "^7.14.9"
to-fast-properties "^2.0.0"
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -1474,6 +1579,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-walk@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84"
integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==
domexception@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
@@ -1983,6 +2093,14 @@ global-dirs@^2.0.1:
dependencies:
ini "1.3.7"
global@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406"
integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==
dependencies:
min-document "^2.19.0"
process "^0.11.10"
globals@^11.1.0:
version "11.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -3138,6 +3256,13 @@ mimic-response@^1.0.0, mimic-response@^1.0.1:
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
min-document@^2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=
dependencies:
dom-walk "^0.1.0"
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
@@ -3536,6 +3661,11 @@ process-nextick-args@~2.0.0:
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
progress@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class externalIdOrder1638952072999 implements MigrationInterface {
name = "externalIdOrder1638952072999"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "order" ADD "external_id" character varying`
)
await queryRunner.query(
`ALTER TABLE "product" ADD "external_id" character varying`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "external_id"`)
await queryRunner.query(`ALTER TABLE "order" DROP COLUMN "external_id"`)
}
}

View File

@@ -50,45 +50,45 @@ export class Address {
id: string
@Index()
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
customer_id: string | null
@ManyToOne(() => Customer)
@JoinColumn({ name: "customer_id" })
customer: Customer | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
company: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
first_name: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
last_name: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
address_1: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
address_2: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
city: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
country_code: string | null
@ManyToOne(() => Country)
@JoinColumn({ name: "country_code", referencedColumnName: "iso_2" })
country: Country | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
province: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
postal_code: string | null
@Column({ type: "text", nullable: true })
@Column({ type: "varchar", nullable: true })
phone: string | null
@CreateDateColumn({ type: resolveDbType("timestamptz") })

View File

@@ -175,7 +175,7 @@ export class Order {
@OneToMany(
() => ShippingMethod,
method => method.order,
(method) => method.order,
{
cascade: ["insert"],
}
@@ -184,14 +184,14 @@ export class Order {
@OneToMany(
() => Payment,
payment => payment.order,
(payment) => payment.order,
{ cascade: ["insert"] }
)
payments: Payment[]
@OneToMany(
() => Fulfillment,
fulfillment => fulfillment.order,
(fulfillment) => fulfillment.order,
{
cascade: ["insert"],
}
@@ -200,28 +200,28 @@ export class Order {
@OneToMany(
() => Return,
ret => ret.order,
(ret) => ret.order,
{ cascade: ["insert"] }
)
returns: Return[]
@OneToMany(
() => ClaimOrder,
co => co.order,
(co) => co.order,
{ cascade: ["insert"] }
)
claims: ClaimOrder[]
@OneToMany(
() => Refund,
ref => ref.order,
(ref) => ref.order,
{ cascade: ["insert"] }
)
refunds: Refund[]
@OneToMany(
() => Swap,
swap => swap.order,
(swap) => swap.order,
{ cascade: ["insert"] }
)
swaps: Swap[]
@@ -235,7 +235,7 @@ export class Order {
@OneToMany(
() => LineItem,
lineItem => lineItem.order,
(lineItem) => lineItem.order,
{
cascade: ["insert"],
}
@@ -244,7 +244,7 @@ export class Order {
@OneToMany(
() => GiftCardTransaction,
gc => gc.order
(gc) => gc.order
)
gift_card_transactions: GiftCardTransaction[]
@@ -266,6 +266,9 @@ export class Order {
@Column({ nullable: true })
idempotency_key: string
@Column({ type: "varchar", nullable: true })
external_id: string | null
// Total fields
shipping_total: number
discount_total: number

View File

@@ -150,6 +150,9 @@ export class Product {
@Column({ default: true })
discountable: boolean
@Column({ nullable: true })
external_id: string
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date

View File

@@ -393,6 +393,44 @@ class OrderService extends BaseService {
return order
}
/**
* Gets an order by id.
* @param {string} externalId - id of order to retrieve
* @param {object} config - query config to get order by
* @return {Promise<Order>} the order document
*/
async retrieveByExternalId(externalId, config = {}) {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(config)
const query = {
where: { external_id: externalId },
}
if (relations && relations.length > 0) {
query.relations = relations
}
if (select && select.length > 0) {
query.select = select
}
const rels = query.relations
delete query.relations
const raw = await orderRepo.findOneWithRelations(rels, query)
if (!raw) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Order with external id ${externalId} was not found`
)
}
const order = this.decorateTotals_(raw, totalsToSelect)
return order
}
/**
* Checks the existence of an order by cart id.
* @param {string} cartId - cart id to find order

View File

@@ -70,6 +70,30 @@ class ProductCollectionService extends BaseService {
return collection
}
/**
* Retrieves a product collection by id.
* @param {string} collectionHandle - the handle of the collection to retrieve.
* @param {object} config - query config for request
* @return {Promise<ProductCollection>} the collection.
*/
async retrieveByHandle(collectionHandle, config = {}) {
const collectionRepo = this.manager_.getCustomRepository(
this.productCollectionRepository_
)
const query = this.buildQuery_({ handle: collectionHandle }, config)
const collection = await collectionRepo.findOne(query)
if (!collection) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product collection with handle: ${collectionHandle} was not found`
)
}
return collection
}
/**
* Creates a product collection
* @param {object} collection - the collection to create

View File

@@ -614,6 +614,7 @@ class ProductVariantService extends BaseService {
.emit(ProductVariantService.Events.DELETED, {
id: variant.id,
product_id: variant.product_id,
metadata: variant.metadata,
})
return Promise.resolve()

View File

@@ -201,6 +201,78 @@ class ProductService extends BaseService {
return product
}
/**
* Gets a product by handle.
* Throws in case of DB Error and if product was not found.
* @param {string} productHandle - handle of the product to get.
* @param {object} config - details about what to get from the product
* @return {Promise<Product>} the result of the find one operation.
*/
async retrieveByHandle(productHandle, config = {}) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const query = { where: { handle: productHandle } }
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
}
if (config.select && config.select.length > 0) {
query.select = config.select
}
const rels = query.relations
delete query.relations
const product = await productRepo.findOneWithRelations(rels, query)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with handle: ${productHandle} was not found`
)
}
return product
}
/**
* Gets a product by external id.
* Throws in case of DB Error and if product was not found.
* @param {string} externalId - handle of the product to get.
* @param {object} config - details about what to get from the product
* @return {Promise<Product>} the result of the find one operation.
*/
async retrieveByExternalId(externalId, config = {}) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const query = { where: { external_id: externalId } }
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
}
if (config.select && config.select.length > 0) {
query.select = config.select
}
const rels = query.relations
delete query.relations
const product = await productRepo.findOneWithRelations(rels, query)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with exteral_id: ${externalId} was not found`
)
}
return product
}
/**
* Gets all variants belonging to a product.
* @param {string} productId - the id of the product to get variants from.

View File

@@ -336,6 +336,34 @@ class RegionService extends BaseService {
return country
}
async retrieveByCountryCode(code, config = {}) {
const countryRepository = this.manager_.getCustomRepository(
this.countryRepository_
)
const country = await countryRepository.findOne({
where: {
iso_2: code.toLowerCase(),
},
})
if (!country) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Country with code ${code} not found`
)
}
if (!country.region_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Country does not belong to a region`
)
}
return await this.retrieve(country.region_id, config)
}
/**
* Retrieves a region by its id.
* @param {string} regionId - the id of the region to retrieve

View File

@@ -5387,20 +5387,20 @@ medusa-core-utils@^0.1.27:
"@hapi/joi" "^16.1.8"
joi-objectid "^3.0.1"
medusa-core-utils@^1.1.29:
version "1.1.29"
resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.29.tgz#3ad1c21aae18d627063a6548504e24ce70031015"
integrity sha512-knlHwetXkyYjkGNU/a6JVT9IkKtYa5UD45I5C0jAs9BqzacKN/WCxeKzOwCJPBMP1LrSBajwm45kgvICUerWzA==
medusa-core-utils@^1.1.30:
version "1.1.30"
resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.30.tgz#6fe670a9df1ddbbd786f7a3a89c167eb3596aea2"
integrity sha512-vWtSr2uZzRRv3HfUc9rclLxmMx+7lL+5qFZrgSZK5Wf19jO4Jp15wWgNsf3bp7Bf5ICaPfT96G1jbyqiMvI6HA==
dependencies:
joi "^17.3.0"
joi-objectid "^3.0.1"
medusa-interfaces@^1.1.30:
version "1.1.30"
resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-1.1.30.tgz#9a6fa19a88854db67b0c82cef9061afda560bd3a"
integrity sha512-OQVPO/6Gr5OJOWjSCaDQcTZDMCo7cJoQP4w5P/+XoTM6YvD7PpJhVJR17W7du5XIXoiM5i/eFDi3/Gb7nsZdJg==
medusa-interfaces@^1.1.31:
version "1.1.31"
resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-1.1.31.tgz#50180132fa6b785c595ebba0e9c4c80cb7eba948"
integrity sha512-85IEL2K7E1hBB9L2VrLok1KiYOdnoQxY3cGm5VcjYzkxJSxb3GjDcbptRIaq199w/j4ZfAyDXusXzLrfaZHcLw==
dependencies:
medusa-core-utils "^1.1.29"
medusa-core-utils "^1.1.30"
medusa-telemetry@^0.0.10:
version "0.0.10"
@@ -5417,13 +5417,13 @@ medusa-telemetry@^0.0.10:
remove-trailing-slash "^0.1.1"
uuid "^8.3.2"
medusa-test-utils@^1.1.32:
version "1.1.32"
resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.1.32.tgz#c0797b517d9d4475483147514a383b2c4ed88b8c"
integrity sha512-RBme+gBI7pmRiFcrxvU4dO4a509zLINrRZwW4p1UiQwg2JCfzTt9iMwhV9Mn6Z+SDAP2n1M2TFBYmNYuXwoJGQ==
medusa-test-utils@^1.1.33:
version "1.1.33"
resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.1.33.tgz#0c2f159f0cd5f3d3e79f5aabb30286a9bf839ef9"
integrity sha512-VM29+5h4NM3isIKh1AqssCbvj+aC/Meqqf+c3PNdhtK93+jNmahadpYtGPhT5i/8lMOqnleatAgfnhjq8a7X9A==
dependencies:
"@babel/plugin-transform-classes" "^7.9.5"
medusa-core-utils "^1.1.29"
medusa-core-utils "^1.1.30"
randomatic "^3.1.1"
merge-descriptors@1.0.1:
@@ -7062,11 +7062,16 @@ side-channel@^1.0.4:
get-intrinsic "^1.0.2"
object-inspect "^1.9.0"
signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
signal-exit@^3.0.0, signal-exit@^3.0.2:
version "3.0.6"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.6.tgz#24e630c4b0f03fea446a2bd299e62b4a6ca8d0af"
integrity sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==
signal-exit@^3.0.3:
version "3.0.5"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f"
integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"