diff --git a/.changeset/tame-fireants-compare.md b/.changeset/tame-fireants-compare.md new file mode 100644 index 0000000000..bd01611e71 --- /dev/null +++ b/.changeset/tame-fireants-compare.md @@ -0,0 +1,5 @@ +--- +"@medusajs/product": patch +--- + +feat(product): build variant images when retrieving product 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 70e94e1b15..ed486a8d3d 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 @@ -3,11 +3,7 @@ import { ProductCategoryDTO, ProductTagDTO, } from "@medusajs/framework/types" -import { - kebabCase, - Modules, - ProductStatus, -} from "@medusajs/framework/utils" +import { kebabCase, Modules, ProductStatus } from "@medusajs/framework/utils" import { Product, ProductCategory, @@ -16,9 +12,7 @@ import { ProductType, } from "@models" -import { - moduleIntegrationTestRunner, -} from "@medusajs/test-utils" +import { moduleIntegrationTestRunner } from "@medusajs/test-utils" import { UpdateProductInput } from "@types" import { buildProductAndRelationsData, @@ -566,7 +560,6 @@ moduleIntegrationTestRunner({ ) }) - it("should add relationships to a product", async () => { const updateData = { id: productOne.id, @@ -1053,7 +1046,6 @@ moduleIntegrationTestRunner({ ) }) - it("should throw because variant doesn't have all options set", async () => { const error = await service .createProducts([ @@ -1232,7 +1224,6 @@ moduleIntegrationTestRunner({ expect(softDeleted).toHaveLength(1) }) - }) describe("restore", function () { @@ -1569,6 +1560,114 @@ moduleIntegrationTestRunner({ }), ]) }) + + it("should populate variant.images when variants.images relation is requested", async () => { + const images = [ + { url: "general-image-1" }, + { url: "general-image-2" }, + { url: "variant-specific-image" }, + ] + + const [product] = await service.createProducts([ + buildProductAndRelationsData({ + images, + options: [{ title: "size", values: ["small", "large"] }], + variants: [ + { title: "Small", options: { size: "small" } }, + { title: "Large", options: { size: "large" } }, + ], + }), + ]) + + const generalImage1 = product.images.find( + (img) => img.url === "general-image-1" + )! + const generalImage2 = product.images.find( + (img) => img.url === "general-image-2" + )! + const variantSpecificImage = product.images.find( + (img) => img.url === "variant-specific-image" + )! + + const smallVariant = product.variants.find( + (v) => v.title === "Small" + )! + const largeVariant = product.variants.find( + (v) => v.title === "Large" + )! + + // Add variant-specific image assignment + await service.addImageToVariant([ + { + image_id: variantSpecificImage.id, + variant_id: smallVariant.id, + }, + ]) + + // Test retrieveProduct with variants.images relation + const retrievedProduct = await service.retrieveProduct(product.id, { + relations: ["variants", "variants.images", "images"], + }) + + expect(retrievedProduct.variants).toHaveLength(2) + + // First variant (Small) should have general images + variant-specific image + const retrievedSmallVariant = retrievedProduct.variants.find( + (v) => v.title === "Small" + )! + expect(retrievedSmallVariant.images).toHaveLength(3) // 2 general + 1 variant-specific + expect(retrievedSmallVariant.images).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: generalImage1.id }), + expect.objectContaining({ id: generalImage2.id }), + expect.objectContaining({ id: variantSpecificImage.id }), + ]) + ) + + // Second variant (Large) should have only general images + const retrievedLargeVariant = retrievedProduct.variants.find( + (v) => v.title === "Large" + )! + expect(retrievedLargeVariant.images).toHaveLength(2) // 2 general images only + expect(retrievedLargeVariant.images).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: generalImage1.id }), + expect.objectContaining({ id: generalImage2.id }), + ]) + ) + + // Test listProducts with variants.images relation + const products = await service.listProducts( + { id: product.id }, + { relations: ["variants", "variants.images", "images"] } + ) + + expect(products).toHaveLength(1) + expect(products[0].variants).toHaveLength(2) + + const listSmallVariant = products[0].variants.find( + (v) => v.title === "Small" + )! + expect(listSmallVariant.images).toHaveLength(3) + expect(listSmallVariant.images).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: generalImage1.id }), + expect.objectContaining({ id: generalImage2.id }), + expect.objectContaining({ id: variantSpecificImage.id }), + ]) + ) + + const listLargeVariant = products[0].variants.find( + (v) => v.title === "Large" + )! + expect(listLargeVariant.images).toHaveLength(2) + expect(listLargeVariant.images).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: generalImage1.id }), + expect.objectContaining({ id: generalImage2.id }), + ]) + ) + }) }) }) }, diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index 8004aed146..88533955cb 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -41,6 +41,7 @@ import { MedusaService, MessageAggregator, Modules, + partitionArray, ProductStatus, removeUndefined, toHandle, @@ -199,12 +200,30 @@ export default class ProductModuleService config?: FindConfig, @MedusaContext() sharedContext?: Context ): Promise { + const relationsSet = new Set(config?.relations ?? []) + const shouldLoadVariantImages = relationsSet.has("variants.images") + if (shouldLoadVariantImages) { + relationsSet.add("variants") + relationsSet.add("images") + } + const product = await this.productService_.retrieve( productId, - this.getProductFindConfig_(config), + this.getProductFindConfig_({ + ...config, + relations: Array.from(relationsSet), + }), sharedContext ) + if (shouldLoadVariantImages && product.variants && product.images) { + await this.buildVariantImagesFromProduct( + product.variants, + product.images, + sharedContext + ) + } + return this.baseRepository_.serialize(product) } @@ -215,12 +234,34 @@ export default class ProductModuleService config?: FindConfig, sharedContext?: Context ): Promise { + const relationsSet = new Set(config?.relations ?? []) + const shouldLoadVariantImages = relationsSet.has("variants.images") + if (shouldLoadVariantImages) { + relationsSet.add("variants") + relationsSet.add("images") + } + const products = await this.productService_.list( filters, - this.getProductFindConfig_(config), + this.getProductFindConfig_({ + ...config, + relations: Array.from(relationsSet), + }), sharedContext ) + if (shouldLoadVariantImages) { + for (const product of products) { + if (product.variants && product.images) { + await this.buildVariantImagesFromProduct( + product.variants, + product.images, + sharedContext + ) + } + } + } + return this.baseRepository_.serialize(products) } @@ -231,12 +272,38 @@ export default class ProductModuleService config?: FindConfig, sharedContext?: Context ): Promise<[ProductTypes.ProductDTO[], number]> { + const shouldLoadVariantImages = + config?.relations?.includes("variants.images") + + // Ensure we load necessary relations + const relations = [...(config?.relations || [])] + if (shouldLoadVariantImages) { + if (!relations.includes("variants")) { + relations.push("variants") + } + if (!relations.includes("images")) { + relations.push("images") + } + } + const [products, count] = await this.productService_.listAndCount( filters, - this.getProductFindConfig_(config), + this.getProductFindConfig_({ ...config, relations }), sharedContext ) + if (shouldLoadVariantImages) { + for (const product of products) { + if (product.variants && product.images) { + await this.buildVariantImagesFromProduct( + product.variants, + product.images, + sharedContext + ) + } + } + } + const serializedProducts = await this.baseRepository_.serialize< ProductTypes.ProductDTO[] >(products) @@ -2450,4 +2517,57 @@ export default class ProductModuleService return result } + + private async buildVariantImagesFromProduct( + variants: InferEntityType[], + productImages: InferEntityType[], + sharedContext: Context = {} + ): Promise { + // Create a clean map of images without problematic collections + const imagesMap = new Map>() + for (const img of productImages) { + imagesMap.set(img.id, img) + } + + const variantIds = variants.map((v) => v.id) + const variantImageRelations = + await this.productVariantProductImageService_.list( + { variant_id: variantIds }, + { select: ["variant_id", "image_id"] }, + sharedContext + ) + + const variantIdImageIdsMap = new Map() + const imageIdVariantIdsMap = new Map() + + // build both lookup + for (const relation of variantImageRelations) { + if (!variantIdImageIdsMap.has(relation.variant_id)) { + variantIdImageIdsMap.set(relation.variant_id, []) + } + variantIdImageIdsMap.get(relation.variant_id)!.push(relation.image_id) + + if (!imageIdVariantIdsMap.has(relation.image_id)) { + imageIdVariantIdsMap.set(relation.image_id, []) + } + imageIdVariantIdsMap.get(relation.image_id)!.push(relation.variant_id) + } + + const [generalImages, variantImages] = partitionArray( + productImages, + (img) => !imageIdVariantIdsMap.has(img.id) // if image doesn't have any variants, it is a general image + ) + + for (const variant of variants) { + const variantImageIds = variantIdImageIdsMap.get(variant.id) || [] + + variant.images = [...generalImages] + + for (const image of variantImages) { + if (variantImageIds.includes(image.id)) { + variant.images.push(image) + } + } + } + } }