@sommpicks/sommpicks-shopify 24.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/Logger.ts +18 -0
  2. package/README.md +258 -0
  3. package/addTypings.sh +2 -0
  4. package/bitbucket-pipelines.yml +38 -0
  5. package/index.ts +132 -0
  6. package/package.json +57 -0
  7. package/publish.sh +20 -0
  8. package/services/CacheWrapper.ts +30 -0
  9. package/services/CountryCodeService.ts +507 -0
  10. package/shopify/ShopifyAppService.ts +109 -0
  11. package/shopify/ShopifyAssetService.ts +20 -0
  12. package/shopify/ShopifyBillingService.ts +73 -0
  13. package/shopify/ShopifyCartTrasnformationService.ts +207 -0
  14. package/shopify/ShopifyCollectionService.ts +523 -0
  15. package/shopify/ShopifyCustomerService.ts +472 -0
  16. package/shopify/ShopifyDeliveryCustomisationService.ts +220 -0
  17. package/shopify/ShopifyDiscountService.ts +131 -0
  18. package/shopify/ShopifyDraftOrderService.ts +125 -0
  19. package/shopify/ShopifyFulfillmentService.ts +41 -0
  20. package/shopify/ShopifyFunctionsProductDiscountsService.ts +166 -0
  21. package/shopify/ShopifyInventoryService.ts +415 -0
  22. package/shopify/ShopifyLocationService.ts +29 -0
  23. package/shopify/ShopifyOrderRefundsService.ts +138 -0
  24. package/shopify/ShopifyOrderRiskService.ts +19 -0
  25. package/shopify/ShopifyOrderService.ts +1143 -0
  26. package/shopify/ShopifyPageService.ts +62 -0
  27. package/shopify/ShopifyProductService.ts +772 -0
  28. package/shopify/ShopifyShippingZonesService.ts +37 -0
  29. package/shopify/ShopifyShopService.ts +101 -0
  30. package/shopify/ShopifyTemplateService.ts +30 -0
  31. package/shopify/ShopifyThemeService.ts +33 -0
  32. package/shopify/ShopifyUtils.ts +56 -0
  33. package/shopify/ShopifyWebhookService.ts +110 -0
  34. package/shopify/base/APIVersion.ts +4 -0
  35. package/shopify/base/AbstractService.ts +152 -0
  36. package/shopify/base/ErrorHelper.ts +24 -0
  37. package/shopify/errors/InspiraShopifyCustomError.ts +7 -0
  38. package/shopify/errors/InspiraShopifyError.ts +15 -0
  39. package/shopify/errors/InspiraShopifyUnableToReserveInventoryError.ts +7 -0
  40. package/shopify/helpers/ShopifyProductServiceHelper.ts +450 -0
  41. package/shopify/product/ShopifyProductCountService.ts +110 -0
  42. package/shopify/product/ShopifyProductListService.ts +333 -0
  43. package/shopify/product/ShopifyProductMetafieldsService.ts +405 -0
  44. package/shopify/product/ShopifyProductPublicationsService.ts +112 -0
  45. package/shopify/product/ShopifyVariantService.ts +584 -0
  46. package/shopify/router/ShopifyMandatoryRouter.ts +37 -0
  47. package/shopify/router/ShopifyRouter.ts +85 -0
  48. package/shopify/router/ShopifyRouterBis.ts +85 -0
  49. package/shopify/router/ShopifyRouterBisBis.ts +85 -0
  50. package/shopify/router/ShopifyRouterBisBisBis.ts +85 -0
  51. package/shopify/router/ShopifyRouterBisBisBisBis.ts +85 -0
  52. package/shopify/router/WebhookSkipMiddleware.ts +73 -0
  53. package/shopify/router/services/CryptoService.ts +26 -0
  54. package/shopify/router/services/HmacValidator.ts +36 -0
  55. package/shopify/router/services/OauthService.ts +17 -0
  56. package/shopify/router/services/RestUtils.ts +13 -0
  57. package/shopify/router/services/rateLimiter/MemoryStores.ts +46 -0
  58. package/shopify/router/services/rateLimiter/StoreRateLimiter.ts +46 -0
  59. package/test/README.md +223 -0
  60. package/test/router/ShopifyRouter.test.ts +71 -0
  61. package/test/router/WebhookSkipMiddleware.test.ts +86 -0
  62. package/test/router/services/HmacValidator.test.ts +24 -0
  63. package/test/router/services/RestUtils.test.ts +13 -0
  64. package/test/router/services/rateLimiter/StoreRateLimiter.test.ts +62 -0
  65. package/test/services/CacheWrapper.test.ts +30 -0
  66. package/test/shopify/ShopifyOrderService.test.ts +29 -0
  67. package/test/shopify/ShopifyProductService.test.ts +118 -0
  68. package/test/shopify/ShopifyWebhookService.test.ts +105 -0
  69. package/tsconfig.json +10 -0
  70. package/typings/axios.d.ts +8 -0
  71. package/typings/index.d.ts +1682 -0
@@ -0,0 +1,772 @@
1
+ import { AxiosInstance } from 'axios';
2
+ import { print } from 'graphql';
3
+ import gql from 'graphql-tag';
4
+ import { Logger } from '../Logger';
5
+ import InspiraShopifyError from './errors/InspiraShopifyError';
6
+ import { ShopifyProductServiceHelper } from './helpers/ShopifyProductServiceHelper';
7
+ import { AbstractService } from './base/AbstractService';
8
+ import { ShopifyProductPublicationsService } from './product/ShopifyProductPublicationsService';
9
+ import { ShopifyProductCountService } from './product/ShopifyProductCountService';
10
+ import { ShopifyProductListService } from './product/ShopifyProductListService';
11
+ import ErrorHelper from './base/ErrorHelper';
12
+ import { ShopifyProductMetafieldsService } from './product/ShopifyProductMetafieldsService';
13
+ import { ShopifyVariantService } from './product/ShopifyVariantService';
14
+
15
+ export class ShopifyProductService extends AbstractService {
16
+
17
+ public publications: ShopifyProductPublicationsService;
18
+ public count: ShopifyProductCountService;
19
+ public list: ShopifyProductListService;
20
+ public metafields: ShopifyProductMetafieldsService;
21
+ public variants: ShopifyVariantService;
22
+ private shopifyProductServiceHelper: ShopifyProductServiceHelper;
23
+
24
+ constructor(private axiosInstance: AxiosInstance) {
25
+ super();
26
+ this.publications = new ShopifyProductPublicationsService(axiosInstance);
27
+ this.count = new ShopifyProductCountService(axiosInstance);
28
+ this.list = new ShopifyProductListService(axiosInstance);
29
+ this.metafields = new ShopifyProductMetafieldsService(axiosInstance);
30
+ this.shopifyProductServiceHelper = new ShopifyProductServiceHelper();
31
+ this.variants = new ShopifyVariantService(axiosInstance, this.shopifyProductServiceHelper);
32
+ }
33
+
34
+ /**
35
+ * Product must specify the options names of the variants.<br>
36
+ * To assign images to variants the variants must have the image_id & product images list must have the variant_ids list.<br>
37
+ * Does not create more than 150 variants at a time.<br>
38
+ * Does not create more than 150 images.<br>
39
+ * When no locationID provided no stock is added.
40
+ *
41
+ *
42
+ * @param {IProduct} product
43
+ * @param {number} locationId
44
+ * @param {boolean} retrieveInventory
45
+ * @returns created product
46
+ */
47
+ public create = async (product: IProduct, locationId: number, retrieveInventory: boolean): Promise<IProduct> => {
48
+ try {
49
+ Logger.info('create', JSON.stringify(product));
50
+ const productCreateQuery = ShopifyProductServiceHelper.graphQLQuery.getProductCreateMutation(retrieveInventory);
51
+ const productToCreate: IProductCreateRequest = {};
52
+
53
+ const mediaToCreate: { alt: string; mediaContentType: 'EXTERNAL_VIDEO' | 'IMAGE' | 'VIDEO', originalSource: string; }[] = [];
54
+ const mediaToCreateForVarinats: { alt: string; mediaContentType: 'EXTERNAL_VIDEO' | 'IMAGE' | 'VIDEO', originalSource: string; }[] = [];
55
+ if (product.variants && product.variants.length > 0) {
56
+ productToCreate.productOptions = [];
57
+ if (product.variants[0].option1) {
58
+ const optionValues: { name: string; }[] = [];
59
+ for (const v of product.variants) {
60
+ if (!optionValues.map(op => op.name).includes(v.option1)) {
61
+ optionValues.push({ name: v.option1 });
62
+ }
63
+ }
64
+ productToCreate.productOptions.push({ position: 1, name: product.options[0].name, values: optionValues });
65
+ }
66
+ if (product.variants[0].option2) {
67
+ const optionValues: { name: string; }[] = [];
68
+ for (const v of product.variants) {
69
+ if (!optionValues.map(op => op.name).includes(v.option2)) {
70
+ optionValues.push({ name: v.option2 });
71
+ }
72
+ }
73
+ productToCreate.productOptions.push({ position: 2, name: product.options[1].name, values: optionValues });
74
+ }
75
+ if (product.variants[0].option3) {
76
+ const optionValues: { name: string; }[] = [];
77
+ for (const v of product.variants) {
78
+ if (!optionValues.map(op => op.name).includes(v.option3)) {
79
+ optionValues.push({ name: v.option3 });
80
+ }
81
+ }
82
+ productToCreate.productOptions.push({ position: 3, name: product.options[2].name, values: optionValues });
83
+ }
84
+ }
85
+ if (product.vendor) {
86
+ productToCreate.vendor = product.vendor;
87
+ }
88
+ if (product.product_type) {
89
+ productToCreate.productType = product.product_type;
90
+ }
91
+ if (product.title) {
92
+ productToCreate.title = product.title;
93
+ }
94
+ if (product.tags) {
95
+ productToCreate.tags = Array.isArray(product.tags) ? product.tags : (product.tags ? product.tags.split(',').map(tag => tag.trim()) : []);
96
+ }
97
+ if (product.body_html) {
98
+ productToCreate.descriptionHtml = product.body_html;
99
+ }
100
+ if (product.images && product.images.length > 0) {
101
+ for (const img of product.images) {
102
+ if (img.src) {
103
+ if (!img.variant_ids || img.variant_ids.length === 0) {
104
+ mediaToCreate.push({ mediaContentType: 'IMAGE', alt: '', originalSource: img.src.trim().replace(/ /g, '%20') });
105
+ } else {
106
+ mediaToCreateForVarinats.push({ mediaContentType: 'IMAGE', alt: '', originalSource: img.src.trim().replace(/ /g, '%20') });
107
+ }
108
+ }
109
+ }
110
+ }
111
+ productToCreate.status = 'ACTIVE';
112
+ if(product.status.toUpperCase() === 'ARCHIVED') {
113
+ productToCreate.status = 'ARCHIVED';
114
+ } else if(product.status.toUpperCase() === 'DRAFT') {
115
+ productToCreate.status = 'DRAFT';
116
+ }
117
+ Logger.info(`Product creating .... ${JSON.stringify(productToCreate)}`);
118
+ Logger.info(`Product media creating for no variant assigned .... ${JSON.stringify(mediaToCreate)}`);
119
+ Logger.info(`Product media creating for variants .... ${JSON.stringify(mediaToCreateForVarinats)}`);
120
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(gql`${productCreateQuery}`), variables: { product: productToCreate, media: mediaToCreate } }, { query_cost: 50 });
121
+
122
+ if (response && response.data && response.data.data && response.data.data.productCreate && response.data.data.productCreate.product) {
123
+ const productGraph: ShopifygraphQl.IGenericProductResponse = response.data.data.productCreate.product;
124
+
125
+ const createdProduct: IProduct = this.shopifyProductServiceHelper.getProduct(productGraph);
126
+ if (createdProduct && createdProduct.id) {
127
+ Logger.info(`Product created with id ${createdProduct.id}`);
128
+ const variantsToCreate: ShopifygraphQl.IVariantToPost[] = [];
129
+ for (const variant of product.variants) {
130
+ let imageSrc = '';
131
+ if (variant.image_id) {
132
+ const originalImage = product.images.find(i => i.id === variant.image_id);
133
+ if (originalImage && originalImage.src) {
134
+ imageSrc = originalImage.src;
135
+ }
136
+ }
137
+ const variantToCreate: ShopifygraphQl.IVariantToPost = this.shopifyProductServiceHelper.getVariantToPost(variant, { imageSrc: imageSrc }, product.options.map(op => op.name), locationId);
138
+ variantsToCreate.push(variantToCreate);
139
+ }
140
+ const variantsCreateQuery = ShopifyProductServiceHelper.graphQLQuery.getVariantsBulkCreateMutation(true, locationId ? true : false, true);
141
+ Logger.info(`Product Variants create .... ${JSON.stringify(variantsToCreate)}`);
142
+ Logger.info(`Product Variants create query .... ${JSON.stringify(variantsCreateQuery)}`);
143
+ const variantsResponse = await this.axiosInstance.post('/graphql.json', { query: print(gql`${variantsCreateQuery}`), variables: { productId: this.getGraphProductIdFromId(createdProduct.id), variants: variantsToCreate, media: mediaToCreateForVarinats } }, { query_cost: 50 });
144
+ if (variantsResponse && variantsResponse.data && variantsResponse.data.data && variantsResponse.data.data.productVariantsBulkCreate && variantsResponse.data.data.productVariantsBulkCreate.productVariants) {
145
+ createdProduct.variants = [];
146
+ for (const graphVariant of variantsResponse.data.data.productVariantsBulkCreate.productVariants) {
147
+ createdProduct.variants.push(this.shopifyProductServiceHelper.getVariant(graphVariant));
148
+ }
149
+ } else {
150
+ Logger.error(`Product variants were not created. ${createdProduct.id}`);
151
+ }
152
+ } else {
153
+ Logger.error(`Product was not created. ${createdProduct.id}`);
154
+ }
155
+ return createdProduct;
156
+ } else {
157
+ throw new InspiraShopifyError({ message: JSON.stringify(response.data.errors) });
158
+ }
159
+ } catch (error) { this.logErrorAndThrow(error); }
160
+ };
161
+
162
+ /**
163
+ * @param {number} productId where to create the option
164
+ * @param {string} optionName
165
+ * @param {string[]} optionValues
166
+ * @param {number} position
167
+ */
168
+ public createOption = async (productId: number, optionName: string, optionValues: string[], position: number): Promise<boolean> => {
169
+ try {
170
+ Logger.info(`ShopifyVariantService - createOption -> option Name ${optionName} --- position ${position} --- optionValues ${JSON.stringify(optionValues)}`);
171
+
172
+ const optionCreateQuery = ShopifyProductServiceHelper.graphQLQuery.productOptionCreateMutation();
173
+ Logger.info(`ShopifyVariantService - createOption -> query .... ${JSON.stringify(optionCreateQuery)}`);
174
+ const variables = { productId: this.getGraphProductIdFromId(productId), options: [{ name: optionName, position: position, values: optionValues.map(v => ({ name: v })) }] };
175
+ Logger.info(`ShopifyVariantService - createOption -> variables .... ${JSON.stringify(variables)}`);
176
+
177
+ const optionsResponse = await this.axiosInstance.post('/graphql.json', { query: print(gql`${optionCreateQuery}`), variables: variables }, { query_cost: 50 });
178
+ let isOptionCreated = false;
179
+ if (optionsResponse && optionsResponse.data && optionsResponse.data.data && optionsResponse.data.data.productOptionsCreate) {
180
+ isOptionCreated = true;
181
+ } else {
182
+ Logger.error(`ShopifyVariantService - createOption -> ${optionsResponse.data.data}`);
183
+ }
184
+ return isOptionCreated;
185
+ } catch (error) { this.logErrorAndThrow(error); }
186
+ };
187
+
188
+ public update = async (product: IProductUpdateRequest): Promise<void> => {
189
+ try {
190
+ Logger.info(`update product with id ${product.id}`, product);
191
+ const productUpdateQuery = ShopifyProductServiceHelper.graphQLQuery.updateProductMutation;
192
+ if (`${product.id}`.indexOf('gid') === -1) {
193
+ product.id = this.getGraphProductIdFromId(product.id as number);
194
+ }
195
+ Logger.info(`Product update query .... ${JSON.stringify(productUpdateQuery)}`);
196
+ Logger.info(`Product update variable .... ${JSON.stringify(product)}`);
197
+ const productUpdateResponse = await this.axiosInstance.post('/graphql.json', { query: print(gql`${productUpdateQuery}`), variables: { product: product } }, { query_cost: 10 });
198
+ if (!productUpdateResponse?.data?.data?.productUpdate?.product?.id) {
199
+ Logger.error(`Product did not update ${JSON.stringify(productUpdateResponse?.data?.data)}`);
200
+ throw new InspiraShopifyError({ message: `Product did not update ${JSON.stringify(productUpdateResponse?.data?.data?.productUpdate?.userErrors)}` });
201
+ }
202
+ } catch (error) { this.logErrorAndThrow(error); }
203
+ };
204
+
205
+ /**
206
+ *
207
+ * @param cursor in the first request send null
208
+ * @param metafields
209
+ * @returns array of elements or empty array
210
+ */
211
+ public getProductsWithMetafiledsInBatch = async (cursor: string, metafields: { namespace: string; key: string; }[]): Promise<IProductMetafieldInBatch[]> => {
212
+ try {
213
+ if (metafields && metafields.length > 0) {
214
+ Logger.info(`Get product with metafields from cursor: ${cursor}`);
215
+ const numOfProductsToGet = parseInt(`${200 / metafields.length}`, 10);
216
+ const query = gql`{
217
+ products(first: ${numOfProductsToGet}${cursor ? `,after: "${cursor}"` : ''}) {
218
+ edges {
219
+ cursor
220
+ node {
221
+ id
222
+ ${metafields.map((m, i) => (`m${i}: metafield(namespace: "${m.namespace}", key: "${m.key}") {
223
+ value
224
+ }`)).join('')}
225
+ }
226
+ }
227
+ }
228
+ }`;
229
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) }, { query_cost: numOfProductsToGet * 2 * metafields.length });
230
+ if (response && response.data && response.data.data && response.data.data.products) {
231
+ const productsGraph = response.data.data.products.edges;
232
+ const productsWithMetafields = productsGraph.map((p: any) => ({ id: this.getIdFromGraphId(p.node.id), cursor: p.cursor, metafields: metafields.map((m, i) => ({ namespace: m.namespace, key: m.key, value: p.node[`m${i}`] ? p.node[`m${i}`].value : null })) }));
233
+ return productsWithMetafields;
234
+ } else {
235
+ throw new InspiraShopifyError({ message: JSON.stringify(response.data.errors) });
236
+ }
237
+ } else {
238
+ Logger.info('No metafields provided');
239
+ }
240
+ } catch (error) { this.logErrorAndThrow(error); }
241
+ };
242
+
243
+ /**
244
+ * Gets a product By Id with only stock information.
245
+ *
246
+ * @param {number} productId
247
+ * @returns Promise
248
+ */
249
+ public getProductWithStockInfoById = async (productId: number): Promise<IProductStocks> => {
250
+ try {
251
+ Logger.info(`Get product stock with ID: ${productId}`);
252
+ const productStock: IProductStocks = await this.getProductWithStockInfoByIdFromCursor(productId, 50, null);
253
+ let lastVariantsSize: number = productStock.variants.length;
254
+ while (lastVariantsSize >= 50) {
255
+ const productStockAlt: IProductStocks = await this.getProductWithStockInfoByIdFromCursor(productId, 50, productStock.variants[productStock.variants.length - 1].cursor);
256
+ lastVariantsSize = productStockAlt.variants.length;
257
+ productStock.variants = productStock.variants.concat(productStockAlt.variants);
258
+ }
259
+ return productStock;
260
+ } catch (error) { this.logErrorAndThrow(error); }
261
+ };
262
+
263
+ /**
264
+ * Gets a product By Id with only stock information with 50 variants. Starts on a variant specified cursor from another search.
265
+ *
266
+ * @param {number} productId
267
+ * @returns Promise
268
+ */
269
+ public getProductWithStockInfoByIdFromCursor = async (productId: number, numOfVariants: number, cursor: string): Promise<IProductStocks> => {
270
+ try {
271
+ Logger.info(`getProductWithStockInfoByIdFromCursor -> Get product stock with ID: ${productId} num of variants ${numOfVariants} and cursor ${cursor}`);
272
+ const query = gql`{
273
+ product(id: "${this.getGraphProductIdFromId(productId)}") {
274
+ id
275
+ variants(first: ${numOfVariants}, ${cursor ? `after: "${cursor}"` : ''}) {
276
+ edges {
277
+ cursor
278
+ node {
279
+ id
280
+ inventoryQuantity
281
+ inventoryPolicy
282
+ inventoryItem {
283
+ id
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }`;
290
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) }, { query_cost: 110 });
291
+ if (response && response.data && response.data.data && response.data.data.product) {
292
+ const productGraph = response.data.data.product;
293
+ return {
294
+ id: productId, variants: productGraph.variants.edges.map((gv) => ({
295
+ id: this.getIdFromGraphId(gv.node.id),
296
+ cursor: gv.cursor,
297
+ inventory_quantity: gv.node.inventoryQuantity,
298
+ inventory_policy: gv.node.inventoryPolicy,
299
+ inventory_item_id: this.getIdFromGraphId(gv.node.inventoryItem.id)
300
+ } as IVariantStock))
301
+ };
302
+ } else {
303
+ return null;
304
+ }
305
+ } catch (error) { this.logErrorAndThrow(error); }
306
+ };
307
+
308
+ /**
309
+ * Add tags to a list of products.
310
+ *
311
+ * @param {number[]} productIds
312
+ * @returns Promise
313
+ */
314
+ public addTagsToProducts = async (productIds: number[], tagsToAdd: string): Promise<any[]> => {
315
+ try {
316
+ if (productIds && productIds.length > 0) {
317
+ const errors = [];
318
+ Logger.info(`Add tags to products: ${productIds.toString()}`);
319
+ const productIdschunks: number[][] = this.sliceIntoChunks(productIds, 30);
320
+ for (const productIdsChunk of productIdschunks) {
321
+ Logger.info(`Add tags to products chunck with: ${productIdsChunk.length} products `);
322
+ const query = gql`mutation {
323
+ ${productIdsChunk.map((id) => (`
324
+ Product${id}: tagsAdd(id: "${this.getGraphProductIdFromId(id)}", tags: "${this.escapeQuotes(tagsToAdd)}") {
325
+ node {
326
+ id
327
+ }
328
+ userErrors {
329
+ field
330
+ message
331
+ }
332
+ }`)).join('')}
333
+ }`;
334
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) }, { query_cost: productIdsChunk.length * 15 });
335
+
336
+ Logger.info(`Add tags to products response: ${JSON.stringify(response.data)} `);
337
+ if (response && response.data && response.data.data) {
338
+ const updatesResponse = response.data.data;
339
+ for (const key in updatesResponse) {
340
+ const err = updatesResponse[key].userErrors;
341
+ if (err && err.length > 0) {
342
+ errors.push(err);
343
+ }
344
+ }
345
+ } else {
346
+ errors.push('No response arrived');
347
+ }
348
+ }
349
+ return errors;
350
+ } else {
351
+ Logger.info('Add tags to products without product IDs provided');
352
+ return [];
353
+ }
354
+ } catch (error) { this.logErrorAndThrow(error); }
355
+ };
356
+
357
+ /**
358
+ * Remove tags to a list of products
359
+ *
360
+ * @param {number[]} productIds
361
+ * @returns Promise
362
+ */
363
+ public removeTagsToProducts = async (productIds: number[], tagsToRemove: string): Promise<any[]> => {
364
+ try {
365
+ if (productIds && productIds.length > 0) {
366
+ const errors = [];
367
+ Logger.info(`Remove tags to products: ${productIds.toString()}`);
368
+ const productIdschunks: number[][] = this.sliceIntoChunks(productIds, 30);
369
+ for (const productIdsChunk of productIdschunks) {
370
+ const query = gql`mutation { ${productIdsChunk.map((id) => (`
371
+ Product${id}: tagsRemove(id: "${this.getGraphProductIdFromId(id)}", tags: "${this.escapeQuotes(tagsToRemove)}") {
372
+ node {
373
+ id
374
+ }
375
+ userErrors {
376
+ field
377
+ message
378
+ }
379
+ }`)).join('')}
380
+ }`;
381
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) }, { query_cost: productIdsChunk.length * 15 });
382
+ if (response && response.data && response.data.data) {
383
+ const updatesResponse = response.data.data;
384
+ for (const key in updatesResponse) {
385
+ const err = updatesResponse[key].userErrors;
386
+ if (err && err.length > 0) {
387
+ errors.push(err);
388
+ }
389
+ }
390
+ } else {
391
+ errors.push('No response arrived');
392
+ }
393
+ }
394
+ return errors;
395
+ } else {
396
+ Logger.info('Remove tags to products without Product IDs');
397
+ return [];
398
+ }
399
+ } catch (error) { this.logErrorAndThrow(error); }
400
+ };
401
+
402
+ /**
403
+ * Gets a product By Id with only stock information. It retrieves up to 20 variants and 20 invenotry items ids
404
+ *
405
+ * @param {number} productId
406
+ * @returns Promise
407
+ */
408
+ public getProductWithInventoryInfoById = async (productId: number): Promise<IProductInventory> => {
409
+ try {
410
+ Logger.info(`Get product Inventory stock with ID: ${productId}`);
411
+ const query = gql` {
412
+ product(id: "${this.getGraphProductIdFromId(productId)}") {
413
+ id
414
+ variants(first: 20) {
415
+ edges {
416
+ node {
417
+ id
418
+ price
419
+ sku
420
+ availableForSale
421
+ inventoryQuantity
422
+ inventoryPolicy
423
+ inventoryItem {
424
+ id
425
+ tracked
426
+ unitCost {
427
+ amount
428
+ }
429
+ inventoryLevels(first: 20) {
430
+ edges {
431
+ node {
432
+ quantities(names:["available"]){
433
+ quantity
434
+ }
435
+ location {
436
+ id
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+ }
446
+ }`;
447
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) }, { query_cost: 883 });
448
+ if (response && response.data && response.data.data && response.data.data.product) {
449
+ const productGraph = response.data.data.product;
450
+ return {
451
+ id: productId,
452
+ variants: productGraph.variants.edges.map((gv) => ({
453
+ id: this.getIdFromGraphId(gv.node.id),
454
+ inventory_quantity: gv.node.inventoryQuantity,
455
+ inventory_policy: gv.node.inventoryPolicy,
456
+ sku: gv.node.sku,
457
+ available_for_sale: gv.node.availableForSale,
458
+ inventory_item_id: this.getIdFromGraphId(gv.node.inventoryItem.id),
459
+ inventory_item: {
460
+ id: this.getIdFromGraphId(gv.node.inventoryItem.id),
461
+ tracked: gv.node.inventoryItem.tracked,
462
+ cost: gv.node.inventoryItem.unitCost ? gv.node.inventoryItem.unitCost.amount : null,
463
+ levels: gv.node.inventoryItem.inventoryLevels.edges.map((gl) => ({
464
+ location_id: this.getIdFromGraphId(gl.node.location.id),
465
+ id: this.getIdFromGraphId(gl.node.id),
466
+ available: gl.node.quantities && gl.node.quantities[0] ? gl.node.quantities[0].quantity : 0,
467
+ inventory_item_id: this.getIdFromGraphId(gv.node.inventoryItem.id)
468
+ } as IInventoryLevel)
469
+ )
470
+ } as IInventoryItem
471
+ } as IVariantInventory)
472
+ )
473
+ };
474
+ } else {
475
+ Logger.error(`getProductWithInventoryInfoById not the expected response. Data ${JSON.stringify(response.data.data)}. Error is ${JSON.stringify(response.data.errors)}`);
476
+ return null;
477
+ }
478
+ } catch (error) { this.logErrorAndThrow(error); }
479
+ };
480
+
481
+ /**
482
+ * Gets a product By SKU
483
+ *
484
+ * @param {string} sku
485
+ * @returns Promise<IProduct[]>
486
+ */
487
+ public getBySKU = async (sku: string): Promise<IProduct[]> => {
488
+ try {
489
+ Logger.info(`getBySKU - Get Product by SKU - ${sku}`);
490
+ const query = gql`query {
491
+ products(first: 10, query: "sku:${sku}") {
492
+ id
493
+ title
494
+ vendor
495
+ handle
496
+ productType
497
+ tags
498
+ publishedAt
499
+ updatedAt
500
+ hasOnlyDefaultVariant
501
+ isGiftCard
502
+ status
503
+ }
504
+ }`;
505
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) });
506
+ Logger.info(`getBySKU - Get Product by SKU - Query Response - ${response}`);
507
+ if (response && response.data && response.data.data && response.data.data.products) {
508
+ const productsGraph = response.data.data.products.nodes;
509
+ const mappedResponse: IProduct[] = productsGraph.map(el => ({
510
+ id: el.id,
511
+ title: el.title,
512
+ vendor: el.vendor,
513
+ handle: el.handle,
514
+ productType: el.productType,
515
+ tags: el.tags,
516
+ publishedAt: el.publishedAt,
517
+ updatedAt: el.updatedAt,
518
+ hasOnlyDefaultVariant: el.hasOnlyDefaultVariant,
519
+ isGiftCard: el.isGiftCard,
520
+ status: el.status,
521
+ }));
522
+ return mappedResponse;
523
+ } else {
524
+ throw new InspiraShopifyError({ message: JSON.stringify(response.data.errors) });
525
+ }
526
+ } catch (error) {
527
+ this.logErrorAndThrow(error);
528
+ }
529
+ };
530
+
531
+ public delete = async (id: number): Promise<void> => {
532
+ try {
533
+ Logger.info(`ShopifyProductService - delete -> ${id}`);
534
+ const rawQuery = `mutation {
535
+ productDelete(input: { id: "${this.getGraphProductIdFromId(id)}"}) {
536
+ deletedProductId
537
+ userErrors {
538
+ field
539
+ message
540
+ }
541
+ }
542
+ }`;
543
+ Logger.info(`ShopifyProductService - delete -> rawQuery ${rawQuery}`);
544
+ const query = gql`${rawQuery}`;
545
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) }, { query_cost: 10 });
546
+ if (response && response.data && response.data.data && response.data.data.productDelete) {
547
+ return;
548
+ } else {
549
+ Logger.error(`ShopifyProductService - delete -> Data ${JSON.stringify(response.data.data)}. Error is ${JSON.stringify(response.data.errors)}`);
550
+ throw new InspiraShopifyError({ message: `Product was not deleted - ${JSON.stringify(response.data.errors)}` });
551
+ }
552
+ } catch (error) { Logger.error(error, ErrorHelper.getErrorFromResponse(error)); throw new InspiraShopifyError(error); }
553
+ };
554
+
555
+ public getProductsByIdsWithCollectionsInfo = async (ids: number[], collectionIds: number[]): Promise<IProductCollection[]> => {
556
+ try {
557
+ Logger.info(`getProductsByIdsWithCollectionsInfo: productIds ${ids} collectionIds: ${collectionIds}`);
558
+ const rawQuery = ` {
559
+ products(first: 50, query: "${ids.map(id => `(id:${id})`).join(' OR ')}") {
560
+ edges {
561
+ node {
562
+ id
563
+ tags
564
+ vendor
565
+ productType
566
+ ${collectionIds.map(cId => `
567
+ c_${cId}: inCollection(id: "gid://shopify/Collection/${cId}")`).join('')}
568
+ }
569
+ }
570
+ }
571
+ }`;
572
+ Logger.info(`getProductsByIdsWithCollectionsInfo - rawQuery ${rawQuery}`);
573
+ const query = gql`${rawQuery}`;
574
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(query) }, { query_cost: 900 });
575
+ if (response && response.data && response.data.data && response.data.data.products) {
576
+ const productsGraph: IProductCollection[] = response.data.data.products.edges.map(p => {
577
+ const product: IProductCollection = { id: this.getIdFromGraphId(p.node.id), vendor: p.node.vendor, productType: p.node.productType, tags: p.node.tags, collectionsContained: collectionIds.map(cId => p.node[`c_${cId}`] ? cId : null).filter(c => c !== null) };
578
+ return product;
579
+ });
580
+ return productsGraph;
581
+ } else {
582
+ Logger.error(`getProductsByIdsWithCollectionsInfo not the expected response. Data ${JSON.stringify(response.data.data)}. Error is ${JSON.stringify(response.data.errors)}`);
583
+ return null;
584
+ }
585
+ } catch (error) { this.logErrorAndThrow(error); }
586
+ };
587
+
588
+ /**
589
+ * Finds product by titleand returns the first 100 matches.
590
+ *
591
+ * @param {string} title
592
+ *
593
+ * @returns Promise
594
+ */
595
+ public searchByTitle = async (title: string): Promise<{ title: string; id: number; handle: string; image: string; variants_count: number; variants: { id: number; title: string; }; }[]> => {
596
+ try {
597
+ Logger.info(`searchByTitle ${title}`);
598
+ const metafieldQuery = gql`query {
599
+ products(first: 100, query: "title:*${title}*") {
600
+ edges {
601
+ node {
602
+ title
603
+ id
604
+ handle
605
+ featuredImage {
606
+ url
607
+ }
608
+ variants(first: 100) {
609
+ edges {
610
+ node {
611
+ id
612
+ title
613
+ }
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ }`;
620
+
621
+ const response = await this.axiosInstance.post('/graphql.json', { query: print(metafieldQuery), variables: {} }, { query_cost: 200 });
622
+
623
+ if (response && response.data && response.data.data && response.data.data.products && response.data.data.products.edges.length > 0) {
624
+ return response.data.data.products.edges.map((p) => ({ id: this.getIdFromGraphId(p.node.id), title: p.node.title, handle: p.node.handle, image: p.node.featuredImage ? p.node.featuredImage.url : '', variants_count: p.node.variants.edges.length, variants: p.node.variants.edges.map(v => ({ id: this.getIdFromGraphId(v.node.id), title: v.node.title })) }));
625
+ } else {
626
+ return [];
627
+ }
628
+ } catch (error) { this.logErrorAndThrow(error); }
629
+ };
630
+
631
+ /**
632
+ *
633
+ * Create a product image and link it to variants.
634
+ *
635
+ * @param {number} productId - product ID
636
+ * @param { alt: string; src: string; } img of image
637
+ * @param {number[]} variantIds of image
638
+ *
639
+ * @returns Promise
640
+ */
641
+ public createImage = async (productId: number, img: { alt?: string; src: string; }, variantIds: number[]): Promise<void> => {
642
+ try {
643
+ if (img.src) {
644
+ Logger.info(`Creating image for product id -> ${productId}, src ${img.src} for variants ${variantIds}`);
645
+ if (variantIds?.length > 0) {
646
+ const rawQuery = `mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!, $media: [CreateMediaInput!]) {
647
+ productVariantsBulkUpdate(productId: $productId, variants: $variants, media: $media)
648
+ { product { id }
649
+ }
650
+ }`;
651
+ const addImageResponse = await this.axiosInstance.post('/graphql.json', { query: print(gql`${rawQuery}`), variables: { productId: this.getGraphProductIdFromId(productId), variants: variantIds.map(vid => ({ id: this.getGraphProductVariantIdFromId(vid), mediaSrc: [img.src] })), media: [{ originalSource: img.src.trim().replace(/ /g, '%20'), mediaContentType: 'IMAGE', alt: img.alt ? img.alt : '' }] } }, { query_cost: 60 });
652
+ if (addImageResponse && addImageResponse.data && addImageResponse.data.data?.productVariantsBulkUpdate.product?.id) {
653
+ Logger.info(`Added image ${img.src} to products ${productId} and linke to variants ${variantIds}`);
654
+ } else {
655
+ throw new InspiraShopifyError({ message: `No images have been added to product ${productId} - response ${JSON.stringify(addImageResponse.data)}` });
656
+ }
657
+ } else {
658
+ const rawQuery = `mutation productUpdate($input: ProductInput!, $media: [CreateMediaInput!]) {
659
+ productUpdate(input: $input, media: $media)
660
+ { product {
661
+ media(first: 5, sortKey: ID ) {
662
+ nodes {
663
+ id
664
+ alt
665
+ mediaContentType
666
+ preview {
667
+ status
668
+ }
669
+ }
670
+ }
671
+ }
672
+ }
673
+ }`;
674
+ const addImageResponse = await this.axiosInstance.post('/graphql.json', { query: print(gql`${rawQuery}`), variables: { input: { id: this.getGraphProductIdFromId(productId) }, media: [{ originalSource: img.src.trim().replace(/ /g, '%20'), mediaContentType: 'IMAGE', alt: img.alt ? img.alt : '' }] } }, { query_cost: 60 });
675
+
676
+ if (addImageResponse && addImageResponse.data && addImageResponse.data.data?.productUpdate && addImageResponse.data.data?.productUpdate?.product) {
677
+ Logger.info(`Added image ${img.src} to products ${productId}`);
678
+ } else {
679
+ throw new InspiraShopifyError({ message: `No images have been added to product ${productId} - response ${JSON.stringify(addImageResponse.data)}` });
680
+ }
681
+ }
682
+
683
+ } else {
684
+ throw new InspiraShopifyError({ message: `No image source provided to add to product ${productId}` });
685
+ }
686
+ } catch (error) { this.logErrorAndThrow(error); }
687
+ };
688
+
689
+ /**
690
+ *
691
+ * Create a product image and link it to variants.
692
+ *
693
+ * @param {number} productId - product ID
694
+ *
695
+ * @returns Promise
696
+ */
697
+ public deleteMedia = async (productId: number): Promise<void> => {
698
+ try {
699
+ Logger.info(`Delete product Media product id -> ${productId}`);
700
+ const media = await this.getMedia(productId);
701
+ if (media.length) {
702
+ const rawQuery = `mutation productDeleteMedia($mediaIds: [ID!]!, $productId: ID!) {
703
+ productDeleteMedia(mediaIds: $mediaIds, productId: $productId) {
704
+ deletedMediaIds
705
+ deletedProductImageIds
706
+ mediaUserErrors {
707
+ field
708
+ message
709
+ }
710
+ product {
711
+ id
712
+ title
713
+ media(first: 5) {
714
+ nodes {
715
+ alt
716
+ mediaContentType
717
+ status
718
+ }
719
+ }
720
+ }
721
+ }
722
+ }`;
723
+ const deleteImageResponse = await this.axiosInstance.post('/graphql.json', { query: print(gql`${rawQuery}`), variables: { mediaIds: media.map(m => m.id), productId: this.getGraphProductIdFromId(productId) } }, { query_cost: 60 });
724
+
725
+ if (deleteImageResponse && deleteImageResponse.data && deleteImageResponse.data.data?.productDeleteMedia && deleteImageResponse.data.data?.productDeleteMedia) {
726
+ Logger.info(`Deleted media from product ${productId}. Images deleted -> ${deleteImageResponse.data.data?.productDeleteMedia.deletedProductImageIds}. Media deleted -> ${deleteImageResponse.data.data?.productDeleteMedia.deletedMediaIds}`);
727
+ } else {
728
+ throw new InspiraShopifyError({ message: `No images have been deleted on product ${productId} - response ${JSON.stringify(deleteImageResponse.data)}` });
729
+ }
730
+ }
731
+ } catch (error) { this.logErrorAndThrow(error); }
732
+ };
733
+
734
+ /**
735
+ *
736
+ * Returns media of a product. Max of 250
737
+ *
738
+ * @param {number} productId - product ID
739
+ *
740
+ * @returns Promise
741
+ */
742
+ public getMedia = async (productId: number): Promise<{ id: string; mediaContentType: 'IMAGE' | 'VIDEO'; }[]> => {
743
+ try {
744
+ Logger.info(`Get product Media product id -> ${productId}`);
745
+ const rawQuery = `query ProductMedia($productId: ID!) {
746
+ product(id: $productId) {
747
+ media(first: 250) {
748
+ edges {
749
+ node {
750
+ id
751
+ mediaContentType
752
+ }
753
+ }
754
+ }
755
+ }
756
+ }`;
757
+ const getMediaResponse = await this.axiosInstance.post('/graphql.json', { query: print(gql`${rawQuery}`), variables: { productId: this.getGraphProductIdFromId(productId) } }, { query_cost: 60 });
758
+
759
+ if (getMediaResponse && getMediaResponse.data && getMediaResponse.data.data?.product && getMediaResponse.data.data?.product) {
760
+ Logger.info(`Get media from product ${productId}. ${JSON.stringify(getMediaResponse.data.data)}`);
761
+ if (getMediaResponse.data.data?.product?.media?.edges?.length > 0) {
762
+ return getMediaResponse.data.data?.product?.media?.edges.map((edge: { node: { id: string; mediaContentType: string; } }) => edge.node);
763
+ } else {
764
+ return [];
765
+ }
766
+ } else {
767
+ throw new InspiraShopifyError({ message: `No images could be retrieved for product ${productId} - response ${JSON.stringify(getMediaResponse.data)}` });
768
+ }
769
+ } catch (error) { this.logErrorAndThrow(error); }
770
+ };
771
+
772
+ }