diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index cc6f88369a..a485d35f1a 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -1221,6 +1221,7 @@ medusaIntegrationTestRunner({ }) }) + // TODO: Remove in V2, endpoint changed describe("POST /admin/product-categories/:id/products/batch", () => { beforeEach(async () => { productCategory = await simpleProductCategoryFactory(dbConnection, { @@ -1335,6 +1336,7 @@ medusaIntegrationTestRunner({ }) }) + // TODO: Remove in v2, endpoint changed describe("DELETE /admin/product-categories/:id/products/batch", () => { let testProduct1, testProduct2 @@ -1455,5 +1457,56 @@ medusaIntegrationTestRunner({ }) }) }) + + // Skipping because the test is for V2 only + describe.skip("POST /admin/product-categories/:id/products", () => { + beforeEach(async () => { + productCategory = await productModuleService.createCategory({ + name: "category parent", + description: "category parent", + parent_category_id: null, + }) + }) + + it("successfully updates a product category", async () => { + const product1Response = await api.post( + "/admin/products", + { + title: "product 1", + categories: [{ id: productCategory.id }], + }, + adminHeaders + ) + + const product2Response = await api.post( + "/admin/products", + { + title: "product 2", + }, + adminHeaders + ) + + const categoryResponse = await api.post( + `/admin/product-categories/${productCategory.id}/products`, + { + remove: [product1Response.data.product.id], + add: [product2Response.data.product.id], + }, + adminHeaders + ) + + const productsInCategoryResponse = await api.get( + `/admin/products?category_id[]=${productCategory.id}`, + adminHeaders + ) + + expect(categoryResponse.status).toEqual(200) + expect(productsInCategoryResponse.data.products).toEqual([ + expect.objectContaining({ + id: product2Response.data.product.id, + }), + ]) + }) + }) }, }) diff --git a/packages/core-flows/src/product/steps/batch-link-products-in-category.ts b/packages/core-flows/src/product/steps/batch-link-products-in-category.ts new file mode 100644 index 0000000000..dea22577cc --- /dev/null +++ b/packages/core-flows/src/product/steps/batch-link-products-in-category.ts @@ -0,0 +1,91 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IProductModuleService, ProductCategoryWorkflow } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const batchLinkProductsToCategoryStepId = + "batch-link-products-to-category" +export const batchLinkProductsToCategoryStep = createStep( + batchLinkProductsToCategoryStepId, + async ( + data: ProductCategoryWorkflow.BatchUpdateProductsOnCategoryWorkflowInput, + { container } + ) => { + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + if (!data.add?.length && !data.remove?.length) { + return new StepResponse(void 0, null) + } + + const toRemoveSet = new Set(data.remove?.map((id) => id)) + const dbProducts = await service.list( + { id: [...(data.add ?? []), ...(data.remove ?? [])] }, + { + take: null, + select: ["id", "categories"], + } + ) + + const productsWithUpdatedCategories = dbProducts.map((p) => { + if (toRemoveSet.has(p.id)) { + return { + id: p.id, + category_ids: (p.categories ?? []) + .filter((c) => c.id !== data.id) + .map((c) => c.id), + } + } + + return { + id: p.id, + category_ids: [...(p.categories ?? []).map((c) => c.id), data.id], + } + }) + + await service.upsert(productsWithUpdatedCategories) + + return new StepResponse(void 0, { + id: data.id, + remove: data.remove, + add: data.add, + productIds: productsWithUpdatedCategories.map((p) => p.id), + }) + }, + async (prevData, { container }) => { + if (!prevData) { + return + } + + const service = container.resolve( + ModuleRegistrationName.PRODUCT + ) + + const dbProducts = await service.list( + { id: prevData.productIds }, + { + take: null, + select: ["id", "categories"], + } + ) + + const toRemoveSet = new Set(prevData.remove?.map((id) => id)) + const productsWithRevertedCategories = dbProducts.map((p) => { + if (toRemoveSet.has(p.id)) { + return { + id: p.id, + category_ids: [...(p.categories ?? []).map((c) => c.id), prevData.id], + } + } + + return { + id: p.id, + category_ids: (p.categories ?? []) + .filter((c) => c.id !== prevData.id) + .map((c) => c.id), + } + }) + + await service.upsert(productsWithRevertedCategories) + } +) diff --git a/packages/core-flows/src/product/workflows/batch-products-in-category.ts b/packages/core-flows/src/product/workflows/batch-products-in-category.ts new file mode 100644 index 0000000000..e16a04f5df --- /dev/null +++ b/packages/core-flows/src/product/workflows/batch-products-in-category.ts @@ -0,0 +1,15 @@ +import { ProductCategoryWorkflow } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { batchLinkProductsToCategoryStep } from "../steps/batch-link-products-in-category" + +export const batchLinkProductsToCategoryWorkflowId = + "batch-link-products-to-category" +export const batchLinkProductsToCategoryWorkflow = createWorkflow( + batchLinkProductsToCategoryWorkflowId, + ( + // eslint-disable-next-line max-len + input: WorkflowData + ): WorkflowData => { + return batchLinkProductsToCategoryStep(input) + } +) diff --git a/packages/core-flows/src/product/workflows/index.ts b/packages/core-flows/src/product/workflows/index.ts index 72c42e9969..fbe3c61b37 100644 --- a/packages/core-flows/src/product/workflows/index.ts +++ b/packages/core-flows/src/product/workflows/index.ts @@ -1,18 +1,19 @@ -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" export * from "./batch-link-products-collection" +export * from "./batch-product-variants" +export * from "./batch-products" +export * from "./batch-products-in-category" +export * from "./create-collections" +export * from "./create-product-options" export * from "./create-product-types" +export * from "./create-product-variants" +export * from "./create-products" +export * from "./delete-collections" +export * from "./delete-product-options" export * from "./delete-product-types" +export * from "./delete-product-variants" +export * from "./delete-products" +export * from "./update-collections" +export * from "./update-product-options" export * from "./update-product-types" +export * from "./update-product-variants" +export * from "./update-products" diff --git a/packages/medusa/src/api-v2/admin/product-categories/[id]/products/route.ts b/packages/medusa/src/api-v2/admin/product-categories/[id]/products/route.ts new file mode 100644 index 0000000000..460fa664c1 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/product-categories/[id]/products/route.ts @@ -0,0 +1,35 @@ +import { batchLinkProductsToCategoryWorkflow } from "@medusajs/core-flows" +import { + AdminProductCategoryResponse, + LinkMethodRequest, +} from "@medusajs/types" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { refetchCategory } from "../../helpers" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + + const { errors } = await batchLinkProductsToCategoryWorkflow(req.scope).run({ + input: { id, ...req.validatedBody }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const category = await refetchCategory( + req.params.id, + req.scope, + req.remoteQueryConfig.fields, + req.filterableFields + ) + + res.status(200).json({ product_category: category }) +} diff --git a/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts b/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts index 5284e2e201..e62f07a9a6 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts @@ -2,6 +2,7 @@ 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 { createLinkBody } from "../../utils/validators" import * as QueryConfig from "./query-config" import { AdminCreateProductCategory, @@ -58,4 +59,15 @@ export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/product-categories/:id/products", + middlewares: [ + validateAndTransformBody(createLinkBody()), + validateAndTransformQuery( + AdminProductCategoryParams, + QueryConfig.retrieveProductCategoryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/products/validators.ts b/packages/medusa/src/api-v2/admin/products/validators.ts index 97b5ee0130..2b55d5f1c1 100644 --- a/packages/medusa/src/api-v2/admin/products/validators.ts +++ b/packages/medusa/src/api-v2/admin/products/validators.ts @@ -194,6 +194,10 @@ export const AdminBatchUpdateProductVariant = AdminUpdateProductVariant.extend({ id: z.string(), }) +export const AdminCreateProductProductCategory = z.object({ + id: z.string(), +}) + export type AdminCreateProductType = z.infer export const AdminCreateProduct = z .object({ @@ -208,6 +212,7 @@ export const AdminCreateProduct = z status: statusEnum.optional().default(ProductStatus.DRAFT), type_id: z.string().nullable().optional(), collection_id: z.string().nullable().optional(), + categories: z.array(AdminCreateProductProductCategory).optional(), tags: z.array(AdminUpdateProductTag).optional(), options: z.array(AdminCreateProductOption).optional(), variants: z.array(AdminCreateProductVariant).optional(), diff --git a/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts b/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts index d549030f90..b7b889ffac 100644 --- a/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts +++ b/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts @@ -1,7 +1,11 @@ import { Modules } from "@medusajs/modules-sdk" import { IProductModuleService, ProductTypes } from "@medusajs/types" import { Product, ProductCategory } from "@models" -import { MockEventBusService, SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils" +import { + MockEventBusService, + SuiteOptions, + moduleIntegrationTestRunner, +} from "medusa-test-utils" import { createProductCategories } from "../../../__fixtures__/product-category" import { productCategoriesRankData } from "../../../__fixtures__/product-category/data" diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index 3eade63508..f7723e938d 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -127,12 +127,15 @@ export default class ProductModuleService< // eslint-disable-next-line max-len protected readonly productCategoryService_: ProductCategoryService + // eslint-disable-next-line max-len protected readonly productTagService_: ModulesSdkTypes.InternalModuleService // eslint-disable-next-line max-len protected readonly productCollectionService_: ModulesSdkTypes.InternalModuleService // eslint-disable-next-line max-len protected readonly productImageService_: ModulesSdkTypes.InternalModuleService + // eslint-disable-next-line max-len protected readonly productTypeService_: ModulesSdkTypes.InternalModuleService + // eslint-disable-next-line max-len protected readonly productOptionService_: ModulesSdkTypes.InternalModuleService // eslint-disable-next-line max-len protected readonly productOptionValueService_: ModulesSdkTypes.InternalModuleService @@ -1369,6 +1372,15 @@ export default class ProductModuleService< }) } + if (productData.category_ids) { + ;(productData as any).categories = productData.category_ids.map( + (cid) => ({ + id: cid, + }) + ) + delete productData.category_ids + } + return productData } diff --git a/packages/product/src/services/product.ts b/packages/product/src/services/product.ts index c50cf1ed36..4532be530e 100644 --- a/packages/product/src/services/product.ts +++ b/packages/product/src/services/product.ts @@ -4,6 +4,7 @@ import { FindConfig, ProductTypes, BaseFilterable, + FilterableProductProps, } from "@medusajs/types" import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils" import { Product } from "@models" @@ -60,21 +61,22 @@ export default class ProductService< } protected static normalizeFilters( - filters: NormalizedFilterableProductProps = {} + filters: FilterableProductProps = {} ): NormalizedFilterableProductProps { - if (filters.category_id) { - if (Array.isArray(filters.category_id)) { - filters.categories = { - id: { $in: filters.category_id }, + const normalized = filters as NormalizedFilterableProductProps + if (normalized.category_id) { + if (Array.isArray(normalized.category_id)) { + normalized.categories = { + id: { $in: normalized.category_id }, } } else { - filters.categories = { - id: filters.category_id as string, + normalized.categories = { + id: normalized.category_id as string, } } - delete filters.category_id + delete normalized.category_id } - return filters + return normalized } } diff --git a/packages/types/src/product/common.ts b/packages/types/src/product/common.ts index 9dfb8680e6..5e62cd0d95 100644 --- a/packages/types/src/product/common.ts +++ b/packages/types/src/product/common.ts @@ -324,6 +324,12 @@ export interface ProductCategoryDTO { * @expandable */ category_children: ProductCategoryDTO[] + /** + * The associated products. + * + * @expandable + */ + products: ProductDTO[] /** * When the product category was created. */ @@ -702,9 +708,14 @@ export interface FilterableProductProps */ type_id?: string | string[] /** + * @deprecated - Use `categories` instead * Filter a product by the IDs of their associated categories. */ category_id?: string | string[] | OperatorMap + /** + * Filter a product by the IDs of their associated categories. + */ + categories?: { id: OperatorMap } | { id: OperatorMap } /** * Filters a product by the IDs of their associated collections. */ diff --git a/packages/types/src/workflow/index.ts b/packages/types/src/workflow/index.ts index 3bd928bcba..4c44c78b6c 100644 --- a/packages/types/src/workflow/index.ts +++ b/packages/types/src/workflow/index.ts @@ -9,4 +9,3 @@ export * as ProductCategoryWorkflow from "./product-category" export * as RegionWorkflow from "./region" export * as ReservationWorkflow from "./reservation" export * as UserWorkflow from "./user" - diff --git a/packages/types/src/workflow/product-category/index.ts b/packages/types/src/workflow/product-category/index.ts index d4bcd822ad..33e937e028 100644 --- a/packages/types/src/workflow/product-category/index.ts +++ b/packages/types/src/workflow/product-category/index.ts @@ -1,3 +1,4 @@ +import { LinkWorkflowInput } from "../../common" import { CreateProductCategoryDTO, UpdateProductCategoryDTO, @@ -14,3 +15,6 @@ export interface UpdateProductCategoryWorkflowInput { id: string data: UpdateProductCategoryDTO } + +export interface BatchUpdateProductsOnCategoryWorkflowInput + extends LinkWorkflowInput {}