diff --git a/.changeset/swift-ghosts-double.md b/.changeset/swift-ghosts-double.md new file mode 100644 index 0000000000000..09aa9244d71cc --- /dev/null +++ b/.changeset/swift-ghosts-double.md @@ -0,0 +1,6 @@ +--- +"@medusajs/product": patch +"@medusajs/types": patch +--- + +feat(product, types): added product module service update diff --git a/packages/product/integration-tests/__fixtures__/product/index.ts b/packages/product/integration-tests/__fixtures__/product/index.ts index 1e7d1f98dcc6b..b769251dcf0dd 100644 --- a/packages/product/integration-tests/__fixtures__/product/index.ts +++ b/packages/product/integration-tests/__fixtures__/product/index.ts @@ -5,6 +5,7 @@ import { Product, ProductCategory, ProductCollection, + ProductType, ProductVariant, } from "@models" import ProductOption from "../../../src/models/product-option" @@ -59,6 +60,22 @@ export async function createCollections( return collections } +export async function createTypes( + manager: SqlEntityManager, + typesData: { + id?: string + value: string + }[] +) { + const types: any[] = typesData.map((typesData) => { + return manager.create(ProductType, typesData) + }) + + await manager.persistAndFlush(types) + + return types +} + export async function createOptions( manager: SqlEntityManager, optionsData: { diff --git a/packages/product/integration-tests/__tests__/services/product-module-service/products.spec.ts b/packages/product/integration-tests/__tests__/services/product-module-service/products.spec.ts new file mode 100644 index 0000000000000..4c80bab349e1b --- /dev/null +++ b/packages/product/integration-tests/__tests__/services/product-module-service/products.spec.ts @@ -0,0 +1,464 @@ +import { MedusaModule } from "@medusajs/modules-sdk" +import { Product, ProductCategory, ProductCollection, ProductType, ProductVariant } from "@models" +import { IProductModuleService, ProductTypes } from "@medusajs/types" + +import { initialize } from "../../../../src" +import { DB_URL, TestDatabase } from "../../../utils" +import { buildProductAndRelationsData } from "../../../__fixtures__/product/data/create-product" +import { createProductCategories } from "../../../__fixtures__/product-category" +import { createCollections, createTypes } from "../../../__fixtures__/product" + +const beforeEach_ = async () => { + await TestDatabase.setupDatabase() + return await TestDatabase.forkManager() +} + +const afterEach_ = async () => { + await TestDatabase.clearDatabase() +} + +describe("ProductModuleService products", function () { + describe("update", function () { + let module: IProductModuleService + let productOne: Product + let productTwo: Product + let productCategoryOne: ProductCategory + let productCategoryTwo: ProductCategory + let productCollectionOne: ProductCollection + let productCollectionTwo: ProductCollection + let variantOne: ProductVariant + let variantTwo: ProductVariant + let variantThree: ProductVariant + let productTypeOne: ProductType + let productTypeTwo: ProductType + let images = ["image-1"] + + const productCategoriesData = [{ + id: "test-1", + name: "category 1", + }, { + id: "test-2", + name: "category 2", + }] + + const productCollectionsData = [ + { + id: "test-1", + title: "col 1", + }, + { + id: "test-2", + title: "col 2", + }, + ] + + const productTypesData = [ + { + id: "type-1", + value: "type 1", + }, + { + id: "type-2", + value: "type 2", + }, + ] + + const tagsData = [{ + id: "tag-1", + value: "tag 1", + }] + + beforeEach(async () => { + const testManager = await beforeEach_() + + const collections = await createCollections( + testManager, + productCollectionsData + ) + + productCollectionOne = collections[0] + productCollectionTwo = collections[1] + + const types = await createTypes( + testManager, + productTypesData, + ) + + productTypeOne = types[0] + productTypeTwo = types[1] + + const categories = (await createProductCategories( + testManager, + productCategoriesData + )) + + productCategoryOne = categories[0] + productCategoryTwo = categories[1] + + productOne = testManager.create(Product, { + id: "product-1", + title: "product 1", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + productTwo = testManager.create(Product, { + id: "product-2", + title: "product 2", + status: ProductTypes.ProductStatus.PUBLISHED, + categories: [productCategoryOne], + collection_id: productCollectionOne.id, + tags: tagsData, + }) + + variantOne = testManager.create(ProductVariant, { + id: "variant-1", + title: "variant 1", + inventory_quantity: 10, + product: productOne, + }) + + variantTwo = testManager.create(ProductVariant, { + id: "variant-2", + title: "variant 2", + inventory_quantity: 10, + product: productTwo, + }) + + variantThree = testManager.create(ProductVariant, { + id: "variant-3", + title: "variant 3", + inventory_quantity: 10, + product: productTwo, + }) + + await testManager.persistAndFlush([productOne, productTwo]) + + MedusaModule.clearInstances() + + module = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA, + }, + }) + }) + + afterEach(afterEach_) + + it("should update a product and upsert relations that are not created yet", async () => { + const data = buildProductAndRelationsData({ + images, + thumbnail: images[0], + }) + + const updateData = { + ...data, + id: productOne.id, + title: "updated title" + } + + const updatedProducts = await module.update([updateData]) + expect(updatedProducts).toHaveLength(1) + + const product = await module.retrieve(updateData.id, { + relations: ["images", "variants", "options", "options.values", "variants.options", "tags", "type",] + }) + + expect(product.images).toHaveLength(1) + expect(product.variants[0].options).toHaveLength(1) + expect(product.tags).toHaveLength(1) + expect(product.variants).toHaveLength(1) + + expect(product).toEqual( + expect.objectContaining({ + id: expect.any(String), + title: "updated title", + description: updateData.description, + subtitle: updateData.subtitle, + is_giftcard: updateData.is_giftcard, + discountable: updateData.discountable, + thumbnail: images[0], + status: updateData.status, + images: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + url: images[0], + }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + title: updateData.options[0].title, + values: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + value: updateData.variants[0].options?.[0].value, + }), + ]), + }), + ]), + tags: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + value: updateData.tags[0].value, + }), + ]), + type: expect.objectContaining({ + id: expect.any(String), + value: updateData.type.value, + }), + variants: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + title: updateData.variants[0].title, + sku: updateData.variants[0].sku, + allow_backorder: false, + manage_inventory: true, + inventory_quantity: "100", + variant_rank: "0", + options: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + value: updateData.variants[0].options?.[0].value, + }), + ]), + }), + ]), + }) + ) + }) + + it("should add relationships to a product", async () => { + const updateData = { + id: productOne.id, + categories: [{ + id: productCategoryOne.id + }], + collection_id: productCollectionOne.id, + type_id: productTypeOne.id + } + + await module.update([updateData]) + + const product = await module.retrieve(updateData.id, { + relations: ["categories", "collection", "type"] + }) + + expect(product).toEqual( + expect.objectContaining({ + id: productOne.id, + categories: [ + expect.objectContaining({ + id: productCategoryOne.id + }) + ], + collection: expect.objectContaining({ + id: productCollectionOne.id + }), + type: expect.objectContaining({ + id: productTypeOne.id + }) + }) + ) + }) + + it("should upsert a product type when type object is passed", async () => { + let updateData = { + id: productTwo.id, + type: { + id: productTypeOne.id, + value: productTypeOne.value + } + } + + await module.update([updateData]) + + let product = await module.retrieve(updateData.id, { + relations: ["type"] + }) + + expect(product).toEqual( + expect.objectContaining({ + id: productTwo.id, + type: expect.objectContaining({ + id: productTypeOne.id + }) + }) + ) + + updateData = { + id: productTwo.id, + type: { + id: "new-type-id", + value: "new-type-value" + } + } + + await module.update([updateData]) + + product = await module.retrieve(updateData.id, { + relations: ["type"] + }) + + expect(product).toEqual( + expect.objectContaining({ + id: productTwo.id, + type: expect.objectContaining({ + ...updateData.type + }) + }) + ) + }) + + it("should replace relationships of a product", async () => { + const newTagData = { + id: "tag-2", + value: "tag 2", + } + + const updateData = { + id: productTwo.id, + categories: [{ + id: productCategoryTwo.id + }], + collection_id: productCollectionTwo.id, + type_id: productTypeTwo.id, + tags: [newTagData], + } + + await module.update([updateData]) + + const product = await module.retrieve(updateData.id, { + relations: ["categories", "collection", "tags", "type"] + }) + + expect(product).toEqual( + expect.objectContaining({ + id: productTwo.id, + categories: [ + expect.objectContaining({ + id: productCategoryTwo.id + }) + ], + collection: expect.objectContaining({ + id: productCollectionTwo.id + }), + tags: [ + expect.objectContaining({ + id: newTagData.id, + value: newTagData.value + }) + ], + type: expect.objectContaining({ + id: productTypeTwo.id + }) + }) + ) + }) + + it("should remove relationships of a product", async () => { + const updateData = { + id: productTwo.id, + categories: [], + collection_id: null, + type_id: null, + tags: [] + } + + await module.update([updateData]) + + const product = await module.retrieve(updateData.id, { + relations: ["categories", "collection", "tags"] + }) + + expect(product).toEqual( + expect.objectContaining({ + id: productTwo.id, + categories: [], + tags: [], + collection: null, + type: null + }) + ) + }) + + it("should throw an error when product ID does not exist", async () => { + let error + const updateData = { + id: "does-not-exist", + title: "test" + } + + try { + await module.update([updateData]) + } catch (e) { + error = e.message + } + + expect(error).toEqual(`Product with id "does-not-exist" not found`) + }) + + it("should update, create and delete variants", async () => { + const updateData = { + id: productTwo.id, + // Note: VariantThree is already assigned to productTwo, that should be deleted + variants: [{ + id: variantTwo.id, + title: "updated-variant" + }, { + title: "created-variant" + }] + } + + await module.update([updateData]) + + const product = await module.retrieve(updateData.id, { + relations: ["variants"] + }) + + expect(product.variants).toHaveLength(2) + expect(product).toEqual( + expect.objectContaining({ + id: expect.any(String), + variants: expect.arrayContaining([ + expect.objectContaining({ + id: variantTwo.id, + title: "updated-variant", + }), + expect.objectContaining({ + id: expect.any(String), + title: "created-variant", + }), + ]), + }) + ) + }) + + it("should throw an error when variant with id does not exist", async () => { + let error + + const updateData = { + id: productTwo.id, + // Note: VariantThree is already assigned to productTwo, that should be deleted + variants: [{ + id: "does-not-exist", + title: "updated-variant" + }, { + title: "created-variant" + }] + } + + try { + await module.update([updateData]) + } catch (e) { + error = e + } + + await module.retrieve(updateData.id, { + relations: ["variants"] + }) + + expect(error.message).toEqual(`ProductVariant with id "does-not-exist" not found`) + }) + }) +}) diff --git a/packages/product/integration-tests/__tests__/services/product/index.ts b/packages/product/integration-tests/__tests__/services/product/index.ts index c316ee5a80239..347caea3c4d97 100644 --- a/packages/product/integration-tests/__tests__/services/product/index.ts +++ b/packages/product/integration-tests/__tests__/services/product/index.ts @@ -11,7 +11,7 @@ import { variantsData, } from "../../../__fixtures__/product/data" -import { ProductDTO } from "@medusajs/types" +import { ProductDTO, ProductTypes } from "@medusajs/types" import { ProductRepository } from "@repositories" import { ProductService } from "@services" import { SqlEntityManager } from "@mikro-orm/postgresql" @@ -27,6 +27,7 @@ describe("Product Service", () => { let testManager: SqlEntityManager let repositoryManager: SqlEntityManager let products!: Product[] + let productOne: Product let variants!: ProductVariant[] let categories!: ProductCategory[] @@ -47,6 +48,51 @@ describe("Product Service", () => { await TestDatabase.clearDatabase() }) + describe("retrieve", () => { + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + productOne = testManager.create(Product, { + id: "product-1", + title: "product 1", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + await testManager.persistAndFlush([productOne]) + }) + + it("should throw an error when an id is not provided", async () => { + let error + + try { + await service.retrieve(undefined as unknown as string) + } catch (e) { + error = e + } + + expect(error.message).toEqual('"productId" must be defined') + }) + + it("should throw an error when product with id does not exist", async () => { + let error + + try { + await service.retrieve("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual('Product with id: does-not-exist was not found') + }) + + it("should return a product when product with an id exists", async () => { + const result = await service.retrieve(productOne.id) + + expect(result).toEqual(expect.objectContaining({ + id: productOne.id + })) + }) + }) + describe("create", function () { let images: Image[] = [] @@ -87,6 +133,94 @@ describe("Product Service", () => { }) }) + describe("update", function () { + let images: Image[] = [] + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + images = await createImages(testManager, ["image-1", "image-2"]) + + productOne = testManager.create(Product, { + id: "product-1", + title: "product 1", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + await testManager.persistAndFlush([productOne]) + }) + + it("should update a product and its allowed relations", async () => { + const updateData = [{ + id: productOne.id, + title: "update test 1", + images: images, + thumbnail: images[0].url, + }] + + const products = await service.update(updateData) + + expect(products.length).toEqual(1) + + let result = await service.retrieve(productOne.id, {relations: ["images", "thumbnail"]}) + let serialized = JSON.parse(JSON.stringify(result)) + + expect(serialized).toEqual( + expect.objectContaining({ + id: productOne.id, + title: "update test 1", + thumbnail: images[0].url, + images: [ + expect.objectContaining({ + url: images[0].url, + }), + expect.objectContaining({ + url: images[1].url, + }), + ], + }) + ) + }) + + it("should throw an error when id is not present", async () => { + let error + const updateData = [{ + id: productOne.id, + title: "update test 1", + }, { + id: undefined as unknown as string, + title: "update test 2", + }] + + try { + await service.update(updateData) + } catch (e) { + error = e + } + + expect(error.message).toEqual(`Product with id "undefined" not found`) + + let result = await service.retrieve(productOne.id) + + expect(result.title).not.toBe("update test 1") + }) + + it("should throw an error when product with id does not exist", async () => { + let error + const updateData = [{ + id: "does-not-exist", + title: "update test 1", + }] + + try { + await service.update(updateData) + } catch (e) { + error = e + } + + expect(error.message).toEqual(`Product with id "does-not-exist" not found`) + }) + }) + describe("list", () => { describe("soft deleted", function () { let deletedProduct diff --git a/packages/product/src/models/product.ts b/packages/product/src/models/product.ts index 45f77a7dcd389..3528f4610ab55 100644 --- a/packages/product/src/models/product.ts +++ b/packages/product/src/models/product.ts @@ -107,7 +107,7 @@ class Product { nullable: true, fieldName: "collection_id", }) - collection!: ProductCollection + collection!: ProductCollection | null @Property({ columnType: "text", nullable: true }) type_id!: string diff --git a/packages/product/src/repositories/product-variant.ts b/packages/product/src/repositories/product-variant.ts index 2940e1db55472..7562eb38db9d6 100644 --- a/packages/product/src/repositories/product-variant.ts +++ b/packages/product/src/repositories/product-variant.ts @@ -4,11 +4,18 @@ import { LoadStrategy, RequiredEntityData, } from "@mikro-orm/core" -import { Product, ProductVariant } from "@models" -import { Context, DAL } from "@medusajs/types" -import { AbstractBaseRepository } from "./base" +import { ProductVariant } from "@models" +import { Context, DAL, WithRequiredProperty } from "@medusajs/types" import { SqlEntityManager } from "@mikro-orm/postgresql" -import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" +import { + MedusaError, + isDefined, + InjectTransactionManager, + MedusaContext, +} from "@medusajs/utils" + +import { ProductVariantServiceTypes } from "../types/services" +import { AbstractBaseRepository } from "./base" import { doNotForceTransaction } from "../utils" export class ProductVariantRepository extends AbstractBaseRepository { @@ -69,7 +76,7 @@ export class ProductVariantRepository extends AbstractBaseRepository { await (manager as SqlEntityManager).nativeDelete( - Product, + ProductVariant, { id: { $in: ids } }, {} ) @@ -89,4 +96,37 @@ export class ProductVariantRepository extends AbstractBaseRepository[], + context: Context = {} + ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + const productVariantsToUpdate = await manager.find(ProductVariant, { + id: data.map((updateData) => updateData.id) + }) + + const productVariantsToUpdateMap = new Map( + productVariantsToUpdate.map((variant) => [variant.id, variant]) + ) + + const variants = data.map((variantData) => { + const productVariant = productVariantsToUpdateMap.get(variantData.id) + + if (!productVariant) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `ProductVariant with id "${variantData.id}" not found` + ) + } + + return manager.assign(productVariant, variantData) + }) + + await manager.persist(variants) + + return variants + } } diff --git a/packages/product/src/repositories/product.ts b/packages/product/src/repositories/product.ts index 2bba6dd8e2eb4..a1ca522c6cd11 100644 --- a/packages/product/src/repositories/product.ts +++ b/packages/product/src/repositories/product.ts @@ -1,18 +1,29 @@ -import { Product } from "@models" +import { + Product, + ProductCategory, + ProductCollection, + ProductType, + ProductTag, +} from "@models" + import { FilterQuery as MikroFilterQuery, FindOptions as MikroOptions, LoadStrategy, + wrap } from "@mikro-orm/core" + import { Context, DAL, ProductTypes, WithRequiredProperty, } from "@medusajs/types" -import { AbstractBaseRepository } from "./base" import { SqlEntityManager } from "@mikro-orm/postgresql" -import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" +import { MedusaError, isDefined, InjectTransactionManager, MedusaContext } from "@medusajs/utils" + +import { AbstractBaseRepository } from "./base" +import { ProductServiceTypes } from "../types/services" export class ProductRepository extends AbstractBaseRepository { protected readonly manager_: SqlEntityManager @@ -27,6 +38,7 @@ export class ProductRepository extends AbstractBaseRepository { findOptions: DAL.FindOptions = { where: {} }, context: Context = {} ): Promise { + // TODO: use the getter method (getActiveManager) const manager = (context.transactionManager ?? this.manager_) as SqlEntityManager @@ -78,6 +90,7 @@ export class ProductRepository extends AbstractBaseRepository { findOptions: DAL.FindOptions = { where: {} }, context: Context = {} ): Promise { + // TODO: use the getter method (getActiveManager) const manager = (context.transactionManager ?? this.manager_) as SqlEntityManager @@ -134,4 +147,165 @@ export class ProductRepository extends AbstractBaseRepository { return products } + + @InjectTransactionManager() + async update( + data: WithRequiredProperty[], + @MedusaContext() context: Context = {} + ): Promise { + let categoryIds: string[] = [] + let tagIds: string[] = [] + let collectionIds: string[] = [] + let typeIds: string[] = [] + // TODO: use the getter method (getActiveManager) + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + data.forEach((productData) => { + categoryIds = categoryIds.concat( + productData?.categories?.map(c => c.id) || [] + ) + + tagIds = tagIds.concat( + productData?.tags?.map(c => c.id) || [] + ) + + if (productData.collection_id) { + collectionIds.push(productData.collection_id) + } + + if (productData.type_id) { + typeIds.push(productData.type_id) + } + }) + + const productsToUpdate = await manager.find(Product, { + id: data.map((updateData) => updateData.id) + }, { + populate: ["tags", "categories"] + }) + + const collectionsToAssign = collectionIds.length ? await manager.find(ProductCollection, { + id: collectionIds + }) : [] + + const typesToAssign = typeIds.length ? await manager.find(ProductType, { + id: typeIds + }) : [] + + const categoriesToAssign = categoryIds.length ? await manager.find(ProductCategory, { + id: categoryIds + }) : [] + + const tagsToAssign = tagIds.length ? await manager.find(ProductTag, { + id: tagIds + }) : [] + + const categoriesToAssignMap = new Map( + categoriesToAssign.map((category) => [category.id, category]) + ) + + const tagsToAssignMap = new Map( + tagsToAssign.map((tag) => [tag.id, tag]) + ) + + const collectionsToAssignMap = new Map( + collectionsToAssign.map((collection) => [collection.id, collection]) + ) + + const typesToAssignMap = new Map( + typesToAssign.map((type) => [type.id, type]) + ) + + const productsToUpdateMap = new Map( + productsToUpdate.map((product) => [product.id, product]) + ) + + const products = await Promise.all( + data.map(async (updateData) => { + const product = productsToUpdateMap.get(updateData.id) + + if (!product) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product with id "${updateData.id}" not found` + ) + } + + const { + categories: categoriesData, + tags: tagsData, + collection_id: collectionId, + type_id: typeId, + } = updateData + + delete updateData?.categories + delete updateData?.tags + delete updateData?.collection_id + delete updateData?.type_id + + if (isDefined(categoriesData)) { + await product.categories.init() + + for (const categoryData of categoriesData) { + const productCategory = categoriesToAssignMap.get(categoryData.id) + + if (productCategory) { + await product.categories.add(productCategory) + } + } + + const categoryIdsToAssignSet = new Set(categoriesData.map(cd => cd.id)) + const categoriesToDelete = product.categories.getItems().filter( + (existingCategory) => !categoryIdsToAssignSet.has(existingCategory.id) + ) + + await product.categories.remove(categoriesToDelete) + } + + if (isDefined(tagsData)) { + await product.tags.init() + + for (const tagData of tagsData) { + let productTag = tagsToAssignMap.get(tagData.id) + + if (tagData instanceof ProductTag) { + productTag = tagData + } + + if (productTag) { + await product.tags.add(productTag) + } + } + + const tagIdsToAssignSet = new Set(tagsData.map(cd => cd.id)) + const tagsToDelete = product.tags.getItems().filter( + (existingTag) => !tagIdsToAssignSet.has(existingTag.id) + ) + + await product.tags.remove(tagsToDelete) + } + + if (isDefined(collectionId)) { + const collection = collectionsToAssignMap.get(collectionId) + + product.collection = collection || null + } + + if (isDefined(typeId)) { + const type = typesToAssignMap.get(typeId) + + if (type) { + product.type = type + } + } + + return manager.assign(product, updateData) + }) + ) + + await manager.persist(products) + + return products + } } diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index 6997bd1b5c386..4bb1afbe5be44 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -26,13 +26,17 @@ import { JoinerServiceConfig, ProductTypes, } from "@medusajs/types" +import { serialize } from "@mikro-orm/core" + import ProductImageService from "./product-image" +import { ProductServiceTypes, ProductVariantServiceTypes } from "../types/services" import { InjectTransactionManager, isDefined, isString, kebabCase, MedusaContext, + MedusaError, } from "@medusajs/utils" import { shouldForceTransaction } from "../utils" import { joinerConfig } from "./../joiner-config" @@ -118,10 +122,12 @@ export default class ProductModuleService< async retrieve( productId: string, + config: FindConfig = {}, sharedContext?: Context ): Promise { const product = await this.productService_.retrieve( productId, + config, sharedContext ) @@ -282,7 +288,7 @@ export default class ProductModuleService< return JSON.parse(JSON.stringify(categories)) } - async create(data: ProductTypes.CreateProductDTO[], sharedContext?: Context) { + async create(data: ProductTypes.CreateProductDTO[], sharedContext?: Context): Promise { const products = await this.create_(data, sharedContext) return this.baseRepository_.serialize(products, { @@ -290,6 +296,19 @@ export default class ProductModuleService< }) } + async update( + data: ProductTypes.UpdateProductDTO[], + sharedContext?: Context + ): Promise { + const products = await this.update_(data, sharedContext) + + return this.baseRepository_.serialize< + ProductTypes.ProductDTO[] + >(products, { + populate: true, + }) + } + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") protected async create_( data: ProductTypes.CreateProductDTO[], @@ -329,30 +348,9 @@ export default class ProductModuleService< productData.discountable = false } - if (productData.images?.length) { - productData.images = await this.productImageService_.upsert( - productData.images.map((image) => - isString(image) ? image : image.url - ), - sharedContext - ) - } - - if (productData.tags?.length) { - productData.tags = await this.productTagService_.upsert( - productData.tags, - sharedContext - ) - } - - if (isDefined(productData.type)) { - productData.type = ( - await this.productTypeService_.upsert( - [productData.type as ProductTypes.CreateProductTypeDTO], - sharedContext - ) - )?.[0]! - } + await this.upsertAndAssignImagesToProductData(productData, sharedContext) + await this.upsertAndAssignProductTagsToProductData(productData, sharedContext) + await this.upsertAndAssignProductTypeToProductData(productData, sharedContext) return productData as CreateProductOnlyDTO }) @@ -408,6 +406,223 @@ export default class ProductModuleService< return products } + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + protected async update_( + data: ProductTypes.UpdateProductDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const productIds = data.map(pd => pd.id) + const existingProductVariants = await this.productVariantService_.list( + { product_id: productIds }, + {}, + sharedContext + ) + + const existingProductVariantsMap = new Map< + string, + ProductVariant[] + >( + data.map((productData) => { + const productVariantsForProduct = existingProductVariants + .filter((variant) => variant.product_id === productData.id) + + return [ + productData.id, + productVariantsForProduct, + ] + }) + ) + + const productVariantsMap = new Map< + string, + (ProductTypes.CreateProductVariantDTO | ProductTypes.UpdateProductVariantDTO)[] + >() + + const productOptionsMap = new Map< + string, + ProductTypes.CreateProductOptionDTO[] + >() + + const productsData = await Promise.all( + data.map(async (product) => { + const { variants, options, ...productData } = product + + if (!isDefined(productData.id)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Cannot update product without id` + ) + } + + productVariantsMap.set(productData.id, variants ?? []) + productOptionsMap.set(productData.id, options ?? []) + + if (productData.is_giftcard) { + productData.discountable = false + } + + await this.upsertAndAssignImagesToProductData(productData, sharedContext) + await this.upsertAndAssignProductTagsToProductData(productData, sharedContext) + await this.upsertAndAssignProductTypeToProductData(productData, sharedContext) + + return productData as ProductServiceTypes.UpdateProductDTO + }) + ) + + const products = await this.productService_.update( + productsData, + sharedContext + ) + + const productByIdMap = new Map( + products.map((product) => [product.id, product]) + ) + + const productOptionsData = [...productOptionsMap] + .map(([id, options]) => options.map((option) => ({ + ...option, + product: productByIdMap.get(id)!, + }))) + .flat() + + const productOptions = await this.productOptionService_.create( + productOptionsData, + sharedContext + ) + + const productVariantIdsToDelete: string[] = [] + const productVariantsToCreateMap = new Map< + string, + ProductTypes.CreateProductVariantDTO[] + >() + + const productVariantsToUpdateMap = new Map< + string, + ProductTypes.UpdateProductVariantDTO[] + >() + + for (const [productId, variants] of productVariantsMap) { + const variantsToCreate: ProductTypes.CreateProductVariantDTO[] = [] + const variantsToUpdate: ProductTypes.UpdateProductVariantDTO[] = [] + const existingVariants = existingProductVariantsMap.get(productId) + + variants.forEach((variant) => { + const isVariantIdDefined = ("id" in variant) && isDefined(variant.id) + + if (isVariantIdDefined) { + variantsToUpdate.push(variant as ProductTypes.UpdateProductVariantDTO) + } else { + variantsToCreate.push(variant as ProductTypes.CreateProductVariantDTO) + } + + const variantOptions = variant.options?.map((option, index) => { + const productOption = productOptions[index] + return { + option: productOption, + value: option.value, + } + }) + + if (variantOptions) { + variant.options = variantOptions + } + }) + + productVariantsToCreateMap.set(productId, variantsToCreate) + productVariantsToUpdateMap.set(productId, variantsToUpdate) + + const variantsToUpdateIds = variantsToUpdate.map(v => v?.id) as string[] + const existingVariantIds = existingVariants?.map(v => v.id) || [] + const variantsToUpdateSet = new Set(variantsToUpdateIds) + + productVariantIdsToDelete.push( + ...new Set( + existingVariantIds.filter(x => !variantsToUpdateSet.has(x)) + ) + ) + } + + const promises: Promise[] = [] + + productVariantsToCreateMap.forEach((variants, productId) => { + promises.push( + this.productVariantService_.create( + productByIdMap.get(productId)!, + variants as unknown as ProductTypes.CreateProductVariantOnlyDTO[], + sharedContext + ) + ) + }) + + productVariantsToUpdateMap.forEach((variants, productId) => { + promises.push( + this.productVariantService_.update( + productByIdMap.get(productId)!, + variants as unknown as ProductVariantServiceTypes.UpdateProductVariantDTO[], + sharedContext + ) + ) + }) + + if (productVariantIdsToDelete.length) { + promises.push( + this.productVariantService_.delete(productVariantIdsToDelete, sharedContext) + ) + } + + await Promise.all(promises) + + return products + } + + protected async upsertAndAssignImagesToProductData( + productData: ProductTypes.CreateProductDTO | ProductTypes.UpdateProductDTO, + sharedContext: Context = {} + ) { + if (!productData.thumbnail && productData.images?.length) { + productData.thumbnail = isString(productData.images[0]) + ? (productData.images[0] as string) + : (productData.images[0] as { url: string }).url + } + + if (productData.images?.length) { + productData.images = await this.productImageService_.upsert( + productData.images.map((image) => + isString(image) ? image : image.url + ), + sharedContext + ) + } + } + + protected async upsertAndAssignProductTagsToProductData( + productData: ProductTypes.CreateProductDTO | ProductTypes.UpdateProductDTO, + sharedContext: Context = {} + ) { + if (productData.tags?.length) { + productData.tags = await this.productTagService_.upsert( + productData.tags, + sharedContext + ) + } + } + + protected async upsertAndAssignProductTypeToProductData( + productData: ProductTypes.CreateProductDTO | ProductTypes.UpdateProductDTO, + sharedContext: Context = {} + ) { + if (isDefined(productData.type)) { + const productType = ( + await this.productTypeService_.upsert( + [productData.type as ProductTypes.CreateProductTypeDTO], + sharedContext + ) + ) + + productData.type = productType?.[0] + } + } + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") async delete( productIds: string[], diff --git a/packages/product/src/services/product-variant.ts b/packages/product/src/services/product-variant.ts index d04c7b6240ec0..762a3d40b02ae 100644 --- a/packages/product/src/services/product-variant.ts +++ b/packages/product/src/services/product-variant.ts @@ -1,5 +1,6 @@ import { Product, ProductVariant } from "@models" import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" +import { ProductVariantRepository } from "@repositories" import { InjectTransactionManager, isString, @@ -8,9 +9,9 @@ import { retrieveEntity, } from "@medusajs/utils" +import { ProductVariantServiceTypes } from "../types/services" import ProductService from "./product" import { doNotForceTransaction } from "../utils" -import { ProductVariantRepository } from "@repositories" type InjectedDependencies = { productVariantRepository: DAL.RepositoryService @@ -91,7 +92,8 @@ export default class ProductVariantService< if (isString(productOrId)) { product = await this.productService_.retrieve( - productOrId as string, + productOrId, + {}, sharedContext ) } @@ -112,4 +114,38 @@ export default class ProductVariantService< transactionManager: sharedContext.transactionManager, })) as TEntity[] } + + @InjectTransactionManager(doNotForceTransaction, "productVariantRepository_") + async update( + productOrId: TProduct | string, + data: ProductVariantServiceTypes.UpdateProductVariantDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + let product = productOrId as unknown as Product + + if (isString(productOrId)) { + product = await this.productService_.retrieve( + productOrId, + {}, + sharedContext + ) + } + + const variantsData = [...data] + variantsData.forEach((variant) => Object.assign(variant, { product })) + + return await (this.productVariantRepository_ as ProductVariantRepository).update(variantsData, { + transactionManager: sharedContext.transactionManager, + }) as TEntity[] + } + + @InjectTransactionManager(doNotForceTransaction, "productVariantRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.productVariantRepository_.delete(ids, { + transactionManager: sharedContext.transactionManager, + }) + } } diff --git a/packages/product/src/services/product.ts b/packages/product/src/services/product.ts index b31b865aff3cf..78b8024547354 100644 --- a/packages/product/src/services/product.ts +++ b/packages/product/src/services/product.ts @@ -12,8 +12,11 @@ import { MedusaContext, MedusaError, ModulesSdkUtils, + isDefined, } from "@medusajs/utils" import { ProductRepository } from "@repositories" + +import { ProductServiceTypes } from "../types/services" import { doNotForceTransaction } from "../utils" type InjectedDependencies = { @@ -27,10 +30,22 @@ export default class ProductService { this.productRepository_ = productRepository } - async retrieve(productId: string, sharedContext?: Context): Promise { + async retrieve( + productId: string, + config: FindConfig = {}, + sharedContext?: Context + ): Promise { + if (!isDefined(productId)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `"productId" must be defined` + ) + } + const queryOptions = ModulesSdkUtils.buildQuery({ id: productId, - }) + }, config) + const product = await this.productRepository_.find( queryOptions, sharedContext @@ -116,6 +131,22 @@ export default class ProductService { )) as TEntity[] } + @InjectTransactionManager(doNotForceTransaction, "productRepository_") + async update( + data: ProductServiceTypes.UpdateProductDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await (this.productRepository_ as ProductRepository).update( + data as WithRequiredProperty< + ProductServiceTypes.UpdateProductDTO, + "id" + >[], + { + transactionManager: sharedContext.transactionManager, + } + ) as TEntity[] + } + @InjectTransactionManager(doNotForceTransaction, "productRepository_") async delete( ids: string[], diff --git a/packages/product/src/types/index.ts b/packages/product/src/types/index.ts index 17d032b4c367d..1c7b8e8c5281c 100644 --- a/packages/product/src/types/index.ts +++ b/packages/product/src/types/index.ts @@ -3,3 +3,5 @@ import { IEventBusService } from "@medusajs/types" export type InitializeModuleInjectableDependencies = { eventBusService?: IEventBusService } + +export * from "./services" diff --git a/packages/product/src/types/services/index.ts b/packages/product/src/types/services/index.ts new file mode 100644 index 0000000000000..e2797cdc2f626 --- /dev/null +++ b/packages/product/src/types/services/index.ts @@ -0,0 +1,2 @@ +export * as ProductServiceTypes from "./product" +export * as ProductVariantServiceTypes from "./product-variant" diff --git a/packages/product/src/types/services/product-variant.ts b/packages/product/src/types/services/product-variant.ts new file mode 100644 index 0000000000000..1c7a90f95f105 --- /dev/null +++ b/packages/product/src/types/services/product-variant.ts @@ -0,0 +1,23 @@ +import { CreateProductVariantOptionDTO } from "@medusajs/types" + +export interface UpdateProductVariantDTO { + id: string + title?: string + sku?: string + barcode?: string + ean?: string + upc?: string + allow_backorder?: boolean + inventory_quantity?: number + manage_inventory?: boolean + hs_code?: string + origin_country?: string + mid_code?: string + material?: string + weight?: number + length?: number + height?: number + width?: number + options?: CreateProductVariantOptionDTO[] + metadata?: Record +} diff --git a/packages/product/src/types/services/product.ts b/packages/product/src/types/services/product.ts new file mode 100644 index 0000000000000..e2405ddb16d9d --- /dev/null +++ b/packages/product/src/types/services/product.ts @@ -0,0 +1,27 @@ +import { ProductStatus, ProductCategoryDTO } from "@medusajs/types" + +export interface UpdateProductDTO { + id: string + title?: string + subtitle?: string + description?: string + is_giftcard?: boolean + discountable?: boolean + images?: { id?: string; url: string }[] + thumbnail?: string + handle?: string + status?: ProductStatus + collection_id?: string + width?: number + height?: number + length?: number + weight?: number + origin_country?: string + hs_code?: string + material?: string + mid_code?: string + metadata?: Record + tags?: { id: string }[] + categories?: { id: string }[] + type_id?: string +} diff --git a/packages/types/src/dal/repository-service.ts b/packages/types/src/dal/repository-service.ts index 408d5c7edba06..445473aec85d5 100644 --- a/packages/types/src/dal/repository-service.ts +++ b/packages/types/src/dal/repository-service.ts @@ -34,6 +34,9 @@ export interface RepositoryService { create(data: unknown[], context?: Context): Promise + // TODO: remove optionality when all the other repositories have an update + update?(data: unknown[], context?: Context): Promise + delete(ids: string[], context?: Context): Promise softDelete(ids: string[], context?: Context): Promise diff --git a/packages/types/src/inventory/service.ts b/packages/types/src/inventory/service.ts index ea37bea93c121..d0c5ebe34813f 100644 --- a/packages/types/src/inventory/service.ts +++ b/packages/types/src/inventory/service.ts @@ -58,6 +58,7 @@ export interface IInventoryService { context?: SharedContext ): Promise + // TODO make it bulk createReservationItems( input: CreateReservationItemInput[], context?: SharedContext diff --git a/packages/types/src/product/common.ts b/packages/types/src/product/common.ts index 449ad3448cfaf..948f614e0bd84 100644 --- a/packages/types/src/product/common.ts +++ b/packages/types/src/product/common.ts @@ -163,6 +163,7 @@ export interface FilterableProductVariantProps extends BaseFilterable { id?: string | string[] sku?: string | string[] + product_id?: string | string[] options?: { id?: string[] } } @@ -219,6 +220,28 @@ export interface CreateProductVariantDTO { metadata?: Record } +export interface UpdateProductVariantDTO { + id: string + title?: string + sku?: string + barcode?: string + ean?: string + upc?: string + allow_backorder?: boolean + inventory_quantity?: number + manage_inventory?: boolean + hs_code?: string + origin_country?: string + mid_code?: string + material?: string + weight?: number + length?: number + height?: number + width?: number + options?: CreateProductVariantOptionDTO[] + metadata?: Record +} + export interface CreateProductDTO { title: string subtitle?: string @@ -233,7 +256,6 @@ export interface CreateProductDTO { type_id?: string collection_id?: string tags?: CreateProductTagDTO[] - // sales_channel categories?: { id: string }[] options?: CreateProductOptionDTO[] variants?: CreateProductVariantDTO[] @@ -248,6 +270,35 @@ export interface CreateProductDTO { metadata?: Record } +export interface UpdateProductDTO { + id: string + title?: string + subtitle?: string + description?: string + is_giftcard?: boolean + discountable?: boolean + images?: string[] | { id?: string; url: string }[] + thumbnail?: string + handle?: string + status?: ProductStatus + type?: CreateProductTypeDTO + type_id?: string | null + collection_id?: string | null + tags?: CreateProductTagDTO[] + categories?: { id: string }[] + options?: CreateProductOptionDTO[] + variants?: (CreateProductVariantDTO | UpdateProductVariantDTO)[] + width?: number + height?: number + length?: number + weight?: number + origin_country?: string + hs_code?: string + material?: string + mid_code?: string + metadata?: Record +} + export interface CreateProductOnlyDTO { title: string subtitle?: string @@ -294,6 +345,28 @@ export interface CreateProductVariantOnlyDTO { metadata?: Record } +export interface UpdateProductVariantOnlyDTO { + id: string, + title?: string + sku?: string + barcode?: string + ean?: string + upc?: string + allow_backorder?: boolean + inventory_quantity?: number + manage_inventory?: boolean + hs_code?: string + origin_country?: string + mid_code?: string + material?: string + weight?: number + length?: number + height?: number + width?: number + options?: (CreateProductVariantOptionDTO & { option: any })[] + metadata?: Record +} + export interface CreateProductOptionOnlyDTO { product: { id: string } title: string diff --git a/packages/types/src/product/service.ts b/packages/types/src/product/service.ts index 9d04bce26e26e..bf91e2ae0b27e 100644 --- a/packages/types/src/product/service.ts +++ b/packages/types/src/product/service.ts @@ -1,5 +1,6 @@ import { CreateProductDTO, + UpdateProductDTO, FilterableProductCategoryProps, FilterableProductCollectionProps, FilterableProductProps, @@ -19,7 +20,11 @@ import { JoinerServiceConfig } from "../joiner" export interface IProductModuleService { __joinerConfig(): JoinerServiceConfig - retrieve(productId: string, sharedContext?: Context): Promise + retrieve( + productId: string, + config?: FindConfig, + sharedContext?: Context + ): Promise list( filters?: FilterableProductProps, @@ -98,6 +103,11 @@ export interface IProductModuleService { sharedContext?: Context ): Promise + update( + data: UpdateProductDTO[], + sharedContext?: Context + ): Promise + delete(productIds: string[], sharedContext?: Context): Promise softDelete(