diff --git a/.changeset/forty-tables-fetch.md b/.changeset/forty-tables-fetch.md new file mode 100644 index 0000000000000..51de28445db3b --- /dev/null +++ b/.changeset/forty-tables-fetch.md @@ -0,0 +1,8 @@ +--- +"@medusajs/medusa": minor +"@medusajs/product": minor +"@medusajs/core-flows": minor +"@medusajs/types": minor +--- + +feat(medusa,product,core-flows,types): product options redesign (server-side) diff --git a/integration-tests/.env.test b/integration-tests/.env.test index 79690313e43c7..ecf75cd5355ff 100644 --- a/integration-tests/.env.test +++ b/integration-tests/.env.test @@ -2,4 +2,4 @@ DB_HOST=localhost DB_USERNAME=postgres DB_PASSWORD='' -LOG_LEVEL=error \ No newline at end of file +LOG_LEVEL=error diff --git a/integration-tests/http/__tests__/product-option/product-option.spec.ts b/integration-tests/http/__tests__/product-option/product-option.spec.ts new file mode 100644 index 0000000000000..eae06b374de20 --- /dev/null +++ b/integration-tests/http/__tests__/product-option/product-option.spec.ts @@ -0,0 +1,274 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../helpers/create-admin-user" + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ dbConnection, getContainer, api }) => { + let option1 + let option2 + + beforeEach(async () => { + const container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + + option1 = ( + await api.post( + "/admin/product-options", + { + title: "option1", + values: ["A", "B", "C"], + }, + adminHeaders + ) + ).data.product_option + + option2 = ( + await api.post( + "/admin/product-options", + { + title: "option2", + values: ["D", "E"], + is_exclusive: true, + }, + adminHeaders + ) + ).data.product_option + }) + + describe("GET /admin/product-options", () => { + it("should return a list of product options", async () => { + const res = await api.get("/admin/product-options", adminHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "option1", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ value: "A" }), + expect.objectContaining({ value: "B" }), + expect.objectContaining({ value: "C" }), + ]), + }), + expect.objectContaining({ + title: "option2", + is_exclusive: true, + values: expect.arrayContaining([ + expect.objectContaining({ value: "D" }), + expect.objectContaining({ value: "E" }), + ]), + }), + ]) + ) + }) + + it("should return a list of product options matching free text search param", async () => { + const res = await api.get("/admin/product-options?q=1", adminHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(1) + expect(res.data.product_options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "option1" }), + ]) + ) + }) + + it("should return a list of exclusive product options", async () => { + const res = await api.get( + "/admin/product-options?is_exclusive=false", + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(1) + expect(res.data.product_options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "option1" }), + ]) + ) + }) + }) + + describe("POST /admin/product-options", () => { + it("should create a product option with value ranks", async () => { + const option = ( + await api.post( + `/admin/product-options`, + { + title: "option3", + values: ["D", "E"], + ranks: { + E: 1, + D: 2, + }, + }, + adminHeaders + ) + ).data.product_option + + expect(option).toEqual( + expect.objectContaining({ + title: "option3", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ + value: "D", + rank: 2, + }), + expect.objectContaining({ + value: "E", + rank: 1, + }), + ]), + }) + ) + }) + + it("should throw if a rank is specified for invalid value", async () => { + const error = await api + .post( + `/admin/product-options`, + { + title: "option3", + values: ["D", "E"], + ranks: { + E: 1, + invalid: 2, + }, + }, + adminHeaders + ) + .catch((err) => err) + + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual( + 'Value "invalid" is assigned a rank but is not defined in the list of values.' + ) + }) + }) + + describe("GET /admin/product-options/[id]", () => { + it("should return a product option", async () => { + const res = await api.get( + `/admin/product-options/${option1.id}`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product_option.values.length).toEqual(3) + expect(res.data.product_option).toEqual( + expect.objectContaining({ + title: "option1", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ value: "A" }), + expect.objectContaining({ value: "B" }), + expect.objectContaining({ value: "C" }), + ]), + }) + ) + }) + }) + + describe("POST /admin/product-options/[id]", () => { + it("should update a product option", async () => { + const option = ( + await api.post( + `/admin/product-options/${option2.id}`, + { + is_exclusive: false, + }, + adminHeaders + ) + ).data.product_option + + expect(option.values.length).toEqual(2) + expect(option).toEqual( + expect.objectContaining({ + title: "option2", + is_exclusive: false, + values: expect.arrayContaining([ + expect.objectContaining({ value: "D" }), + expect.objectContaining({ value: "E" }), + ]), + }) + ) + + const res = await api.get( + "/admin/product-options?is_exclusive=true", + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(0) + }) + + it("should update a product value ranks", async () => { + const option = ( + await api.post( + `/admin/product-options/${option2.id}`, + { + ranks: { + D: 2, + E: 1, + }, + }, + adminHeaders + ) + ).data.product_option + + expect(option.values.length).toEqual(2) + expect(option).toEqual( + expect.objectContaining({ + title: "option2", + is_exclusive: true, + values: expect.arrayContaining([ + expect.objectContaining({ + value: "D", + rank: 2, + }), + expect.objectContaining({ + value: "E", + rank: 1, + }), + ]), + }) + ) + }) + + it("should throw when trying to update an option that does not exist", async () => { + const error = await api.post( + `/admin/product-options/iDontExist`, + { + is_exclusive: false, + }, + adminHeaders + ).catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + message: "Product option with id \"iDontExist\" not found", + type: "not_found" + }) + }) + }) + + describe("DELETE /admin/product-options/[id]", () => { + it("should delete a product option", async () => { + await api.delete(`/admin/product-options/${option2.id}`, adminHeaders) + + const res = await api.get("/admin/product-options", adminHeaders) + + expect(res.status).toEqual(200) + expect(res.data.product_options.length).toEqual(1) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/product/admin/product-imports.spec.ts b/integration-tests/http/__tests__/product/admin/product-imports.spec.ts index ed534704352e8..fdf6a4baad51d 100644 --- a/integration-tests/http/__tests__/product/admin/product-imports.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product-imports.spec.ts @@ -4,8 +4,8 @@ import { csv2json, json2csv } from "json-2-csv" import { CommonEvents, Modules } from "@medusajs/utils" import { IEventBusModuleService, IFileModuleService } from "@medusajs/types" import { - TestEventUtils, medusaIntegrationTestRunner, + TestEventUtils, } from "@medusajs/test-utils" import { adminHeaders, diff --git a/integration-tests/http/__tests__/product/admin/product.spec.ts b/integration-tests/http/__tests__/product/admin/product.spec.ts index a283ed4af9671..66188a871e847 100644 --- a/integration-tests/http/__tests__/product/admin/product.spec.ts +++ b/integration-tests/http/__tests__/product/admin/product.spec.ts @@ -670,7 +670,6 @@ medusaIntegrationTestRunner({ expect.objectContaining({ value: "100" }), ]), id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), created_at: expect.any(String), updated_at: expect.any(String), }), @@ -712,15 +711,7 @@ medusaIntegrationTestRunner({ title: "Test Giftcard", is_giftcard: true, description: "test-giftcard-description", - options: [{ title: "size", values: ["x", "l"] }], shipping_profile_id: shippingProfile.id, - variants: [ - { - title: "Test variant", - prices: [{ currency_code: "usd", amount: 100 }], - options: { size: "x" }, - }, - ], } await api @@ -758,7 +749,6 @@ medusaIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), created_at: expect.any(String), updated_at: expect.any(String), }), @@ -1517,7 +1507,6 @@ medusaIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), title: "size", values: expect.arrayContaining([ expect.objectContaining({ value: "large" }), @@ -1527,7 +1516,6 @@ medusaIntegrationTestRunner({ }), expect.objectContaining({ id: expect.stringMatching(/^opt_*/), - product_id: expect.stringMatching(/^prod_*/), title: "color", values: expect.arrayContaining([ expect.objectContaining({ value: "green" }), @@ -1979,7 +1967,6 @@ medusaIntegrationTestRunner({ expect.objectContaining({ created_at: expect.any(String), id: expect.stringMatching(/^opt_*/), - product_id: baseProduct.id, title: "size", values: expect.arrayContaining([ expect.objectContaining({ value: "large" }), @@ -2602,39 +2589,6 @@ medusaIntegrationTestRunner({ ) }) - it("add option", async () => { - const payload = { - title: "should_add", - values: ["100"], - } - - const response = await api - .post( - `/admin/products/${baseProduct.id}/options`, - payload, - adminHeaders - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - - expect(response.data.product).toEqual( - expect.objectContaining({ - options: expect.arrayContaining([ - expect.objectContaining({ - title: "should_add", - product_id: baseProduct.id, - values: expect.arrayContaining([ - expect.objectContaining({ value: "100" }), - ]), - }), - ]), - }) - ) - }) - it("creates product with variant inventory kits", async () => { const inventoryItem1 = ( await api.post( @@ -2861,45 +2815,142 @@ medusaIntegrationTestRunner({ }) }) - describe("DELETE /admin/products/:id/options/:option_id", () => { - it("deletes a product option", async () => { - const response = await api - .delete( - `/admin/products/${baseProduct.id}/options/${baseProduct.options[0].id}`, + describe("POST /admin/products/:id/options", () => { + let colorOption + let sizeOption + + beforeEach(async () => { + colorOption = ( + await api.post( + "/admin/product-options", + { title: "Color", values: ["Red", "Blue"] }, adminHeaders ) - .catch((err) => { - console.log(err) - }) + ).data.product_option + + sizeOption = ( + await api.post( + "/admin/product-options", + { title: "Size", values: ["L", "M"] }, + adminHeaders + ) + ).data.product_option + }) + + it("should link existing options to product", async () => { + const payload = { + add: [colorOption.id, sizeOption.id], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) expect(response.status).toEqual(200) - // BREAKING: Delete response changed from returning the deleted product to the current DeleteResponse model - expect(response.data).toEqual( - expect.objectContaining({ - id: baseProduct.options[0].id, - object: "product_option", - parent: expect.objectContaining({ - id: baseProduct.id, + expect(response.data.product.options.length).toEqual(4) // 2 new ones and 2 it already had + expect(response.data.product.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: baseProduct.options[0].id, }), - }) + expect.objectContaining({ + id: baseProduct.options[1].id, + }), + expect.objectContaining({ + id: colorOption.id, + }), + expect.objectContaining({ + id: sizeOption.id, + }), + ]) ) }) - // TODO: This is failing, investigate - it.skip("deletes a values associated with deleted option", async () => { - await api.delete( - `/admin/products/${baseProduct.id}/options/${baseProduct.options[0].id}`, + it("should unlink existing options from product", async () => { + let response = await api.post( + `/admin/products/${baseProduct.id}/options`, + { + add: [colorOption.id, sizeOption.id], + }, adminHeaders ) - const optionsRes = await api.get( - `/admin/products/${baseProduct.id}/options?deleted_at[$gt]=01-26-1990`, + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(4) // 2 new ones and 2 it already had + + const payload = { + remove: [colorOption.id, sizeOption.id], + } + + response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, adminHeaders ) - expect(optionsRes.data.product_options).toEqual([ - expect.objectContaining({ deleted_at: expect.any(Date) }), - ]) + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(2) + }) + + it("should link and unlink existing options to/from product", async () => { + const payload = { + add: [colorOption.id, sizeOption.id], + remove: [baseProduct.options[0].id, baseProduct.options[1].id], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(2) + expect(response.data.product.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: colorOption.id, + }), + expect.objectContaining({ + id: sizeOption.id, + }), + ]) + ) + }) + + it("should link a new options to product", async () => { + const payload = { + add: [ + colorOption.id, + sizeOption.id, + { title: "new", values: ["A", "B"] }, + ], + remove: [baseProduct.options[0].id, baseProduct.options[1].id], + } + + const response = await api.post( + `/admin/products/${baseProduct.id}/options`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product.options.length).toEqual(3) + expect(response.data.product.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: colorOption.id, + }), + expect.objectContaining({ + id: sizeOption.id, + }), + expect.objectContaining({ + title: "new", + }), + ]) + ) }) }) diff --git a/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts b/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts index 2f00faffc3ed9..cd5960f0ff39d 100644 --- a/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts +++ b/integration-tests/modules/__tests__/order/workflows/create-fulfillment.spec.ts @@ -15,12 +15,7 @@ import { ShippingOptionDTO, StockLocationDTO, } from "@medusajs/types" -import { - BigNumber, - ContainerRegistrationKeys, - Modules, - remoteQueryObjectFromString, -} from "@medusajs/utils" +import { BigNumber, ContainerRegistrationKeys, Modules, remoteQueryObjectFromString, } from "@medusajs/utils" jest.setTimeout(500000) @@ -160,7 +155,7 @@ async function prepareDataFixtures({ container }) { }, { [Modules.PRODUCT]: { - variant_id: product.variants[0].id, + variant_id: product.variants.find((v) => v.sku === variantSkuWithInventory)!.id, }, [Modules.INVENTORY]: { inventory_item_id: inventoryItem.id, @@ -233,15 +228,19 @@ async function prepareDataFixtures({ container }) { async function createOrderFixture({ container, product, location }) { const orderService: IOrderModuleService = container.resolve(Modules.ORDER) + + const variantWithInventory = product.variants.find((v) => v.sku === variantSkuWithInventory)! + const variantWithoutInventory = product.variants.find((v) => v.sku === "test-variant-no-inventory")! + let order = await orderService.createOrders({ region_id: "test_region_id", email: "foo@bar.com", items: [ { title: "Custom Item 2", - variant_sku: product.variants[0].sku, - variant_title: product.variants[0].title, - variant_id: product.variants[0].id, + variant_sku: variantWithInventory.sku, + variant_title: variantWithInventory.title, + variant_id: variantWithInventory.id, quantity: 1, unit_price: 50, adjustments: [ @@ -256,9 +255,9 @@ async function createOrderFixture({ container, product, location }) { }, { title: product.title, - variant_sku: product.variants[1].sku, - variant_title: product.variants[1].title, - variant_id: product.variants[1].id, + variant_sku: variantWithoutInventory.sku, + variant_title: variantWithoutInventory.title, + variant_id: variantWithoutInventory.id, quantity: 1, unit_price: 200, }, diff --git a/packages/core/core-flows/src/product/steps/create-product-options.ts b/packages/core/core-flows/src/product/steps/create-product-options.ts index ac311c1b80ee4..7451e9234751a 100644 --- a/packages/core/core-flows/src/product/steps/create-product-options.ts +++ b/packages/core/core-flows/src/product/steps/create-product-options.ts @@ -3,7 +3,7 @@ import type { ProductTypes, } from "@medusajs/framework/types" import { Modules } from "@medusajs/framework/utils" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" export const createProductOptionsStepId = "create-product-options" /** @@ -12,7 +12,12 @@ export const createProductOptionsStepId = "create-product-options" * @example * const data = createProductOptionsStep([{ * title: "Size", - * values: ["S", "M", "L"] + * values: ["S", "M", "L"], + * ranks: { + * "S": 2, + * "M": 1, + * "L": 3 + * } * }]) */ export const createProductOptionsStep = createStep( diff --git a/packages/core/core-flows/src/product/steps/index.ts b/packages/core/core-flows/src/product/steps/index.ts index 85e50f5947034..4913eb5ee231b 100644 --- a/packages/core/core-flows/src/product/steps/index.ts +++ b/packages/core/core-flows/src/product/steps/index.ts @@ -32,3 +32,5 @@ export * from "./get-variant-availability" export * from "./normalize-products" export * from "./normalize-products-to-chunks" export * from "./process-import-chunks" +export * from "./link-product-options-to-product" +export * from "./process-product-options-for-import" diff --git a/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts b/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts new file mode 100644 index 0000000000000..866ccd03f7690 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/link-product-options-to-product.ts @@ -0,0 +1,77 @@ +import { IProductModuleService } from "@medusajs/framework/types" +import { Modules, promiseAll } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +/** + * The data to add/remove one or more product options to/from a product. + */ +export type LinkProductOptionsToProductStepInput = { + /** + * The product ID to add/remove the options to/from. + */ + product_id: string + /** + * The product options to add to the product. + */ + add?: string[] + /** + * The product options to remove from the product. + */ + remove?: string[] +} + +export const linkProductOptionsToProductStepId = + "link-product-options-to-product" +/** + * This step adds/removes one or more product options to/from a product. + * + * @example + * const data = linkProductOptionsToProductStep({ + * product_id: "prod_123", + * add: ["opt_123", "opt_321"] + * }) + */ +export const linkProductOptionsToProductStep = createStep( + linkProductOptionsToProductStepId, + async (input: LinkProductOptionsToProductStepInput, { container }) => { + const service = container.resolve(Modules.PRODUCT) + + const toAdd = (input.add ?? []).map((optionId) => { + return { + product_option_id: optionId, + product_id: input.product_id, + } + }) + + const toRemove = (input.remove ?? []).map((optionId) => { + return { + product_option_id: optionId, + product_id: input.product_id, + } + }) + + const promises: Promise[] = [] + if (toAdd.length) { + promises.push(service.addProductOptionToProduct(toAdd)) + } + if (toRemove.length) { + promises.push(service.removeProductOptionFromProduct(toRemove)) + } + await promiseAll(promises) + + return new StepResponse(void 0, { toAdd, toRemove }) + }, + async (prevData, { container }) => { + if (!prevData) { + return + } + const service = container.resolve(Modules.PRODUCT) + + if (prevData.toAdd.length) { + await service.removeProductOptionFromProduct(prevData.toAdd) + } + if (prevData.toRemove.length) { + await service.addProductOptionToProduct(prevData.toRemove) + } + } +) diff --git a/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts new file mode 100644 index 0000000000000..9898c13972932 --- /dev/null +++ b/packages/core/core-flows/src/product/steps/process-product-options-for-import.ts @@ -0,0 +1,98 @@ +import type { + IProductModuleService, + ProductTypes, + UpdateProductWorkflowInputDTO, +} from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { deepCopy } from "@medusajs/utils" + +export const processProductOptionsForImportStepId = + "process-product-options-for-import" + +export type ProcessProductOptionsForImportInput = { + products: (Omit & { + options?: ProductTypes.CreateProductOptionDTO[] + })[] +} + +/** + * This step processes products with options during import: + * 1. Creates product options + * 2. Transforms product.options to product.option_ids + * 3. Transforms variant options from {title: value} to {optionId: value} + */ +export const processProductOptionsForImportStep = createStep( + processProductOptionsForImportStepId, + async ( + data: ProcessProductOptionsForImportInput, + { container } + ): Promise> => { + const productService = container.resolve( + Modules.PRODUCT + ) + + const processedProducts: UpdateProductWorkflowInputDTO[] = [] + + const allOptions: ProductTypes.CreateProductOptionDTO[] = [] + const productIndices: number[] = [] // Maps option index to product index + + data.products.forEach((product, index) => { + (product.options ?? []).forEach((option) => { + allOptions.push(option) + productIndices.push(index) + }) + }) + + const createdOptions = + allOptions.length > 0 + ? await productService.createProductOptions(allOptions.map(option => ({ + ...option, + is_exclusive: true // Until we change the CSV logic to pass option id in there, we have to default to exclusive + }))) + : [] + const createdOptionIds = createdOptions.map((opt) => opt.id) + + const productOptionsMap = new Map< + number, + ProductTypes.ProductOptionDTO[] + >() + createdOptions.forEach((option, index) => { + const productIndex = productIndices[index] + if (!productOptionsMap.has(productIndex)) { + productOptionsMap.set(productIndex, []) + } + productOptionsMap.get(productIndex)!.push(option) + }) + + data.products.forEach((product, index) => { + const createdOptionsForProduct = productOptionsMap.get(index) + + if (createdOptionsForProduct && createdOptionsForProduct.length) { + // Transform product to use option_ids instead of options + const transformedProduct: any = deepCopy(product) + delete transformedProduct.options + transformedProduct.option_ids = createdOptionsForProduct.map( + (opt) => opt.id + ) + + processedProducts.push(transformedProduct) + } else { + processedProducts.push(product) + } + }) + + return new StepResponse(processedProducts, createdOptionIds) + }, + async (createdOptionIds, { container }) => { + if (!createdOptionIds || createdOptionIds.length === 0) { + return + } + + const productService = container.resolve( + Modules.PRODUCT + ) + + await productService.deleteProductOptions(createdOptionIds) + } +) diff --git a/packages/core/core-flows/src/product/steps/update-product-options.ts b/packages/core/core-flows/src/product/steps/update-product-options.ts index 31b6cc60d0cad..da927663ed7de 100644 --- a/packages/core/core-flows/src/product/steps/update-product-options.ts +++ b/packages/core/core-flows/src/product/steps/update-product-options.ts @@ -3,10 +3,11 @@ import type { ProductTypes, } from "@medusajs/framework/types" import { - Modules, getSelectsAndRelationsFromObjectArray, + MedusaError, + Modules, } from "@medusajs/framework/utils" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" /** * The data to identify and update the product options. @@ -41,15 +42,21 @@ export const updateProductOptionsStep = createStep( async (data: UpdateProductOptionsStepInput, { container }) => { const service = container.resolve(Modules.PRODUCT) - const { selects, relations } = getSelectsAndRelationsFromObjectArray([ - data.update, - ]) + const { ranks, ...cleanedUpdate } = data.update + const { selects } = getSelectsAndRelationsFromObjectArray([cleanedUpdate]) const prevData = await service.listProductOptions(data.selector, { select: selects, - relations, + relations: ["values"], }) + if (!prevData.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product option with id "${data.selector.id}" not found` + ) + } + const productOptions = await service.updateProductOptions( data.selector, data.update @@ -67,8 +74,6 @@ export const updateProductOptionsStep = createStep( prevData.map((o) => ({ ...o, values: o.values?.map((v) => v.value), - product: undefined, - product_id: o.product_id ?? undefined, })) ) } diff --git a/packages/core/core-flows/src/product/workflows/batch-products.ts b/packages/core/core-flows/src/product/workflows/batch-products.ts index fea00eb5483e5..790e91f50bf93 100644 --- a/packages/core/core-flows/src/product/workflows/batch-products.ts +++ b/packages/core/core-flows/src/product/workflows/batch-products.ts @@ -6,16 +6,17 @@ import { UpdateProductWorkflowInputDTO, } from "@medusajs/framework/types" import { - WorkflowData, - WorkflowResponse, createWorkflow, parallelize, transform, when, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { createProductsWorkflow } from "./create-products" import { deleteProductsWorkflow } from "./delete-products" import { updateProductsWorkflow } from "./update-products" +import { processProductOptionsForImportStep } from "../steps/process-product-options-for-import" /** * The products to manage. @@ -26,21 +27,6 @@ export interface BatchProductWorkflowInput UpdateProductWorkflowInputDTO > {} -const conditionallyCreateProducts = (input: BatchProductWorkflowInput) => - when({ input }, ({ input }) => !!input.create?.length).then(() => - createProductsWorkflow.runAsStep({ input: { products: input.create! } }) - ) - -const conditionallyUpdateProducts = (input: BatchProductWorkflowInput) => - when({ input }, ({ input }) => !!input.update?.length).then(() => - updateProductsWorkflow.runAsStep({ input: { products: input.update! } }) - ) - -const conditionallyDeleteProducts = (input: BatchProductWorkflowInput) => - when({ input }, ({ input }) => !!input.delete?.length).then(() => - deleteProductsWorkflow.runAsStep({ input: { ids: input.delete! } }) - ) - export const batchProductsWorkflowId = "batch-products" /** * This workflow creates, updates, or deletes products. It's used by the @@ -97,10 +83,32 @@ export const batchProductsWorkflow = createWorkflow( ( input: WorkflowData ): WorkflowResponse> => { + const productsToUpdate = transform({ input }, ({ input }) => { + return input.update ?? [] + }) + + const processedProductsToUpdate = processProductOptionsForImportStep({ + products: productsToUpdate as unknown as (Omit< + UpdateProductWorkflowInputDTO, + "option_ids" + > & { options: ProductTypes.CreateProductOptionDTO[] })[], + }) + const res = parallelize( - conditionallyCreateProducts(input), - conditionallyUpdateProducts(input), - conditionallyDeleteProducts(input) + when({ input }, ({ input }) => !!input.create?.length).then(() => + createProductsWorkflow.runAsStep({ input: { products: input.create! } }) + ), + when( + { processedProductsToUpdate }, + ({ processedProductsToUpdate }) => !!processedProductsToUpdate.length + ).then(() => + updateProductsWorkflow.runAsStep({ + input: { products: processedProductsToUpdate }, + }) + ), + when({ input }, ({ input }) => !!input.delete?.length).then(() => + deleteProductsWorkflow.runAsStep({ input: { ids: input.delete! } }) + ) ) return new WorkflowResponse( diff --git a/packages/core/core-flows/src/product/workflows/create-and-link-product-options-to-product.ts b/packages/core/core-flows/src/product/workflows/create-and-link-product-options-to-product.ts new file mode 100644 index 0000000000000..f8cb09dc0705e --- /dev/null +++ b/packages/core/core-flows/src/product/workflows/create-and-link-product-options-to-product.ts @@ -0,0 +1,98 @@ +import type { ProductTypes } from "@medusajs/framework/types" +import { + createWorkflow, + transform, + when, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + createProductOptionsStep, + linkProductOptionsToProductStep, +} from "../steps" +import { isString } from "@medusajs/framework/utils" + +/** + * The data to add/remove one or more product options to/from a product. + */ +export type LinkProductOptionsToProductWorkflowInput = { + /** + * The product ID to add/remove the options to/from. + */ + product_id: string + /** + * The product options to add to the product. + */ + add?: (string | ProductTypes.CreateProductOptionDTO)[] + /** + * The product options to remove from the product. + */ + remove?: string[] +} + +export const createAndLinkProductOptionsToProductWorkflowId = + "create-and-link-product-options-to-product" +/** + * This workflow adds/removes one or more product options to/from a product. It's used by the [TODO](TODO). + * This workflow also creates non-existing product options before adding them to the product. + * + * You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product-option and product association. + * + * @example + * const { result } = await createAndLinkProductOptionsToProductWorkflow(container) + * .run({ + * input: { + * product_id: "prod_123" + * add: [ + * { + * title: "Size", + * values: ["S", "M", "L", "XL"] + * }, + * { id: "opt_123" } + * ], + * remove: ["opt_321"] + * } + * }) + * + * @summary + * + * Add/remove one or more product options to/from a product. + */ +export const createAndLinkProductOptionsToProductWorkflow = createWorkflow( + createAndLinkProductOptionsToProductWorkflowId, + (input: WorkflowData) => { + const { toCreate, toAdd } = transform({ input }, ({ input }) => { + const toCreate: ProductTypes.CreateProductOptionDTO[] = [] + const toAdd: string[] = [] + for (const option of input.add ?? []) { + isString(option) ? toAdd.push(option) : toCreate.push(option) + } + + return { toCreate, toAdd } + }) + + const createdIds = when( + "creating-product-options", + { toCreate }, + ({ toCreate }) => toCreate.length > 0 + ).then(() => { + const createdOptions = createProductOptionsStep(toCreate) + return transform({ createdOptions }, ({ createdOptions }) => + createdOptions.map((option) => option.id) + ) + }) + + const toAddProductOptionIds = transform( + { toAdd, createdIds }, + ({ toAdd, createdIds }) => toAdd.concat(createdIds ?? []) + ) + + const productOptions = linkProductOptionsToProductStep({ + product_id: input.product_id, + add: toAddProductOptionIds, + remove: input.remove, + }) + + return new WorkflowResponse(productOptions) + } +) diff --git a/packages/core/core-flows/src/product/workflows/create-product-options.ts b/packages/core/core-flows/src/product/workflows/create-product-options.ts index 8bf1a28e93953..fe981f72ffe22 100644 --- a/packages/core/core-flows/src/product/workflows/create-product-options.ts +++ b/packages/core/core-flows/src/product/workflows/create-product-options.ts @@ -1,11 +1,11 @@ import type { AdditionalData, ProductTypes } from "@medusajs/framework/types" import { ProductOptionWorkflowEvents } from "@medusajs/framework/utils" import { - WorkflowData, - WorkflowResponse, createHook, createWorkflow, transform, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { emitEventStep } from "../../common/steps/emit-event" import { createProductOptionsStep } from "../steps" @@ -41,6 +41,12 @@ export const createProductOptionsWorkflowId = "create-product-options" * { * title: "Color", * values: ["Red", "Blue", "Green"] + * is_exclusive: true, + * ranks: { + * "Red": 2, + * "Blue": 1, + * "Green": 3 + * } * } * ], * additional_data: { diff --git a/packages/core/core-flows/src/product/workflows/index.ts b/packages/core/core-flows/src/product/workflows/index.ts index 9b2d4e17c84c9..6365eaadf0842 100644 --- a/packages/core/core-flows/src/product/workflows/index.ts +++ b/packages/core/core-flows/src/product/workflows/index.ts @@ -16,6 +16,7 @@ export * from "./delete-product-types" export * from "./delete-product-tags" export * from "./delete-product-variants" export * from "./delete-products" +export * from "./create-and-link-product-options-to-product" export * from "./update-collections" export * from "./update-product-options" export * from "./update-product-types" diff --git a/packages/core/types/src/http/product/admin/entitites.ts b/packages/core/types/src/http/product/admin/entitites.ts index 568d314ca376c..896a4cc3a4008 100644 --- a/packages/core/types/src/http/product/admin/entitites.ts +++ b/packages/core/types/src/http/product/admin/entitites.ts @@ -62,9 +62,9 @@ export interface AdminProductVariant extends BaseProductVariant { } export interface AdminProductOption extends BaseProductOption { /** - * The associated product's details. + * The associated products' details. */ - product?: AdminProduct | null + products?: AdminProduct[] | null /** * The option's values. */ diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index 1867e6dda86cf..3f55fbefec3b1 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -235,7 +235,7 @@ export interface AdminCreateProduct { /** * The product's options. */ - options: AdminCreateProductOption[] + options: (AdminCreateProductOption | { id: string })[] /** * The product's variants. */ @@ -457,9 +457,9 @@ export interface AdminUpdateProduct { id: string }[] /** - * The product's options. + * The IDs of the associated product options. */ - options?: AdminUpdateProductOption[] + option_ids?: string[] /** * The product's variants. */ @@ -524,6 +524,14 @@ export interface AdminCreateProductOption { * The option's values. */ values: string[] + /** + * The rank for each option value. + */ + ranks?: Record + /** + * Whether the option is exclusive or global. + */ + is_exclusive?: boolean } export interface AdminUpdateProductOption { @@ -535,6 +543,14 @@ export interface AdminUpdateProductOption { * The option's values. */ values?: string[] + /** + * The rank for each option value. + */ + ranks?: Record + /** + * Whether the option is exclusive or global. + */ + is_exclusive?: boolean } /** @@ -640,3 +656,14 @@ export interface AdminImportProductsRequest { */ mime_type: string } + +export interface AdminLinkProductOptions { + /** + * The list of options to link to the product. + */ + add?: (string | AdminCreateProductOption)[] + /** + * The list of options to unlink to the product. + */ + remove?: string[] +} diff --git a/packages/core/types/src/http/product/admin/queries.ts b/packages/core/types/src/http/product/admin/queries.ts index db6f8b7566b08..a64b9195176ed 100644 --- a/packages/core/types/src/http/product/admin/queries.ts +++ b/packages/core/types/src/http/product/admin/queries.ts @@ -1,6 +1,10 @@ import { BaseFilterable, OperatorMap } from "../../../dal" import { FindParams } from "../../common" -import { BaseProductListParams, BaseProductOptionParams } from "../common" +import { + BaseProductListParams, + BaseProductOptionListParams, + BaseProductOptionParams, +} from "../common" export interface AdminProductOptionParams extends Omit {} @@ -49,6 +53,7 @@ export interface AdminProductVariantParams */ deleted_at?: OperatorMap } + export interface AdminProductListParams extends Omit { /** @@ -65,11 +70,13 @@ export interface AdminProductExportParams extends Omit } } -} \ No newline at end of file +} +export interface AdminProductOptionListParams + extends Omit {} diff --git a/packages/core/types/src/http/product/common.ts b/packages/core/types/src/http/product/common.ts index d966b4c382697..7ba8c6593c20e 100644 --- a/packages/core/types/src/http/product/common.ts +++ b/packages/core/types/src/http/product/common.ts @@ -265,13 +265,13 @@ export interface BaseProductOption { */ title: string /** - * The product that the option belongs to. + * Whether the option is exclusive or global. */ - product?: BaseProduct | null + is_exclusive: boolean /** - * The ID of the product that the option belongs to. + * The products that the option is associated to. */ - product_id?: string | null + products?: BaseProduct[] | null /** * The option's values. */ @@ -334,6 +334,10 @@ export interface BaseProductOptionValue { * The option's value. */ value: string + /** + * The option's rank. + */ + rank?: number /** * The option's details. */ @@ -425,13 +429,42 @@ export interface BaseProductListParams deleted_at?: OperatorMap } +export interface BaseProductOptionListParams + extends FindParams, + BaseFilterable { + /** + * A query or keywords to search the searchable fields by. + */ + q?: string + /** + * Filter by the option's title(s). + */ + title?: string | string[] + /** + * Filter by whether the option is exclusive or global. + */ + is_exclusive?: boolean + /** + * Apply filers on the product's creation date. + */ + created_at?: OperatorMap + /** + * Apply filers on the product's update date. + */ + updated_at?: OperatorMap + /** + * Apply filers on the product's deletion date. + */ + deleted_at?: OperatorMap +} + export interface BaseProductOptionParams extends FindParams, BaseFilterable { q?: string id?: string | string[] title?: string | string[] - product_id?: string | string[] + is_exclusive?: boolean } export interface BaseProductVariantParams diff --git a/packages/core/types/src/product/common.ts b/packages/core/types/src/product/common.ts index c2e9aaa15aef4..bf733c231e3eb 100644 --- a/packages/core/types/src/product/common.ts +++ b/packages/core/types/src/product/common.ts @@ -553,15 +553,15 @@ export interface ProductOptionDTO { */ title: string /** - * The associated product. - * - * @expandable + * Whether the product option is exclusive or global. */ - product?: ProductDTO | null + is_exclusive: boolean /** - * The associated product id. + * The associated products. + * + * @expandable */ - product_id?: string | null + products?: ProductDTO[] | null /** * The associated product option values. * @@ -844,9 +844,9 @@ export interface FilterableProductOptionProps */ title?: string | string[] /** - * Filter the product options by their associated products' IDs. + * Filter the product options by exclusivity. */ - product_id?: string | string[] + is_exclusive?: boolean } /** @@ -1214,9 +1214,17 @@ export interface CreateProductOptionDTO { */ values: string[] /** - * The ID of the associated product. + * The rank for each option value. */ - product_id?: string + ranks?: Record + /** + * Whether the product option is exclusive or global. + */ + is_exclusive?: boolean + /** + * The metadata of the product option. + */ + metadata?: MetadataType } export interface CreateProductOptionValueDTO { @@ -1224,6 +1232,10 @@ export interface CreateProductOptionValueDTO { * The value of the product option value. */ value: string + /** + * The rank of the product option value. + */ + rank?: number /** * The metadata of the product option value. */ @@ -1254,9 +1266,17 @@ export interface UpdateProductOptionDTO { */ values?: string[] /** - * The ID of the associated product. + * The rank for each option value. */ - product_id?: string + ranks?: Record + /** + * Whether the product option is exclusive or global. + */ + is_exclusive?: boolean + /** + * The metadata of the product option. + */ + metadata?: MetadataType } export interface UpdateProductOptionValueDTO { @@ -1534,9 +1554,9 @@ export interface CreateProductDTO { */ category_ids?: string[] /** - * The product options to be created and associated with the product. + * The product options to be created and/or associated with the product. */ - options?: CreateProductOptionDTO[] + options?: (CreateProductOptionDTO | { id: string })[] /** * The product variants to be created and associated with the product. */ @@ -1654,9 +1674,9 @@ export interface UpdateProductDTO { */ category_ids?: string[] /** - * The associated options to create or update. + * The product options to associate with the product. */ - options?: UpsertProductOptionDTO[] + option_ids?: string[] /** * The product variants to be created and associated with the product. * You can also update existing product variants associated with the product. @@ -1699,3 +1719,20 @@ export interface UpdateProductDTO { */ metadata?: MetadataType } + +/** + * @interface + * + * The details of a product option and product pair. + */ +export type ProductOptionProductPair = { + /** + * The product option's ID. + */ + product_option_id: string + + /** + * The product's ID. + */ + product_id: string +} diff --git a/packages/core/types/src/product/service.ts b/packages/core/types/src/product/service.ts index bcd88eeebad9f..16d3a64fc7288 100644 --- a/packages/core/types/src/product/service.ts +++ b/packages/core/types/src/product/service.ts @@ -20,6 +20,7 @@ import { ProductCollectionDTO, ProductDTO, ProductOptionDTO, + ProductOptionProductPair, ProductOptionValueDTO, ProductTagDTO, ProductTypeDTO, @@ -1433,6 +1434,96 @@ export interface IProductModuleService extends IModuleService { sharedContext?: Context ): Promise | void> + /** + * This method adds a product option to a product. + * + * @param {ProductOptionProductPair} productOptionProductPair - The details of the product option and the product it should be added to. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise<{ id: string; }>} The ID of the relation between the product option and the product. + * + * @example + * const productOptionProductId = + * await productModuleService.addProductOptionToProduct({ + * product_id: "prod_123", + * product_option_id: "opt_123", + * }) + */ + addProductOptionToProduct( + productOptionProductPair: ProductOptionProductPair, + sharedContext?: Context + ): Promise<{ + /** + * The ID of the relation between the product option and the product. + */ + id: string + }> + + /** + * This method adds product options to a product. + * + * @param {ProductOptionProductPair[]} productOptionProductPairs - A list of items, each being the details of a product option and the product it should be added to. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise<{ id: string; }[]>} The IDs of the relations between each of the product option and product pairs. + * + * @example + * const productOptionProductIds = + * await productModuleService.addProductOptionToProduct([ + * { + * product_id: "prod_123", + * product_option_id: "opt_123", + * }, + * ]) + */ + addProductOptionToProduct( + productOptionProductPairs: ProductOptionProductPair[], + sharedContext?: Context + ): Promise< + { + /** + * The ID of the relation between the product option and the product. + */ + id: string + }[] + > + + /** + * This method removes a product option from a product. + * + * @param {ProductOptionProductPair} productOptionProductPair - The details of the product option and the product it should be removed from. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the product option is removed from the product successfully. + ** + * @example + * await productModuleService.removeProductOptionFromProduct({ + * product_id: "prod_123", + * product_option_id: "opt_123", + * }) + */ + removeProductOptionFromProduct( + productOptionProductPair: ProductOptionProductPair, + sharedContext?: Context + ): Promise + + /** + * This method removes product options from products. + * + * @param {ProductOptionProductPair[]} productOptionProductPairs - A list of items, each being the details of a product option and the product it should be removed from. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the product options are removed from the products successfully. + * + * @example + * await productModuleService.removeProductOptionFromProduct([ + * { + * product_id: "prod_123", + * product_option_id: "opt_123", + * }, + * ]) + */ + removeProductOptionFromProduct( + productOptionProductPairs: ProductOptionProductPair[], + sharedContext?: Context + ): Promise + /** * This method is used to retrieve a product option value by its ID. * diff --git a/packages/core/utils/src/product/events.ts b/packages/core/utils/src/product/events.ts index 64c1ff2a397f5..724ceff75bd81 100644 --- a/packages/core/utils/src/product/events.ts +++ b/packages/core/utils/src/product/events.ts @@ -5,6 +5,7 @@ const eventBaseNames: [ "product", "productVariant", "productOption", + "productProductOption", "productOptionValue", "productType", "productTag", @@ -15,6 +16,7 @@ const eventBaseNames: [ "product", "productVariant", "productOption", + "productProductOption", "productOptionValue", "productType", "productTag", diff --git a/packages/medusa/src/api/admin/product-categories/middlewares.ts b/packages/medusa/src/api/admin/product-categories/middlewares.ts index c8fa2397f0369..fb65b6c87547e 100644 --- a/packages/medusa/src/api/admin/product-categories/middlewares.ts +++ b/packages/medusa/src/api/admin/product-categories/middlewares.ts @@ -58,7 +58,6 @@ export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["DELETE"], matcher: "/admin/product-categories/:id", - middlewares: [], }, { method: ["POST"], diff --git a/packages/medusa/src/api/admin/product-options/[id]/route.ts b/packages/medusa/src/api/admin/product-options/[id]/route.ts new file mode 100644 index 0000000000000..cd9b2448c4553 --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/[id]/route.ts @@ -0,0 +1,71 @@ +import { + deleteProductOptionsWorkflow, + updateProductOptionsWorkflow, +} from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" + +import { + AdminGetProductOptionParamsType, + AdminUpdateProductOptionType, +} from "../validators" +import { HttpTypes } from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [product_option], + } = await query.graph({ + entity: "product_option", + filters: { id: req.params.id }, + fields: req.queryConfig.fields, + }) + + res.status(200).json({ product_option }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { result } = await updateProductOptionsWorkflow(req.scope).run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody, + }, + }) + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [product_option], + } = await query.graph({ + entity: "product_option", + filters: { id: result[0].id }, + fields: req.queryConfig.fields, + }) + + res.status(200).json({ product_option }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + + await deleteProductOptionsWorkflow(req.scope).run({ + input: { ids: [id] }, + }) + + res.status(200).json({ + id, + object: "product_option", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/admin/product-options/middlewares.ts b/packages/medusa/src/api/admin/product-options/middlewares.ts new file mode 100644 index 0000000000000..0679c2e7bcd7d --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/middlewares.ts @@ -0,0 +1,61 @@ +import * as QueryConfig from "./query-config" +import { MiddlewareRoute } from "@medusajs/framework/http" +import { + validateAndTransformBody, + validateAndTransformQuery, +} from "@medusajs/framework" +import { + AdminCreateProductOption, + AdminGetProductOptionParams, + AdminGetProductOptionsParams, + AdminUpdateProductOption, +} from "./validators" + +export const adminProductOptionRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/product-options", + middlewares: [ + validateAndTransformQuery( + AdminGetProductOptionsParams, + QueryConfig.listProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/product-options/:id", + middlewares: [ + validateAndTransformQuery( + AdminGetProductOptionParams, + QueryConfig.retrieveProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/product-options", + middlewares: [ + validateAndTransformBody(AdminCreateProductOption), + validateAndTransformQuery( + AdminGetProductOptionParams, + QueryConfig.retrieveProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/product-options/:id", + middlewares: [ + validateAndTransformBody(AdminUpdateProductOption), + validateAndTransformQuery( + AdminGetProductOptionParams, + QueryConfig.retrieveProductOptionsTransformQueryConfig + ), + ], + }, + { + method: ["DELETE"], + matcher: "/admin/product-options/:id", + }, +] diff --git a/packages/medusa/src/api/admin/product-options/query-config.ts b/packages/medusa/src/api/admin/product-options/query-config.ts new file mode 100644 index 0000000000000..d3bca4cf41765 --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/query-config.ts @@ -0,0 +1,21 @@ +export const defaultAdminProductOptionsFields = [ + "id", + "title", + "is_exclusive", + "values.*", + "products.*", + "created_at", + "updated_at", + "metadata", +] + +export const retrieveProductOptionsTransformQueryConfig = { + defaults: defaultAdminProductOptionsFields, + isList: false, +} + +export const listProductOptionsTransformQueryConfig = { + ...retrieveProductOptionsTransformQueryConfig, + defaultLimit: 20, + isList: true, +} diff --git a/packages/medusa/src/api/admin/product-options/route.ts b/packages/medusa/src/api/admin/product-options/route.ts new file mode 100644 index 0000000000000..76bc24ed609eb --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/route.ts @@ -0,0 +1,50 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" + +import { createProductOptionsWorkflow } from "@medusajs/core-flows" +import { HttpTypes } from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: product_options, metadata } = await query.graph({ + entity: "product_option", + filters: req.filterableFields, + fields: req.queryConfig.fields, + pagination: req.queryConfig.pagination, + }) + + res.json({ + product_options, + count: metadata!.count, + offset: metadata!.skip, + limit: metadata!.take, + }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const input = [req.validatedBody] + + const { result } = await createProductOptionsWorkflow(req.scope).run({ + input: { product_options: input }, + }) + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [productOption], + } = await query.graph({ + entity: "product_option", + filters: { id: result[0].id }, + fields: req.queryConfig.fields, + }) + + res.status(200).json({ product_option: productOption }) +} diff --git a/packages/medusa/src/api/admin/product-options/validators.ts b/packages/medusa/src/api/admin/product-options/validators.ts new file mode 100644 index 0000000000000..701b298591af2 --- /dev/null +++ b/packages/medusa/src/api/admin/product-options/validators.ts @@ -0,0 +1,55 @@ +import { z } from "zod" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" +import { + applyAndAndOrOperators, + booleanString, +} from "../../utils/common-validators" + +export type AdminGetProductOptionParamsType = z.infer< + typeof AdminGetProductOptionParams +> +export const AdminGetProductOptionParams = createSelectParams() + +export const AdminGetProductOptionsParamsFields = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + title: z.union([z.string(), z.array(z.string())]).optional(), + is_exclusive: booleanString().optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) + +export const AdminGetProductOptionsParams = createFindParams({ + limit: 20, + offset: 0, +}) + .merge(AdminGetProductOptionsParamsFields) + .merge(applyAndAndOrOperators(AdminGetProductOptionsParamsFields)) + +export const AdminCreateProductOption = z + .object({ + title: z.string(), + values: z.array(z.string()), + ranks: z.record(z.number()).optional(), + is_exclusive: z.boolean().optional(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() + +export type AdminUpdateProductOptionType = z.infer< + typeof AdminUpdateProductOption +> +export const AdminUpdateProductOption = z + .object({ + title: z.string().optional(), + values: z.array(z.string()).optional(), + ranks: z.record(z.number()).optional(), + is_exclusive: z.boolean().optional(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() diff --git a/packages/medusa/src/api/admin/product-tags/middlewares.ts b/packages/medusa/src/api/admin/product-tags/middlewares.ts index 4fb15e9f4eef2..67387f7ee7a55 100644 --- a/packages/medusa/src/api/admin/product-tags/middlewares.ts +++ b/packages/medusa/src/api/admin/product-tags/middlewares.ts @@ -1,9 +1,6 @@ import * as QueryConfig from "./query-config" import { MiddlewareRoute } from "@medusajs/framework/http" -import { - validateAndTransformBody, - validateAndTransformQuery, -} from "@medusajs/framework" +import { validateAndTransformBody, validateAndTransformQuery, } from "@medusajs/framework" import { AdminCreateProductTag, AdminGetProductTagParams, @@ -58,6 +55,5 @@ export const adminProductTagRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["DELETE"], matcher: "/admin/product-tags/:id", - middlewares: [], }, ] diff --git a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts b/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts deleted file mode 100644 index ea893302b304c..0000000000000 --- a/packages/medusa/src/api/admin/products/[id]/options/[option_id]/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - AuthenticatedMedusaRequest, - MedusaResponse, - refetchEntity, -} from "@medusajs/framework/http" -import { - deleteProductOptionsWorkflow, - updateProductOptionsWorkflow, -} from "@medusajs/core-flows" - -import { remapKeysForProduct, remapProductResponse } from "../../../helpers" -import { AdditionalData, HttpTypes } from "@medusajs/framework/types" - -export const GET = async ( - req: AuthenticatedMedusaRequest, - res: MedusaResponse -) => { - const productId = req.params.id - const optionId = req.params.option_id - const productOption = await refetchEntity({ - entity: "product_option", - idOrFilter: { id: optionId, product_id: productId }, - scope: req.scope, - fields: req.queryConfig.fields, - }) - - res.status(200).json({ product_option: productOption }) -} - -export const POST = async ( - req: AuthenticatedMedusaRequest< - HttpTypes.AdminUpdateProductOption & AdditionalData, - HttpTypes.SelectParams - >, - res: MedusaResponse -) => { - const productId = req.params.id - const optionId = req.params.option_id - const { additional_data, ...update } = req.validatedBody - - await updateProductOptionsWorkflow(req.scope).run({ - input: { - selector: { id: optionId, product_id: productId }, - update, - additional_data, - }, - }) - - const product = await refetchEntity({ - entity: "product", - idOrFilter: productId, - scope: req.scope, - fields: remapKeysForProduct(req.queryConfig.fields ?? []), - }) - - res.status(200).json({ product: remapProductResponse(product) }) -} - -export const DELETE = async ( - req: AuthenticatedMedusaRequest<{}, HttpTypes.SelectParams>, - res: MedusaResponse -) => { - const productId = req.params.id - const optionId = req.params.option_id - - // TODO: I believe here we cannot even enforce the product ID based on the standard API we provide? - await deleteProductOptionsWorkflow(req.scope).run({ - input: { ids: [optionId] /* product_id: productId */ }, - }) - - const product = await refetchEntity({ - entity: "product", - idOrFilter: productId, - scope: req.scope, - fields: remapKeysForProduct(req.queryConfig.fields ?? []), - }) - - res.status(200).json({ - id: optionId, - object: "product_option", - deleted: true, - parent: product, - }) -} diff --git a/packages/medusa/src/api/admin/products/[id]/options/route.ts b/packages/medusa/src/api/admin/products/[id]/options/route.ts index 102f2f4e80197..9dc70ff2d4447 100644 --- a/packages/medusa/src/api/admin/products/[id]/options/route.ts +++ b/packages/medusa/src/api/admin/products/[id]/options/route.ts @@ -4,10 +4,9 @@ import { refetchEntities, refetchEntity, } from "@medusajs/framework/http" - -import { createProductOptionsWorkflow } from "@medusajs/core-flows" +import { HttpTypes } from "@medusajs/framework/types" import { remapKeysForProduct, remapProductResponse } from "../../helpers" -import { AdditionalData, HttpTypes } from "@medusajs/framework/types" +import { createAndLinkProductOptionsToProductWorkflow } from "@medusajs/core-flows" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -16,7 +15,7 @@ export const GET = async ( const productId = req.params.id const { data: product_options, metadata } = await refetchEntities({ entity: "product_option", - idOrFilter: { ...req.filterableFields, product_id: productId }, + idOrFilter: { ...req.filterableFields, products: { id: productId } }, scope: req.scope, fields: req.queryConfig.fields, pagination: req.queryConfig.pagination, @@ -31,24 +30,15 @@ export const GET = async ( } export const POST = async ( - req: AuthenticatedMedusaRequest< - HttpTypes.AdminCreateProductOption & AdditionalData, - HttpTypes.SelectParams - >, + req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { const productId = req.params.id - const { additional_data, ...rest } = req.validatedBody - await createProductOptionsWorkflow(req.scope).run({ + await createAndLinkProductOptionsToProductWorkflow(req.scope).run({ input: { - product_options: [ - { - ...rest, - product_id: productId, - }, - ], - additional_data, + product_id: productId, + ...req.validatedBody, }, }) diff --git a/packages/medusa/src/api/admin/products/middlewares.ts b/packages/medusa/src/api/admin/products/middlewares.ts index cd85d096459d4..609cc423fdc10 100644 --- a/packages/medusa/src/api/admin/products/middlewares.ts +++ b/packages/medusa/src/api/admin/products/middlewares.ts @@ -19,18 +19,16 @@ import { AdminBatchUpdateProductVariant, AdminBatchUpdateVariantInventoryItem, AdminCreateProduct, - AdminCreateProductOption, AdminCreateProductVariant, AdminCreateVariantInventoryItem, - AdminGetProductOptionParams, AdminGetProductOptionsParams, AdminGetProductParams, AdminGetProductsParams, AdminGetProductVariantParams, AdminGetProductVariantsParams, AdminImportProducts, + AdminLinkProductOptions, AdminUpdateProduct, - AdminUpdateProductOption, AdminUpdateProductVariant, AdminUpdateVariantInventoryItem, CreateProduct, @@ -242,43 +240,11 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, - // Note: New endpoint in v2 - { - method: ["GET"], - matcher: "/admin/products/:id/options/:option_id", - middlewares: [ - validateAndTransformQuery( - AdminGetProductOptionParams, - QueryConfig.retrieveOptionConfig - ), - ], - }, { method: ["POST"], matcher: "/admin/products/:id/options", middlewares: [ - validateAndTransformBody(AdminCreateProductOption), - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig - ), - ], - }, - { - method: ["POST"], - matcher: "/admin/products/:id/options/:option_id", - middlewares: [ - validateAndTransformBody(AdminUpdateProductOption), - validateAndTransformQuery( - AdminGetProductParams, - QueryConfig.retrieveProductQueryConfig - ), - ], - }, - { - method: ["DELETE"], - matcher: "/admin/products/:id/options/:option_id", - middlewares: [ + validateAndTransformBody(AdminLinkProductOptions), validateAndTransformQuery( AdminGetProductParams, QueryConfig.retrieveProductQueryConfig diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index 4a26c2e184e5e..b72bd6d1909ff 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -13,6 +13,7 @@ import { createSelectParams, WithAdditionalData, } from "../../utils/validators" +import { AdminCreateProductOption } from "../product-options/validators" const statusEnum = z.nativeEnum(ProductStatus) @@ -100,12 +101,13 @@ export const AdminUpdateProductTag = z.object({ value: z.string().optional(), }) -export type AdminCreateProductOptionType = z.infer -export const CreateProductOption = z.object({ - title: z.string(), - values: z.array(z.string()), +export type AdminLinkProductOptionsType = z.infer< + typeof AdminLinkProductOptions +> +export const AdminLinkProductOptions = z.object({ + add: z.array(z.union([z.string(), AdminCreateProductOption])).optional(), + remove: z.array(z.string()).optional(), }) -export const AdminCreateProductOption = WithAdditionalData(CreateProductOption) export type AdminUpdateProductOptionType = z.infer export const UpdateProductOption = z.object({ @@ -237,7 +239,9 @@ export const CreateProduct = z collection_id: z.string().nullish(), categories: z.array(IdAssociation).optional(), tags: z.array(IdAssociation).optional(), - options: z.array(CreateProductOption).optional(), + options: z + .array(z.union([AdminCreateProductOption, IdAssociation])) + .optional(), variants: z.array(CreateProductVariant).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(), shipping_profile_id: z.string().optional(), @@ -261,7 +265,17 @@ export const UpdateProduct = z title: z.string().optional(), discountable: booleanString().optional(), is_giftcard: booleanString().optional(), - options: z.array(UpdateProductOption).optional(), + options: z.any().superRefine((val, ctx) => { + if (val !== undefined) { + // TODO set version and link to release notes + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "The 'options' property was removed in version X.Y.Z. Please remove it from your request payload.", + }) + } + }), + option_ids: z.array(IdAssociation).optional(), variants: z.array(UpdateProductVariant).optional(), status: statusEnum.optional(), subtitle: z.string().nullish(), diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index ec8f03f310324..010b771bf146e 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -22,6 +22,7 @@ import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middleware import { adminPricePreferencesRoutesMiddlewares } from "./admin/price-preferences/middlewares" import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares" import { adminProductTagRoutesMiddlewares } from "./admin/product-tags/middlewares" +import { adminProductOptionRoutesMiddlewares } from "./admin/product-options/middlewares" import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares" import { adminProductVariantRoutesMiddlewares } from "./admin/product-variants/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" @@ -111,6 +112,7 @@ export default defineMiddlewares([ ...adminShippingOptionTypeRoutesMiddlewares, ...adminProductTypeRoutesMiddlewares, ...adminProductTagRoutesMiddlewares, + ...adminProductOptionRoutesMiddlewares, ...adminUploadRoutesMiddlewares, ...adminFulfillmentSetsRoutesMiddlewares, ...adminNotificationRoutesMiddlewares, diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts index 9c01a2cfc2de6..d7e6093d7fecc 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/events.spec.ts @@ -76,8 +76,9 @@ moduleIntegrationTestRunner({ // 3. Product option values created (5 values total: 3 sizes + 2 colors) // 4. Product variants created (2 variants) // 5. Product images created (2 images) + // 6. Product product options created (pivot table) (2 product-productOption links) - const expectedEventsCount = 1 + 2 + 5 + 2 + 2 // 12 total events + const expectedEventsCount = 1 + 2 + 5 + 2 + 2 + 2 // 14 total events expect(emittedEvents).toHaveLength(expectedEventsCount) // Verify product created event @@ -183,6 +184,16 @@ moduleIntegrationTestRunner({ }) it("should emit cascade events when updating product with relations", async () => { + const newOption = ( + await service.createProductOptions([ + { + title: "new-size-option", + values: ["small", "large"], + }, + ]) + )[0] + eventBusSpy.mockClear() + const existingOption = existingProduct.options.find( (option: any) => option.title === "existing-option" )! as InferEntityType @@ -196,31 +207,21 @@ moduleIntegrationTestRunner({ id: existingProduct.id, title: "Updated Product", images: [{ url: "new-image-1.jpg" }, { url: "new-image-2.jpg" }], - options: [ - { - title: "new-size-option", - values: ["small", "large"], - }, - { - id: existingOption.id, - title: "updated-existing-option", - values: ["value-1"], - }, - ], + option_ids: [newOption.id, existingOption.id], variants: [ { id: existingVariant.id, title: "updated-existing-variant", options: { "new-size-option": "small", - "updated-existing-option": "value-1", + "existing-option": "value-1", }, }, { title: "New Variant", options: { "new-size-option": "large", - "updated-existing-option": "value-1", + "existing-option": "value-1", }, }, ], @@ -243,8 +244,8 @@ moduleIntegrationTestRunner({ expect(eventBusSpy).toHaveBeenCalledTimes(1) const emittedEvents = eventBusSpy.mock.calls[0][0] - // Total count should include: 1 product update + 1 option created + 2 option values created + 1 option update + 1 option deleted + 1 option value deleted + 1 variant created + 1 variant updated + 2 images created + 1 image deleted = 12 events - expect(emittedEvents).toHaveLength(12) + // Total count should include: 1 product update + 1 option linked + 1 option unlinked + 1 variant created + 1 variant updated + 2 images created + 1 image deleted = 8 events + expect(emittedEvents).toHaveLength(8) // Should emit product update event expect(emittedEvents).toEqual( @@ -258,68 +259,24 @@ moduleIntegrationTestRunner({ ]) ) - // Should emit option created event for new option + // Should emit option link event for new option expect(emittedEvents).toEqual( expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_CREATED, { + composeMessage(ProductEvents.PRODUCT_PRODUCT_OPTION_CREATED, { data: expect.objectContaining({ id: expect.any(String) }), - object: "product_option", + object: "product_product_option", source: Modules.PRODUCT, action: CommonEvents.CREATED, }), ]) ) - // Should emit option value created events for new option values - const newOptionValues = updatedProduct.options.find( - (option) => option.title === "new-size-option" - )!.values - - newOptionValues.forEach((value) => { - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_VALUE_CREATED, { - data: expect.objectContaining({ id: value.id }), - object: "product_option_value", - source: Modules.PRODUCT, - action: CommonEvents.CREATED, - }), - ]) - ) - }) - - // should emit option updated event for updated option - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_UPDATED, { - data: expect.objectContaining({ id: existingOption.id }), - object: "product_option", - source: Modules.PRODUCT, - action: CommonEvents.UPDATED, - }), - ]) - ) - - // Should emit option deleted event for deleted option + // Should emit option unlink event for new option expect(emittedEvents).toEqual( expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_DELETED, { - data: expect.objectContaining({ id: expectedDeletedOption.id }), - object: "product_option", - source: Modules.PRODUCT, - action: CommonEvents.DELETED, - }), - ]) - ) - - // Should emit option value event for deleted option value - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_VALUE_DELETED, { - data: expect.objectContaining({ - id: expectedDeletedOption.values[0].id, - }), - object: "product_option_value", + composeMessage(ProductEvents.PRODUCT_PRODUCT_OPTION_DELETED, { + data: expect.objectContaining({ id: expect.any(String) }), + object: "product_product_option", source: Modules.PRODUCT, action: CommonEvents.DELETED, }), @@ -409,8 +366,8 @@ moduleIntegrationTestRunner({ expect(eventBusSpy).toHaveBeenCalledTimes(1) const emittedEvents = eventBusSpy.mock.calls[0][0] - // Total count should include: 1 product deleted + 1 variant deleted + 1 option deleted + 2 option values deleted + 2 images deleted = 7 events - expect(emittedEvents).toHaveLength(7) + // Total count should include: 1 product deleted + 1 variant deleted + 2 images deleted = 4 events + expect(emittedEvents).toHaveLength(4) // Should emit delete events for product and all its relations expect(emittedEvents).toEqual( @@ -436,34 +393,6 @@ moduleIntegrationTestRunner({ ]) ) - // Should emit delete events for options - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_DELETED, { - data: { id: createdProduct.options[0].id }, - object: "product_option", - source: Modules.PRODUCT, - action: CommonEvents.DELETED, - }), - ]) - ) - - // Should emit delete events for option values - createdProduct.options[0].values.forEach((value) => { - expect(emittedEvents).toEqual( - expect.arrayContaining([ - composeMessage(ProductEvents.PRODUCT_OPTION_VALUE_DELETED, { - data: { - id: value.id, - }, - object: "product_option_value", - source: Modules.PRODUCT, - action: CommonEvents.DELETED, - }), - ]) - ) - }) - // Should emit delete events for images createdProduct.images.forEach((image) => { expect(emittedEvents).toEqual( diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts index 84e5923675a00..457b95762339f 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/product-options.spec.ts @@ -6,6 +6,7 @@ import { } from "@medusajs/framework/utils" import { Product, ProductOption } from "@models" import { moduleIntegrationTestRunner } from "@medusajs/test-utils" +import { ProductProductOption } from "../../../src/models" jest.setTimeout(30000) @@ -20,6 +21,8 @@ moduleIntegrationTestRunner({ beforeEach(async () => { const testManager = await MikroOrmWrapper.forkManager() + + // Create products productOne = testManager.create(toMikroORMEntity(Product), { id: "product-1", title: "product 1", @@ -34,19 +37,45 @@ moduleIntegrationTestRunner({ status: ProductStatus.PUBLISHED, }) + // Create options (without linking products yet) optionOne = testManager.create(toMikroORMEntity(ProductOption), { id: "option-1", title: "option 1", - product: productOne, }) optionTwo = testManager.create(toMikroORMEntity(ProductOption), { id: "option-2", - title: "option 1", - product: productTwo, + title: "option 2", }) - await testManager.persistAndFlush([optionOne, optionTwo]) + // Create pivot entities to link products ↔ options + const productOptionOneLink = testManager.create( + toMikroORMEntity(ProductProductOption), + { + id: "prodopt-1", + product: productOne, + product_option: optionOne, + } + ) + + const productOptionTwoLink = testManager.create( + toMikroORMEntity(ProductProductOption), + { + id: "prodopt-2", + product: productTwo, + product_option: optionTwo, + } + ) + + // Persist everything + await testManager.persistAndFlush([ + productOne, + productTwo, + optionOne, + optionTwo, + productOptionOneLink, + productOptionTwoLink, + ]) }) describe("listOptions", () => { @@ -93,8 +122,8 @@ moduleIntegrationTestRunner({ id: optionOne.id, }, { - select: ["title", "product.id"], - relations: ["product"], + select: ["title", "products.id"], + relations: ["products"], take: 1, } ) @@ -103,10 +132,11 @@ moduleIntegrationTestRunner({ { id: optionOne.id, title: optionOne.title, - product_id: productOne.id, - product: { - id: productOne.id, - }, + products: [ + { + id: productOne.id, + } + ], }, ]) }) @@ -167,8 +197,8 @@ moduleIntegrationTestRunner({ id: optionOne.id, }, { - select: ["title", "product.id"], - relations: ["product"], + select: ["title", "products.id"], + relations: ["products"], take: 1, } ) @@ -178,10 +208,11 @@ moduleIntegrationTestRunner({ { id: optionOne.id, title: optionOne.title, - product_id: productOne.id, - product: { - id: productOne.id, - }, + products: [ + { + id: productOne.id, + } + ], }, ]) }) @@ -200,19 +231,20 @@ moduleIntegrationTestRunner({ it("should return requested attributes when requested through config", async () => { const option = await service.retrieveProductOption(optionOne.id, { - select: ["id", "product.handle", "product.title"], - relations: ["product"], + select: ["id", "products.handle", "products.title"], + relations: ["products"], }) expect(option).toEqual( expect.objectContaining({ id: optionOne.id, - product: { - id: "product-1", - handle: "product-1", - title: "product 1", - }, - product_id: "product-1", + products: [ + { + id: "product-1", + handle: "product-1", + title: "product 1", + } + ], }) ) }) @@ -283,7 +315,6 @@ moduleIntegrationTestRunner({ { title: "test", values: [], - product_id: productOne.id, }, ]) @@ -292,17 +323,15 @@ moduleIntegrationTestRunner({ title: "test", }, { - select: ["id", "title", "product.id"], - relations: ["product"], + select: ["id", "title", "products.id"], + relations: ["products"], } ) expect(productOption).toEqual( expect.objectContaining({ title: "test", - product: expect.objectContaining({ - id: productOne.id, - }), + products: [] }) ) }) diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index ed486a8d3d7f9..111982fa2ee89 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -223,7 +223,6 @@ moduleIntegrationTestRunner({ }, ...data.variants, ] - productBefore.options = data.options productBefore.images = data.images productBefore.thumbnail = data.thumbnail productBefore.tag_ids = data.tag_ids @@ -232,6 +231,8 @@ moduleIntegrationTestRunner({ productBefore.length = 201 productBefore.height = 301 productBefore.width = 401 + productBefore.option_ids = [productOne.options.map((o) => o.id)] + delete productBefore.options const updatedProducts = await service.upsertProducts([productBefore]) expect(updatedProducts).toHaveLength(1) @@ -280,11 +281,11 @@ moduleIntegrationTestRunner({ options: expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), - title: productBefore.options?.[0].title, + title: productOne.options[0].title, values: expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), - value: data.options[0].values[0], + value: productOne.options[0].values[0].value, }), ]), }), @@ -493,20 +494,7 @@ moduleIntegrationTestRunner({ { id: productBefore.id, title: "updated title", - options: [ - { - title: "size", - values: ["large", "small"], - }, - { - title: "color", - values: ["red"], - }, - { - title: "material", - values: ["cotton"], - }, - ], + option_ids: productBefore.options.map((o) => o.id), }, ]) @@ -523,36 +511,32 @@ moduleIntegrationTestRunner({ ], }) - const beforeOption = productBefore.options.find( + const beforeOptionOne = productBefore.options.find( (opt) => opt.title === "size" )! - expect(product.options).toHaveLength(3) + const beforeOptionTwo = productBefore.options.find( + (opt) => opt.title === "color" + )! + expect(product.options).toHaveLength(2) expect(product.options).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: beforeOption.id, - title: beforeOption.title, - values: expect.arrayContaining([ - expect.objectContaining({ - id: beforeOption.values[0].id, - value: beforeOption.values[0].value, - }), - ]), - }), - expect.objectContaining({ - title: "color", + id: beforeOptionOne.id, + title: beforeOptionOne.title, values: expect.arrayContaining([ expect.objectContaining({ - value: "red", + id: beforeOptionOne.values[0].id, + value: beforeOptionOne.values[0].value, }), ]), }), expect.objectContaining({ - id: expect.any(String), - title: "material", + id: beforeOptionTwo.id, + title: beforeOptionTwo.title, values: expect.arrayContaining([ expect.objectContaining({ - value: "cotton", + id: beforeOptionTwo.values[0].id, + value: beforeOptionTwo.values[0].value, }), ]), }), @@ -810,9 +794,15 @@ moduleIntegrationTestRunner({ }) it("should simultaneously update options and variants", async () => { + const option = ( + await service.createProductOptions([ + { title: "material", values: ["cotton", "silk"] }, + ]) + )[0] + const updateData = { id: productTwo.id, - options: [{ title: "material", values: ["cotton", "silk"] }], + option_ids: [option.id], variants: [{ title: "variant 1", options: { material: "cotton" } }], } @@ -1162,6 +1152,111 @@ moduleIntegrationTestRunner({ await service.softDeleteProducts([products[0].id]) + const deletedProducts = await service.listProducts( + { id: products[0].id }, + { + relations: ["variants"], + withDeleted: true, + } + ) + + expect(deletedProducts).toHaveLength(1) + expect(deletedProducts[0].deleted_at).not.toBeNull() + + for (const variant of deletedProducts[0].variants) { + expect(variant.deleted_at).not.toBeNull() + } + }) + + it("should not soft delete a product's options and option values", async () => { + const data = buildProductAndRelationsData({ + images, + thumbnail: images[0].url, + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["red", "blue"] }, + { title: "material", values: ["cotton", "polyester"] }, + ], + variants: [ + { + title: "Large Red Cotton", + sku: "LRG-RED-CTN", + options: { + size: "large", + color: "red", + material: "cotton", + }, + }, + { + title: "Large Red Polyester", + sku: "LRG-RED-PLY", + options: { + size: "large", + color: "red", + material: "polyester", + }, + }, + { + title: "Large Blue Cotton", + sku: "LRG-BLU-CTN", + options: { + size: "large", + color: "blue", + material: "cotton", + }, + }, + { + title: "Large Blue Polyester", + sku: "LRG-BLU-PLY", + options: { + size: "large", + color: "blue", + material: "polyester", + }, + }, + { + title: "Small Red Cotton", + sku: "SML-RED-CTN", + options: { + size: "small", + color: "red", + material: "cotton", + }, + }, + { + title: "Small Red Polyester", + sku: "SML-RED-PLY", + options: { + size: "small", + color: "red", + material: "polyester", + }, + }, + { + title: "Small Blue Cotton", + sku: "SML-BLU-CTN", + options: { + size: "small", + color: "blue", + material: "cotton", + }, + }, + { + title: "Small Blue Polyester", + sku: "SML-BLU-PLY", + options: { + size: "small", + color: "blue", + material: "polyester", + }, + }, + ], + }) + + const products = await service.createProducts([data]) + + await service.softDeleteProducts([products[0].id]) + const deletedProducts = await service.listProducts( { id: products[0].id }, { @@ -1175,11 +1270,8 @@ moduleIntegrationTestRunner({ } ) - expect(deletedProducts).toHaveLength(1) - expect(deletedProducts[0].deleted_at).not.toBeNull() - for (const option of deletedProducts[0].options) { - expect(option.deleted_at).not.toBeNull() + expect(option.deleted_at).toBeNull() } const productOptionsValues = deletedProducts[0].options @@ -1187,11 +1279,7 @@ moduleIntegrationTestRunner({ .flat() for (const optionValue of productOptionsValues) { - expect(optionValue.deleted_at).not.toBeNull() - } - - for (const variant of deletedProducts[0].variants) { - expect(variant.deleted_at).not.toBeNull() + expect(optionValue.deleted_at).toBeNull() } const variantsOptions = deletedProducts[0].options @@ -1199,7 +1287,7 @@ moduleIntegrationTestRunner({ .flat() for (const option of variantsOptions) { - expect(option.deleted_at).not.toBeNull() + expect(option.deleted_at).toBeNull() } }) diff --git a/packages/modules/product/src/migrations/.snapshot-medusa-product.json b/packages/modules/product/src/migrations/.snapshot-medusa-product.json index 9da8bee385711..ee09c22c914c5 100644 --- a/packages/modules/product/src/migrations/.snapshot-medusa-product.json +++ b/packages/modules/product/src/migrations/.snapshot-medusa-product.json @@ -308,6 +308,244 @@ "foreignKeys": {}, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "title": { + "name": "title", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "is_exclusive": { + "name": "is_exclusive", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "product_option", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_product_option_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_deleted_at\" ON \"product_option\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "product_option_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "value": { + "name": "value", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "rank": { + "name": "rank", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "integer" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "option_id": { + "name": "option_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "product_option_value", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_product_option_value_option_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_option_id\" ON \"product_option_value\" (option_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_product_option_value_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_deleted_at\" ON \"product_option_value\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_option_value_option_id_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_option_value_option_id_unique\" ON \"product_option_value\" (option_id, value) WHERE deleted_at IS NULL" + }, + { + "keyName": "product_option_value_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "product_option_value_option_id_foreign": { + "constraintName": "product_option_value_option_id_foreign", + "columnNames": [ + "option_id" + ], + "localTableName": "public.product_option_value", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.product_option", + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { @@ -838,24 +1076,6 @@ "nullable": false, "mappedType": "text" }, - "title": { - "name": "title", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "json" - }, "product_id": { "name": "product_id", "type": "text", @@ -865,111 +1085,8 @@ "nullable": false, "mappedType": "text" }, - "created_at": { - "name": "created_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "default": "now()", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "default": "now()", - "mappedType": "datetime" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 6, - "mappedType": "datetime" - } - }, - "name": "product_option", - "schema": "public", - "indexes": [ - { - "keyName": "IDX_product_option_product_id", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_product_id\" ON \"product_option\" (product_id) WHERE deleted_at IS NULL" - }, - { - "keyName": "IDX_product_option_deleted_at", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_deleted_at\" ON \"product_option\" (deleted_at) WHERE deleted_at IS NULL" - }, - { - "keyName": "IDX_option_product_id_title_unique", - "columnNames": [], - "composite": false, - "constraint": false, - "primary": false, - "unique": false, - "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_option_product_id_title_unique\" ON \"product_option\" (product_id, title) WHERE deleted_at IS NULL" - }, - { - "keyName": "product_option_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "product_option_product_id_foreign": { - "constraintName": "product_option_product_id_foreign", - "columnNames": [ - "product_id" - ], - "localTableName": "public.product_option", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.product", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": {} - }, - { - "columns": { - "id": { - "name": "id", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "value": { - "name": "value", + "product_option_id": { + "name": "product_option_id", "type": "text", "unsigned": false, "autoincrement": false, @@ -977,24 +1094,6 @@ "nullable": false, "mappedType": "text" }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "json" - }, - "option_id": { - "name": "option_id", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "text" - }, "created_at": { "name": "created_at", "type": "timestamptz", @@ -1028,38 +1127,38 @@ "mappedType": "datetime" } }, - "name": "product_option_value", + "name": "product_product_option", "schema": "public", "indexes": [ { - "keyName": "IDX_product_option_value_option_id", + "keyName": "IDX_product_product_option_product_id", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_option_id\" ON \"product_option_value\" (option_id) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_product_id\" ON \"product_product_option\" (product_id) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_product_option_value_deleted_at", + "keyName": "IDX_product_product_option_product_option_id", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_option_value_deleted_at\" ON \"product_option_value\" (deleted_at) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_product_option_id\" ON \"product_product_option\" (product_option_id) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_option_value_option_id_unique", + "keyName": "IDX_product_product_option_deleted_at", "columnNames": [], "composite": false, "constraint": false, "primary": false, "unique": false, - "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_option_value_option_id_unique\" ON \"product_option_value\" (option_id, value) WHERE deleted_at IS NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_product_option_deleted_at\" ON \"product_product_option\" (deleted_at) WHERE deleted_at IS NULL" }, { - "keyName": "product_option_value_pkey", + "keyName": "product_product_option_pkey", "columnNames": [ "id" ], @@ -1070,21 +1169,7 @@ } ], "checks": [], - "foreignKeys": { - "product_option_value_option_id_foreign": { - "constraintName": "product_option_value_option_id_foreign", - "columnNames": [ - "option_id" - ], - "localTableName": "public.product_option_value", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.product_option", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, + "foreignKeys": {}, "nativeEnums": {} }, { diff --git a/packages/modules/product/src/migrations/Migration20251022153442.ts b/packages/modules/product/src/migrations/Migration20251022153442.ts new file mode 100644 index 0000000000000..a766c550f33ab --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20251022153442.ts @@ -0,0 +1,121 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20251022153442 extends Migration { + override async up(): Promise { + this.addSql(` + create table if not exists "product_product_option" ( + "id" text not null, + "product_id" text not null, + "product_option_id" text not null, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now(), + "deleted_at" timestamptz null, + constraint "product_product_option_pkey" primary key ("id") + ); + `) + + this.addSql(` + alter table if exists "product_product_option" + add constraint "product_product_option_product_id_foreign" + foreign key ("product_id") references "product" ("id") + on update cascade on delete cascade; + `) + + this.addSql(` + alter table if exists "product_product_option" + add constraint "product_product_option_product_option_id_foreign" + foreign key ("product_option_id") references "product_option" ("id") + on update cascade on delete cascade; + `) + + this.addSql(` + CREATE INDEX IF NOT EXISTS "IDX_product_product_option_product_id" + ON "product_product_option" (product_id) WHERE deleted_at IS NULL; + `) + + this.addSql(` + CREATE INDEX IF NOT EXISTS "IDX_product_product_option_product_option_id" + ON "product_product_option" (product_option_id) WHERE deleted_at IS NULL; + `) + + this.addSql(` + CREATE INDEX IF NOT EXISTS "IDX_product_product_option_deleted_at" + ON "product_product_option" (deleted_at) WHERE deleted_at IS NULL; + `) + + this.addSql(` + alter table if exists "product_option" + add column if not exists "is_exclusive" boolean not null default false; + `) + + this.addSql(` + insert into "product_product_option" ("id", "product_id", "product_option_id") + select gen_random_uuid(), "product_id", "id" + from "product_option" + where "product_id" is not null; + `) + + this.addSql(` + update "product_option" set "is_exclusive" = true + where "product_id" is not null; + `) + + this.addSql( + `alter table if exists "product_option" drop constraint if exists "product_option_product_id_foreign";` + ) + this.addSql(`drop index if exists "IDX_product_option_product_id";`) + this.addSql(`drop index if exists "IDX_option_product_id_title_unique";`) + this.addSql( + `alter table if exists "product_option" drop column if exists "product_id";` + ) + } + + override async down(): Promise { + // Recreate product_id column before removing the pivot + this.addSql(` + alter table if exists "product_option" + add column if not exists "product_id" text; + `) + + // Migrate data back from join table + this.addSql(` + update "product_option" po + set "product_id" = ppo."product_id" + from "product_product_option" ppo + where po."id" = ppo."product_option_id" + and ppo."deleted_at" is null; + `) + + // Make product_id NOT NULL + this.addSql(` + alter table if exists "product_option" + alter column "product_id" set not null; + `) + + // Re-add foreign key and indexes + this.addSql(` + alter table if exists "product_option" + add constraint "product_option_product_id_foreign" + foreign key ("product_id") references "product" ("id") + on update cascade on delete cascade; + `) + + this.addSql(` + CREATE INDEX IF NOT EXISTS "IDX_product_option_product_id" + ON "product_option" (product_id) WHERE deleted_at IS NULL; + `) + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_option_product_id_title_unique" + ON "product_option" (product_id, title) WHERE deleted_at IS NULL; + `) + + // Drop the join table + this.addSql(`drop table if exists "product_product_option" cascade;`) + + // Drop is_exclusive column + this.addSql(` + alter table if exists "product_option" drop column if exists "is_exclusive"; + `) + } +} diff --git a/packages/modules/product/src/migrations/Migration20251029150809.ts b/packages/modules/product/src/migrations/Migration20251029150809.ts new file mode 100644 index 0000000000000..d6e3011e24926 --- /dev/null +++ b/packages/modules/product/src/migrations/Migration20251029150809.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251029150809 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "product_option_value" add column if not exists "rank" integer null;`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "product_option_value" drop column if exists "rank";`); + } + +} diff --git a/packages/modules/product/src/models/index.ts b/packages/modules/product/src/models/index.ts index e8de0f19b0165..2bdcec17126bf 100644 --- a/packages/modules/product/src/models/index.ts +++ b/packages/modules/product/src/models/index.ts @@ -3,6 +3,7 @@ export { default as ProductCategory } from "./product-category" export { default as ProductCollection } from "./product-collection" export { default as ProductImage } from "./product-image" export { default as ProductOption } from "./product-option" +export { default as ProductProductOption } from "./product-product-option" export { default as ProductOptionValue } from "./product-option-value" export { default as ProductTag } from "./product-tag" export { default as ProductType } from "./product-type" diff --git a/packages/modules/product/src/models/product-option-value.ts b/packages/modules/product/src/models/product-option-value.ts index 21e0aa7461191..8b618cea4b3f3 100644 --- a/packages/modules/product/src/models/product-option-value.ts +++ b/packages/modules/product/src/models/product-option-value.ts @@ -5,6 +5,7 @@ const ProductOptionValue = model .define("ProductOptionValue", { id: model.id({ prefix: "optval" }).primaryKey(), value: model.text(), + rank: model.number().nullable(), metadata: model.json().nullable(), option: model .belongsTo(() => ProductOption, { diff --git a/packages/modules/product/src/models/product-option.ts b/packages/modules/product/src/models/product-option.ts index 553209193a840..1ca35090964ef 100644 --- a/packages/modules/product/src/models/product-option.ts +++ b/packages/modules/product/src/models/product-option.ts @@ -6,24 +6,16 @@ const ProductOption = model .define("ProductOption", { id: model.id({ prefix: "opt" }).primaryKey(), title: model.text().searchable(), + is_exclusive: model.boolean().default(false), metadata: model.json().nullable(), - product: model.belongsTo(() => Product, { - mappedBy: "options", - }), + products: model.manyToMany(() => Product), values: model.hasMany(() => ProductOptionValue, { mappedBy: "option", }), }) .cascades({ delete: ["values"], + detach: ["products"], }) - .indexes([ - { - name: "IDX_option_product_id_title_unique", - on: ["product_id", "title"], - unique: true, - where: "deleted_at IS NULL", - }, - ]) export default ProductOption diff --git a/packages/modules/product/src/models/product-product-option.ts b/packages/modules/product/src/models/product-product-option.ts new file mode 100644 index 0000000000000..45013b5fbffda --- /dev/null +++ b/packages/modules/product/src/models/product-product-option.ts @@ -0,0 +1,15 @@ +import { model } from "@medusajs/framework/utils" +import Product from "./product" +import ProductOption from "./product-option" + +const ProductProductOption = model.define("ProductProductOption", { + id: model.id({ prefix: "prodopt" }).primaryKey(), + product: model.belongsTo(() => Product, { + mappedBy: "options", + }), + product_option: model.belongsTo(() => ProductOption, { + mappedBy: "products", + }), +}) + +export default ProductProductOption diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index a9daced7c0260..d27754b689b13 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -7,6 +7,7 @@ import ProductOption from "./product-option" import ProductTag from "./product-tag" import ProductType from "./product-type" import ProductVariant from "./product-variant" +import ProductProductOption from "./product-product-option" const Product = model .define("Product", { @@ -43,8 +44,8 @@ const Product = model mappedBy: "products", pivotTable: "product_tags", }), - options: model.hasMany(() => ProductOption, { - mappedBy: "product", + options: model.manyToMany(() => ProductOption, { + pivotEntity: () => ProductProductOption, }), images: model.hasMany(() => ProductImage, { mappedBy: "product", @@ -60,7 +61,8 @@ const Product = model }), }) .cascades({ - delete: ["variants", "options", "images"], + delete: ["variants", "images"], + detach: ["options"], }) .indexes([ { diff --git a/packages/modules/product/src/repositories/product.ts b/packages/modules/product/src/repositories/product.ts index fff62ada8b0b2..70cbfa792cd1d 100644 --- a/packages/modules/product/src/repositories/product.ts +++ b/packages/modules/product/src/repositories/product.ts @@ -3,13 +3,12 @@ import { Product, ProductOption } from "@models" import { Context, DAL, InferEntityType } from "@medusajs/framework/types" import { arrayDifference, - buildQuery, DALUtils, - MedusaError, + deepCopy, + isDefined, isPresent, + MedusaError, mergeMetadata, - isDefined, - deepCopy, } from "@medusajs/framework/utils" import { SqlEntityManager, @@ -95,15 +94,12 @@ export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory( const relationsToLoad = ProductRepository.#getProductDeepUpdateRelationsToLoad(productsToUpdate_) - const findOptions = buildQuery( + const manager = super.getActiveManager(context) + const products = await manager.find>( + Product.name, { id: productIdsToUpdate }, - { - relations: relationsToLoad, - take: productsToUpdate_.length, - } + { populate: relationsToLoad, limit: productsToUpdate_.length } as any ) - - const products = await this.find(findOptions, context) const productsMap = new Map(products.map((p) => [p.id, p])) const productIds = Array.from(productsMap.keys()) diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 88533955cbaf7..0ac78d5c983e0 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -17,6 +17,7 @@ import { ProductImage, ProductOption, ProductOptionValue, + ProductProductOption, ProductTag, ProductType, ProductVariant, @@ -46,7 +47,6 @@ import { removeUndefined, toHandle, } from "@medusajs/framework/utils" -import { EntityManager } from "@mikro-orm/core" import { ProductRepository } from "../repositories" import { UpdateCategoryInput, @@ -60,6 +60,8 @@ import { } from "../types" import { joinerConfig } from "./../joiner-config" import { eventBuilders } from "../utils/events" +import { EntityManager } from "@mikro-orm/core" +import { CreateProductOptionDTO } from "@medusajs/types" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -73,6 +75,7 @@ type InjectedDependencies = { productImageProductService: ModulesSdkTypes.IMedusaInternalService productTypeService: ModulesSdkTypes.IMedusaInternalService productOptionService: ModulesSdkTypes.IMedusaInternalService + productProductOptionService: ModulesSdkTypes.IMedusaInternalService productOptionValueService: ModulesSdkTypes.IMedusaInternalService productVariantProductImageService: ModulesSdkTypes.IMedusaInternalService [Modules.EVENT_BUS]?: IEventBusModuleService @@ -147,6 +150,9 @@ export default class ProductModuleService protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > + protected readonly productProductOptionService_: ModulesSdkTypes.IMedusaInternalService< + InferEntityType + > protected readonly productVariantProductImageService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > @@ -164,6 +170,7 @@ export default class ProductModuleService productImageService, productTypeService, productOptionService, + productProductOptionService, productOptionValueService, productVariantProductImageService, [Modules.EVENT_BUS]: eventBusModuleService, @@ -184,6 +191,7 @@ export default class ProductModuleService this.productImageService_ = productImageService this.productTypeService_ = productTypeService this.productOptionService_ = productOptionService + this.productProductOptionService_ = productProductOptionService this.productOptionValueService_ = productOptionValueService this.productVariantProductImageService_ = productVariantProductImageService this.eventBusModuleService_ = eventBusModuleService @@ -378,10 +386,12 @@ export default class ProductModuleService const productOptions = await this.productOptionService_.list( { - product_id: [...new Set(data.map((v) => v.product_id!))], + products: { + id: [...new Set(data.map((v) => v.product_id!))], + }, }, { - relations: ["values"], + relations: ["values", "products"], }, sharedContext ) @@ -545,11 +555,13 @@ export default class ProductModuleService const productOptions = await this.productOptionService_.list( { - product_id: Array.from( - new Set(variantsWithProductId.map((v) => v.product_id!)) - ), + products: { + id: Array.from( + new Set(variantsWithProductId.map((v) => v.product_id!)) + ), + }, }, - { relations: ["values"] }, + { relations: ["values", "products"] }, sharedContext ) @@ -876,26 +888,32 @@ export default class ProductModuleService data: ProductTypes.CreateProductOptionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { - if (data.some((v) => !v.product_id)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Tried to create options without specifying a product_id" - ) - } - const normalizedInput = data.map((opt) => { + Object.keys(opt.ranks ?? []).forEach((value) => { + if (!opt.values.includes(value)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Value "${value}" is assigned a rank but is not defined in the list of values.` + ) + } + }) + return { ...opt, values: opt.values?.map((v) => { - return typeof v === "string" ? { value: v } : v + // Normalize each value into an object and attach rank if available + const valueObj = isString(v) ? { value: v } : v + const rank = + opt.ranks && isString(v) + ? opt.ranks[v] + : opt.ranks?.[valueObj.value] + + return rank !== undefined ? { ...valueObj, rank } : valueObj }), } }) - return await this.productOptionService_.create( - normalizedInput, - sharedContext - ) + return this.productOptionService_.create(normalizedInput, sharedContext) } async upsertProductOptions( @@ -1019,35 +1037,66 @@ export default class ProductModuleService // Data normalization const normalizedInput = data.map((opt) => { - const dbValues = dbOptions.find(({ id }) => id === opt.id)?.values || [] - const normalizedValues = opt.values?.map((v) => { - return typeof v === "string" ? { value: v } : v - }) + const dbOption = dbOptions.find(({ id }) => id === opt.id) + const dbValues = dbOption?.values || [] + + if (opt.ranks) { + const validValues = opt.values ?? dbValues.map((v) => v.value) + Object.keys(opt.ranks).forEach((value) => { + if (!validValues.includes(value)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Value "${value}" is assigned a rank but is not defined in the list of values.` + ) + } + }) + } + + let normalizedValues + if (opt.values) { + // If new values are provided → normalize and apply ranks + normalizedValues = opt.values.map((v) => { + const valueObj = isString(v) ? { value: v } : v + + const rank = + opt.ranks && isString(v) + ? opt.ranks[v] + : opt.ranks?.[valueObj.value] + + const rankedValue = + rank !== undefined ? { ...valueObj, rank } : valueObj + + if ("id" in rankedValue) { + return rankedValue + } + + const dbVal = dbValues.find( + (dbVal) => dbVal.value === rankedValue.value + ) + if (!dbVal) { + return rankedValue + } + + return { + id: dbVal.id, + ...rankedValue, + } + }) + } else if (opt.ranks) { + // If only ranks were provided → update existing DB values with ranks + normalizedValues = dbValues.map((dbVal) => { + const rank = opt.ranks![dbVal.value] + return rank !== undefined + ? { id: dbVal.id, value: dbVal.value, rank } + : { id: dbVal.id, value: dbVal.value } + }) + } + + const { ranks, ...cleanOpt } = opt return { - ...opt, - ...(normalizedValues - ? { - // Oftentimes the options are only passed by value without an id, even if they exist in the DB - values: normalizedValues.map((normVal) => { - if ("id" in normVal) { - return normVal - } - - const dbVal = dbValues.find( - (dbVal) => dbVal.value === normVal.value - ) - if (!dbVal) { - return normVal - } - - return { - id: dbVal.id, - value: normVal.value, - } - }), - } - : {}), + ...cleanOpt, + ...(normalizedValues ? { values: normalizedValues } : {}), } as UpdateProductOptionInput }) @@ -1061,6 +1110,93 @@ export default class ProductModuleService return productOptions } + async addProductOptionToProduct( + productOptionProductPair: ProductTypes.ProductOptionProductPair, + sharedContext?: Context + ): Promise<{ id: string }> + + async addProductOptionToProduct( + productOptionProductPairs: ProductTypes.ProductOptionProductPair[], + sharedContext?: Context + ): Promise<{ id: string }[]> + + @InjectManager() + @EmitEvents() + async addProductOptionToProduct( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise<{ id: string } | { id: string }[]> { + const productOptionProducts = await this.addProductOptionToProduct_( + data, + sharedContext + ) + + return productOptionProducts + } + + @InjectTransactionManager() + protected async addProductOptionToProduct_( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise<{ id: string } | { id: string }[]> { + const productOptionProducts = + await this.productProductOptionService_.create(data, sharedContext) + + if (Array.isArray(data)) { + return ( + productOptionProducts as unknown as InferEntityType< + typeof ProductProductOption + >[] + ).map((ppo) => ({ id: ppo.id })) + } + + return { id: productOptionProducts.id } + } + + async removeProductOptionFromProduct( + groupCustomerPair: ProductTypes.ProductOptionProductPair, + sharedContext?: Context + ): Promise + + async removeProductOptionFromProduct( + groupCustomerPairs: ProductTypes.ProductOptionProductPair[], + sharedContext?: Context + ): Promise + + @InjectManager() + @EmitEvents() + async removeProductOptionFromProduct( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.removeProductOptionFromProduct_(data, sharedContext) + } + + @InjectTransactionManager() + protected async removeProductOptionFromProduct_( + data: + | ProductTypes.ProductOptionProductPair + | ProductTypes.ProductOptionProductPair[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const pairs = Array.isArray(data) ? data : [data] + const productOptionsProducts = await this.productProductOptionService_.list( + { + $or: pairs, + } + ) + await this.productProductOptionService_.delete( + productOptionsProducts.map(({ id }) => id), + sharedContext + ) + } + // @ts-expect-error createProductCollections( data: ProductTypes.CreateProductCollectionDTO[], @@ -1636,8 +1772,60 @@ export default class ProductModuleService data: ProductTypes.CreateProductDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { - const normalizedProducts = await this.normalizeCreateProductInput( - data, + const existingOptionIds = data + .flatMap((p) => p.options ?? []) + .filter((o) => "id" in o) + .map((o) => o.id) + + let existingOptions: InferEntityType[] = [] + if (existingOptionIds.length > 0) { + existingOptions = await this.productOptionService_.list( + { id: existingOptionIds }, + { relations: ["values"] }, + sharedContext + ) + + const fetchedIds = new Set(existingOptions.map((opt) => opt.id)) + const missingIds = existingOptionIds.filter((id) => !fetchedIds.has(id)) + if (missingIds.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Some product options were not found: [${missingIds.join(", ")}]` + ) + } + } + + const existingOptionsMap = new Map( + existingOptions.map((opt) => [opt.id, opt]) + ) + + const hydratedData = data.map((product) => { + if (!product.options?.length) return product + + const hydratedOptions = product.options.map((option) => { + if ("id" in option) { + const dbOption = existingOptionsMap.get(option.id) + if (!dbOption) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Product option with id ${option.id} not found.` + ) + } + + return { + id: dbOption.id, + title: dbOption.title, + values: dbOption.values?.map((v) => ({ value: v.value })), + } + } + return option + }) + + return { ...product, options: hydratedOptions } + }) + + const normalizedProducts = this.normalizeCreateProductInput( + hydratedData, sharedContext ) @@ -1662,6 +1850,11 @@ export default class ProductModuleService const existingTagsMap = new Map(existingTags.map((tag) => [tag.id, tag])) + const productOptionsToCreate = new Map< + string, + ProductTypes.CreateProductOptionDTO[] + >() + const productsToCreate = normalizedProducts.map((product) => { const productId = generateEntityId(product.id, "prod") product.id = productId @@ -1672,6 +1865,15 @@ export default class ProductModuleService ) } + if (product.options?.length) { + const newOptions = product.options.filter( + (o) => !("id" in o) + ) as CreateProductOptionDTO[] + if (newOptions.length) { + productOptionsToCreate.set(productId, newOptions) + } + } + if (product.variants?.length) { const normalizedVariants = product.variants.map((variant) => { const variantId = generateEntityId((variant as any).id, "variant") @@ -1679,9 +1881,9 @@ export default class ProductModuleService Object.entries(variant.options ?? {}).forEach(([key, value]) => { const productOption = product.options?.find( - (option) => option.title === key + (option) => (option as any).title === key )! - const productOptionValue = productOption.values?.find( + const productOptionValue = (productOption as any).values?.find( (optionValue) => (optionValue as any).value === value )! ;(productOptionValue as any).variants ??= [] @@ -1712,15 +1914,93 @@ export default class ProductModuleService ) } + delete product.options + return product }) - const createdProducts = await this.productService_.create( - productsToCreate, + const productToOptionIdsMap = new Map() + const allOptionsWithIds: (ProductTypes.CreateProductOptionDTO & { + id: string + })[] = [] + + for (const [productId, options] of productOptionsToCreate.entries()) { + const optionIds: string[] = [] + + for (const option of options) { + const optionId = generateEntityId(undefined, "opt") + optionIds.push(optionId) + allOptionsWithIds.push({ + ...option, + id: optionId, + }) + } + + productToOptionIdsMap.set(productId, optionIds) + } + + const [createdProducts] = await Promise.all([ + this.productService_.create(productsToCreate, sharedContext), + allOptionsWithIds.length > 0 + ? this.createOptions_(allOptionsWithIds, sharedContext) + : Promise.resolve([]), + ]) + + const linkPairs: ProductTypes.ProductOptionProductPair[] = [] + for (const product of createdProducts) { + const hydratedProduct = hydratedData.find( + (p) => p.title === product.title + ) + const allOptionIds: string[] = [] + + if (hydratedProduct?.options?.length) { + for (const option of hydratedProduct.options) { + if ("id" in option) { + allOptionIds.push(option.id) + } + } + } + + const newOptionIds = productToOptionIdsMap.get(product.id) ?? [] + const optionIds = [...new Set([...allOptionIds, ...newOptionIds])] + + for (const optionId of optionIds) { + linkPairs.push({ + product_id: product.id, + product_option_id: optionId, + }) + } + } + + if (linkPairs.length > 0) { + await this.addProductOptionToProduct_(linkPairs, sharedContext) + } + + const productIds = createdProducts.map((p) => p.id) + + const productsWithOptions = await this.productService_.list( + { id: productIds }, + { + relations: [ + "options", + "options.values", + "options.products", + "variants", + "images", + "tags", + ], + }, sharedContext ) - return createdProducts + const productIdOrder = new Map(productIds.map((id, index) => [id, index])) + + const orderedProductsWithOptions = [...productsWithOptions].sort( + (a, b) => + (productIdOrder.get(a.id) ?? 0) - (productIdOrder.get(b.id) ?? 0) + ) + + return orderedProductsWithOptions } @InjectTransactionManager() @@ -1743,20 +2023,93 @@ export default class ProductModuleService .registerSubscriber(new subscriber(sharedContext)) } - const originalProducts = await this.productService_.list( - { - id: data.map((d) => d.id), - }, - { - relations: ["options", "options.values", "variants", "images", "tags"], - }, - sharedContext - ) + const allOptionIds = data + .flatMap((p) => p.option_ids ?? []) + .filter((id) => !!id) - const normalizedProducts = await this.normalizeUpdateProductInput( - data, - originalProducts - ) + const [originalProducts, existingOptions] = await Promise.all([ + this.productService_.list( + { id: data.map((d) => d.id) }, + { + relations: [ + "options", + "options.values", + "options.products", + "variants", + "images", + "tags", + ], + }, + sharedContext + ), + allOptionIds.length + ? this.productOptionService_.list( + { id: allOptionIds }, + { + relations: ["values", "products"], + }, + sharedContext + ) + : Promise.resolve([]), + ]) + + if (allOptionIds.length && existingOptions.length !== allOptionIds.length) { + const found = new Set(existingOptions.map((opt) => opt.id)) + const missing = allOptionIds.filter((id) => !found.has(id)) + if (missing.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Some product options were not found: [${missing.join(", ")}]` + ) + } + } + + const linkPairs: ProductTypes.ProductOptionProductPair[] = [] + const unlinkPairs: ProductTypes.ProductOptionProductPair[] = [] + for (const product of data) { + if (!product.option_ids) { + continue + } + + const newOptionIds = new Set(product.option_ids) + + const existingOptionIds = new Set( + originalProducts + .find((p) => p.id === product.id) + ?.options?.map((o) => o.id) ?? [] + ) + + for (const optionId of newOptionIds) { + if (!existingOptionIds.has(optionId)) { + linkPairs.push({ + product_id: product.id, + product_option_id: optionId, + }) + } + } + + for (const optionId of existingOptionIds) { + if (!newOptionIds.has(optionId)) { + unlinkPairs.push({ + product_id: product.id, + product_option_id: optionId, + }) + } + } + + delete product.option_ids + } + + await Promise.all([ + linkPairs.length && + this.addProductOptionToProduct_(linkPairs, sharedContext), + unlinkPairs.length && + this.removeProductOptionFromProduct_(unlinkPairs, sharedContext), + ]) + + await (sharedContext.transactionManager as any).flush() + + const normalizedProducts = this.normalizeUpdateProductInput(data) for (const product of normalizedProducts) { this.validateProductUpdatePayload(product) @@ -1792,7 +2145,7 @@ export default class ProductModuleService ): Promise< ProductTypes.ProductOptionValueDTO | ProductTypes.ProductOptionValueDTO[] > { - // TODO: There is a missmatch in the API which lead to function with different number of + // TODO: There is a mismatch in the API which lead to function with different number of // arguments. Therefore, applying the MedusaContext() decorator to the function will not work // because the context arg index will differ from method to method. sharedContext.messageAggregator ??= new MessageAggregator() @@ -1903,7 +2256,7 @@ export default class ProductModuleService if (options?.length) { productData.variants?.forEach((variant) => { options.forEach((option) => { - if (!variant.options?.[option.title]) { + if (!variant.options?.[(option as any).title]) { missingOptionsVariants.push(variant.title) } }) @@ -1926,22 +2279,33 @@ export default class ProductModuleService this.validateProductPayload(productData) } - protected async normalizeCreateProductInput< + protected normalizeCreateProductInput< T extends ProductTypes.CreateProductDTO | ProductTypes.CreateProductDTO[], TOutput = T extends ProductTypes.CreateProductDTO[] ? ProductTypes.CreateProductDTO[] : ProductTypes.CreateProductDTO - >( - products: T, - @MedusaContext() sharedContext: Context = {} - ): Promise { + >(products: T, @MedusaContext() sharedContext: Context = {}): TOutput { const products_ = Array.isArray(products) ? products : [products] - const normalizedProducts = (await this.normalizeUpdateProductInput( + const normalizedProducts = this.normalizeUpdateProductInput( products_ as UpdateProductInput[] - )) as ProductTypes.CreateProductDTO[] + ) as ProductTypes.CreateProductDTO[] for (const productData of normalizedProducts) { + if (productData.options?.length) { + ;(productData as any).options = productData.options?.map((option) => { + return { + title: (option as any).title, + values: (option as any).values?.map((value) => { + return { + value: value, + } + }), + ...((option as any).id ? { id: (option as any).id } : {}), + } + }) + } + if (!productData.handle && productData.title) { productData.handle = toHandle(productData.title) } @@ -1992,30 +2356,13 @@ export default class ProductModuleService * @param originalProducts - The original products to use for the normalization (must include options and option values relations) * @returns The normalized products */ - protected async normalizeUpdateProductInput< + protected normalizeUpdateProductInput< T extends UpdateProductInput | UpdateProductInput[], TOutput = T extends UpdateProductInput[] ? UpdateProductInput[] : UpdateProductInput - >( - products: T, - originalProducts?: InferEntityType[] - ): Promise { + >(products: T): TOutput { const products_ = Array.isArray(products) ? products : [products] - const productsIds = products_.map((p) => p.id).filter(Boolean) - - let dbOptions: InferEntityType[] = [] - - if (productsIds.length) { - // Re map options to handle non serialized data as well - dbOptions = - originalProducts - ?.map((originalProduct) => - originalProduct.options.map((option) => option) - ) - .flat() - .filter(Boolean) ?? [] - } const normalizedProducts: UpdateProductInput[] = [] @@ -2025,32 +2372,9 @@ export default class ProductModuleService productData.discountable = false } - if (productData.options?.length) { - ;(productData as any).options = productData.options?.map((option) => { - const dbOption = dbOptions.find( - (o) => - (o.title === option.title || o.id === option.id) && - o.product_id === productData.id - ) - return { - title: option.title, - values: option.values?.map((value) => { - const dbValue = dbOption?.values?.find( - (val) => val.value === value - ) - return { - value: value, - ...(dbValue ? { id: dbValue.id } : {}), - } - }), - ...(dbOption ? { id: dbOption.id } : {}), - } - }) - } - if (productData.tag_ids) { - ;(productData as any).tags = productData.tag_ids.map((cid) => ({ - id: cid, + ;(productData as any).tags = productData.tag_ids.map((tid) => ({ + id: tid, })) delete productData.tag_ids } @@ -2112,7 +2436,10 @@ export default class ProductModuleService variants.map((v) => ({ ...v, // adding product_id to the variant to make it valid for the assignOptionsToVariants function - ...(options.length ? { product_id: options[0].product_id } : {}), + // get product_id from the first product in the products array of the first option + ...(options.length && options[0].products?.length + ? { product_id: options[0].products[0].id } + : {}), })), options ) @@ -2138,9 +2465,13 @@ export default class ProductModuleService variant.options || {} ).length - const productsOptions = options.filter( - (o) => o.product_id === variant.product_id - ) + const productsOptions = options.filter((o) => { + // products could be a Collection object or array, normalize to array + const productsArray = Array.isArray(o.products) + ? o.products + : (o.products as any)?.toArray?.() ?? [] + return productsArray.some((p) => p.id === variant.product_id) + }) if ( numOfProvidedVariantOptionValues &&