From 89143e103222e7de3f6cfd28096153a6e7d16ff1 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Mon, 22 Apr 2024 10:36:22 +0200 Subject: [PATCH] feat: Add batch method to collection and clean up some batch implementations (#7102) --- .../api/__tests__/admin/product.js | 38 ++++++- .../modules/__tests__/inventory/index.spec.ts | 2 +- .../price-lists/admin/price-lists.spec.ts | 20 ++-- .../steps/batch-link-products-collection.ts | 54 +++++++++ .../core-flows/src/product/steps/index.ts | 1 + .../batch-link-products-collection.ts | 14 +++ .../core-flows/src/product/workflows/index.ts | 1 + .../admin/collections/[id]/products/route.ts | 39 +++++++ .../api-v2/admin/collections/middlewares.ts | 13 ++- .../location-levels/{op => }/batch/route.ts | 7 +- .../admin/inventory-items/middlewares.ts | 54 +++++---- .../src/api-v2/admin/payments/middlewares.ts | 2 +- .../price-lists/[id]/prices/batch/route.ts | 28 ++--- .../admin/price-lists/[id]/products/route.ts | 60 ++++++++++ .../api-v2/admin/price-lists/middlewares.ts | 19 +++- .../api-v2/admin/price-lists/validators.ts | 11 -- .../[id]/variants/{op => }/batch/route.ts | 6 +- .../admin/products/{op => }/batch/route.ts | 7 +- .../src/api-v2/admin/products/middlewares.ts | 106 +++++++++++------- .../medusa/src/api-v2/utils/validate-body.ts | 1 - .../medusa/src/api-v2/utils/validators.ts | 7 ++ .../src/services/product-module-service.ts | 46 +++++++- packages/types/src/common/batch.ts | 5 + packages/types/src/workflows/index.ts | 1 + .../types/src/workflows/products/index.ts | 1 + .../types/src/workflows/products/mutations.ts | 5 + 26 files changed, 425 insertions(+), 123 deletions(-) create mode 100644 packages/core-flows/src/product/steps/batch-link-products-collection.ts create mode 100644 packages/core-flows/src/product/workflows/batch-link-products-collection.ts create mode 100644 packages/medusa/src/api-v2/admin/collections/[id]/products/route.ts rename packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/{op => }/batch/route.ts (89%) create mode 100644 packages/medusa/src/api-v2/admin/price-lists/[id]/products/route.ts rename packages/medusa/src/api-v2/admin/products/[id]/variants/{op => }/batch/route.ts (93%) rename packages/medusa/src/api-v2/admin/products/{op => }/batch/route.ts (82%) create mode 100644 packages/types/src/workflows/products/index.ts create mode 100644 packages/types/src/workflows/products/mutations.ts diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 74d049b544..fe79f23fc3 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -2844,7 +2844,7 @@ medusaIntegrationTestRunner({ } const response = await api.post( - "/admin/products/op/batch", + "/admin/products/batch", { create: [createPayload], update: [updatePayload], @@ -2952,7 +2952,7 @@ medusaIntegrationTestRunner({ } const response = await api.post( - `/admin/products/${createdProduct.id}/variants/op/batch`, + `/admin/products/${createdProduct.id}/variants/batch`, { create: [createPayload], update: [updatePayload], @@ -2983,6 +2983,40 @@ medusaIntegrationTestRunner({ } ) }) + + it("successfully adds and removes products to a collection", async () => { + await breaking( + () => {}, + async () => { + const response = await api.post( + `/admin/collections/${baseCollection.id}/products`, + { + add: [publishedProduct.id], + remove: [baseProduct.id], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.added).toHaveLength(1) + expect(response.data.removed).toHaveLength(1) + + const collection = ( + await api.get( + `/admin/collections/${baseCollection.id}?fields=*products`, + adminHeaders + ) + ).data.collection + + expect(collection.products).toHaveLength(1) + expect(collection.products[0]).toEqual( + expect.objectContaining({ + id: publishedProduct.id, + }) + ) + } + ) + }) }) // TODO: Discuss how this should be handled diff --git a/integration-tests/modules/__tests__/inventory/index.spec.ts b/integration-tests/modules/__tests__/inventory/index.spec.ts index 9f10108765..a1928a99cb 100644 --- a/integration-tests/modules/__tests__/inventory/index.spec.ts +++ b/integration-tests/modules/__tests__/inventory/index.spec.ts @@ -334,7 +334,7 @@ medusaIntegrationTestRunner({ it("should delete an inventory location level and create a new one", async () => { const result = await api.post( - `/admin/inventory-items/${inventoryItem.id}/location-levels/op/batch`, + `/admin/inventory-items/${inventoryItem.id}/location-levels/batch`, { create: [ { diff --git a/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts b/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts index b02d8dedec..24212aa0ae 100644 --- a/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts +++ b/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts @@ -618,23 +618,21 @@ medusaIntegrationTestRunner({ { relations: ["prices"] } ) - const data = { product_id: [product.id] } + const data = { remove: [product.id] } const response = await api.post( - `admin/price-lists/${priceList.id}/prices/batch`, + `admin/price-lists/${priceList.id}/products`, data, adminHeaders ) expect(response.status).toEqual(200) - expect(response.data).toEqual({ - created: [], - updated: [], - deleted: { - ids: ["price-to-delete-1", "price-to-delete-2"], - object: "price", - deleted: true, - }, - }) + expect(response.data.price_list).toEqual( + expect.objectContaining({ + id: expect.any(String), + title: "test price list", + description: "test", + }) + ) }) }) }) diff --git a/packages/core-flows/src/product/steps/batch-link-products-collection.ts b/packages/core-flows/src/product/steps/batch-link-products-collection.ts new file mode 100644 index 0000000000..e31eaa3b2f --- /dev/null +++ b/packages/core-flows/src/product/steps/batch-link-products-collection.ts @@ -0,0 +1,54 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IProductModuleService } from "@medusajs/types" +import { BatchLinkProductsToCollectionDTO } from "@medusajs/types/src" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const batchLinkProductsToCollectionStepId = + "batch-link-products-to-collection" +export const batchLinkProductsToCollectionStep = createStep( + batchLinkProductsToCollectionStepId, + async (data: BatchLinkProductsToCollectionDTO, { container }) => { + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + if (!data.add?.length && !data.remove?.length) { + return new StepResponse(void 0, null) + } + + const dbCollection = await service.retrieveCollection(data.id, { + take: null, + select: ["id", "products.id"], + relations: ["products"], + }) + const existingProductIds = dbCollection.products?.map((p) => p.id) ?? [] + const toRemoveMap = new Map(data.remove?.map((id) => [id, true]) ?? []) + + const newProductIds = [ + ...existingProductIds.filter((id) => !toRemoveMap.has(id)), + ...(data.add ?? []), + ] + + await service.updateCollections(data.id, { + product_ids: newProductIds, + }) + + return new StepResponse(void 0, { + id: data.id, + productIds: existingProductIds, + }) + }, + async (prevData, { container }) => { + if (!prevData) { + return + } + + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + await service.updateCollections(prevData.id, { + product_ids: prevData.productIds, + }) + } +) diff --git a/packages/core-flows/src/product/steps/index.ts b/packages/core-flows/src/product/steps/index.ts index 0d32a697c3..28c889e516 100644 --- a/packages/core-flows/src/product/steps/index.ts +++ b/packages/core-flows/src/product/steps/index.ts @@ -14,6 +14,7 @@ export * from "./batch-product-variants" export * from "./create-collections" export * from "./update-collections" export * from "./delete-collections" +export * from "./batch-link-products-collection" export * from "./create-product-types" export * from "./update-product-types" export * from "./delete-product-types" diff --git a/packages/core-flows/src/product/workflows/batch-link-products-collection.ts b/packages/core-flows/src/product/workflows/batch-link-products-collection.ts new file mode 100644 index 0000000000..23c67eb1cc --- /dev/null +++ b/packages/core-flows/src/product/workflows/batch-link-products-collection.ts @@ -0,0 +1,14 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { BatchLinkProductsToCollectionDTO } from "@medusajs/types/src" +import { batchLinkProductsToCollectionStep } from "../steps/batch-link-products-collection" + +export const batchLinkProductsToCollectionWorkflowId = + "batch-link-products-to-collection" +export const batchLinkProductsToCollectionWorkflow = createWorkflow( + batchLinkProductsToCollectionWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return batchLinkProductsToCollectionStep(input) + } +) diff --git a/packages/core-flows/src/product/workflows/index.ts b/packages/core-flows/src/product/workflows/index.ts index 231314f68d..72c42e9969 100644 --- a/packages/core-flows/src/product/workflows/index.ts +++ b/packages/core-flows/src/product/workflows/index.ts @@ -12,6 +12,7 @@ export * from "./batch-product-variants" export * from "./create-collections" export * from "./delete-collections" export * from "./update-collections" +export * from "./batch-link-products-collection" export * from "./create-product-types" export * from "./delete-product-types" export * from "./update-product-types" diff --git a/packages/medusa/src/api-v2/admin/collections/[id]/products/route.ts b/packages/medusa/src/api-v2/admin/collections/[id]/products/route.ts new file mode 100644 index 0000000000..b8abee70ab --- /dev/null +++ b/packages/medusa/src/api-v2/admin/collections/[id]/products/route.ts @@ -0,0 +1,39 @@ +import { batchLinkProductsToCollectionWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { LinkMethodRequest } from "@medusajs/types/src" +import { refetchCollection } from "../../helpers" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + const { add = [], remove = [] } = req.validatedBody + + const workflow = batchLinkProductsToCollectionWorkflow(req.scope) + const { result, errors } = await workflow.run({ + input: { + id, + add, + remove, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const collection = await refetchCollection( + req.params.id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ + collection, + }) +} diff --git a/packages/medusa/src/api-v2/admin/collections/middlewares.ts b/packages/medusa/src/api-v2/admin/collections/middlewares.ts index 85d27b1fb4..a584c53d4e 100644 --- a/packages/medusa/src/api-v2/admin/collections/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/collections/middlewares.ts @@ -9,6 +9,7 @@ import { AdminUpdateCollection, } from "./validators" import { validateAndTransformBody } from "../../utils/validate-body" +import { createLinkBody } from "../../utils/validators" export const adminCollectionRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -64,5 +65,15 @@ export const adminCollectionRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/collections/:id", middlewares: [], }, - // TODO: There were two batch methods, they need to be handled + { + method: ["POST"], + matcher: "/admin/collections/:id/products", + middlewares: [ + validateAndTransformBody(createLinkBody()), + validateAndTransformQuery( + AdminGetCollectionParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/op/batch/route.ts b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/batch/route.ts similarity index 89% rename from packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/op/batch/route.ts rename to packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/batch/route.ts index 810f6132c7..5cde87d9ad 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/op/batch/route.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/batch/route.ts @@ -1,11 +1,8 @@ import { AdminCreateInventoryLocationLevelType, AdminUpdateInventoryLocationLevelType, -} from "../../../../validators" -import { - MedusaRequest, - MedusaResponse, -} from "../../../../../../../types/routing" +} from "../../../validators" +import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" import { bulkCreateDeleteLevelsWorkflow } from "@medusajs/core-flows" import { BatchMethodRequest } from "@medusajs/types" diff --git a/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts index 2c62a6662f..4448c33c39 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts @@ -14,6 +14,7 @@ import { } from "./validators" import { validateAndTransformBody } from "../../utils/validate-body" import { createBatchBody } from "../../utils/validators" +import { unlessPath } from "../../utils/unless-path" export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -84,30 +85,9 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, - { - method: ["DELETE"], - matcher: "/admin/inventory-items/:id/location-levels/:location_id", - middlewares: [ - validateAndTransformQuery( - AdminGetInventoryItemParams, - QueryConfig.retrieveTransformQueryConfig - ), - ], - }, { method: ["POST"], - matcher: "/admin/inventory-items/:id/location-levels/:location_id", - middlewares: [ - validateAndTransformBody(AdminUpdateInventoryLocationLevel), - validateAndTransformQuery( - AdminGetInventoryItemParams, - QueryConfig.retrieveTransformQueryConfig - ), - ], - }, - { - method: ["POST"], - matcher: "/admin/inventory-items/:id/location-levels/op/batch", + matcher: "/admin/inventory-items/:id/location-levels/batch", middlewares: [ validateAndTransformBody( createBatchBody( @@ -121,4 +101,34 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["DELETE"], + matcher: "/admin/inventory-items/:id/location-levels/:location_id", + middlewares: [ + unlessPath( + /.*\/location-levels\/batch/, + validateAndTransformQuery( + AdminGetInventoryItemParams, + QueryConfig.retrieveTransformQueryConfig + ) + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/inventory-items/:id/location-levels/:location_id", + middlewares: [ + unlessPath( + /.*\/location-levels\/batch/, + validateAndTransformBody(AdminUpdateInventoryLocationLevel) + ), + unlessPath( + /.*\/location-levels\/batch/, + validateAndTransformQuery( + AdminGetInventoryItemParams, + QueryConfig.retrieveTransformQueryConfig + ) + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/payments/middlewares.ts b/packages/medusa/src/api-v2/admin/payments/middlewares.ts index 3c21cb97b8..7d0bcc3f4a 100644 --- a/packages/medusa/src/api-v2/admin/payments/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/payments/middlewares.ts @@ -43,7 +43,7 @@ export const adminPaymentRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/payments/:id", middlewares: [ unlessPath( - new RegExp("/admin/payments/payment-providers"), + /.*\/payments\/payment-providers/, validateAndTransformQuery( AdminGetPaymentParams, queryConfig.retrieveTransformQueryConfig diff --git a/packages/medusa/src/api-v2/admin/price-lists/[id]/prices/batch/route.ts b/packages/medusa/src/api-v2/admin/price-lists/[id]/prices/batch/route.ts index 27037efd2c..3d9be66ddc 100644 --- a/packages/medusa/src/api-v2/admin/price-lists/[id]/prices/batch/route.ts +++ b/packages/medusa/src/api-v2/admin/price-lists/[id]/prices/batch/route.ts @@ -1,16 +1,24 @@ -import { batchPriceListPricesWorkflow } from "@medusajs/core-flows" import { promiseAll } from "@medusajs/utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../../../types/routing" -import { fetchPriceListPriceIdsForProduct } from "../../../helpers" import { listPrices } from "../../../queries" import { adminPriceListPriceRemoteQueryFields } from "../../../query-config" -import { AdminBatchPriceListPricesType } from "../../../validators" +import { BatchMethodRequest } from "@medusajs/types" +import { + AdminCreatePriceListPriceType, + AdminUpdatePriceListPriceType, +} from "../../../validators" +import { batchPriceListPricesWorkflow } from "@medusajs/core-flows" export const POST = async ( - req: AuthenticatedMedusaRequest, + req: AuthenticatedMedusaRequest< + BatchMethodRequest< + AdminCreatePriceListPriceType, + AdminUpdatePriceListPriceType + > + >, res: MedusaResponse ) => { const id = req.params.id @@ -18,16 +26,8 @@ export const POST = async ( create = [], update = [], delete: deletePriceIds = [], - product_id: productIds = [], } = req.validatedBody - const productPriceIds = await fetchPriceListPriceIdsForProduct( - id, - productIds, - req.scope - ) - - const priceIdsToDelete = [...deletePriceIds, ...productPriceIds] const workflow = batchPriceListPricesWorkflow(req.scope) const { result, errors } = await workflow.run({ input: { @@ -35,7 +35,7 @@ export const POST = async ( id, create, update, - delete: priceIdsToDelete, + delete: deletePriceIds, }, }, throwOnError: false, @@ -62,7 +62,7 @@ export const POST = async ( created, updated, deleted: { - ids: priceIdsToDelete, + ids: deletePriceIds, object: "price", deleted: true, }, diff --git a/packages/medusa/src/api-v2/admin/price-lists/[id]/products/route.ts b/packages/medusa/src/api-v2/admin/price-lists/[id]/products/route.ts new file mode 100644 index 0000000000..4543f5483a --- /dev/null +++ b/packages/medusa/src/api-v2/admin/price-lists/[id]/products/route.ts @@ -0,0 +1,60 @@ +import { MedusaError } from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { batchPriceListPricesWorkflow } from "@medusajs/core-flows" +import { LinkMethodRequest } from "@medusajs/types/src" +import { fetchPriceList, fetchPriceListPriceIdsForProduct } from "../../helpers" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + const { add, remove = [] } = req.validatedBody + if (add?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Adding products directly to a price list is not supported, please use the /admin/price-lists/:id/prices/batch endpoint instead" + ) + } + + if (!remove.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "No product ids passed to remove from price list" + ) + } + + const productPriceIds = await fetchPriceListPriceIdsForProduct( + id, + remove, + req.scope + ) + + const workflow = batchPriceListPricesWorkflow(req.scope) + const { result, errors } = await workflow.run({ + input: { + data: { + id, + create: [], + update: [], + delete: productPriceIds, + }, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const priceList = await fetchPriceList( + id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ price_list: priceList }) +} diff --git a/packages/medusa/src/api-v2/admin/price-lists/middlewares.ts b/packages/medusa/src/api-v2/admin/price-lists/middlewares.ts index 2411a6fbfb..4982c54976 100644 --- a/packages/medusa/src/api-v2/admin/price-lists/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/price-lists/middlewares.ts @@ -2,14 +2,16 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" +import { createBatchBody, createLinkBody } from "../../utils/validators" import * as QueryConfig from "./query-config" import { - AdminBatchPriceListPrices, AdminCreatePriceList, + AdminCreatePriceListPrice, AdminGetPriceListParams, AdminGetPriceListPricesParams, AdminGetPriceListsParams, AdminUpdatePriceList, + AdminUpdatePriceListPrice, } from "./validators" export const adminPriceListsRoutesMiddlewares: MiddlewareRoute[] = [ @@ -60,11 +62,24 @@ export const adminPriceListsRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/price-lists/:id/products", + middlewares: [ + validateAndTransformBody(createLinkBody()), + validateAndTransformQuery( + AdminGetPriceListParams, + QueryConfig.listPriceListQueryConfig + ), + ], + }, { method: ["POST"], matcher: "/admin/price-lists/:id/prices/batch", middlewares: [ - validateAndTransformBody(AdminBatchPriceListPrices), + validateAndTransformBody( + createBatchBody(AdminCreatePriceListPrice, AdminUpdatePriceListPrice) + ), validateAndTransformQuery( AdminGetPriceListPricesParams, QueryConfig.listPriceListPriceQueryConfig diff --git a/packages/medusa/src/api-v2/admin/price-lists/validators.ts b/packages/medusa/src/api-v2/admin/price-lists/validators.ts index 91c1f5dc0d..6b422afaa7 100644 --- a/packages/medusa/src/api-v2/admin/price-lists/validators.ts +++ b/packages/medusa/src/api-v2/admin/price-lists/validators.ts @@ -40,17 +40,6 @@ export type AdminUpdatePriceListPriceType = z.infer< typeof AdminUpdatePriceListPrice > -export const AdminBatchPriceListPrices = z.object({ - create: z.array(AdminCreatePriceListPrice).optional(), - update: z.array(AdminUpdatePriceListPrice).optional(), - delete: z.array(z.string()).optional(), - product_id: z.array(z.string()).optional(), -}) - -export type AdminBatchPriceListPricesType = z.infer< - typeof AdminBatchPriceListPrices -> - export const AdminCreatePriceList = z.object({ title: z.string(), description: z.string(), 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/batch/route.ts similarity index 93% rename from packages/medusa/src/api-v2/admin/products/[id]/variants/op/batch/route.ts rename to packages/medusa/src/api-v2/admin/products/[id]/variants/batch/route.ts index d690609ff4..2e9de2391d 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/variants/op/batch/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/variants/batch/route.ts @@ -2,13 +2,13 @@ import { batchProductVariantsWorkflow } from "@medusajs/core-flows" import { AuthenticatedMedusaRequest, MedusaResponse, -} from "../../../../../../../types/routing" +} from "../../../../../../types/routing" import { AdminBatchUpdateProductVariantType, AdminCreateProductType, -} from "../../../../validators" +} from "../../../validators" import { BatchMethodRequest } from "@medusajs/types" -import { refetchBatchVariants, remapVariantResponse } from "../../../../helpers" +import { refetchBatchVariants, remapVariantResponse } from "../../../helpers" export const POST = async ( req: AuthenticatedMedusaRequest< diff --git a/packages/medusa/src/api-v2/admin/products/op/batch/route.ts b/packages/medusa/src/api-v2/admin/products/batch/route.ts similarity index 82% rename from packages/medusa/src/api-v2/admin/products/op/batch/route.ts rename to packages/medusa/src/api-v2/admin/products/batch/route.ts index 4377695ca7..51a56ddbd7 100644 --- a/packages/medusa/src/api-v2/admin/products/op/batch/route.ts +++ b/packages/medusa/src/api-v2/admin/products/batch/route.ts @@ -2,14 +2,13 @@ import { batchProductsWorkflow } from "@medusajs/core-flows" import { AuthenticatedMedusaRequest, MedusaResponse, -} from "../../../../../types/routing" +} from "../../../../types/routing" import { AdminBatchUpdateProductType, AdminCreateProductType, -} from "../../validators" +} from "../validators" import { BatchMethodRequest } from "@medusajs/types" -import { refetchBatchProducts, remapProductResponse } from "../../helpers" -import { CreateProductDTO, UpsertProductDTO } from "@medusajs/types" +import { refetchBatchProducts, remapProductResponse } from "../helpers" export const POST = async ( req: AuthenticatedMedusaRequest< diff --git a/packages/medusa/src/api-v2/admin/products/middlewares.ts b/packages/medusa/src/api-v2/admin/products/middlewares.ts index 5b1f6f3274..5f431ba4e9 100644 --- a/packages/medusa/src/api-v2/admin/products/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/products/middlewares.ts @@ -1,6 +1,7 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter" +import { unlessPath } from "../../utils/unless-path" import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" import { createBatchBody } from "../../utils/validators" @@ -45,16 +46,6 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ maybeApplyPriceListsFilter(), ], }, - { - method: ["GET"], - matcher: "/admin/products/:id", - middlewares: [ - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig - ), - ], - }, { method: ["POST"], matcher: "/admin/products", @@ -68,7 +59,7 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ }, { method: ["POST"], - matcher: "/admin/products/op/batch", + matcher: "/admin/products/batch", middlewares: [ validateAndTransformBody( createBatchBody(AdminCreateProduct, AdminBatchUpdateProduct) @@ -79,14 +70,33 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["GET"], + matcher: "/admin/products/:id", + middlewares: [ + unlessPath( + /.*\/products\/batch/, + validateAndTransformQuery( + AdminGetProductParams, + QueryConfig.retrieveProductQueryConfig + ) + ), + ], + }, { method: ["POST"], matcher: "/admin/products/:id", middlewares: [ - validateAndTransformBody(AdminUpdateProduct), - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig + unlessPath( + /.*\/products\/batch/, + validateAndTransformBody(AdminUpdateProduct) + ), + unlessPath( + /.*\/products\/batch/, + validateAndTransformQuery( + AdminGetProductParams, + QueryConfig.retrieveProductQueryConfig + ) ), ], }, @@ -94,13 +104,15 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ method: ["DELETE"], matcher: "/admin/products/:id", middlewares: [ - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig + unlessPath( + /.*\/products\/batch/, + validateAndTransformQuery( + AdminGetProductParams, + QueryConfig.retrieveProductQueryConfig + ) ), ], }, - { method: ["GET"], matcher: "/admin/products/:id/variants", @@ -113,7 +125,18 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ }, { method: ["POST"], - matcher: "/admin/products/:id/variants/op/batch", + matcher: "/admin/products/:id/variants", + middlewares: [ + validateAndTransformBody(AdminCreateProductVariant), + validateAndTransformQuery( + AdminGetProductParams, + QueryConfig.retrieveProductQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/products/:id/variants/batch", middlewares: [ validateAndTransformBody( createBatchBody( @@ -132,20 +155,12 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ method: ["GET"], matcher: "/admin/products/:id/variants/:variant_id", middlewares: [ - validateAndTransformQuery( - AdminGetProductVariantParams, - QueryConfig.retrieveVariantConfig - ), - ], - }, - { - method: ["POST"], - matcher: "/admin/products/:id/variants", - middlewares: [ - validateAndTransformBody(AdminCreateProductVariant), - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig + unlessPath( + /.*\/variants\/batch/, + validateAndTransformQuery( + AdminGetProductVariantParams, + QueryConfig.retrieveVariantConfig + ) ), ], }, @@ -153,10 +168,16 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ method: ["POST"], matcher: "/admin/products/:id/variants/:variant_id", middlewares: [ - validateAndTransformBody(AdminUpdateProductVariant), - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig + unlessPath( + /.*\/variants\/batch/, + validateAndTransformBody(AdminUpdateProductVariant) + ), + unlessPath( + /.*\/variants\/batch/, + validateAndTransformQuery( + AdminGetProductParams, + QueryConfig.retrieveProductQueryConfig + ) ), ], }, @@ -164,9 +185,12 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ method: ["DELETE"], matcher: "/admin/products/:id/variants/:variant_id", middlewares: [ - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig + unlessPath( + /.*\/variants\/batch/, + validateAndTransformQuery( + AdminGetProductParams, + QueryConfig.retrieveProductQueryConfig + ) ), ], }, diff --git a/packages/medusa/src/api-v2/utils/validate-body.ts b/packages/medusa/src/api-v2/utils/validate-body.ts index 85ce4aa2ad..a9cff172e6 100644 --- a/packages/medusa/src/api-v2/utils/validate-body.ts +++ b/packages/medusa/src/api-v2/utils/validate-body.ts @@ -8,7 +8,6 @@ export async function zodValidator( body: T ): Promise { try { - zodSchema return await zodSchema.parseAsync(body) } catch (err) { if (err instanceof ZodError) { diff --git a/packages/medusa/src/api-v2/utils/validators.ts b/packages/medusa/src/api-v2/utils/validators.ts index 4f0725718a..25f2265ed6 100644 --- a/packages/medusa/src/api-v2/utils/validators.ts +++ b/packages/medusa/src/api-v2/utils/validators.ts @@ -11,6 +11,13 @@ export const createBatchBody = ( }) } +export const createLinkBody = () => { + return z.object({ + add: z.array(z.string()).optional(), + remove: z.array(z.string()).optional(), + }) +} + export const createSelectParams = () => { return z.object({ fields: z.string().optional(), diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index e2b70147ed..3eade63508 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -31,6 +31,7 @@ import { ModulesSdkUtils, ProductStatus, promiseAll, + removeUndefined, } from "@medusajs/utils" import { ProductCategoryEventData, @@ -771,6 +772,8 @@ export default class ProductModuleService< ProductModuleService.normalizeCreateProductCollectionInput ) + // It's safe to use upsertWithReplace here since we only have product IDs and the only operation to do is update the product + // with the collection ID return await this.productCollectionService_.upsertWithReplace( normalizedInput, { relations: ["products"] }, @@ -906,13 +909,48 @@ export default class ProductModuleService< ): Promise { const normalizedInput = data.map( ProductModuleService.normalizeUpdateProductCollectionInput - ) + ) as UpdateCollectionInput[] - return await this.productCollectionService_.upsertWithReplace( - normalizedInput, - { relations: ["products"] }, + // TODO: Maybe we can update upsertWithReplace to not remove oneToMany entities, but just disassociate them? With that we can remove the code below. + // Another alternative is to not allow passing product_ids to a collection, and instead set the collection_id through the product update call. + const updatedCollections = await this.productCollectionService_.update( + normalizedInput.map((c) => + removeUndefined({ ...c, products: undefined }) + ), sharedContext ) + + const collectionWithProducts = await promiseAll( + updatedCollections.map(async (collection, i) => { + const input = normalizedInput.find((c) => c.id === collection.id) + const productsToUpdate = (input as any)?.products + if (!productsToUpdate) { + return { ...collection, products: [] } + } + + await this.productService_.update( + { + selector: { collection_id: collection.id }, + data: { collection_id: null }, + }, + sharedContext + ) + + if (productsToUpdate.length > 0) { + await this.productService_.update( + productsToUpdate.map((p) => ({ + id: p.id, + collection_id: collection.id, + })), + sharedContext + ) + } + + return { ...collection, products: productsToUpdate } + }) + ) + + return collectionWithProducts } @InjectManager("baseRepository_") diff --git a/packages/types/src/common/batch.ts b/packages/types/src/common/batch.ts index b3718b71af..424ea5e6a3 100644 --- a/packages/types/src/common/batch.ts +++ b/packages/types/src/common/batch.ts @@ -1,3 +1,8 @@ +export type LinkMethodRequest = { + add?: string[] + remove?: string[] +} + export type BatchMethodRequest = { create?: TCreate[] update?: TUpdate[] diff --git a/packages/types/src/workflows/index.ts b/packages/types/src/workflows/index.ts index 1f3c0fe2ae..6b4dab2e48 100644 --- a/packages/types/src/workflows/index.ts +++ b/packages/types/src/workflows/index.ts @@ -1 +1,2 @@ export * from "./stock-locations" +export * from "./products" diff --git a/packages/types/src/workflows/products/index.ts b/packages/types/src/workflows/products/index.ts new file mode 100644 index 0000000000..bd086bcaef --- /dev/null +++ b/packages/types/src/workflows/products/index.ts @@ -0,0 +1 @@ +export * from "./mutations" diff --git a/packages/types/src/workflows/products/mutations.ts b/packages/types/src/workflows/products/mutations.ts new file mode 100644 index 0000000000..eef3dd4885 --- /dev/null +++ b/packages/types/src/workflows/products/mutations.ts @@ -0,0 +1,5 @@ +import { LinkMethodRequest } from "../../common" + +export interface BatchLinkProductsToCollectionDTO extends LinkMethodRequest { + id: string +}