diff --git a/.changeset/dull-apples-tease.md b/.changeset/dull-apples-tease.md new file mode 100644 index 0000000000..8ba171fc7d --- /dev/null +++ b/.changeset/dull-apples-tease.md @@ -0,0 +1,7 @@ +--- +"@medusajs/orchestration": patch +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +--- + +feat(orchestration,core-flows,medusa): product import uses workflows diff --git a/integration-tests/plugins/__tests__/product/admin/export-products.spec.ts b/integration-tests/plugins/__tests__/product/admin/export-products.spec.ts new file mode 100644 index 0000000000..3280eedcf8 --- /dev/null +++ b/integration-tests/plugins/__tests__/product/admin/export-products.spec.ts @@ -0,0 +1,471 @@ +import fs from "fs/promises" +import path, { resolve, sep } from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import { simpleSalesChannelFactory } from "../../../../factories" +import adminSeeder from "../../../../helpers/admin-seeder" +import productSeeder from "../../../../helpers/product-seeder" +import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types" + +const setupServer = require("../../../../environment-helpers/setup-server") +const userSeeder = require("../../../../helpers/user-seeder") + +const adminReqConfig = { + headers: { + "x-medusa-access-token": "test_token", + }, +} + +const env: Record = { + MEDUSA_FF_MEDUSA_V2: true, +} + +jest.setTimeout(180000) + +describe("Batch job of product-export type", () => { + let medusaProcess + let dbConnection + let exportFilePath = "" + let topDir = "" + let shutdownServer + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + medusaProcess = await setupServer({ + cwd, + uploadDir: __dirname, + env, + verbose: true, + }) + }) + + afterAll(async () => { + if (topDir !== "") { + await fs.rm(resolve(__dirname, topDir), { recursive: true }) + } + + const db = useDb() + await db.shutdown() + + await medusaProcess.kill() + await shutdownServer() + }) + + beforeEach(async () => { + const container = getContainer() + await createDefaultRuleTypes(container) + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + await userSeeder(dbConnection) + + await simpleSalesChannelFactory(dbConnection, { + id: "test-channel", + is_default: true, + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + + // @ts-ignore + try { + const isFileExists = (await fs.stat(exportFilePath))?.isFile() + + if (isFileExists) { + const [, relativeRoot] = exportFilePath + .replace(__dirname, "") + .split(sep) + + if ((await fs.stat(resolve(__dirname, relativeRoot)))?.isDirectory()) { + topDir = relativeRoot + } + + await fs.unlink(exportFilePath) + } + } catch (err) { + // noop + } + }) + + it("should export a csv file containing the expected products", async () => { + const api = useApi() + + const productPayload = { + title: "Test export product", + description: "test-product-description", + type: { value: "test-type" }, + images: ["test-image.png", "test-image-2.png"], + collection_id: "test-collection", + tags: [{ value: "123" }, { value: "456" }], + options: [{ title: "size" }, { title: "color" }], + variants: [ + { + title: "Test variant", + inventory_quantity: 10, + sku: "test-variant-sku-product-export", + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "eur", + amount: 45, + }, + { + currency_code: "dkk", + amount: 30, + }, + ], + options: [{ value: "large" }, { value: "green" }], + }, + ], + } + + const createProductRes = await api.post( + "/admin/products", + productPayload, + adminReqConfig + ) + + const productId = createProductRes.data.product.id + const variantId = createProductRes.data.product.variants[0].id + + const batchPayload = { + type: "product-export", + context: { + filterable_fields: { + title: "Test export product", + }, + }, + } + + const batchJobRes = await api.post( + "/admin/batch-jobs", + batchPayload, + adminReqConfig + ) + const batchJobId = batchJobRes.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + // Pull to check the status until it is completed + let batchJob + let shouldContinuePulling = true + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + exportFilePath = path.resolve(__dirname, batchJob.result.file_key) + const isFileExists = (await fs.stat(exportFilePath)).isFile() + + expect(isFileExists).toBeTruthy() + + const fileSize = (await fs.stat(exportFilePath)).size + expect(batchJob.result?.file_size).toBe(fileSize) + + const data = (await fs.readFile(exportFilePath)).toString() + const [, ...lines] = data.split("\r\n").filter((l) => l) + + expect(lines.length).toBe(1) + + const lineColumn = lines[0].split(";") + + expect(lineColumn[0]).toBe(productId) + expect(lineColumn[2]).toBe(productPayload.title) + expect(lineColumn[4]).toBe(productPayload.description) + expect(lineColumn[23]).toBe(variantId) + expect(lineColumn[24]).toBe(productPayload.variants[0].title) + expect(lineColumn[25]).toBe(productPayload.variants[0].sku) + }) + + it("should export a csv file containing the expected products including new line char in the cells", async () => { + const api = useApi() + + const productPayload = { + title: "Test export product", + description: "test-product-description\ntest line 2", + type: { value: "test-type" }, + images: ["test-image.png", "test-image-2.png"], + collection_id: "test-collection", + tags: [{ value: "123" }, { value: "456" }], + options: [{ title: "size" }, { title: "color" }], + variants: [ + { + title: "Test variant", + inventory_quantity: 10, + sku: "test-variant-sku-product-export", + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "eur", + amount: 45, + }, + { + currency_code: "dkk", + amount: 30, + }, + ], + options: [{ value: "large" }, { value: "green" }], + }, + ], + } + const createProductRes = await api.post( + "/admin/products", + productPayload, + adminReqConfig + ) + const productId = createProductRes.data.product.id + const variantId = createProductRes.data.product.variants[0].id + + const batchPayload = { + type: "product-export", + context: { + filterable_fields: { + title: "Test export product", + }, + }, + } + const batchJobRes = await api.post( + "/admin/batch-jobs", + batchPayload, + adminReqConfig + ) + const batchJobId = batchJobRes.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + // Pull to check the status until it is completed + let batchJob + let shouldContinuePulling = true + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + exportFilePath = path.resolve(__dirname, batchJob.result.file_key) + const isFileExists = (await fs.stat(exportFilePath)).isFile() + + expect(isFileExists).toBeTruthy() + + const fileSize = (await fs.stat(exportFilePath)).size + expect(batchJob.result?.file_size).toBe(fileSize) + + const data = (await fs.readFile(exportFilePath)).toString() + const [, ...lines] = data.split("\r\n").filter((l) => l) + + expect(lines.length).toBe(1) + + const lineColumn = lines[0].split(";") + + expect(lineColumn[0]).toBe(productId) + expect(lineColumn[2]).toBe(productPayload.title) + expect(lineColumn[4]).toBe(`"${productPayload.description}"`) + expect(lineColumn[23]).toBe(variantId) + expect(lineColumn[24]).toBe(productPayload.variants[0].title) + expect(lineColumn[25]).toBe(productPayload.variants[0].sku) + }) + + it("should export a csv file containing a limited number of products", async () => { + const api = useApi() + + const batchPayload = { + type: "product-export", + context: { + batch_size: 1, + filterable_fields: { collection_id: "test-collection" }, + order: "created_at", + }, + } + + const batchJobRes = await api.post( + "/admin/batch-jobs", + batchPayload, + adminReqConfig + ) + const batchJobId = batchJobRes.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + // Pull to check the status until it is completed + let batchJob + let shouldContinuePulling = true + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + exportFilePath = path.resolve(__dirname, batchJob.result.file_key) + const isFileExists = (await fs.stat(exportFilePath)).isFile() + + expect(isFileExists).toBeTruthy() + + const data = (await fs.readFile(exportFilePath)).toString() + const [, ...lines] = data.split("\r\n").filter((l) => l) + + expect(lines.length).toBe(4) + + const csvLine = lines[0].split(";") + expect(csvLine[0]).toBe("test-product") + }) + + it("should be able to import an exported csv file", async () => { + const api = useApi() + + const batchPayload = { + type: "product-export", + context: { + batch_size: 1, + filterable_fields: { collection_id: "test-collection" }, + order: "created_at", + }, + } + + const batchJobRes = await api.post( + "/admin/batch-jobs", + batchPayload, + adminReqConfig + ) + let batchJobId = batchJobRes.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + // Pull to check the status until it is completed + let batchJob + let shouldContinuePulling = true + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + exportFilePath = path.resolve(__dirname, batchJob.result.file_key) + const isFileExists = (await fs.stat(exportFilePath)).isFile() + + expect(isFileExists).toBeTruthy() + + const data = (await fs.readFile(exportFilePath)).toString() + const [header, ...lines] = data.split("\r\n").filter((l) => l) + + expect(lines.length).toBe(4) + + const csvLine = lines[0].split(";") + expect(csvLine[0]).toBe("test-product") + expect(csvLine[2]).toBe("Test product") + + csvLine[2] = "Updated test product" + lines.splice(0, 1, csvLine.join(";")) + + await fs.writeFile(exportFilePath, [header, ...lines].join("\r\n")) + + const importBatchJobRes = await api.post( + "/admin/batch-jobs", + { + type: "product-import", + context: { + fileKey: exportFilePath, + }, + }, + adminReqConfig + ) + + batchJobId = importBatchJobRes.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + shouldContinuePulling = true + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + const productsResponse = await api.get("/admin/products", adminReqConfig) + expect(productsResponse.data.count).toBe(5) + expect(productsResponse.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: csvLine[0], + handle: csvLine[1], + title: csvLine[2], + }), + ]) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/product/admin/import-products.spec.ts b/integration-tests/plugins/__tests__/product/admin/import-products.spec.ts new file mode 100644 index 0000000000..91bb9a7d38 --- /dev/null +++ b/integration-tests/plugins/__tests__/product/admin/import-products.spec.ts @@ -0,0 +1,406 @@ +import fs from "fs" +import path from "path" + +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import { simpleProductFactory } from "../../../../factories" +import { simpleProductCollectionFactory } from "../../../../factories/simple-product-collection-factory" +import adminSeeder from "../../../../helpers/admin-seeder" +import batchJobSeeder from "../../../../helpers/batch-job-seeder" +import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types" + +const setupServer = require("../../../../environment-helpers/setup-server") +const userSeeder = require("../../../../helpers/user-seeder") + +const adminReqConfig = { + headers: { + "x-medusa-access-token": "test_token", + }, +} + +function getImportFile() { + return path.resolve("__tests__", "product", "admin", "product-import.csv") +} + +function copyTemplateFile() { + const csvTemplate = path.resolve( + "__tests__", + "product", + "admin", + "product-import-template.csv" + ) + const destination = getImportFile() + + fs.copyFileSync(csvTemplate, destination) +} + +jest.setTimeout(1000000) + +function cleanTempData() { + // cleanup tmp ops files + const opsFiles = path.resolve("__tests__", "product", "admin", "imports") + + fs.rmSync(opsFiles, { recursive: true, force: true }) +} + +const env: Record = { + MEDUSA_FF_MEDUSA_V2: true, +} + +describe("Product import batch job", () => { + let dbConnection + let shutdownServer + let medusaProcess + + const collectionHandle1 = "test-collection1" + const collectionHandle2 = "test-collection2" + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + env.UPLOAD_DIR = __dirname + + cleanTempData() // cleanup if previous process didn't manage to do it + + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + medusaProcess = await setupServer({ + cwd, + uploadDir: __dirname, + env, + verbose: true, + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + cleanTempData() + + await medusaProcess.kill() + await shutdownServer() + }) + + beforeEach(async () => { + const container = getContainer() + await createDefaultRuleTypes(container) + await batchJobSeeder(dbConnection) + await adminSeeder(dbConnection) + await userSeeder(dbConnection) + await simpleProductCollectionFactory(dbConnection, [ + { + handle: collectionHandle1, + }, + { + handle: collectionHandle2, + }, + ]) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should import a csv file", async () => { + jest.setTimeout(1000000) + const api = useApi() + copyTemplateFile() + + const existingProductToBeUpdated = await simpleProductFactory( + dbConnection, + { + id: "existing-product-id", + title: "Test product", + options: [{ id: "opt-1-id", title: "Size" }], + variants: [ + { + id: "existing-variant-id", + title: "Initial tile", + sku: "test-sku-4", + options: [ + { + option_id: "opt-1-id", + value: "Large", + }, + ], + }, + ], + } + ) + + const response = await api.post( + "/admin/batch-jobs", + { + type: "product-import", + context: { + fileKey: "product-import.csv", + }, + }, + adminReqConfig + ) + + const batchJobId = response.data.batch_job.id + + expect(batchJobId).toBeTruthy() + + // Pull to check the status until it is completed + let batchJob + let shouldContinuePulling = true + + while (shouldContinuePulling) { + const res = await api.get( + `/admin/batch-jobs/${batchJobId}`, + adminReqConfig + ) + + await new Promise((resolve, _) => { + setTimeout(resolve, 1000) + }) + + batchJob = res.data.batch_job + + shouldContinuePulling = !( + batchJob.status === "completed" || batchJob.status === "failed" + ) + } + + expect(batchJob.status).toBe("completed") + + const productsResponse = await api.get("/admin/products", adminReqConfig) + expect(productsResponse.data.count).toBe(3) + + expect(productsResponse.data.products).toEqual( + expect.arrayContaining([ + // NEW PRODUCT + expect.objectContaining({ + title: "Test product", + description: + "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", + handle: "test-product-product-1", + is_giftcard: false, + status: "draft", + thumbnail: "test-image.png", + variants: [ + // NEW VARIANT + expect.objectContaining({ + title: "Test variant", + sku: "test-sku-1", + barcode: "test-barcode-1", + ean: null, + upc: null, + // inventory_quantity: 10, + prices: expect.arrayContaining([ + expect.objectContaining({ + currency_code: "eur", + amount: 100, + region_id: "region-product-import-0", + }), + expect.objectContaining({ + currency_code: "usd", + amount: 110, + }), + expect.objectContaining({ + currency_code: "dkk", + amount: 130, + region_id: "region-product-import-1", + }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ + value: "option 1 value red", + }), + expect.objectContaining({ + value: "option 2 value 1", + }), + ]), + }), + ], + type: null, + images: expect.arrayContaining([ + expect.objectContaining({ + url: "test-image.png", + }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ + title: "test-option-1", + }), + expect.objectContaining({ + title: "test-option-2", + }), + ]), + tags: expect.arrayContaining([ + expect.objectContaining({ + value: "123_1", + }), + ]), + collection: expect.objectContaining({ + handle: collectionHandle1, + }), + }), + expect.objectContaining({ + title: "Test product", + description: + "Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\\n100% organic cotton, soft and crisp to the touch. Made in Portugal.", + handle: "test-product-product-1-1", + is_giftcard: false, + status: "draft", + thumbnail: "test-image.png", + variants: [ + // NEW VARIANT + expect.objectContaining({ + title: "Test variant", + sku: "test-sku-1-1", + barcode: "test-barcode-1-1", + ean: null, + upc: null, + // inventory_quantity: 10, + prices: expect.arrayContaining([ + expect.objectContaining({ + currency_code: "eur", + amount: 100, + region_id: "region-product-import-0", + }), + expect.objectContaining({ + currency_code: "usd", + amount: 110, + }), + expect.objectContaining({ + currency_code: "dkk", + amount: 130, + region_id: "region-product-import-1", + }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ + value: "option 1 value red", + }), + expect.objectContaining({ + value: "option 2 value 1", + }), + ]), + }), + ], + type: null, + images: expect.arrayContaining([ + expect.objectContaining({ + url: "test-image.png", + }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ + title: "test-option-1", + }), + expect.objectContaining({ + title: "test-option-2", + }), + ]), + tags: [], + collection: expect.objectContaining({ + handle: collectionHandle1, + }), + }), + // // UPDATED PRODUCT + expect.objectContaining({ + id: existingProductToBeUpdated?.id, + title: "Test product", + description: "test-product-description", + handle: "test-product-product-2", + is_giftcard: false, + status: "draft", + thumbnail: "test-image.png", + profile_id: expect.any(String), + variants: expect.arrayContaining([ + // UPDATED VARIANT + expect.objectContaining({ + id: "existing-variant-id", + title: "Test variant changed", + sku: "test-sku-4", + barcode: "test-barcode-4", + options: [ + expect.objectContaining({ + value: "Large", + option_id: "opt-1-id", + }), + ], + }), + // CREATED VARIANT + expect.objectContaining({ + title: "Test variant", + product_id: existingProductToBeUpdated.id, + sku: "test-sku-2", + barcode: "test-barcode-2", + ean: null, + upc: null, + // inventory_quantity: 10, + allow_backorder: false, + manage_inventory: true, + prices: [ + expect.objectContaining({ + currency_code: "dkk", + amount: 110, + region_id: "region-product-import-2", + }), + ], + options: [ + expect.objectContaining({ + value: "Small", + option_id: "opt-1-id", + }), + ], + }), + // CREATED VARIANT + expect.objectContaining({ + title: "Test variant", + product_id: existingProductToBeUpdated.id, + sku: "test-sku-3", + barcode: "test-barcode-3", + ean: null, + upc: null, + // inventory_quantity: 10, + allow_backorder: false, + manage_inventory: true, + prices: [ + expect.objectContaining({ + currency_code: "usd", + amount: 120, + region_id: null, + }), + ], + options: [ + expect.objectContaining({ + value: "Medium", + option_id: "opt-1-id", + }), + ], + }), + ]), + images: [ + expect.objectContaining({ + url: "test-image.png", + }), + ], + options: [ + expect.objectContaining({ + product_id: existingProductToBeUpdated.id, + id: "opt-1-id", + title: "Size", + }), + ], + type: expect.objectContaining({ value: "test-type" }), + tags: [ + expect.objectContaining({ + value: "123", + }), + ], + collection: expect.objectContaining({ + handle: collectionHandle2, + }), + }), + ]) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/product/admin/product-import-template.csv b/integration-tests/plugins/__tests__/product/admin/product-import-template.csv new file mode 100644 index 0000000000..072a23baeb --- /dev/null +++ b/integration-tests/plugins/__tests__/product/admin/product-import-template.csv @@ -0,0 +1,6 @@ +Product Id,Product Handle,Product Title,Product Subtitle,Product Description,Product Status,Product Thumbnail,Product Weight,Product Length,Product Width,Product Height,Product HS Code,Product Origin Country,Product MID Code,Product Material,Product Collection Title,Product Collection Handle,Product Type,Product Tags,Product Discountable,Product External Id,Variant Id,Variant Title,Variant SKU,Variant Barcode,Variant Inventory Quantity,Variant Allow Backorder,Variant Manage Inventory,Variant Weight,Variant Length,Variant Width,Variant Height,Variant HS Code,Variant Origin Country,Variant MID Code,Variant Material,Price ImportLand [EUR],Price USD,Price denmark [DKK],Price Denmark [DKK],Option 1 Name,Option 1 Value,Option 2 Name,Option 2 Value,Image 1 Url +,test-product-product-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,123_1,TRUE,,,Test variant,test-sku-1,test-barcode-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png +,test-product-product-1-1,Test product,,"Hopper Stripes Bedding, available as duvet cover, pillow sham and sheet.\n100% organic cotton, soft and crisp to the touch. Made in Portugal.",draft,,,,,,,,,,Test collection 1,test-collection1,,,TRUE,,,Test variant,test-sku-1-1,test-barcode-1-1,10,FALSE,TRUE,,,,,,,,,1.00,1.10,1.30,,test-option-1,option 1 value red,test-option-2,option 2 value 1,test-image.png +existing-product-id,test-product-product-2,Test product,,test-product-description,draft,test-image.png,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-2,test-barcode-2,10,FALSE,TRUE,,,,,,,,,,,,1.10,Size,Small,,,test-image.png +existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,,Test variant,test-sku-3,test-barcode-3,10,FALSE,TRUE,,,,,,,,,,1.20,,,Size,Medium,,,test-image.png +existing-product-id,test-product-product-2,Test product,,test-product-description,draft,,,,,,,,,,Test collection,test-collection2,test-type,123,TRUE,,existing-variant-id,Test variant changed,test-sku-4,test-barcode-4,10,FALSE,TRUE,,,,,,,,,,,,,Size,Large,,,test-image.png \ No newline at end of file diff --git a/integration-tests/plugins/src/services/local-file-service.js b/integration-tests/plugins/src/services/local-file-service.js new file mode 100644 index 0000000000..dc4fbfbe33 --- /dev/null +++ b/integration-tests/plugins/src/services/local-file-service.js @@ -0,0 +1,96 @@ +import { AbstractFileService } from "@medusajs/medusa" +import * as fs from "fs" +import mkdirp from "mkdirp" +import { resolve } from "path" +import stream from "stream" + +export default class LocalFileService extends AbstractFileService { + constructor({}, options) { + super({}, options) + this.upload_dir_ = + process.env.UPLOAD_DIR ?? options.upload_dir ?? "uploads/images" + + if (!fs.existsSync(this.upload_dir_)) { + fs.mkdirSync(this.upload_dir_) + } + } + + upload(file) { + return new Promise((resolvePromise, reject) => { + const path = resolve(this.upload_dir_, file.originalname) + + let content = "" + if (file.filename) { + content = fs.readFileSync( + resolve(process.cwd(), "uploads", file.filename) + ) + } + + const pathSegments = path.split("/") + pathSegments.splice(-1) + const dirname = pathSegments.join("/") + mkdirp.sync(dirname, { recursive: true }) + + fs.writeFile(path, content.toString(), (err) => { + if (err) { + reject(err) + } + + resolvePromise({ url: path }) + }) + }) + } + + delete({ fileKey }) { + return new Promise((resolvePromise, reject) => { + const path = resolve(this.upload_dir_, fileKey) + fs.unlink(path, (err) => { + if (err) { + reject(err) + } + + resolvePromise("file unlinked") + }) + }) + } + + async getUploadStreamDescriptor({ name, ext }) { + const fileKey = `${name}.${ext}` + const path = resolve(this.upload_dir_, fileKey) + + const isFileExists = fs.existsSync(path) + if (!isFileExists) { + await this.upload({ originalname: fileKey }) + } + + const pass = new stream.PassThrough() + pass.pipe(fs.createWriteStream(path)) + + return { + writeStream: pass, + promise: Promise.resolve(), + url: `${this.upload_dir_}/${fileKey}`, + fileKey, + } + } + + async getDownloadStream({ fileKey }) { + return new Promise((resolvePromise, reject) => { + try { + const path = resolve(this.upload_dir_, fileKey) + const data = fs.readFileSync(path) + const readable = stream.Readable() + readable._read = function () {} + readable.push(data.toString()) + readable.push(null) + resolvePromise(readable) + } catch (e) { + reject(e) + } + }) + } + + async getPresignedDownloadUrl({ fileKey }) { + return `${this.upload_dir_}/${fileKey}` + } +} diff --git a/packages/core-flows/src/handlers/product/create-product-variants-prepare-data.ts b/packages/core-flows/src/handlers/product/create-product-variants-prepare-data.ts index 9ea768a403..7bae20b7d8 100644 --- a/packages/core-flows/src/handlers/product/create-product-variants-prepare-data.ts +++ b/packages/core-flows/src/handlers/product/create-product-variants-prepare-data.ts @@ -23,7 +23,6 @@ export async function createProductVariantsPrepareData({ container, data, }: WorkflowArguments): Promise { - const featureFlagRouter = container.resolve("featureFlagRouter") const productVariants: ProductWorkflow.CreateProductVariantsInputDTO[] = data.productVariants || [] diff --git a/packages/core-flows/src/handlers/product/update-product-variants.ts b/packages/core-flows/src/handlers/product/update-product-variants.ts index b340fbd535..1615c18d39 100644 --- a/packages/core-flows/src/handlers/product/update-product-variants.ts +++ b/packages/core-flows/src/handlers/product/update-product-variants.ts @@ -18,7 +18,10 @@ export async function updateProductVariants({ const productModuleService: ProductTypes.IProductModuleService = container.resolve(ModulesDefinition[Modules.PRODUCT].registrationName) - for (const [product_id, variantsUpdateData = []] of productVariantsMap) { + for (const [ + product_id, + variantsUpdateData = [], + ] of productVariantsMap) { updateVariantsData.push( ...(variantsUpdateData as unknown as UpdateProductVariantOnlyDTO[]).map( (update) => ({ ...update, product_id }) diff --git a/packages/core-flows/src/handlers/product/upsert-variant-prices.ts b/packages/core-flows/src/handlers/product/upsert-variant-prices.ts index d4de20e930..ac54cf7f5c 100644 --- a/packages/core-flows/src/handlers/product/upsert-variant-prices.ts +++ b/packages/core-flows/src/handlers/product/upsert-variant-prices.ts @@ -95,7 +95,9 @@ export async function upsertVariantPrices({ min_quantity: price.min_quantity, max_quantity: price.max_quantity, amount: price.amount, - currency_code: region_currency_code ?? price.currency_code, + currency_code: ( + region_currency_code ?? price.currency_code + ).toLowerCase(), } moneyAmountsToUpdate.push(priceToUpdate) @@ -104,7 +106,9 @@ export async function upsertVariantPrices({ min_quantity: price.min_quantity, max_quantity: price.max_quantity, amount: price.amount, - currency_code: region_currency_code ?? price.currency_code, + currency_code: ( + region_currency_code ?? price.currency_code + ).toLowerCase(), rules: region_rules ?? {}, } diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts index ec36d3838e..aaebd5817b 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts @@ -19,7 +19,7 @@ import { pickBy } from "lodash" import { ProductStatus } from "../../../../models" import PriceListService from "../../../../services/price-list" import { FilterableProductProps } from "../../../../types/product" -import { listAndCountProductWithIsolatedProductModule } from "../products/list-products" +import { listProducts } from "../../../../utils" /** * @oas [get] /admin/price-lists/{id}/products @@ -196,8 +196,8 @@ export default async (req: Request, res) => { } if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { - ;[products, count] = await listAndCountProductWithIsolatedProductModule( - req, + ;[products, count] = await listProducts( + req.scope, filterableFields, req.listConfig ) diff --git a/packages/medusa/src/api/routes/admin/products/create-product.ts b/packages/medusa/src/api/routes/admin/products/create-product.ts index 86f578043c..c39cc21482 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.ts +++ b/packages/medusa/src/api/routes/admin/products/create-product.ts @@ -1,5 +1,5 @@ +import { Workflows, createProducts } from "@medusajs/core-flows" import { IInventoryService, WorkflowTypes } from "@medusajs/types" -import { createProducts, Workflows } from "@medusajs/core-flows" import { IsArray, IsBoolean, @@ -45,7 +45,7 @@ import { EntityManager } from "typeorm" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import { ProductStatus } from "../../../../models" import { Logger } from "../../../../types/global" -import { validator } from "../../../../utils" +import { retrieveProduct, validator } from "../../../../utils" import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" /** @@ -257,7 +257,11 @@ export default async (req, res) => { let rawProduct if (isMedusaV2Enabled) { - rawProduct = await getProductWithIsolatedProductModule(req, product.id) + rawProduct = await retrieveProduct( + req.scope, + product.id, + defaultAdminProductRemoteQueryObject + ) } else { rawProduct = await productService.retrieve(product.id, { select: defaultAdminProductFields, @@ -272,26 +276,6 @@ export default async (req, res) => { res.json({ product: pricedProduct }) } -async function getProductWithIsolatedProductModule(req, id) { - // TODO: Add support for fields/expands - const remoteQuery = req.scope.resolve("remoteQuery") - - const variables = { id } - - const query = { - product: { - __args: variables, - ...defaultAdminProductRemoteQueryObject, - }, - } - - const [product] = await remoteQuery(query) - - product.profile_id = product.profile?.id - - return product -} - class ProductVariantOptionReq { @IsString() value: string diff --git a/packages/medusa/src/api/routes/admin/products/create-variant.ts b/packages/medusa/src/api/routes/admin/products/create-variant.ts index 961a6229ca..50ea1e32e2 100644 --- a/packages/medusa/src/api/routes/admin/products/create-variant.ts +++ b/packages/medusa/src/api/routes/admin/products/create-variant.ts @@ -1,6 +1,7 @@ +import { CreateProductVariants } from "@medusajs/core-flows" import { IInventoryService, WorkflowTypes } from "@medusajs/types" import { FlagRouter, MedusaV2Flag } from "@medusajs/utils" -import { CreateProductVariants } from "@medusajs/core-flows" +import { Type } from "class-transformer" import { IsArray, IsBoolean, @@ -10,7 +11,12 @@ import { IsString, ValidateNested, } from "class-validator" -import { defaultAdminProductFields, defaultAdminProductRelations } from "." +import { EntityManager } from "typeorm" +import { + defaultAdminProductFields, + defaultAdminProductRelations, + defaultAdminProductRemoteQueryObject, +} from "." import { PricingService, ProductService, @@ -21,10 +27,7 @@ import { CreateProductVariantInput, ProductVariantPricesCreateReq, } from "../../../../types/product-variant" -import { Type } from "class-transformer" -import { EntityManager } from "typeorm" -import { validator } from "../../../../utils/validator" -import { getProductWithIsolatedProductModule } from "./get-product" +import { retrieveProduct, validator } from "../../../../utils" import { createVariantsTransaction } from "./transaction/create-product-variant" /** @@ -157,10 +160,10 @@ export default async (req, res) => { }, }) - rawProduct = await getProductWithIsolatedProductModule( - req, + rawProduct = await retrieveProduct( + req.scope, id, - req.retrieveConfig + defaultAdminProductRemoteQueryObject ) } else { await manager.transaction(async (transactionManager) => { diff --git a/packages/medusa/src/api/routes/admin/products/get-product.ts b/packages/medusa/src/api/routes/admin/products/get-product.ts index b1a238a79e..d60d69bf71 100644 --- a/packages/medusa/src/api/routes/admin/products/get-product.ts +++ b/packages/medusa/src/api/routes/admin/products/get-product.ts @@ -5,8 +5,9 @@ import { SalesChannelService, } from "../../../../services" -import { MedusaError, MedusaV2Flag, promiseAll } from "@medusajs/utils" +import { MedusaV2Flag, promiseAll } from "@medusajs/utils" import { FindParams } from "../../../../types/common" +import { retrieveProduct } from "../../../../utils" import { defaultAdminProductRemoteQueryObject } from "./index" /** @@ -76,10 +77,10 @@ export default async (req, res) => { let rawProduct if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { - rawProduct = await getProductWithIsolatedProductModule( - req, + rawProduct = await retrieveProduct( + req.scope, id, - req.retrieveConfig + defaultAdminProductRemoteQueryObject ) } else { rawProduct = await productService.retrieve(id, req.retrieveConfig) @@ -118,35 +119,4 @@ export default async (req, res) => { res.json({ product }) } -export async function getProductWithIsolatedProductModule( - req, - id, - retrieveConfig -) { - // TODO: Add support for fields/expands - const remoteQuery = req.scope.resolve("remoteQuery") - - const variables = { id } - - const query = { - product: { - __args: variables, - ...defaultAdminProductRemoteQueryObject, - }, - } - - const [product] = await remoteQuery(query) - - if (!product) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Product with id: ${id} not found` - ) - } - - product.profile_id = product.profile?.id - - return product -} - export class AdminGetProductParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/products/list-products.ts b/packages/medusa/src/api/routes/admin/products/list-products.ts index 549b260398..3854e6faab 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.ts +++ b/packages/medusa/src/api/routes/admin/products/list-products.ts @@ -1,23 +1,19 @@ import { IsNumber, IsOptional, IsString } from "class-validator" import { - PriceListService, PricingService, ProductService, ProductVariantInventoryService, SalesChannelService, } from "../../../../services" -import { - IInventoryService, - IPricingModuleService, - IProductModuleService, -} from "@medusajs/types" -import { MedusaV2Flag, promiseAll } from "@medusajs/utils" +import { listProducts } from "../../../../utils" + +import { IInventoryService } from "@medusajs/types" +import { MedusaV2Flag } from "@medusajs/utils" import { Type } from "class-transformer" import { Product } from "../../../../models" import { PricedProduct } from "../../../../types/pricing" import { FilterableProductProps } from "../../../../types/product" -import { defaultAdminProductRemoteQueryObject } from "./index" /** * @oas [get] /admin/products @@ -252,12 +248,11 @@ export default async (req, res) => { let count if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { - const [products, count_] = - await listAndCountProductWithIsolatedProductModule( - req, - req.filterableFields, - req.listConfig - ) + const [products, count_] = await listProducts( + req.scope, + req.filterableFields, + req.listConfig + ) rawProducts = products count = count_ @@ -305,171 +300,6 @@ export default async (req, res) => { }) } -async function getVariantsFromPriceList(req, priceListId) { - const remoteQuery = req.scope.resolve("remoteQuery") - const pricingModuleService: IPricingModuleService = req.scope.resolve( - "pricingModuleService" - ) - const productModuleService: IProductModuleService = req.scope.resolve( - "productModuleService" - ) - - const [priceList] = await pricingModuleService.listPriceLists( - { id: [priceListId] }, - { - relations: [ - "price_set_money_amounts", - "price_set_money_amounts.price_set", - ], - select: ["price_set_money_amounts.price_set.id"], - } - ) - - const priceSetIds = priceList.price_set_money_amounts?.map( - (psma) => psma.price_set?.id - ) - - const query = { - product_variant_price_set: { - __args: { - price_set_id: priceSetIds, - }, - fields: ["variant_id", "price_set_id"], - }, - } - - const variantPriceSets = await remoteQuery(query) - const variantIds = variantPriceSets.map((vps) => vps.variant_id) - - return await productModuleService.listVariants( - { - id: variantIds, - }, - { - select: ["product_id"], - } - ) -} - -export async function listAndCountProductWithIsolatedProductModule( - req, - filterableFields, - listConfig -) { - // TODO: Add support for fields/expands - - const remoteQuery = req.scope.resolve("remoteQuery") - const featureFlagRouter = req.scope.resolve("featureFlagRouter") - - const productIdsFilter: Set = new Set() - const variantIdsFilter: Set = new Set() - - const promises: Promise[] = [] - - // This is not the best way of handling cross filtering but for now I would say it is fine - const salesChannelIdFilter = filterableFields.sales_channel_id - delete filterableFields.sales_channel_id - - if (salesChannelIdFilter) { - const salesChannelService = req.scope.resolve( - "salesChannelService" - ) as SalesChannelService - - promises.push( - salesChannelService - .listProductIdsBySalesChannelIds(salesChannelIdFilter) - .then((productIdsInSalesChannel) => { - let filteredProductIds = - productIdsInSalesChannel[salesChannelIdFilter] - - if (filterableFields.id) { - filterableFields.id = Array.isArray(filterableFields.id) - ? filterableFields.id - : [filterableFields.id] - - const salesChannelProductIdsSet = new Set(filteredProductIds) - - filteredProductIds = filterableFields.id.filter((productId) => - salesChannelProductIdsSet.has(productId) - ) - } - - filteredProductIds.map((id) => productIdsFilter.add(id)) - }) - ) - } - - const priceListId = filterableFields.price_list_id - delete filterableFields.price_list_id - - if (priceListId) { - if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { - const variants = await getVariantsFromPriceList(req, priceListId) - - variants.forEach((pv) => variantIdsFilter.add(pv.id)) - } else { - // TODO: it is working but validate the behaviour. - // e.g pricing context properly set. - // At the moment filtering by price list but not having any customer id or - // include discount forces the query to filter with price list id is null - const priceListService = req.scope.resolve( - "priceListService" - ) as PriceListService - promises.push( - priceListService - .listPriceListsVariantIdsMap(priceListId) - .then((priceListVariantIdsMap) => { - priceListVariantIdsMap[priceListId].map((variantId) => - variantIdsFilter.add(variantId) - ) - }) - ) - } - } - - const discountConditionId = filterableFields.discount_condition_id - delete filterableFields.discount_condition_id - - if (discountConditionId) { - // TODO implement later - } - - await promiseAll(promises) - - if (productIdsFilter.size > 0) { - filterableFields.id = Array.from(productIdsFilter) - } - - if (variantIdsFilter.size > 0) { - filterableFields.variants = { id: Array.from(variantIdsFilter) } - } - - const variables = { - filters: filterableFields, - order: listConfig.order, - skip: listConfig.skip, - take: listConfig.take, - } - - const query = { - product: { - __args: variables, - ...defaultAdminProductRemoteQueryObject, - }, - } - - const { - rows: products, - metadata: { count }, - } = await remoteQuery(query) - - products.forEach((product) => { - product.profile_id = product.profile?.id - }) - - return [products, count] -} - /** * Parameters used to filter and configure the pagination of the retrieved products. */ diff --git a/packages/medusa/src/api/routes/admin/products/update-product.ts b/packages/medusa/src/api/routes/admin/products/update-product.ts index 85b7cecf70..d7d0804e74 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.ts +++ b/packages/medusa/src/api/routes/admin/products/update-product.ts @@ -1,3 +1,4 @@ +import { Workflows, updateProducts } from "@medusajs/core-flows" import { DistributedTransaction } from "@medusajs/orchestration" import { FlagRouter, @@ -5,7 +6,6 @@ import { MedusaV2Flag, promiseAll, } from "@medusajs/utils" -import { updateProducts, Workflows } from "@medusajs/core-flows" import { Type } from "class-transformer" import { IsArray, @@ -45,6 +45,7 @@ import { ProductVariantPricesUpdateReq, UpdateProductVariantInput, } from "../../../../types/product-variant" +import { retrieveProduct } from "../../../../utils" import { createVariantsTransaction, revertVariantTransaction, @@ -292,7 +293,11 @@ export default async (req, res) => { let rawProduct if (isMedusaV2Enabled) { - rawProduct = await getProductWithIsolatedProductModule(req, id) + rawProduct = await retrieveProduct( + req.scope, + id, + defaultAdminProductRemoteQueryObject + ) } else { rawProduct = await productService.retrieve(id, { select: defaultAdminProductFields, @@ -305,26 +310,6 @@ export default async (req, res) => { res.json({ product }) } -async function getProductWithIsolatedProductModule(req, id) { - // TODO: Add support for fields/expands - const remoteQuery = req.scope.resolve("remoteQuery") - - const variables = { id } - - const query = { - product: { - __args: variables, - ...defaultAdminProductRemoteQueryObject, - }, - } - - const [product] = await remoteQuery(query) - - product.profile_id = product.profile?.id - - return product -} - class ProductVariantOptionReq { @IsString() value: string diff --git a/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts b/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts index 3ccfc8391d..f410c72278 100644 --- a/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts +++ b/packages/medusa/src/strategies/__tests__/batch-jobs/product/export.ts @@ -1,7 +1,10 @@ import { FlagRouter } from "@medusajs/utils" import { Request } from "express" import { IdMap, MockManager } from "medusa-test-utils" -import { AdminPostBatchesReq, defaultAdminProductRelations, } from "../../../../api" +import { + AdminPostBatchesReq, + defaultAdminProductRelations, +} from "../../../../api" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import { User } from "../../../../models" import { BatchJobStatus } from "../../../../types/batch-job" @@ -117,6 +120,7 @@ describe("Product export strategy", () => { batchJobService: batchJobServiceMock as any, productService: productServiceMock as any, featureFlagRouter: new FlagRouter({}), + remoteQuery: (() => {}) as any, }) it("should generate the appropriate template", async () => { @@ -264,6 +268,7 @@ describe("Product export strategy", () => { productService: productServiceWithNoDataMock as any, manager: MockManager, featureFlagRouter: new FlagRouter({}), + remoteQuery: (() => {}) as any, }) await productExportStrategy.prepareBatchJobForProcessing( @@ -283,6 +288,7 @@ describe("Product export strategy", () => { productService: productServiceMock as any, manager: MockManager, featureFlagRouter: new FlagRouter({}), + remoteQuery: (() => {}) as any, }) await productExportStrategy.prepareBatchJobForProcessing( @@ -381,6 +387,7 @@ describe("Product export strategy with sales Channels", () => { fileService: fileServiceMock as any, batchJobService: batchJobServiceMock as any, productService: productServiceMock as any, + remoteQuery: (() => {}) as any, featureFlagRouter: new FlagRouter({ [SalesChannelFeatureFlag.key]: true, }), diff --git a/packages/medusa/src/strategies/batch-jobs/product/export.ts b/packages/medusa/src/strategies/batch-jobs/product/export.ts index d073adf580..dae28f8746 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/export.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/export.ts @@ -1,8 +1,7 @@ -import { EntityManager } from "typeorm" - +import { MedusaContainer } from "@medusajs/types" +import { FlagRouter, MedusaV2Flag, createContainerLike } from "@medusajs/utils" import { humanizeAmount } from "medusa-core-utils" - -import { FlagRouter } from "@medusajs/utils" +import { EntityManager } from "typeorm" import { defaultAdminProductRelations } from "../../../api" import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces" import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories" @@ -11,19 +10,19 @@ import { Product, ProductVariant } from "../../../models" import { BatchJobService, ProductService } from "../../../services" import { BatchJobStatus, CreateBatchJobInput } from "../../../types/batch-job" import { FindProductConfig } from "../../../types/product" -import { csvCellContentFormatter } from "../../../utils" +import { csvCellContentFormatter, listProducts } from "../../../utils" import { prepareListQuery } from "../../../utils/get-query-config" import { - DynamicProductExportDescriptor, - ProductExportBatchJob, - ProductExportBatchJobContext, - ProductExportInjectedDependencies, - ProductExportPriceData, + DynamicProductExportDescriptor, + ProductExportBatchJob, + ProductExportBatchJobContext, + ProductExportInjectedDependencies, + ProductExportPriceData, } from "./types" import { - productCategoriesColumnsDefinition, - productColumnsDefinition, - productSalesChannelColumnsDefinition, + productCategoriesColumnsDefinition, + productColumnsDefinition, + productSalesChannelColumnsDefinition, } from "./types/columns-definition" export default class ProductExportStrategy extends AbstractBatchJobStrategy { @@ -37,6 +36,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { protected readonly productService_: ProductService protected readonly fileService_: IFileService protected readonly featureFlagRouter_: FlagRouter + protected readonly remoteQuery_: any protected readonly defaultRelations_ = [ ...defaultAdminProductRelations, @@ -68,6 +68,7 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { productService, fileService, featureFlagRouter, + remoteQuery, }: ProductExportInjectedDependencies) { super({ manager, @@ -75,8 +76,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { productService, fileService, featureFlagRouter, + remoteQuery, }) + this.remoteQuery_ = remoteQuery this.manager_ = manager this.batchJobService_ = batchJobService this.productService_ = productService @@ -135,6 +138,10 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { } async preProcessBatchJob(batchJobId: string): Promise { + const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled( + MedusaV2Flag.key + ) + return await this.atomicPhase_(async (transactionManager) => { const batchJob = (await this.batchJobService_ .withTransaction(transactionManager) @@ -144,12 +151,29 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { const limit = batchJob.context?.list_config?.take ?? this.DEFAULT_LIMIT const { list_config = {}, filterable_fields = {} } = batchJob.context - const [productList, count] = await this.productService_ - .withTransaction(transactionManager) - .listAndCount(filterable_fields, { - ...(list_config ?? {}), - take: Math.min(batchJob.context.batch_size ?? Infinity, limit), - } as FindProductConfig) + let productList: Product[] = [] + let count = 0 + const container = createContainerLike( + this.__container__ + ) as MedusaContainer + + if (isMedusaV2Enabled) { + ;[productList, count] = await listProducts( + container, + filterable_fields, + { + ...list_config, + take: null, + } + ) + } else { + ;[productList, count] = await this.productService_ + .withTransaction(transactionManager) + .listAndCount(filterable_fields, { + ...(list_config ?? {}), + take: Math.min(batchJob.context.batch_size ?? Infinity, limit), + } as FindProductConfig) + } const productCount = batchJob.context?.batch_size ?? count let products: Product[] = productList @@ -162,13 +186,25 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { while (offset < productCount) { if (!products?.length) { - products = await this.productService_ - .withTransaction(transactionManager) - .list(filterable_fields, { - ...list_config, - skip: offset, - take: Math.min(productCount - offset, limit), - } as FindProductConfig) + if (isMedusaV2Enabled) { + ;[productList, count] = await listProducts( + container, + filterable_fields, + { + ...list_config, + skip: offset, + take: Math.min(productCount - offset, limit), + } + ) + } else { + products = await this.productService_ + .withTransaction(transactionManager) + .list(filterable_fields, { + ...list_config, + skip: offset, + take: Math.min(productCount - offset, limit), + } as FindProductConfig) + } } const shapeData = this.getProductRelationsDynamicColumnsShape(products) @@ -669,7 +705,8 @@ export default class ProductExportStrategy extends AbstractBatchJobStrategy { if ( this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) ) { - const salesChannelCount = product?.sales_channels?.length ?? 0 + const salesChannelCount = + (product as Product)?.sales_channels?.length ?? 0 salesChannelsColumnCount = Math.max( salesChannelsColumnCount, salesChannelCount diff --git a/packages/medusa/src/strategies/batch-jobs/product/import.ts b/packages/medusa/src/strategies/batch-jobs/product/import.ts index 158e4c359b..30f0189c80 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/import.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/import.ts @@ -1,8 +1,14 @@ /* eslint-disable valid-jsdoc */ -import { computerizeAmount, MedusaError } from "medusa-core-utils" +import { + CreateProductVariants, + UpdateProductVariants, + createProducts, + updateProducts, +} from "@medusajs/core-flows" +import { ProductWorkflow } from "@medusajs/types" +import { FlagRouter, MedusaV2Flag, promiseAll } from "@medusajs/utils" +import { MedusaError, computerizeAmount } from "medusa-core-utils" import { EntityManager } from "typeorm" - -import { FlagRouter, promiseAll } from "@medusajs/utils" import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces" import ProductCategoryFeatureFlag from "../../../loaders/feature-flags/product-categories" import SalesChannelFeatureFlag from "../../../loaders/feature-flags/sales-channels" @@ -109,7 +115,6 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { }) this.featureFlagRouter_ = featureFlagRouter - this.manager_ = manager this.fileService_ = fileService this.batchJobService_ = batchJobService @@ -168,7 +173,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { const variantsUpdate: TParsedProductImportRowData[] = [] for (const row of csvData) { - if ((row["variant.prices"] as Record[]).length) { + if ((row["variant.prices"] as Record[])?.length) { await this.prepareVariantPrices(row) } @@ -278,6 +283,7 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { let totalOperationCount = 0 const operationsCounts = {} + Object.keys(ops).forEach((key) => { operationsCounts[key] = ops[key].length totalOperationCount += ops[key].length @@ -417,6 +423,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { return } + const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled( + MedusaV2Flag.key + ) + const transactionManager = this.transactionManager_ ?? this.manager_ const productOps = await this.downloadImportOpsFile( @@ -468,10 +478,27 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { ) } - // TODO: we should only pass the expected data and should not have to cast the entire object. Here we are passing everything contained in productData - await productServiceTx.create( - productData as unknown as CreateProductInput - ) + if (isMedusaV2Enabled) { + const createProductWorkflow = createProducts(this.__container__) + + const input = { + products: [ + productData, + ] as unknown as ProductWorkflow.CreateProductInputDTO[], + } + + await createProductWorkflow.run({ + input, + context: { + manager: transactionManager, + }, + }) + } else { + // TODO: we should only pass the expected data and should not have to cast the entire object. Here we are passing everything contained in productData + await productServiceTx.create( + productData as unknown as CreateProductInput + ) + } } catch (e) { ProductImportStrategy.throwDescriptiveError(productOp, e.message) } @@ -490,6 +517,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { return } + const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled( + MedusaV2Flag.key + ) + const transactionManager = this.transactionManager_ ?? this.manager_ const productOps = await this.downloadImportOpsFile( batchJob, @@ -541,11 +572,31 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { ) } - // TODO: we should only pass the expected data. Here we are passing everything contained in productData - await productServiceTx.update( - productOp["product.id"] as string, - productData - ) + if (isMedusaV2Enabled) { + const updateProductWorkflow = updateProducts(this.__container__) + + const input = { + products: [ + { + id: productOp["product.id"] as string, + ...productData, + }, + ], + } + + await updateProductWorkflow.run({ + input, + context: { + manager: transactionManager, + }, + }) + } else { + // TODO: we should only pass the expected data. Here we are passing everything contained in productData + await productServiceTx.update( + productOp["product.id"] as string, + productData + ) + } } catch (e) { ProductImportStrategy.throwDescriptiveError(productOp, e.message) } @@ -565,6 +616,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { return } + const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled( + MedusaV2Flag.key + ) + const transactionManager = this.transactionManager_ ?? this.manager_ const variantOps = await this.downloadImportOpsFile( @@ -600,9 +655,30 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { delete variant.id delete variant.product - await this.productVariantService_ - .withTransaction(transactionManager) - .create(product!, variant as unknown as CreateProductVariantInput) + if (isMedusaV2Enabled) { + const createProductVariantsWorkflow = + CreateProductVariants.createProductVariants(this.__container__) + + const input: ProductWorkflow.CreateProductVariantsWorkflowInputDTO = { + productVariants: [ + { + ...variant, + product_id: product.id, + }, + ], + } + + await createProductVariantsWorkflow.run({ + input, + context: { + manager: transactionManager, + }, + }) + } else { + await this.productVariantService_ + .withTransaction(transactionManager) + .create(product!, variant as unknown as CreateProductVariantInput) + } await this.updateProgress(batchJob.id) } catch (e) { @@ -621,6 +697,10 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { return } + const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled( + MedusaV2Flag.key + ) + const transactionManager = this.transactionManager_ ?? this.manager_ const variantOps = await this.downloadImportOpsFile( @@ -637,15 +717,36 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { variantOp["product.handle"] as string ) - await this.prepareVariantOptions(variantOp, product.id) + await this.prepareVariantOptions(variantOp, product.id!) const updateData = transformVariantData(variantOp) delete updateData.product delete updateData["product.handle"] - await this.productVariantService_ - .withTransaction(transactionManager) - .update(variantOp["variant.id"] as string, updateData) + if (isMedusaV2Enabled) { + const updateProductVariantsWorkflow = + UpdateProductVariants.updateProductVariants(this.__container__) + + const input: ProductWorkflow.UpdateProductVariantsWorkflowInputDTO = { + productVariants: [ + { + id: variantOp["variant.id"] as string, + ...updateData, + }, + ], + } + + await updateProductVariantsWorkflow.run({ + input, + context: { + manager: transactionManager, + }, + }) + } else { + await this.productVariantService_ + .withTransaction(transactionManager) + .update(variantOp["variant.id"] as string, updateData) + } } catch (e) { ProductImportStrategy.throwDescriptiveError(variantOp, e.message) } diff --git a/packages/medusa/src/strategies/batch-jobs/product/types/index.ts b/packages/medusa/src/strategies/batch-jobs/product/types/index.ts index bedd1632a1..09aa1fdb67 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/types/index.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/types/index.ts @@ -1,3 +1,4 @@ +import { RemoteQueryFunction } from "@medusajs/types" import { FlagRouter } from "@medusajs/utils" import { FileService } from "medusa-interfaces" import { EntityManager } from "typeorm" @@ -5,14 +6,14 @@ import { IFileService } from "../../../../interfaces" import { CsvSchema, CsvSchemaColumn } from "../../../../interfaces/csv-parser" import { BatchJob, Product, ProductVariant } from "../../../../models" import { - BatchJobService, - ProductCategoryService, - ProductCollectionService, - ProductService, - ProductVariantService, - RegionService, - SalesChannelService, - ShippingProfileService, + BatchJobService, + ProductCategoryService, + ProductCollectionService, + ProductService, + ProductVariantService, + RegionService, + SalesChannelService, + ShippingProfileService, } from "../../../../services" import { Selector } from "../../../../types/common" @@ -22,6 +23,7 @@ export type ProductExportInjectedDependencies = { productService: ProductService fileService: IFileService featureFlagRouter: FlagRouter + remoteQuery: RemoteQueryFunction } export type ProductExportBatchJobContext = { diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts index 8a305a854f..3b175244ef 100644 --- a/packages/medusa/src/utils/index.ts +++ b/packages/medusa/src/utils/index.ts @@ -11,6 +11,7 @@ export * from "./is-object" export * from "./is-string" export * from "./omit-deep" export * from "./product-category" +export * from "./queries" export * from "./remote-query-fetch-data" export * from "./remove-undefined-properties" export * from "./set-metadata" diff --git a/packages/medusa/src/utils/queries/index.ts b/packages/medusa/src/utils/queries/index.ts new file mode 100644 index 0000000000..0413143cb8 --- /dev/null +++ b/packages/medusa/src/utils/queries/index.ts @@ -0,0 +1 @@ +export * from "./products" diff --git a/packages/medusa/src/utils/queries/products/get-variants-from-price-list.ts b/packages/medusa/src/utils/queries/products/get-variants-from-price-list.ts new file mode 100644 index 0000000000..8543755bc3 --- /dev/null +++ b/packages/medusa/src/utils/queries/products/get-variants-from-price-list.ts @@ -0,0 +1,34 @@ +import { MedusaContainer, ProductVariantDTO } from "@medusajs/types" + +export async function getVariantsFromPriceList( + container: MedusaContainer, + priceListId: string +) { + const remoteQuery = container.resolve("remoteQuery") + + const query = { + price_list: { + __args: { id: [priceListId] }, + price_set_money_amounts: { + price_set: { + variant_link: { variant: { fields: ["id", "product_id"] } }, + }, + }, + }, + } + + const priceLists = await remoteQuery(query) + const variants: ProductVariantDTO[] = [] + + priceLists.forEach((priceList) => { + priceList.price_set_money_amounts?.forEach((psma) => { + const variant = psma.price_set?.variant_link?.variant + + if (variant) { + variants.push(variant) + } + }) + }) + + return variants +} diff --git a/packages/medusa/src/utils/queries/products/index.ts b/packages/medusa/src/utils/queries/products/index.ts new file mode 100644 index 0000000000..d33b807605 --- /dev/null +++ b/packages/medusa/src/utils/queries/products/index.ts @@ -0,0 +1,3 @@ +export * from "./get-variants-from-price-list" +export * from "./list-products" +export * from "./retrieve-product" diff --git a/packages/medusa/src/utils/queries/products/list-products.ts b/packages/medusa/src/utils/queries/products/list-products.ts new file mode 100644 index 0000000000..5bc2402b1b --- /dev/null +++ b/packages/medusa/src/utils/queries/products/list-products.ts @@ -0,0 +1,248 @@ +import { MedusaContainer } from "@medusajs/types" +import { MedusaV2Flag, promiseAll } from "@medusajs/utils" + +import { PriceListService, SalesChannelService } from "../../../services" +import { getVariantsFromPriceList } from "./get-variants-from-price-list" + +export async function listProducts( + container: MedusaContainer, + filterableFields, + listConfig +) { + // TODO: Add support for fields/expands + + const remoteQuery = container.resolve("remoteQuery") + const featureFlagRouter = container.resolve("featureFlagRouter") + + const productIdsFilter: Set = new Set() + const variantIdsFilter: Set = new Set() + + const promises: Promise[] = [] + + // This is not the best way of handling cross filtering but for now I would say it is fine + const salesChannelIdFilter = filterableFields.sales_channel_id + delete filterableFields.sales_channel_id + + if (salesChannelIdFilter) { + const salesChannelService = container.resolve( + "salesChannelService" + ) as SalesChannelService + + promises.push( + salesChannelService + .listProductIdsBySalesChannelIds(salesChannelIdFilter) + .then((productIdsInSalesChannel) => { + let filteredProductIds = + productIdsInSalesChannel[salesChannelIdFilter] + + if (filterableFields.id) { + filterableFields.id = Array.isArray(filterableFields.id) + ? filterableFields.id + : [filterableFields.id] + + const salesChannelProductIdsSet = new Set(filteredProductIds) + + filteredProductIds = filterableFields.id.filter((productId) => + salesChannelProductIdsSet.has(productId) + ) + } + + filteredProductIds.map((id) => productIdsFilter.add(id)) + }) + ) + } + + const priceListId = filterableFields.price_list_id + delete filterableFields.price_list_id + + if (priceListId) { + if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { + const variants = await getVariantsFromPriceList(container, priceListId) + + variants.forEach((pv) => variantIdsFilter.add(pv.id)) + } else { + // TODO: it is working but validate the behaviour. + // e.g pricing context properly set. + // At the moment filtering by price list but not having any customer id or + // include discount forces the query to filter with price list id is null + const priceListService = container.resolve( + "priceListService" + ) as PriceListService + + promises.push( + priceListService + .listPriceListsVariantIdsMap(priceListId) + .then((priceListVariantIdsMap) => { + priceListVariantIdsMap[priceListId].map((variantId) => + variantIdsFilter.add(variantId) + ) + }) + ) + } + } + + const discountConditionId = filterableFields.discount_condition_id + delete filterableFields.discount_condition_id + + if (discountConditionId) { + // TODO implement later + } + + await promiseAll(promises) + + if (productIdsFilter.size > 0) { + filterableFields.id = Array.from(productIdsFilter) + } + + if (variantIdsFilter.size > 0) { + filterableFields.variants = { id: Array.from(variantIdsFilter) } + } + + const variables = { + filters: filterableFields, + order: listConfig.order, + skip: listConfig.skip, + take: listConfig.take, + } + + const query = { + product: { + __args: variables, + ...defaultAdminProductRemoteQueryObject, + }, + } + + const { + rows: products, + metadata: { count }, + } = await remoteQuery(query) + + products.forEach((product) => { + product.profile_id = product.profile?.id + }) + + return [products, count] +} + +export const defaultAdminProductRemoteQueryObject = { + fields: [ + "id", + "title", + "subtitle", + "status", + "external_id", + "description", + "handle", + "is_giftcard", + "discountable", + "thumbnail", + "collection_id", + "type_id", + "weight", + "length", + "height", + "width", + "hs_code", + "origin_country", + "mid_code", + "material", + "created_at", + "updated_at", + "deleted_at", + "metadata", + ], + images: { + fields: ["id", "created_at", "updated_at", "deleted_at", "url", "metadata"], + }, + tags: { + fields: ["id", "created_at", "updated_at", "deleted_at", "value"], + }, + + type: { + fields: ["id", "created_at", "updated_at", "deleted_at", "value"], + }, + + collection: { + fields: ["title", "handle", "id", "created_at", "updated_at", "deleted_at"], + }, + + categories: { + fields: [ + "id", + "name", + "description", + "handle", + "is_active", + "is_internal", + "parent_category_id", + ], + }, + + options: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "title", + "product_id", + "metadata", + ], + values: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "value", + "option_id", + "variant_id", + "metadata", + ], + }, + }, + + variants: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "title", + "product_id", + "sku", + "barcode", + "ean", + "upc", + "variant_rank", + "inventory_quantity", + "allow_backorder", + "manage_inventory", + "hs_code", + "origin_country", + "mid_code", + "material", + "weight", + "length", + "height", + "width", + "metadata", + ], + + options: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "value", + "option_id", + "variant_id", + "metadata", + ], + }, + }, + profile: { + fields: ["id", "created_at", "updated_at", "deleted_at", "name", "type"], + }, +} diff --git a/packages/medusa/src/utils/queries/products/retrieve-product.ts b/packages/medusa/src/utils/queries/products/retrieve-product.ts new file mode 100644 index 0000000000..336e5d52cf --- /dev/null +++ b/packages/medusa/src/utils/queries/products/retrieve-product.ts @@ -0,0 +1,28 @@ +import { MedusaError } from "@medusajs/utils" + +export async function retrieveProduct(container, id, remoteQueryObject = {}) { + // TODO: Add support for fields/expands + const remoteQuery = container.resolve("remoteQuery") + + const variables = { id } + + const query = { + product: { + __args: variables, + ...remoteQueryObject, + }, + } + + const [product] = await remoteQuery(query) + + if (!product) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product with id: ${id} not found` + ) + } + + product.profile_id = product.profile?.id + + return product +} diff --git a/packages/orchestration/src/workflow/global-workflow.ts b/packages/orchestration/src/workflow/global-workflow.ts index 8545512fc5..0cc69e2ac0 100644 --- a/packages/orchestration/src/workflow/global-workflow.ts +++ b/packages/orchestration/src/workflow/global-workflow.ts @@ -1,9 +1,9 @@ import { Context, LoadedModule, MedusaContainer } from "@medusajs/types" -import { WorkflowDefinition, WorkflowManager } from "./workflow-manager" +import { createContainerLike, createMedusaContainer } from "@medusajs/utils" +import { asValue } from "awilix" import { DistributedTransaction } from "../transaction" -import { asValue } from "awilix" -import { createMedusaContainer } from "@medusajs/utils" +import { WorkflowDefinition, WorkflowManager } from "./workflow-manager" export class GlobalWorkflow extends WorkflowManager { protected static workflows: Map = new Map() @@ -16,17 +16,17 @@ export class GlobalWorkflow extends WorkflowManager { ) { super() - const container = createMedusaContainer() + let container - // Medusa container if (!Array.isArray(modulesLoaded) && modulesLoaded) { - const cradle = modulesLoaded.cradle - for (const key in cradle) { - container.register(key, asValue(cradle[key])) + if (!("cradle" in modulesLoaded)) { + container = createContainerLike(modulesLoaded) + } else { + container = modulesLoaded } - } - // Array of modules - else if (modulesLoaded?.length) { + } else if (Array.isArray(modulesLoaded) && modulesLoaded.length) { + container = createMedusaContainer() + for (const mod of modulesLoaded) { const registrationName = mod.__definition.registrationName container.register(registrationName, asValue(mod)) diff --git a/packages/orchestration/src/workflow/local-workflow.ts b/packages/orchestration/src/workflow/local-workflow.ts index c19d7ca45d..5228b2ee28 100644 --- a/packages/orchestration/src/workflow/local-workflow.ts +++ b/packages/orchestration/src/workflow/local-workflow.ts @@ -1,5 +1,5 @@ import { Context, LoadedModule, MedusaContainer } from "@medusajs/types" -import { createMedusaContainer } from "@medusajs/utils" +import { createContainerLike, createMedusaContainer } from "@medusajs/utils" import { asValue } from "awilix" import { DistributedTransaction, @@ -27,7 +27,7 @@ export class LocalWorkflow { constructor( workflowId: string, - modulesLoaded?: LoadedModule[] | MedusaContainer + modulesLoaded: LoadedModule[] | MedusaContainer ) { const globalWorkflow = WorkflowManager.getWorkflow(workflowId) if (!globalWorkflow) { @@ -39,17 +39,17 @@ export class LocalWorkflow { this.workflow = globalWorkflow this.handlers = new Map(globalWorkflow.handlers_) - const container = createMedusaContainer() + let container - // Medusa container if (!Array.isArray(modulesLoaded) && modulesLoaded) { - const cradle = modulesLoaded.cradle - for (const key of Object.keys(cradle ?? {})) { - container.register(key, asValue(cradle[key])) + if (!("cradle" in modulesLoaded)) { + container = createContainerLike(modulesLoaded) + } else { + container = modulesLoaded } - } - // Array of modules - else if (modulesLoaded?.length) { + } else if (Array.isArray(modulesLoaded) && modulesLoaded.length) { + container = createMedusaContainer() + for (const mod of modulesLoaded) { const registrationName = mod.__definition.registrationName container.register(registrationName, asValue(mod)) diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index 52b0f4dc30..c67a27bbbf 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -67,15 +67,15 @@ import { MedusaError, promiseAll, } from "@medusajs/utils" +import { + CreateProductOptionValueDTO, + UpdateProductOptionValueDTO, +} from "../types/services/product-option-value" import { entityNameToLinkableKeysMap, joinerConfig, LinkableKeys, } from "./../joiner-config" -import { - CreateProductOptionValueDTO, - UpdateProductOptionValueDTO, -} from "../types/services/product-option-value" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -1063,6 +1063,10 @@ export default class ProductModuleService< const existingProductVariantsMap = new Map( data.map((productData) => { + if (productData.variants === undefined) { + return [productData.id, []] + } + const productVariantsForProduct = existingProductVariants.filter( (variant) => variant.product_id === productData.id ) diff --git a/packages/types/src/common/medusa-container.ts b/packages/types/src/common/medusa-container.ts index cf18456010..c3a8fc19e2 100644 --- a/packages/types/src/common/medusa-container.ts +++ b/packages/types/src/common/medusa-container.ts @@ -4,3 +4,7 @@ export type MedusaContainer = AwilixContainer & { registerAdd: (name: string, registration: T) => MedusaContainer createScope: () => MedusaContainer } + +export type ContainerLike = { + resolve(key: string): T +} diff --git a/packages/utils/src/common/create-container-like.ts b/packages/utils/src/common/create-container-like.ts new file mode 100644 index 0000000000..445c6adbfe --- /dev/null +++ b/packages/utils/src/common/create-container-like.ts @@ -0,0 +1,9 @@ +import { ContainerLike } from "@medusajs/types" + +export function createContainerLike(obj): ContainerLike { + return { + resolve(key: string) { + return obj[key] + }, + } +} diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index d901a1baf1..509d40b162 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -2,6 +2,7 @@ export * from "./array-difference" export * from "./build-query" export * from "./camel-to-snake-case" export * from "./container" +export * from "./create-container-like" export * from "./deduplicate" export * from "./deep-equal-obj" export * from "./errors"