From fd83e75e4bf32080ad276d992934079ff7ab8cc0 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Mon, 15 Apr 2024 16:48:29 +0200 Subject: [PATCH] feat: Add support for batch method for products and product variants (#7038) * feat(products): Add batch methods for product and variants * chore: Rename batch validator and minor changes based on PR review --- .../api/__tests__/admin/product.js | 157 ++++++++++++++++++ .../src/pricing/steps/update-price-sets.ts | 61 ++++--- .../product/steps/batch-product-variants.ts | 71 ++++++++ .../src/product/steps/batch-products.ts | 75 +++++++++ .../core-flows/src/product/steps/index.ts | 2 + .../product/steps/update-product-variants.ts | 35 +++- .../src/product/steps/update-products.ts | 33 +++- .../workflows/batch-product-variants.ts | 33 ++++ .../src/product/workflows/batch-products.ts | 34 ++++ .../src/product/workflows/create-products.ts | 3 +- .../src/product/workflows/delete-products.ts | 3 +- .../core-flows/src/product/workflows/index.ts | 2 + .../workflows/update-product-variants.ts | 46 ++++- .../src/product/workflows/update-products.ts | 14 +- .../[id]/variants/[variant_id]/route.ts | 1 - .../products/[id]/variants/op/batch/route.ts | 57 +++++++ .../admin/products/[id]/variants/route.ts | 7 +- .../src/api-v2/admin/products/helpers.ts | 94 ++++++++++- .../src/api-v2/admin/products/middlewares.ts | 33 +++- .../api-v2/admin/products/op/batch/route.ts | 43 +++++ .../src/api-v2/admin/products/validators.ts | 14 ++ .../medusa/src/api-v2/utils/validators.ts | 11 ++ packages/types/src/common/batch.ts | 15 ++ packages/types/src/common/index.ts | 1 + .../workflows-sdk/src/utils/composer/type.ts | 4 +- 25 files changed, 793 insertions(+), 56 deletions(-) create mode 100644 packages/core-flows/src/product/steps/batch-product-variants.ts create mode 100644 packages/core-flows/src/product/steps/batch-products.ts create mode 100644 packages/core-flows/src/product/workflows/batch-product-variants.ts create mode 100644 packages/core-flows/src/product/workflows/batch-products.ts create mode 100644 packages/medusa/src/api-v2/admin/products/[id]/variants/op/batch/route.ts create mode 100644 packages/medusa/src/api-v2/admin/products/op/batch/route.ts create mode 100644 packages/types/src/common/batch.ts diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 66cab04b8f..74d049b544 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -2828,6 +2828,163 @@ medusaIntegrationTestRunner({ }) }) + describe("batch methods", () => { + it("successfully creates, updates, and deletes products", async () => { + await breaking( + () => {}, + async () => { + const createPayload = getProductFixture({ + title: "Test batch create", + handle: "test-batch-create", + }) + + const updatePayload = { + id: publishedProduct.id, + title: "Test batch update", + } + + const response = await api.post( + "/admin/products/op/batch", + { + create: [createPayload], + update: [updatePayload], + delete: [baseProduct.id], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.created).toHaveLength(1) + expect(response.data.updated).toHaveLength(1) + expect(response.data.deleted.ids).toHaveLength(1) + + expect(response.data.created).toEqual([ + expect.objectContaining({ + title: "Test batch create", + }), + ]) + + expect(response.data.updated).toEqual([ + expect.objectContaining({ + title: "Test batch update", + }), + ]) + + expect(response.data.deleted).toEqual( + expect.objectContaining({ ids: [baseProduct.id] }) + ) + + const dbData = (await api.get("/admin/products", adminHeaders)) + .data.products + + expect(dbData).toHaveLength(3) + expect(dbData).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "Test batch create", + }), + expect.objectContaining({ + title: "Test batch update", + }), + ]) + ) + } + ) + }) + + it("successfully creates, updates, and deletes product variants", async () => { + await breaking( + () => {}, + async () => { + const productWithMultipleVariants = getProductFixture({ + title: "Test batch variants", + handle: "test-batch-variants", + variants: [ + { + title: "Variant 1", + inventory_quantity: 5, + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + }, + { + title: "Variant 2", + inventory_quantity: 20, + prices: [ + { + currency_code: "usd", + amount: 200, + }, + ], + }, + ], + }) + + const createdProduct = ( + await api.post( + "/admin/products", + productWithMultipleVariants, + adminHeaders + ) + ).data.product + + const createPayload = { + title: "Test batch create variant", + inventory_quantity: 10, + prices: [ + { + currency_code: "usd", + amount: 20, + }, + { + currency_code: "dkk", + amount: 10, + }, + ], + } + + const updatePayload = { + id: createdProduct.variants[0].id, + title: "Test batch update variant", + } + + const response = await api.post( + `/admin/products/${createdProduct.id}/variants/op/batch`, + { + create: [createPayload], + update: [updatePayload], + delete: [createdProduct.variants[1].id], + }, + adminHeaders + ) + + const dbData = ( + await api.get( + `/admin/products/${createdProduct.id}`, + adminHeaders + ) + ).data.product.variants + + expect(response.status).toEqual(200) + expect(dbData).toHaveLength(2) + expect(dbData).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "Test batch create variant", + }), + expect.objectContaining({ + title: "Test batch update variant", + }), + ]) + ) + } + ) + }) + }) + // TODO: Discuss how this should be handled describe.skip("GET /admin/products/tag-usage", () => { it("successfully gets the tags usage", async () => { diff --git a/packages/core-flows/src/pricing/steps/update-price-sets.ts b/packages/core-flows/src/pricing/steps/update-price-sets.ts index 43b30f9357..3520f4643b 100644 --- a/packages/core-flows/src/pricing/steps/update-price-sets.ts +++ b/packages/core-flows/src/pricing/steps/update-price-sets.ts @@ -1,27 +1,48 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { IPricingModuleService, PricingTypes } from "@medusajs/types" import { - convertItemResponseToUpdateRequest, + MedusaError, getSelectsAndRelationsFromObjectArray, } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" -type UpdatePriceSetsStepInput = { - selector?: PricingTypes.FilterablePriceSetProps - update?: PricingTypes.UpdatePriceSetDTO -} +type UpdatePriceSetsStepInput = + | { + selector?: PricingTypes.FilterablePriceSetProps + update?: PricingTypes.UpdatePriceSetDTO + } + | { + price_sets: PricingTypes.UpsertPriceSetDTO[] + } + export const updatePriceSetsStepId = "update-price-sets" export const updatePriceSetsStep = createStep( updatePriceSetsStepId, async (data: UpdatePriceSetsStepInput, { container }) => { - if (!data.selector || !data.update) { - return new StepResponse([], null) - } - const pricingModule = container.resolve( ModuleRegistrationName.PRICING ) + if ("price_sets" in data) { + if (data.price_sets.some((p) => !p.id)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Price set id is required when doing a batch update" + ) + } + + const prevData = await pricingModule.list({ + id: data.price_sets.map((p) => p.id) as string[], + }) + + const priceSets = await pricingModule.upsert(data.price_sets) + return new StepResponse(priceSets, prevData) + } + + if (!data.selector || !data.update) { + return new StepResponse([], null) + } + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ data.update, ]) @@ -36,27 +57,17 @@ export const updatePriceSetsStep = createStep( data.update ) - return new StepResponse(updatedPriceSets, { - dataBeforeUpdate, - selects, - relations, - }) + return new StepResponse(updatedPriceSets, dataBeforeUpdate) }, async (revertInput, { container }) => { - if (!revertInput || !revertInput.dataBeforeUpdate?.length) { - return - } - - const { dataBeforeUpdate, selects, relations } = revertInput - const pricingModule = container.resolve( ModuleRegistrationName.PRICING ) - await pricingModule.upsert( - dataBeforeUpdate.map((data) => - convertItemResponseToUpdateRequest(data, selects, relations) - ) - ) + if (!revertInput) { + return + } + + await pricingModule.upsert(revertInput as PricingTypes.UpsertPriceSetDTO[]) } ) diff --git a/packages/core-flows/src/product/steps/batch-product-variants.ts b/packages/core-flows/src/product/steps/batch-product-variants.ts new file mode 100644 index 0000000000..0bf7fe6046 --- /dev/null +++ b/packages/core-flows/src/product/steps/batch-product-variants.ts @@ -0,0 +1,71 @@ +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { deleteProductVariantsWorkflow } from "../workflows/delete-product-variants" +import { createProductVariantsWorkflow } from "../workflows/create-product-variants" +import { updateProductVariantsWorkflow } from "../workflows/update-product-variants" +import { PricingTypes, ProductTypes } from "@medusajs/types" + +type BatchProductVariantsInput = { + create: (ProductTypes.CreateProductVariantDTO & { + prices?: PricingTypes.CreateMoneyAmountDTO[] + })[] + update: (ProductTypes.UpsertProductVariantDTO & { + prices?: PricingTypes.CreateMoneyAmountDTO[] + })[] + delete: string[] +} + +export const batchProductVariantsStepId = "batch-product-variants" +export const batchProductVariantsStep = createStep( + batchProductVariantsStepId, + async (data: BatchProductVariantsInput, { container }) => { + const { transaction: createTransaction, result: created } = + await createProductVariantsWorkflow(container).run({ + input: { product_variants: data.create }, + }) + const { transaction: updateTransaction, result: updated } = + await updateProductVariantsWorkflow(container).run({ + input: { product_variants: data.update }, + }) + const { transaction: deleteTransaction } = + await deleteProductVariantsWorkflow(container).run({ + input: { ids: data.delete }, + }) + + return new StepResponse( + { + created, + updated, + deleted: { + ids: data.delete, + object: "product_variant", + deleted: true, + }, + }, + { createTransaction, updateTransaction, deleteTransaction } + ) + }, + + async (flow, { container }) => { + if (!flow) { + return + } + + if (flow.createTransaction) { + await createProductVariantsWorkflow(container).cancel({ + transaction: flow.createTransaction, + }) + } + + if (flow.updateTransaction) { + await updateProductVariantsWorkflow(container).cancel({ + transaction: flow.updateTransaction, + }) + } + + if (flow.deleteTransaction) { + await deleteProductVariantsWorkflow(container).cancel({ + transaction: flow.deleteTransaction, + }) + } + } +) diff --git a/packages/core-flows/src/product/steps/batch-products.ts b/packages/core-flows/src/product/steps/batch-products.ts new file mode 100644 index 0000000000..4818c45afe --- /dev/null +++ b/packages/core-flows/src/product/steps/batch-products.ts @@ -0,0 +1,75 @@ +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { createProductsWorkflow } from "../workflows/create-products" +import { updateProductsWorkflow } from "../workflows/update-products" +import { deleteProductsWorkflow } from "../workflows/delete-products" +import { PricingTypes, ProductTypes } from "@medusajs/types" + +type WorkflowInput = { + create: (Omit & { + sales_channels?: { id: string }[] + variants?: (ProductTypes.CreateProductVariantDTO & { + prices?: PricingTypes.CreateMoneyAmountDTO[] + })[] + })[] + update: (ProductTypes.UpsertProductDTO & { + sales_channels?: { id: string }[] + })[] + delete: string[] +} + +export const batchProductsStepId = "batch-products" +export const batchProductsStep = createStep( + batchProductsStepId, + async (data: WorkflowInput, { container }) => { + const { transaction: createTransaction, result: created } = + await createProductsWorkflow(container).run({ + input: { products: data.create }, + }) + const { transaction: updateTransaction, result: updated } = + await updateProductsWorkflow(container).run({ + input: { products: data.update }, + }) + const { transaction: deleteTransaction } = await deleteProductsWorkflow( + container + ).run({ + input: { ids: data.delete }, + }) + + return new StepResponse( + { + created, + updated, + deleted: { + ids: data.delete, + object: "product", + deleted: true, + }, + }, + { createTransaction, updateTransaction, deleteTransaction } + ) + }, + + async (flow, { container }) => { + if (!flow) { + return + } + + if (flow.createTransaction) { + await createProductsWorkflow(container).cancel({ + transaction: flow.createTransaction, + }) + } + + if (flow.updateTransaction) { + await updateProductsWorkflow(container).cancel({ + transaction: flow.updateTransaction, + }) + } + + if (flow.deleteTransaction) { + await deleteProductsWorkflow(container).cancel({ + transaction: flow.deleteTransaction, + }) + } + } +) diff --git a/packages/core-flows/src/product/steps/index.ts b/packages/core-flows/src/product/steps/index.ts index 6054d82f45..0d32a697c3 100644 --- a/packages/core-flows/src/product/steps/index.ts +++ b/packages/core-flows/src/product/steps/index.ts @@ -2,6 +2,7 @@ export * from "./create-products" export * from "./update-products" export * from "./delete-products" export * from "./get-products" +export * from "./batch-products" export * from "./create-variant-pricing-link" export * from "./create-product-options" export * from "./update-product-options" @@ -9,6 +10,7 @@ export * from "./delete-product-options" export * from "./create-product-variants" export * from "./update-product-variants" export * from "./delete-product-variants" +export * from "./batch-product-variants" export * from "./create-collections" export * from "./update-collections" export * from "./delete-collections" diff --git a/packages/core-flows/src/product/steps/update-product-variants.ts b/packages/core-flows/src/product/steps/update-product-variants.ts index fdfab02fe6..8bb19570ca 100644 --- a/packages/core-flows/src/product/steps/update-product-variants.ts +++ b/packages/core-flows/src/product/steps/update-product-variants.ts @@ -1,12 +1,19 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { IProductModuleService, ProductTypes } from "@medusajs/types" -import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { + MedusaError, + getSelectsAndRelationsFromObjectArray, +} from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" -type UpdateProductVariantsStepInput = { - selector: ProductTypes.FilterableProductVariantProps - update: ProductTypes.UpdateProductVariantDTO -} +type UpdateProductVariantsStepInput = + | { + selector: ProductTypes.FilterableProductVariantProps + update: ProductTypes.UpdateProductVariantDTO + } + | { + product_variants: ProductTypes.UpsertProductVariantDTO[] + } export const updateProductVariantsStepId = "update-product-variants" export const updateProductVariantsStep = createStep( @@ -16,6 +23,24 @@ export const updateProductVariantsStep = createStep( ModuleRegistrationName.PRODUCT ) + if ("product_variants" in data) { + if (data.product_variants.some((p) => !p.id)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Product variant ID is required when doing a batch update of product variants" + ) + } + + const prevData = await service.listVariants({ + id: data.product_variants.map((p) => p.id) as string[], + }) + + const productVariants = await service.upsertVariants( + data.product_variants + ) + return new StepResponse(productVariants, prevData) + } + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ data.update, ]) diff --git a/packages/core-flows/src/product/steps/update-products.ts b/packages/core-flows/src/product/steps/update-products.ts index 0a669a6918..4ad57e416f 100644 --- a/packages/core-flows/src/product/steps/update-products.ts +++ b/packages/core-flows/src/product/steps/update-products.ts @@ -1,12 +1,19 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { IProductModuleService, ProductTypes } from "@medusajs/types" -import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { + MedusaError, + getSelectsAndRelationsFromObjectArray, +} from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" -type UpdateProductsStepInput = { - selector: ProductTypes.FilterableProductProps - update: ProductTypes.UpdateProductDTO -} +type UpdateProductsStepInput = + | { + selector: ProductTypes.FilterableProductProps + update: ProductTypes.UpdateProductDTO + } + | { + products: ProductTypes.UpsertProductDTO[] + } export const updateProductsStepId = "update-products" export const updateProductsStep = createStep( @@ -16,6 +23,22 @@ export const updateProductsStep = createStep( ModuleRegistrationName.PRODUCT ) + if ("products" in data) { + if (data.products.some((p) => !p.id)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Product ID is required when doing a batch update of products" + ) + } + + const prevData = await service.list({ + id: data.products.map((p) => p.id) as string[], + }) + + const products = await service.upsert(data.products) + return new StepResponse(products, prevData) + } + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ data.update, ]) diff --git a/packages/core-flows/src/product/workflows/batch-product-variants.ts b/packages/core-flows/src/product/workflows/batch-product-variants.ts new file mode 100644 index 0000000000..2b058e1087 --- /dev/null +++ b/packages/core-flows/src/product/workflows/batch-product-variants.ts @@ -0,0 +1,33 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { batchProductVariantsStep } from "../steps/batch-product-variants" +import { PricingTypes, ProductTypes } from "@medusajs/types" + +type BatchProductVariantsInput = { + create: (ProductTypes.CreateProductVariantDTO & { + prices?: PricingTypes.CreateMoneyAmountDTO[] + })[] + update: (ProductTypes.UpsertProductVariantDTO & { + prices?: PricingTypes.CreateMoneyAmountDTO[] + })[] + delete: string[] +} + +type BatchProductVariantsOutput = { + created: ProductTypes.ProductVariantDTO[] + updated: ProductTypes.ProductVariantDTO[] + deleted: { + ids: string[] + object: string + deleted: boolean + } +} + +export const batchProductVariantsWorkflowId = "batch-product-variants" +export const batchProductVariantsWorkflow = createWorkflow( + batchProductVariantsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return batchProductVariantsStep(input) + } +) diff --git a/packages/core-flows/src/product/workflows/batch-products.ts b/packages/core-flows/src/product/workflows/batch-products.ts new file mode 100644 index 0000000000..26a09f8439 --- /dev/null +++ b/packages/core-flows/src/product/workflows/batch-products.ts @@ -0,0 +1,34 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { batchProductsStep } from "../steps/batch-products" +import { PricingTypes, ProductTypes } from "@medusajs/types" + +type WorkflowInput = { + create: (Omit & { + sales_channels?: { id: string }[] + variants?: (ProductTypes.CreateProductVariantDTO & { + prices?: PricingTypes.CreateMoneyAmountDTO[] + })[] + })[] + update: (ProductTypes.UpsertProductDTO & { + sales_channels?: { id: string }[] + })[] + delete: string[] +} + +type BatchProductsOutput = { + created: ProductTypes.ProductDTO[] + updated: ProductTypes.ProductDTO[] + deleted: { + ids: string[] + object: string + deleted: boolean + } +} + +export const batchProductsWorkflowId = "batch-products" +export const batchProductsWorkflow = createWorkflow( + batchProductsWorkflowId, + (input: WorkflowData): WorkflowData => { + return batchProductsStep(input) + } +) diff --git a/packages/core-flows/src/product/workflows/create-products.ts b/packages/core-flows/src/product/workflows/create-products.ts index f4f180fe8c..9a3e27f830 100644 --- a/packages/core-flows/src/product/workflows/create-products.ts +++ b/packages/core-flows/src/product/workflows/create-products.ts @@ -4,7 +4,8 @@ import { createWorkflow, transform, } from "@medusajs/workflows-sdk" -import { createProductsStep, createVariantPricingLinkStep } from "../steps" +import { createProductsStep } from "../steps/create-products" +import { createVariantPricingLinkStep } from "../steps/create-variant-pricing-link" import { createPriceSetsStep } from "../../pricing" import { associateProductsWithSalesChannelsStep } from "../../sales-channel" diff --git a/packages/core-flows/src/product/workflows/delete-products.ts b/packages/core-flows/src/product/workflows/delete-products.ts index 27522d103f..5c70f6dacd 100644 --- a/packages/core-flows/src/product/workflows/delete-products.ts +++ b/packages/core-flows/src/product/workflows/delete-products.ts @@ -4,7 +4,8 @@ import { transform, } from "@medusajs/workflows-sdk" import { Modules } from "@medusajs/modules-sdk" -import { deleteProductsStep, getProductsStep } from "../steps" +import { deleteProductsStep } from "../steps/delete-products" +import { getProductsStep } from "../steps/get-products" import { removeRemoteLinkStep } from "../../common" type WorkflowInput = { ids: string[] } diff --git a/packages/core-flows/src/product/workflows/index.ts b/packages/core-flows/src/product/workflows/index.ts index a2b99b94f3..231314f68d 100644 --- a/packages/core-flows/src/product/workflows/index.ts +++ b/packages/core-flows/src/product/workflows/index.ts @@ -1,12 +1,14 @@ export * from "./create-products" export * from "./delete-products" export * from "./update-products" +export * from "./batch-products" export * from "./create-product-options" export * from "./delete-product-options" export * from "./update-product-options" export * from "./create-product-variants" export * from "./delete-product-variants" export * from "./update-product-variants" +export * from "./batch-product-variants" export * from "./create-collections" export * from "./delete-collections" export * from "./update-collections" diff --git a/packages/core-flows/src/product/workflows/update-product-variants.ts b/packages/core-flows/src/product/workflows/update-product-variants.ts index b2e2bfd1d1..ae32de9389 100644 --- a/packages/core-flows/src/product/workflows/update-product-variants.ts +++ b/packages/core-flows/src/product/workflows/update-product-variants.ts @@ -8,12 +8,18 @@ import { updateProductVariantsStep } from "../steps" import { updatePriceSetsStep } from "../../pricing" import { getVariantPricingLinkStep } from "../steps/get-variant-pricing-link" -type UpdateProductVariantsStepInput = { - selector: ProductTypes.FilterableProductVariantProps - update: ProductTypes.UpdateProductVariantDTO & { - prices?: Partial[] - } -} +type UpdateProductVariantsStepInput = + | { + selector: ProductTypes.FilterableProductVariantProps + update: ProductTypes.UpdateProductVariantDTO & { + prices?: Partial[] + } + } + | { + product_variants: (ProductTypes.UpsertProductVariantDTO & { + prices?: Partial[] + })[] + } type WorkflowInput = UpdateProductVariantsStepInput @@ -25,6 +31,17 @@ export const updateProductVariantsWorkflow = createWorkflow( ): WorkflowData => { // Passing prices to the product module will fail, we want to keep them for after the variant is updated. const updateWithoutPrices = transform({ input }, (data) => { + if ("product_variants" in data.input) { + return { + product_variants: data.input.product_variants.map((variant) => { + return { + ...variant, + prices: undefined, + } + }), + } + } + return { selector: data.input.selector, update: { @@ -38,6 +55,10 @@ export const updateProductVariantsWorkflow = createWorkflow( // We don't want to do any pricing updates if the prices didn't change const variantIds = transform({ input, updatedVariants }, (data) => { + if ("product_variants" in data.input) { + return data.updatedVariants.map((v) => v.id) + } + if (!data.input.update.prices) { return [] } @@ -56,6 +77,19 @@ export const updateProductVariantsWorkflow = createWorkflow( return {} } + if ("product_variants" in data.input) { + return data.variantPriceSetLinks.map((link) => { + const variant = (data.input as any).product_variants.find( + (v) => v.id === link.variant_id + ) + + return { + id: link.price_set_id, + prices: variant.prices, + } as PricingTypes.UpsertPriceSetDTO + }) + } + return { selector: { id: data.variantPriceSetLinks.map((link) => link.price_set_id), diff --git a/packages/core-flows/src/product/workflows/update-products.ts b/packages/core-flows/src/product/workflows/update-products.ts index 7ab2266100..6e1312b632 100644 --- a/packages/core-flows/src/product/workflows/update-products.ts +++ b/packages/core-flows/src/product/workflows/update-products.ts @@ -1,11 +1,15 @@ import { ProductTypes } from "@medusajs/types" import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" -import { updateProductsStep } from "../steps" +import { updateProductsStep } from "../steps/update-products" -type UpdateProductsStepInput = { - selector: ProductTypes.FilterableProductProps - update: ProductTypes.UpdateProductDTO -} +type UpdateProductsStepInput = + | { + selector: ProductTypes.FilterableProductProps + update: ProductTypes.UpdateProductDTO + } + | { + products: ProductTypes.UpsertProductDTO[] + } type WorkflowInput = UpdateProductsStepInput diff --git a/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts index 3deb147ead..279a6ec1e4 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts @@ -64,7 +64,6 @@ export const POST = async ( req.remoteQueryConfig.fields ) res.status(200).json({ product: remapProductResponse(product) }) - Response } export const DELETE = async ( diff --git a/packages/medusa/src/api-v2/admin/products/[id]/variants/op/batch/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/variants/op/batch/route.ts new file mode 100644 index 0000000000..d690609ff4 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/products/[id]/variants/op/batch/route.ts @@ -0,0 +1,57 @@ +import { batchProductVariantsWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../../../types/routing" +import { + AdminBatchUpdateProductVariantType, + AdminCreateProductType, +} from "../../../../validators" +import { BatchMethodRequest } from "@medusajs/types" +import { refetchBatchVariants, remapVariantResponse } from "../../../../helpers" + +export const POST = async ( + req: AuthenticatedMedusaRequest< + BatchMethodRequest< + AdminCreateProductType, + AdminBatchUpdateProductVariantType + > + >, + res: MedusaResponse +) => { + const productId = req.params.id + + const normalizedInput = { + create: req.validatedBody.create?.map((c) => ({ + ...c, + product_id: productId, + })), + update: req.validatedBody.update?.map((u) => ({ + ...u, + product_id: productId, + })), + delete: req.validatedBody.delete, + // TODO: Fix types + } as any + + const { result, errors } = await batchProductVariantsWorkflow(req.scope).run({ + input: normalizedInput, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const batchResults = await refetchBatchVariants( + result, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ + created: batchResults.created.map(remapVariantResponse), + updated: batchResults.updated.map(remapVariantResponse), + deleted: batchResults.deleted, + }) +} diff --git a/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts index 887f3c8cbe..0cf89e3a49 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts @@ -4,7 +4,10 @@ import { } from "../../../../../types/routing" import { createProductVariantsWorkflow } from "@medusajs/core-flows" -import { remoteQueryObjectFromString } from "@medusajs/utils" +import { + remoteQueryObjectFromString, + ContainerRegistrationKeys, +} from "@medusajs/utils" import { refetchProduct, remapKeysForVariant, @@ -17,7 +20,7 @@ export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const remoteQuery = req.scope.resolve("remoteQuery") + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const productId = req.params.id const queryObject = remoteQueryObjectFromString({ diff --git a/packages/medusa/src/api-v2/admin/products/helpers.ts b/packages/medusa/src/api-v2/admin/products/helpers.ts index 4d3987fe54..531a647d7e 100644 --- a/packages/medusa/src/api-v2/admin/products/helpers.ts +++ b/packages/medusa/src/api-v2/admin/products/helpers.ts @@ -1,5 +1,11 @@ -import { MedusaContainer, ProductDTO, ProductVariantDTO } from "@medusajs/types" -import { remoteQueryObjectFromString } from "@medusajs/utils" +import { + BatchMethodResponse, + MedusaContainer, + ProductDTO, + ProductVariantDTO, +} from "@medusajs/types" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { promiseAll, remoteQueryObjectFromString } from "@medusajs/utils" const isPricing = (fieldName: string) => fieldName.startsWith("variants.prices") || @@ -67,7 +73,7 @@ export const refetchProduct = async ( scope: MedusaContainer, fields: string[] ) => { - const remoteQuery = scope.resolve("remoteQuery") + const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const queryObject = remoteQueryObjectFromString({ entryPoint: "product", variables: { @@ -79,3 +85,85 @@ export const refetchProduct = async ( const products = await remoteQuery(queryObject) return products[0] } + +export const refetchBatchProducts = async ( + batchResult: BatchMethodResponse, + scope: MedusaContainer, + fields: string[] +) => { + const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + let created = Promise.resolve([]) + let updated = Promise.resolve([]) + + if (batchResult.created.length) { + const createdQuery = remoteQueryObjectFromString({ + entryPoint: "product", + variables: { + filters: { id: batchResult.created.map((p) => p.id) }, + }, + fields: remapKeysForProduct(fields ?? []), + }) + + created = remoteQuery(createdQuery) + } + + if (batchResult.updated.length) { + const updatedQuery = remoteQueryObjectFromString({ + entryPoint: "product", + variables: { + filters: { id: batchResult.updated.map((p) => p.id) }, + }, + fields: remapKeysForProduct(fields ?? []), + }) + + updated = remoteQuery(updatedQuery) + } + + const [createdRes, updatedRes] = await promiseAll([created, updated]) + return { + created: createdRes, + updated: updatedRes, + deleted: batchResult.deleted, + } +} + +export const refetchBatchVariants = async ( + batchResult: BatchMethodResponse, + scope: MedusaContainer, + fields: string[] +) => { + const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + let created = Promise.resolve([]) + let updated = Promise.resolve([]) + + if (batchResult.created.length) { + const createdQuery = remoteQueryObjectFromString({ + entryPoint: "variant", + variables: { + filters: { id: batchResult.created.map((v) => v.id) }, + }, + fields: remapKeysForVariant(fields ?? []), + }) + + created = remoteQuery(createdQuery) + } + + if (batchResult.updated.length) { + const updatedQuery = remoteQueryObjectFromString({ + entryPoint: "variant", + variables: { + filters: { id: batchResult.updated.map((v) => v.id) }, + }, + fields: remapKeysForVariant(fields ?? []), + }) + + updated = remoteQuery(updatedQuery) + } + + const [createdRes, updatedRes] = await promiseAll([created, updated]) + return { + created: createdRes, + updated: updatedRes, + deleted: batchResult.deleted, + } +} diff --git a/packages/medusa/src/api-v2/admin/products/middlewares.ts b/packages/medusa/src/api-v2/admin/products/middlewares.ts index 08c4303ab4..5b1f6f3274 100644 --- a/packages/medusa/src/api-v2/admin/products/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/products/middlewares.ts @@ -1,9 +1,9 @@ -import { transformBody, transformQuery } from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter" import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" +import { createBatchBody } from "../../utils/validators" import * as QueryConfig from "./query-config" import { maybeApplyPriceListsFilter } from "./utils" import { @@ -19,6 +19,8 @@ import { AdminUpdateProductVariant, AdminGetProductOptionsParams, AdminGetProductOptionParams, + AdminBatchUpdateProduct, + AdminBatchUpdateProductVariant, } from "./validators" export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ @@ -64,6 +66,19 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/products/op/batch", + middlewares: [ + validateAndTransformBody( + createBatchBody(AdminCreateProduct, AdminBatchUpdateProduct) + ), + validateAndTransformQuery( + AdminGetProductParams, + QueryConfig.retrieveProductQueryConfig + ), + ], + }, { method: ["POST"], matcher: "/admin/products/:id", @@ -96,6 +111,22 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/products/:id/variants/op/batch", + middlewares: [ + validateAndTransformBody( + createBatchBody( + AdminCreateProductVariant, + AdminBatchUpdateProductVariant + ) + ), + validateAndTransformQuery( + AdminGetProductVariantParams, + QueryConfig.retrieveVariantConfig + ), + ], + }, // Note: New endpoint in v2 { method: ["GET"], diff --git a/packages/medusa/src/api-v2/admin/products/op/batch/route.ts b/packages/medusa/src/api-v2/admin/products/op/batch/route.ts new file mode 100644 index 0000000000..4377695ca7 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/products/op/batch/route.ts @@ -0,0 +1,43 @@ +import { batchProductsWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { + AdminBatchUpdateProductType, + AdminCreateProductType, +} from "../../validators" +import { BatchMethodRequest } from "@medusajs/types" +import { refetchBatchProducts, remapProductResponse } from "../../helpers" +import { CreateProductDTO, UpsertProductDTO } from "@medusajs/types" + +export const POST = async ( + req: AuthenticatedMedusaRequest< + BatchMethodRequest + >, + res: MedusaResponse +) => { + // TODO: Fix types + const input = req.validatedBody as any + + const { result, errors } = await batchProductsWorkflow(req.scope).run({ + input, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const batchResults = await refetchBatchProducts( + result, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ + created: batchResults.created.map(remapProductResponse), + updated: batchResults.updated.map(remapProductResponse), + deleted: batchResults.deleted, + }) +} diff --git a/packages/medusa/src/api-v2/admin/products/validators.ts b/packages/medusa/src/api-v2/admin/products/validators.ts index 51f4ebb65c..97b5ee0130 100644 --- a/packages/medusa/src/api-v2/admin/products/validators.ts +++ b/packages/medusa/src/api-v2/admin/products/validators.ts @@ -187,6 +187,13 @@ export const AdminUpdateProductVariant = AdminCreateProductVariant.extend({ manage_inventory: z.boolean().optional(), }).strict() +export type AdminBatchUpdateProductVariantType = z.infer< + typeof AdminBatchUpdateProductVariant +> +export const AdminBatchUpdateProductVariant = AdminUpdateProductVariant.extend({ + id: z.string(), +}) + export type AdminCreateProductType = z.infer export const AdminCreateProduct = z .object({ @@ -228,6 +235,13 @@ export const AdminUpdateProduct = AdminCreateProduct.omit({ is_giftcard: true }) }) .strict() +export type AdminBatchUpdateProductType = z.infer< + typeof AdminBatchUpdateProduct +> +export const AdminBatchUpdateProduct = AdminUpdateProduct.extend({ + id: z.string(), +}) + // TODO: Handle in create and update product once ready // @IsOptional() // @Type(() => ProductProductCategoryReq) diff --git a/packages/medusa/src/api-v2/utils/validators.ts b/packages/medusa/src/api-v2/utils/validators.ts index 6c7e92c882..e220322fed 100644 --- a/packages/medusa/src/api-v2/utils/validators.ts +++ b/packages/medusa/src/api-v2/utils/validators.ts @@ -1,5 +1,16 @@ import { z } from "zod" +export const createBatchBody = ( + createValidator: z.ZodType, + updateValidator: z.ZodType +) => { + return z.object({ + create: z.array(createValidator).optional(), + update: z.array(updateValidator).optional(), + delete: z.array(z.string()).optional(), + }) +} + export const createSelectParams = () => { return z.object({ fields: z.string().optional(), diff --git a/packages/types/src/common/batch.ts b/packages/types/src/common/batch.ts new file mode 100644 index 0000000000..b3718b71af --- /dev/null +++ b/packages/types/src/common/batch.ts @@ -0,0 +1,15 @@ +export type BatchMethodRequest = { + create?: TCreate[] + update?: TUpdate[] + delete?: string[] +} + +export type BatchMethodResponse = { + created: T[] + updated: T[] + deleted: { + ids: string[] + object: string + deleted: boolean + } +} diff --git a/packages/types/src/common/index.ts b/packages/types/src/common/index.ts index 9d965586f7..d3a280a643 100644 --- a/packages/types/src/common/index.ts +++ b/packages/types/src/common/index.ts @@ -1,4 +1,5 @@ export * from "./common" export * from "./rule" +export * from "./batch" export * from "./config-module" export * from "./medusa-container" diff --git a/packages/workflows-sdk/src/utils/composer/type.ts b/packages/workflows-sdk/src/utils/composer/type.ts index 824527f34a..9a07f80c2b 100644 --- a/packages/workflows-sdk/src/utils/composer/type.ts +++ b/packages/workflows-sdk/src/utils/composer/type.ts @@ -19,6 +19,8 @@ type StepFunctionReturnConfig = { ): WorkflowData } +type KeysOfUnion = T extends T ? keyof T : never + /** * A step function to be used in a workflow. * @@ -28,7 +30,7 @@ type StepFunctionReturnConfig = { export type StepFunction< TInput, TOutput = unknown -> = (keyof TInput extends never +> = (KeysOfUnion extends [] ? // Function that doesn't expect any input { (): WorkflowData & StepFunctionReturnConfig