diff --git a/integration-tests/api/__tests__/batch-jobs/product/import.js b/integration-tests/api/__tests__/batch-jobs/product/import.js index 351aeeed7fea0..0f875df5ccae7 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/import.js +++ b/integration-tests/api/__tests__/batch-jobs/product/import.js @@ -183,7 +183,7 @@ describe("Product import batch job", () => { ean: null, upc: null, inventory_quantity: 10, - prices: [ + prices: expect.arrayContaining([ expect.objectContaining({ currency_code: "eur", amount: 100, @@ -198,7 +198,7 @@ describe("Product import batch job", () => { amount: 130, region_id: "region-product-import-1", }), - ], + ]), options: expect.arrayContaining([ expect.objectContaining({ value: "option 1 value red", @@ -210,24 +210,24 @@ describe("Product import batch job", () => { }), ], type: null, - images: [ + images: expect.arrayContaining([ expect.objectContaining({ url: "test-image.png", }), - ], - options: [ + ]), + options: expect.arrayContaining([ expect.objectContaining({ title: "test-option-1", }), expect.objectContaining({ title: "test-option-2", }), - ], - tags: [ + ]), + tags: expect.arrayContaining([ expect.objectContaining({ value: "123_1", }), - ], + ]), collection: expect.objectContaining({ handle: collectionHandle1, }), @@ -249,7 +249,7 @@ describe("Product import batch job", () => { ean: null, upc: null, inventory_quantity: 10, - prices: [ + prices: expect.arrayContaining([ expect.objectContaining({ currency_code: "eur", amount: 100, @@ -264,7 +264,7 @@ describe("Product import batch job", () => { amount: 130, region_id: "region-product-import-1", }), - ], + ]), options: expect.arrayContaining([ expect.objectContaining({ value: "option 1 value red", @@ -276,19 +276,19 @@ describe("Product import batch job", () => { }), ], type: null, - images: [ + images: expect.arrayContaining([ expect.objectContaining({ url: "test-image.png", }), - ], - options: [ + ]), + options: expect.arrayContaining([ expect.objectContaining({ title: "test-option-1", }), expect.objectContaining({ title: "test-option-2", }), - ], + ]), tags: [], collection: expect.objectContaining({ handle: collectionHandle1, diff --git a/packages/inventory/CHANGELOG.md b/packages/inventory/CHANGELOG.md index bd6ad8291ad93..da3d63f3c2595 100644 --- a/packages/inventory/CHANGELOG.md +++ b/packages/inventory/CHANGELOG.md @@ -148,4 +148,4 @@ - Updated dependencies [[`9dbccd9ca`](https://github.com/medusajs/medusa/commit/9dbccd9ca78b8b66f9a21947bb863622e7ff326b), [`542daeead`](https://github.com/medusajs/medusa/commit/542daeeadd78d939f5144c690e8907374da6d085), [`8c08d0031`](https://github.com/medusajs/medusa/commit/8c08d003198b94c00f8428a51c0e79d2ca9d1dc7), [`017538883`](https://github.com/medusajs/medusa/commit/017538883588792e1ff37abcab0fd2872c9af932), [`b2839e2e4`](https://github.com/medusajs/medusa/commit/b2839e2e4dc0d9344fa2ac8d4d16b796def4c56d), [`76d175231`](https://github.com/medusajs/medusa/commit/76d17523105d3860028a90a45b6038a64040e5ce), [`9e3beaf53`](https://github.com/medusajs/medusa/commit/9e3beaf5319dc785cf84b856cfcc8193df90c3a4), [`7d4b8b9cc`](https://github.com/medusajs/medusa/commit/7d4b8b9cc59672d01cdf0c6f331bc3d1eeec9bee), [`aab163bab`](https://github.com/medusajs/medusa/commit/aab163babb91759a05b852d34c299cdfac96d800), [`a0c4cfe0f`](https://github.com/medusajs/medusa/commit/a0c4cfe0f74cf30c45956c32c2fb22bf833bea68), [`27a29ef24`](https://github.com/medusajs/medusa/commit/27a29ef24e5ea1ba2bc0be8ecb7dd747d4c7c65b), [`aef842123`](https://github.com/medusajs/medusa/commit/aef8421235d8fff68d7d4f8b73f77484073311a5), [`1dc79590b`](https://github.com/medusajs/medusa/commit/1dc79590b3539af09dbc8fbf931d9b5ee225fb0d), [`9c4647383`](https://github.com/medusajs/medusa/commit/9c4647383ebf0a183ccc566636bcf7af06409060), [`a0c4cfe0f`](https://github.com/medusajs/medusa/commit/a0c4cfe0f74cf30c45956c32c2fb22bf833bea68), [`b80124d32`](https://github.com/medusajs/medusa/commit/b80124d32d950790c2a01b49e8c34d562b1d57f4), [`cb1ec0076`](https://github.com/medusajs/medusa/commit/cb1ec0076b4fd932c686d6027e8b060ceded3a64), [`142c8aa70`](https://github.com/medusajs/medusa/commit/142c8aa70f583d9b11a6add2b8f988e9ba4cf979), [`1547dd814`](https://github.com/medusajs/medusa/commit/1547dd8143889fc30045fc3d0241de8e69acb76e), [`d2c692aa9`](https://github.com/medusajs/medusa/commit/d2c692aa96ea89c053f9a694a9ae6dba77e89b14), [`150696de9`](https://github.com/medusajs/medusa/commit/150696de99fc852c5d72a746f168b6f62b2086ed), [`93d0dc1bd`](https://github.com/medusajs/medusa/commit/93d0dc1bdcb54cf6e87428a7bb9b0dac196b4de2), [`b3e4be720`](https://github.com/medusajs/medusa/commit/b3e4be72087d0b528c3cce322edf9325b855c8ae)]: - @medusajs/medusa@1.7.4 - - medusa-interfaces@1.3.4 + - medusa-interfaces@1.3.4 \ No newline at end of file diff --git a/packages/medusa-test-utils/CHANGELOG.md b/packages/medusa-test-utils/CHANGELOG.md index f1298f7e12717..500aea0906ff2 100644 --- a/packages/medusa-test-utils/CHANGELOG.md +++ b/packages/medusa-test-utils/CHANGELOG.md @@ -254,4 +254,4 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Bug Fixes -- updates license ([db519fb](https://github.com/medusajs/medusa/commit/db519fbaa6f8ad02c19cbecba5d4f28ba1ee81aa)) +- updates license ([db519fb](https://github.com/medusajs/medusa/commit/db519fbaa6f8ad02c19cbecba5d4f28ba1ee81aa)) \ No newline at end of file diff --git a/packages/medusa-test-utils/src/mock-manager.js b/packages/medusa-test-utils/src/mock-manager.js index 2e4fe183d763d..da30d5ec4d14b 100644 --- a/packages/medusa-test-utils/src/mock-manager.js +++ b/packages/medusa-test-utils/src/mock-manager.js @@ -1,9 +1,22 @@ export default { + connection: { + getMetadata: (target) => { + + return target["metadata"] ?? { + columns: [] + } + } + }, + getRepository: function (repo) { return repo }, withRepository: function (repo) { + if (repo) { + return Object.assign(repo, { manager: this }) + } + return repo }, diff --git a/packages/medusa-test-utils/src/mock-repository.js b/packages/medusa-test-utils/src/mock-repository.js index c2a7bede14e0f..6a8abafb07069 100644 --- a/packages/medusa-test-utils/src/mock-repository.js +++ b/packages/medusa-test-utils/src/mock-repository.js @@ -14,6 +14,7 @@ class MockRepo { del, count, insertBulk, + metadata }) { this.create_ = create this.update_ = update @@ -28,6 +29,10 @@ class MockRepo { this.findAndCount_ = findAndCount this.findOneWithRelations_ = findOneWithRelations this.insertBulk_ = insertBulk + + this.metadata = metadata ?? { + columns: [] + } } setFindOne(fn) { @@ -83,11 +88,22 @@ class MockRepo { return this.findDescendantsTree_(...args) } }) + findOneOrFail = jest.fn().mockImplementation((...args) => { + if (this.findOneOrFail_) { + return this.findOneOrFail_(...args) + } + }) + find = jest.fn().mockImplementation((...args) => { if (this.find_) { return this.find_(...args) } }) + softRemove = jest.fn().mockImplementation((...args) => { + if (this.softRemove_) { + return this.softRemove_(...args) + } + }) save = jest.fn().mockImplementation((...args) => { if (this.save_) { return this.save_(...args) @@ -107,6 +123,7 @@ class MockRepo { } return {} }) + delete = jest.fn().mockImplementation((...args) => { if (this.delete_) { return this.delete_(...args) diff --git a/packages/medusa/CHANGELOG.md b/packages/medusa/CHANGELOG.md index 38e8393fe93cd..a24150c4e1a97 100644 --- a/packages/medusa/CHANGELOG.md +++ b/packages/medusa/CHANGELOG.md @@ -2407,4 +2407,4 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Bug Fixes -- updates license ([db519fb](https://github.com/medusajs/medusa/commit/db519fbaa6f8ad02c19cbecba5d4f28ba1ee81aa)) +- updates license ([db519fb](https://github.com/medusajs/medusa/commit/db519fbaa6f8ad02c19cbecba5d4f28ba1ee81aa)) \ No newline at end of file diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/update-product.js b/packages/medusa/src/api/routes/admin/products/__tests__/update-product.js index 664cb7ff0f35a..4ea300ca051c5 100644 --- a/packages/medusa/src/api/routes/admin/products/__tests__/update-product.js +++ b/packages/medusa/src/api/routes/admin/products/__tests__/update-product.js @@ -17,7 +17,7 @@ describe("POST /admin/products/:id", () => { description: "Updated test description", handle: "handle", variants: [ - { id: IdMap.getId("variant_1"), title: "Green" }, + { id: IdMap.getId("testVariant"), title: "Green" }, { title: "Blue" }, { title: "Yellow" }, ], @@ -48,7 +48,6 @@ describe("POST /admin/products/:id", () => { }) it("successfully updates variants and create new ones", async () => { - expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(2) expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1) expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(2) }) @@ -73,7 +72,7 @@ describe("POST /admin/products/:id", () => { ) expect(subject.status).toEqual(404) expect(subject.error.text).toEqual( - `{"type":"not_found","message":"Variant with id: test_321 is not associated with this product"}` + `{"type":"not_found","message":"Variants with id: test_321 are not associated with this product"}` ) }) }) diff --git a/packages/medusa/src/api/routes/admin/products/update-product.ts b/packages/medusa/src/api/routes/admin/products/update-product.ts index a5e610ba531df..aa093f60f9b76 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.ts +++ b/packages/medusa/src/api/routes/admin/products/update-product.ts @@ -17,7 +17,7 @@ import { import { EntityManager } from "typeorm" import { defaultAdminProductFields, defaultAdminProductRelations } from "." import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" -import { ProductStatus } from "../../../../models" +import { ProductStatus, ProductVariant } from "../../../../models" import { PricingService, ProductService, @@ -34,10 +34,12 @@ import { import { CreateProductVariantInput, ProductVariantPricesUpdateReq, + UpdateProductVariantInput, } from "../../../../types/product-variant" import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" import { DistributedTransaction } from "../../../../utils/transaction" import { validator } from "../../../../utils/validator" +import { ProductVariantRepository } from "../../../../repositories/product-variant" import { createVariantTransaction, revertVariantTransaction, @@ -112,6 +114,9 @@ export default async (req, res) => { const validated = await validator(AdminPostProductsProductReq, req.body) const logger: Logger = req.scope.resolve("logger") + const productVariantRepo: typeof ProductVariantRepository = req.scope.resolve( + "productVariantRepository" + ) const productService: ProductService = req.scope.resolve("productService") const pricingService: PricingService = req.scope.resolve("pricingService") const productVariantService: ProductVariantService = req.scope.resolve( @@ -124,31 +129,74 @@ export default async (req, res) => { const manager: EntityManager = req.scope.resolve("manager") await manager.transaction(async (transactionManager) => { + const productServiceTx = productService.withTransaction(transactionManager) + const { variants } = validated delete validated.variants - await productService - .withTransaction(transactionManager) - .update(id, validated) + const product = await productServiceTx.update(id, validated) if (!variants) { return } - const product = await productService + const variantRepo = manager.withRepository(productVariantRepo) + const productVariants = await productVariantService .withTransaction(transactionManager) - .retrieve(id, { - relations: ["variants"], - }) + .list( + { product_id: id }, + { + select: variantRepo.metadata.columns.map( + (c) => c.propertyName + ) as (keyof ProductVariant)[], + } + ) + + const productVariantMap = new Map(productVariants.map((v) => [v.id, v])) + const variantWithIdSet = new Set() + + const variantIdsNotBelongingToProduct: string[] = [] + const variantsToUpdate: { + variant: ProductVariant + updateData: UpdateProductVariantInput + }[] = [] + const variantsToCreate: ProductVariantReq[] = [] + + // Preparing the data step + for (const [variantRank, variant] of variants.entries()) { + if (!variant.id) { + Object.assign(variant, { + variant_rank: variantRank, + options: variant.options || [], + prices: variant.prices || [], + }) + variantsToCreate.push(variant) + continue + } + + // Will be used to find the variants that should be removed during the next steps + variantWithIdSet.add(variant.id) - // Iterate product variants and update their properties accordingly - for (const variant of product.variants) { - const exists = variants.find((v) => v.id && variant.id === v.id) - if (!exists) { - await productVariantService - .withTransaction(transactionManager) - .delete(variant.id) + if (!productVariantMap.has(variant.id)) { + variantIdsNotBelongingToProduct.push(variant.id) + continue } + + const productVariant = productVariantMap.get(variant.id)! + Object.assign(variant, { + variant_rank: variantRank, + product_id: productVariant.product_id, + }) + variantsToUpdate.push({ variant: productVariant, updateData: variant }) + } + + if (variantIdsNotBelongingToProduct.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Variants with id: ${variantIdsNotBelongingToProduct.join( + ", " + )} are not associated with this product` + ) } const allVariantTransactions: DistributedTransaction[] = [] @@ -159,57 +207,46 @@ export default async (req, res) => { productVariantService, } - for (const [index, newVariant] of variants.entries()) { - const variantRank = index + const productVariantServiceTx = + productVariantService.withTransaction(transactionManager) - if (newVariant.id) { - const variant = product.variants.find((v) => v.id === newVariant.id) + // Delete the variant that does not exist anymore from the provided variants + const variantIdsToDelete = [...productVariantMap.keys()].filter( + (variantId) => !variantWithIdSet.has(variantId) + ) - if (!variant) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Variant with id: ${newVariant.id} is not associated with this product` - ) - } + if (variantIdsToDelete) { + await productVariantServiceTx.delete(variantIdsToDelete) + } - await productVariantService - .withTransaction(transactionManager) - .update(variant, { - ...newVariant, - variant_rank: variantRank, - product_id: variant.product_id, - }) - } else { - // If the provided variant does not have an id, we assume that it - // should be created - - try { - const input = { - ...newVariant, - variant_rank: variantRank, - options: newVariant.options || [], - prices: newVariant.prices || [], - } + if (variantsToUpdate.length) { + await productVariantServiceTx.update(variantsToUpdate) + } - const varTransation = await createVariantTransaction( - transactionDependencies, - product.id, - input as CreateProductVariantInput - ) - allVariantTransactions.push(varTransation) - } catch (e) { - await Promise.all( - allVariantTransactions.map(async (transaction) => { - await revertVariantTransaction( - transactionDependencies, - transaction - ).catch(() => logger.warn("Transaction couldn't be reverted.")) - }) - ) - - throw e - } - } + if (variantsToCreate.length) { + await Promise.all( + variantsToCreate.map(async (data) => { + try { + const varTransaction = await createVariantTransaction( + transactionDependencies, + product.id, + data as CreateProductVariantInput + ) + allVariantTransactions.push(varTransaction) + } catch (e) { + await Promise.all( + allVariantTransactions.map(async (transaction) => { + await revertVariantTransaction( + transactionDependencies, + transaction + ).catch(() => logger.warn("Transaction couldn't be reverted.")) + }) + ) + + throw e + } + }) + ) } }) diff --git a/packages/medusa/src/helpers/test-request.js b/packages/medusa/src/helpers/test-request.js index bc17b23d6c805..a05a2679bf770 100644 --- a/packages/medusa/src/helpers/test-request.js +++ b/packages/medusa/src/helpers/test-request.js @@ -1,8 +1,3 @@ -import { - moduleHelper, - moduleLoader, - registerModules, -} from "@medusajs/modules-sdk" import { asValue, createContainer } from "awilix" import express from "express" import jwt from "jsonwebtoken" @@ -15,6 +10,13 @@ import featureFlagLoader, { featureFlagRouter } from "../loaders/feature-flags" import passportLoader from "../loaders/passport" import servicesLoader from "../loaders/services" import strategiesLoader from "../loaders/strategies" +import { + moduleHelper, + moduleLoader, + registerModules, +} from "@medusajs/modules-sdk" +import repositories from "../loaders/repositories" +import models from "../loaders/models" const adminSessionOpts = { cookieName: "session", @@ -40,8 +42,32 @@ const config = { const testApp = express() +function asArray(resolvers) { + return { + resolve: (container) => + resolvers.map((resolver) => container.build(resolver)), + } +} + const container = createContainer() +// TODO: remove once the util is merged in master +container.registerAdd = function (name, registration) { + const storeKey = name + "_STORE" + + if (this.registrations[storeKey] === undefined) { + this.register(storeKey, asValue([])) + } + const store = this.resolve(storeKey) + + if (this.registrations[name] === undefined) { + this.register(name, asArray(store)) + } + store.unshift(registration) + + return this +}.bind(container) + container.register("featureFlagRouter", asValue(featureFlagRouter)) container.register("modulesHelper", asValue(moduleHelper)) container.register("configModule", asValue(config)) @@ -66,6 +92,8 @@ testApp.use((req, res, next) => { }) featureFlagLoader(config) +models({ container, configModule: config, isTest: true }) +repositories({ container, isTest: true }) servicesLoader({ container, configModule: config }) strategiesLoader({ container, configModule: config }) passportLoader({ app: testApp, container, configModule: config }) diff --git a/packages/medusa/src/loaders/models.ts b/packages/medusa/src/loaders/models.ts index 91d741f95dbba..c237e734dc844 100644 --- a/packages/medusa/src/loaders/models.ts +++ b/packages/medusa/src/loaders/models.ts @@ -3,16 +3,16 @@ import glob from "glob" import path from "path" import { ClassConstructor, MedusaContainer } from "../types/global" import { EntitySchema } from "typeorm" -import { asClass, asValue, AwilixContainer } from "awilix" +import { asClass, asValue } from "awilix" /** * Registers all models in the model directory */ export default ( - { container }: { container: MedusaContainer }, + { container, isTest }: { container: MedusaContainer; isTest?: boolean }, config = { register: true } ) => { - const corePath = "../models/*.js" + const corePath = isTest ? "../models/*.ts" : "../models/*.js" const coreFull = path.join(__dirname, corePath) const models: (ClassConstructor | EntitySchema)[] = [] diff --git a/packages/medusa/src/loaders/repositories.ts b/packages/medusa/src/loaders/repositories.ts index f640229f90f0c..df38c9f8b7eba 100644 --- a/packages/medusa/src/loaders/repositories.ts +++ b/packages/medusa/src/loaders/repositories.ts @@ -8,8 +8,14 @@ import { asValue } from "awilix" /** * Registers all models in the model directory */ -export default ({ container }: { container: MedusaContainer }): void => { - const corePath = "../repositories/*.js" +export default ({ + container, + isTest, +}: { + container: MedusaContainer + isTest?: boolean +}): void => { + const corePath = isTest ? "../repositories/*.ts" : "../repositories/*.js" const coreFull = path.join(__dirname, corePath) const core = glob.sync(coreFull, { cwd: __dirname }) diff --git a/packages/medusa/src/repositories/image.ts b/packages/medusa/src/repositories/image.ts index 3334121acb6e9..274544d829fc0 100644 --- a/packages/medusa/src/repositories/image.ts +++ b/packages/medusa/src/repositories/image.ts @@ -1,8 +1,26 @@ +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity" + import { Image } from "../models" import { dataSource } from "../loaders/database" import { In } from "typeorm" export const ImageRepository = dataSource.getRepository(Image).extend({ + async insertBulk(data: QueryDeepPartialEntity[]): Promise { + const queryBuilder = this.createQueryBuilder() + .insert() + .into(Image) + .values(data) + + // TODO: remove if statement once this issue is resolved https://github.com/typeorm/typeorm/issues/9850 + if (!queryBuilder.connection.driver.isReturningSqlSupported("insert")) { + const rawImages = await queryBuilder.execute() + return rawImages.generatedMaps.map((d) => this.create(d)) as Image[] + } + + const rawImages = await queryBuilder.returning("*").execute() + return rawImages.generatedMaps.map((d) => this.create(d)) + }, + async upsertImages(imageUrls: string[]) { const existingImages = await this.find({ where: { @@ -14,16 +32,21 @@ export const ImageRepository = dataSource.getRepository(Image).extend({ ) const upsertedImgs: Image[] = [] + const imageToCreate: QueryDeepPartialEntity[] = [] - for (const url of imageUrls) { + imageUrls.forEach((url) => { const aImg = existingImagesMap.get(url) if (aImg) { upsertedImgs.push(aImg) } else { const newImg = this.create({ url }) - const savedImg = await this.save(newImg) - upsertedImgs.push(savedImg) + imageToCreate.push(newImg as QueryDeepPartialEntity) } + }) + + if (imageToCreate.length) { + const newImgs = await this.insertBulk(imageToCreate) + upsertedImgs.push(...newImgs) } return upsertedImgs diff --git a/packages/medusa/src/repositories/money-amount.ts b/packages/medusa/src/repositories/money-amount.ts index e42782327849d..558b241f7f002 100644 --- a/packages/medusa/src/repositories/money-amount.ts +++ b/packages/medusa/src/repositories/money-amount.ts @@ -7,13 +7,15 @@ import { ObjectLiteral, WhereExpressionBuilder, } from "typeorm" +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity" import { MoneyAmount } from "../models" import { PriceListPriceCreateInput, PriceListPriceUpdateInput, } from "../types/price-list" -import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity" import { dataSource } from "../loaders/database" +import { ProductVariantPrice } from "../types/product-variant" +import { isString } from "../utils" type Price = Partial< Omit @@ -24,6 +26,31 @@ type Price = Partial< export const MoneyAmountRepository = dataSource .getRepository(MoneyAmount) .extend({ + async insertBulk( + data: QueryDeepPartialEntity[] + ): Promise { + const queryBuilder = this.createQueryBuilder() + .insert() + .into(MoneyAmount) + .values(data) + + // TODO: remove if statement once this issue is resolved https://github.com/typeorm/typeorm/issues/9850 + if (!queryBuilder.connection.driver.isReturningSqlSupported("insert")) { + const rawMoneyAmounts = await queryBuilder.execute() + return rawMoneyAmounts.generatedMaps.map((d) => + this.create(d) + ) as MoneyAmount[] + } + + const rawMoneyAmounts = await queryBuilder.returning("*").execute() + return rawMoneyAmounts.generatedMaps.map((d) => this.create(d)) + }, + + /** + * Will be removed in a future release. + * Use `deleteVariantPricesNotIn` instead. + * @deprecated + */ async findVariantPricesNotIn( variantId: string, prices: Price[] @@ -44,6 +71,63 @@ export const MoneyAmountRepository = dataSource return pricesNotInPricesPayload }, + async deleteVariantPricesNotIn( + variantIdOrData: + | string + | { variantId: string; prices: ProductVariantPrice[] }[], + prices?: Price[] + ): Promise { + const data = isString(variantIdOrData) + ? [ + { + variantId: variantIdOrData, + prices: prices!, + }, + ] + : variantIdOrData + + const queryBuilder = this.createQueryBuilder().delete() + + for (const data_ of data) { + const where = { + variant_id: data_.variantId, + price_list_id: IsNull(), + } + + const orWhere: ObjectLiteral[] = [] + + for (const price of data_.prices) { + if (price.currency_code) { + orWhere.push( + { + currency_code: Not(price.currency_code), + }, + { + region_id: price.region_id + ? Not(price.region_id) + : Not(IsNull()), + currency_code: price.currency_code, + } + ) + } + + if (price.region_id) { + orWhere.push({ + region_id: Not(price.region_id), + }) + } + } + + queryBuilder.orWhere( + new Brackets((localQueryBuild) => { + localQueryBuild.where(where).andWhere(orWhere) + }) + ) + } + + await queryBuilder.execute() + }, + async upsertVariantCurrencyPrice( variantId: string, price: Price @@ -70,44 +154,6 @@ export const MoneyAmountRepository = dataSource return await this.save(moneyAmount) }, - async deleteVariantPricesNotIn( - variantId: string, - prices: Price[] - ): Promise { - const where = { - variant_id: variantId, - price_list_id: IsNull(), - } - - const orWhere: ObjectLiteral[] = [] - - for (const price of prices) { - if (price.currency_code) { - orWhere.push( - { - currency_code: Not(price.currency_code), - }, - { - region_id: price.region_id ? Not(price.region_id) : Not(IsNull()), - currency_code: price.currency_code, - } - ) - } - - if (price.region_id) { - orWhere.push({ - region_id: Not(price.region_id), - }) - } - } - - await this.createQueryBuilder() - .delete() - .where(where) - .andWhere(orWhere) - .execute() - }, - async addPriceListPrices( priceListId: string, prices: PriceListPriceCreateInput[], diff --git a/packages/medusa/src/repositories/staged-job.ts b/packages/medusa/src/repositories/staged-job.ts index 982ced8083086..fccc053809253 100644 --- a/packages/medusa/src/repositories/staged-job.ts +++ b/packages/medusa/src/repositories/staged-job.ts @@ -12,9 +12,7 @@ export const StagedJobRepository = dataSource.getRepository(StagedJob).extend({ // TODO: remove if statement once this issue is resolved https://github.com/typeorm/typeorm/issues/9850 if (!queryBuilder.connection.driver.isReturningSqlSupported("insert")) { const rawStagedJobs = await queryBuilder.execute() - return rawStagedJobs.generatedMaps.map((d) => - this.create(d) - ) as StagedJob[] + return rawStagedJobs.generatedMaps.map((d) => this.create(d)) } const rawStagedJobs = await queryBuilder.returning("*").execute() diff --git a/packages/medusa/src/services/__tests__/product-variant.js b/packages/medusa/src/services/__tests__/product-variant.js index e9afaf84163f4..7b9699da0f041 100644 --- a/packages/medusa/src/services/__tests__/product-variant.js +++ b/packages/medusa/src/services/__tests__/product-variant.js @@ -276,6 +276,10 @@ describe("ProductVariantService", () => { const productVariantRepository = MockRepository({ findOne: (query) => Promise.resolve({ id: IdMap.getId("ironman") }), + update: (data) => ({ + generatedMaps: [data], + }), + create: (data) => data, }) const moneyAmountRepository = MockRepository({ @@ -315,43 +319,66 @@ describe("ProductVariantService", () => { }) expect(eventBusService.emit).toHaveBeenCalledTimes(1) - expect(eventBusService.emit).toHaveBeenCalledWith( - "product-variant.updated", + expect(eventBusService.emit).toHaveBeenCalledWith([ + { + eventName: "product-variant.updated", + data: { + id: IdMap.getId("ironman"), + fields: ["title"], + }, + }, + ]) + + expect(productVariantRepository.update).toHaveBeenCalledTimes(1) + expect(productVariantRepository.update).toHaveBeenCalledWith( + { id: IdMap.getId("ironman") }, { id: IdMap.getId("ironman"), - fields: ["title"], + title: "new title", } ) - - expect(productVariantRepository.save).toHaveBeenCalledTimes(1) - expect(productVariantRepository.save).toHaveBeenCalledWith({ - id: IdMap.getId("ironman"), - title: "new title", - }) }) it("successfully updates variant", async () => { await productVariantService.update( { id: IdMap.getId("ironman"), title: "new title" }, { - title: "new title", + title: "new title 2", } ) expect(eventBusService.emit).toHaveBeenCalledTimes(1) - expect(eventBusService.emit).toHaveBeenCalledWith( - "product-variant.updated", + expect(eventBusService.emit).toHaveBeenCalledWith([ + { + eventName: "product-variant.updated", + data: { + id: IdMap.getId("ironman"), + fields: ["title"], + }, + }, + ]) + + expect(productVariantRepository.update).toHaveBeenCalledTimes(1) + expect(productVariantRepository.update).toHaveBeenCalledWith( + { id: IdMap.getId("ironman") }, { id: IdMap.getId("ironman"), - fields: ["title"], + title: "new title 2", } ) + }) - expect(productVariantRepository.save).toHaveBeenCalledTimes(1) - expect(productVariantRepository.save).toHaveBeenCalledWith({ - id: IdMap.getId("ironman"), - title: "new title", - }) + it("successfully avoid to update variant if the data have not changed", async () => { + await productVariantService.update( + { id: IdMap.getId("ironman"), title: "new title" }, + { + title: "new title", + } + ) + + expect(eventBusService.emit).toHaveBeenCalledTimes(0) + + expect(productVariantRepository.save).toHaveBeenCalledTimes(0) }) it("throws if provided variant is missing an id", async () => { @@ -376,22 +403,27 @@ describe("ProductVariantService", () => { }) expect(eventBusService.emit).toHaveBeenCalledTimes(1) - expect(eventBusService.emit).toHaveBeenCalledWith( - "product-variant.updated", + expect(eventBusService.emit).toHaveBeenCalledWith([ + { + eventName: "product-variant.updated", + data: { + id: IdMap.getId("ironman"), + fields: ["title", "metadata"], + }, + }, + ]) + + expect(productVariantRepository.update).toHaveBeenCalledTimes(1) + expect(productVariantRepository.update).toHaveBeenCalledWith( + { id: IdMap.getId("ironman") }, { id: IdMap.getId("ironman"), - fields: ["title", "metadata"], + title: "new title", + metadata: { + testing: "this", + }, } ) - - expect(productVariantRepository.save).toHaveBeenCalledTimes(1) - expect(productVariantRepository.save).toHaveBeenCalledWith({ - id: IdMap.getId("ironman"), - title: "new title", - metadata: { - testing: "this", - }, - }) }) it("successfully updates variant inventory_quantity", async () => { @@ -401,20 +433,25 @@ describe("ProductVariantService", () => { }) expect(eventBusService.emit).toHaveBeenCalledTimes(1) - expect(eventBusService.emit).toHaveBeenCalledWith( - "product-variant.updated", + expect(eventBusService.emit).toHaveBeenCalledWith([ + { + eventName: "product-variant.updated", + data: { + id: IdMap.getId("ironman"), + fields: ["title", "inventory_quantity"], + }, + }, + ]) + + expect(productVariantRepository.update).toHaveBeenCalledTimes(1) + expect(productVariantRepository.update).toHaveBeenCalledWith( + { id: IdMap.getId("ironman") }, { id: IdMap.getId("ironman"), - fields: ["title", "inventory_quantity"], + inventory_quantity: 98, + title: "new title", } ) - - expect(productVariantRepository.save).toHaveBeenCalledTimes(1) - expect(productVariantRepository.save).toHaveBeenCalledWith({ - id: IdMap.getId("ironman"), - inventory_quantity: 98, - title: "new title", - }) }) it("successfully updates variant prices", async () => { @@ -429,17 +466,19 @@ describe("ProductVariantService", () => { }) expect(productVariantService.updateVariantPrices).toHaveBeenCalledTimes(1) - expect(productVariantService.updateVariantPrices).toHaveBeenCalledWith( - IdMap.getId("ironman"), - [ - { - currency_code: "dkk", - amount: 1000, - }, - ] - ) + expect(productVariantService.updateVariantPrices).toHaveBeenCalledWith([ + { + variantId: IdMap.getId("ironman"), + prices: [ + { + currency_code: "dkk", + amount: 1000, + }, + ], + }, + ]) - expect(productVariantRepository.save).toHaveBeenCalledTimes(1) + expect(productVariantRepository.update).toHaveBeenCalledTimes(1) }) it("successfully updates variant options", async () => { @@ -460,7 +499,7 @@ describe("ProductVariantService", () => { "red" ) - expect(productVariantRepository.save).toHaveBeenCalledTimes(1) + expect(productVariantRepository.update).toHaveBeenCalledTimes(1) }) }) @@ -628,8 +667,22 @@ describe("ProductVariantService", () => { amount: 750, }) }, + find: (query) => { + if (query.where.region_id === IdMap.getId("cali")) { + return Promise.resolve([]) + } + return Promise.resolve([ + { + id: IdMap.getId("dkk"), + variant_id: IdMap.getId("ironman"), + currency_code: "dkk", + amount: 750, + }, + ]) + }, create: (p) => p, remove: () => Promise.resolve(), + insertBulk: (data) => data, }) const oldPrices = [ @@ -691,7 +744,7 @@ describe("ProductVariantService", () => { jest.clearAllMocks() }) - it("successfully removes obsolete prices and calls save on new/existing prices", async () => { + it("successfully removes obsolete prices and create new prices", async () => { await productVariantService.updateVariantPrices("ironman", [ { currency_code: "usd", @@ -703,15 +756,14 @@ describe("ProductVariantService", () => { moneyAmountRepository.deleteVariantPricesNotIn ).toHaveBeenCalledTimes(1) - expect( - moneyAmountRepository.upsertVariantCurrencyPrice - ).toHaveBeenCalledTimes(1) - expect( - moneyAmountRepository.upsertVariantCurrencyPrice - ).toHaveBeenCalledWith("ironman", { - currency_code: "usd", - amount: 4000, - }) + expect(moneyAmountRepository.insertBulk).toHaveBeenCalledTimes(1) + expect(moneyAmountRepository.insertBulk).toHaveBeenCalledWith([ + { + variant_id: "ironman", + currency_code: "usd", + amount: 4000, + }, + ]) }) it("successfully creates new a region price", async () => { @@ -726,33 +778,31 @@ describe("ProductVariantService", () => { expect(moneyAmountRepository.create).toHaveBeenCalledWith({ variant_id: IdMap.getId("ironman"), region_id: IdMap.getId("cali"), + currency_code: "usd", amount: 100, }) - expect(moneyAmountRepository.save).toHaveBeenCalledTimes(1) + expect(moneyAmountRepository.insertBulk).toHaveBeenCalledTimes(1) }) - it("successfully creates a currency price", async () => { + it("successfully updates a currency price", async () => { await productVariantService.updateVariantPrices(IdMap.getId("ironman"), [ { id: IdMap.getId("dkk"), currency_code: "dkk", - amount: 750, + amount: 850, }, ]) expect(moneyAmountRepository.create).toHaveBeenCalledTimes(0) - expect( - moneyAmountRepository.upsertVariantCurrencyPrice - ).toHaveBeenCalledTimes(1) - expect( - moneyAmountRepository.upsertVariantCurrencyPrice - ).toHaveBeenCalledWith(IdMap.getId("ironman"), { - id: IdMap.getId("dkk"), - currency_code: "dkk", - amount: 750, - }) + expect(moneyAmountRepository.update).toHaveBeenCalledTimes(1) + expect(moneyAmountRepository.update).toHaveBeenCalledWith( + { id: IdMap.getId("dkk") }, + { + amount: 850, + } + ) }) }) @@ -889,14 +939,16 @@ describe("ProductVariantService", () => { describe("delete", () => { const productVariantRepository = MockRepository({ - findOne: (query) => { + find: (query) => { if (query.where.id === IdMap.getId("ironmanv2")) { - return Promise.resolve(undefined) + return Promise.resolve([]) } - return Promise.resolve({ - id: IdMap.getId("ironman"), - product_id: IdMap.getId("product-test"), - }) + return Promise.resolve([ + { + id: IdMap.getId("ironman"), + product_id: IdMap.getId("product-test"), + }, + ]) }, }) @@ -914,20 +966,22 @@ describe("ProductVariantService", () => { await productVariantService.delete(IdMap.getId("ironman")) expect(productVariantRepository.softRemove).toBeCalledTimes(1) - expect(productVariantRepository.softRemove).toBeCalledWith( + expect(productVariantRepository.softRemove).toBeCalledWith([ expect.objectContaining({ id: IdMap.getId("ironman"), - }) - ) + }), + ]) expect(eventBusService.emit).toHaveBeenCalledTimes(1) - expect(eventBusService.emit).toHaveBeenCalledWith( - "product-variant.deleted", + expect(eventBusService.emit).toHaveBeenCalledWith([ { - id: IdMap.getId("ironman"), - product_id: IdMap.getId("product-test"), - } - ) + eventName: "product-variant.deleted", + data: { + id: IdMap.getId("ironman"), + product_id: IdMap.getId("product-test"), + }, + }, + ]) }) it("successfully resolves if variant does not exist", async () => { diff --git a/packages/medusa/src/services/discount.ts b/packages/medusa/src/services/discount.ts index 7e69e07e61610..9d9b32b5946c0 100644 --- a/packages/medusa/src/services/discount.ts +++ b/packages/medusa/src/services/discount.ts @@ -619,12 +619,9 @@ class DiscountService extends TransactionBaseService { }) let fullItemPrice = lineItem.unit_price * lineItem.quantity - if ( - this.featureFlagRouter_.isFeatureEnabled( - TaxInclusivePricingFeatureFlag.key - ) && - lineItem.includes_tax - ) { + const includesTax = this.featureFlagRouter_.isFeatureEnabled(TaxInclusivePricingFeatureFlag.key) && lineItem.includes_tax + + if (includesTax) { const lineItemTotals = await this.newTotalsService_ .withTransaction(transactionManager) .getLineItemTotals([lineItem], { @@ -649,6 +646,7 @@ class DiscountService extends TransactionBaseService { const totals = await this.newTotalsService_.getLineItemTotals( discountedItems, { + includeTax: includesTax, calculationContext, } ) diff --git a/packages/medusa/src/services/event-bus.ts b/packages/medusa/src/services/event-bus.ts index cf4a371336630..ddb7b13bf5aba 100644 --- a/packages/medusa/src/services/event-bus.ts +++ b/packages/medusa/src/services/event-bus.ts @@ -156,7 +156,7 @@ export default class EventBusService * * If we are in a long-running transaction, the ACID properties of a * transaction ensure, that events are kept invisible to the enqueuer - * until the trasaction has commited. + * until the transaction has committed. * * This patterns also gives us at-least-once delivery of events, as events * are only removed from the database, if they are successfully delivered. diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index 63ca5f69d3208..265d8e7b9e63f 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -7,6 +7,7 @@ import { FindOptionsSelect, FindOptionsWhere, ILike, + In, IsNull, SelectQueryBuilder, } from "typeorm" @@ -25,22 +26,34 @@ import { FindWithRelationsOptions, ProductVariantRepository, } from "../repositories/product-variant" +import { FindConfig, WithRequiredProperty } from "../types/common" import { CreateProductVariantInput, FilterableProductVariantProps, GetRegionPriceContext, ProductVariantPrice, + UpdateProductVariantData, UpdateProductVariantInput, + UpdateVariantCurrencyPriceData, + UpdateVariantPricesData, + UpdateVariantRegionPriceData, } from "../types/product-variant" -import { buildQuery, buildRelations, setMetadata } from "../utils" +import { + buildQuery, + buildRelations, + hasChanges, + isObject, + isString, + setMetadata, +} from "../utils" import { CartRepository } from "../repositories/cart" import { MoneyAmountRepository } from "../repositories/money-amount" import { ProductRepository } from "../repositories/product" import { ProductOptionValueRepository } from "../repositories/product-option-value" -import { FindConfig } from "../types/common" import EventBusService from "./event-bus" import RegionService from "./region" +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity" class ProductVariantService extends TransactionBaseService { static Events = { @@ -164,7 +177,7 @@ class ProductVariantService extends TransactionBaseService { let product = productOrProductId - if (typeof product === `string`) { + if (isString(product)) { product = (await productRepo.findOne({ where: { id: productOrProductId as string }, relations: buildRelations([ @@ -229,19 +242,12 @@ class ProductVariantService extends TransactionBaseService { const result = await variantRepo.save(productVariant) if (prices) { - for (const price of prices) { - if (price.region_id) { - const region = await this.regionService_.retrieve(price.region_id) - - await this.setRegionPrice(result.id, { - amount: price.amount, - region_id: price.region_id, - currency_code: region.currency_code, - }) - } else { - await this.setCurrencyPrice(result.id, price) - } - } + await this.updateVariantPrices([ + { + variantId: result.id, + prices, + }, + ]) } await this.eventBus_ @@ -255,84 +261,185 @@ class ProductVariantService extends TransactionBaseService { }) } + /** + * Updates a collection of variant. + * @param variantData - a collection of variant and the data to update. + * @return resolves to the update result. + */ + async update( + variantData: { + variant: ProductVariant + updateData: UpdateProductVariantInput + }[] + ): Promise + /** * Updates a variant. * Price updates should use dedicated methods. * The function will throw, if price updates are attempted. * @param variantOrVariantId - variant or id of a variant. * @param update - an object with the update values. - * @param config - an object with the config values for returning the variant. * @return resolves to the update result. */ async update( variantOrVariantId: string | Partial, update: UpdateProductVariantInput - ): Promise { + ): Promise + + async update( + variantOrVariantId: string | Partial, + update: UpdateProductVariantInput + ): Promise + + async update< + TInput extends + | string + | Partial + | UpdateProductVariantData[], + TResult = TInput extends UpdateProductVariantData[] + ? ProductVariant[] + : ProductVariant + >( + variantOrVariantIdOrData: TInput, + updateData?: UpdateProductVariantInput + ): Promise { + let data = Array.isArray(variantOrVariantIdOrData) + ? variantOrVariantIdOrData + : ([] as UpdateProductVariantData[]) + return await this.atomicPhase_(async (manager: EntityManager) => { const variantRepo = manager.withRepository(this.productVariantRepository_) - let variant = variantOrVariantId - if (typeof variant === `string`) { - const variantRes = await variantRepo.findOne({ - where: { id: variantOrVariantId as string }, - }) - if (!isDefined(variantRes)) { + if (updateData) { + let variant: Partial | null = + variantOrVariantIdOrData as Partial + + if (isString(variantOrVariantIdOrData)) { + variant = await this.retrieve(variantOrVariantIdOrData, { + select: variantRepo.metadata.columns.map( + (c) => c.propertyName + ) as (keyof ProductVariant)[], + }) + } + + if (!variant?.id) { throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Variant with id ${variantOrVariantId} was not found` + MedusaError.Types.INVALID_DATA, + `Variant id missing` ) - } else { - variant = variantRes as ProductVariant } - } else if (!variant.id) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Variant id missing` - ) - } - const { prices, options, metadata, inventory_quantity, ...rest } = update - - if (prices) { - await this.updateVariantPrices(variant.id!, prices) + data = [{ variant: variant as ProductVariant, updateData: updateData }] } - if (options) { - for (const option of options) { - await this.updateOptionValue( - variant.id!, - option.option_id, - option.value - ) - } - } + const result = await this.updateBatch(data) - if (typeof metadata === "object") { - variant.metadata = setMetadata(variant as ProductVariant, metadata) - } + return (Array.isArray(variantOrVariantIdOrData) + ? result + : result[0]) as unknown as TResult + }) + } - if (typeof inventory_quantity === "number") { - variant.inventory_quantity = inventory_quantity as number - } + protected async updateBatch( + variantData: UpdateProductVariantData[] + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const variantRepo = manager.withRepository(this.productVariantRepository_) + + const variantPriceUpdateData = variantData + .filter((data) => isDefined(data.updateData.prices)) + .map((data) => ({ + variantId: data.variant.id, + prices: data.updateData.prices!, + })) - for (const [key, value] of Object.entries(rest)) { - variant[key] = value + if (variantPriceUpdateData.length) { + await this.updateVariantPrices(variantPriceUpdateData) } - const result = await variantRepo.save(variant) + const results: [ProductVariant, UpdateProductVariantInput, boolean][] = + await Promise.all( + variantData.map(async ({ variant, updateData }) => { + const { prices, options, ...rest } = updateData + + const shouldUpdate = hasChanges(variant, rest) + const shouldEmitUpdateEvent = + shouldUpdate || !!options?.length || !!prices?.length + + for (const option of options ?? []) { + await this.updateOptionValue( + variant.id!, + option.option_id, + option.value + ) + } + + const toUpdate: QueryDeepPartialEntity = {} + + if (isObject(rest.metadata)) { + toUpdate["metadata"] = setMetadata( + variant as ProductVariant, + rest.metadata + ) as QueryDeepPartialEntity> + delete rest.metadata + } + + if (Object.keys(rest).length) { + for (const [key, value] of Object.entries(rest)) { + if (variant[key] !== value) { + toUpdate[key] = value + } + } + } + + let result = variant + + // No need to update if nothing on the variant has changed + if (shouldUpdate) { + const { id } = variant + const rawResult = await variantRepo.update( + { id }, + { id, ...toUpdate } + ) + result = variantRepo.create({ + ...variant, + ...rawResult.generatedMaps[0], + }) + } + + return [result, updateData, shouldEmitUpdateEvent] + }) + ) - await this.eventBus_ - .withTransaction(manager) - .emit(ProductVariantService.Events.UPDATED, { - id: result.id, - product_id: result.product_id, - fields: Object.keys(update), + const events = results + .filter(([, , shouldEmitUpdateEvent]) => shouldEmitUpdateEvent) + .map(([result, updatedData]) => { + return { + eventName: ProductVariantService.Events.UPDATED, + data: { + id: result.id, + product_id: result.product_id, + fields: Object.keys(updatedData), + }, + } }) - return result + if (events.length) { + await this.eventBus_.withTransaction(manager).emit(events) + } + + return results.map(([variant]) => variant) }) } + /** + * Updates variant/prices collection. + * Deletes any prices that are not in the update object, and is not associated with a price list. + * @param data + * @returns empty promise + */ + async updateVariantPrices(data: UpdateVariantPricesData[]): Promise + /** * Updates a variant's prices. * Deletes any prices that are not in the update object, and is not associated with a price list. @@ -343,6 +450,25 @@ class ProductVariantService extends TransactionBaseService { async updateVariantPrices( variantId: string, prices: ProductVariantPrice[] + ): Promise + + async updateVariantPrices( + variantIdOrData: string | UpdateVariantPricesData[], + prices?: ProductVariantPrice[] + ): Promise { + let data = !isString(variantIdOrData) + ? variantIdOrData + : ([] as UpdateVariantPricesData[]) + + if (prices && isString(variantIdOrData)) { + data = [{ variantId: variantIdOrData, prices }] + } + + return await this.updateVariantPricesBatch(data) + } + + protected async updateVariantPricesBatch( + data: UpdateVariantPricesData[] ): Promise { return await this.atomicPhase_(async (manager: EntityManager) => { const moneyAmountRepo = manager.withRepository( @@ -350,27 +476,230 @@ class ProductVariantService extends TransactionBaseService { ) // Delete obsolete prices - await moneyAmountRepo.deleteVariantPricesNotIn(variantId, prices) + await moneyAmountRepo.deleteVariantPricesNotIn(data) + + const regionIdsSet: Set = new Set( + data + .map((data_) => + data_.prices + .filter((price) => price.region_id) + .map((price) => price.region_id!) + ) + .flat() + ) + + const regions = await this.regionService_.withTransaction(manager).list( + { + id: [...regionIdsSet], + }, + { + select: ["id", "currency_code"], + } + ) - const regionsServiceTx = this.regionService_.withTransaction(manager) + const regionsMap = new Map(regions.map((r) => [r.id, r])) - for (const price of prices) { - if (price.region_id) { - const region = await regionsServiceTx.retrieve(price.region_id) + const dataRegionPrices: UpdateVariantRegionPriceData[] = [] + const dataCurrencyPrices: UpdateVariantCurrencyPriceData[] = [] - await this.setRegionPrice(variantId, { - currency_code: region.currency_code, - region_id: price.region_id, - amount: price.amount, - }) + data.forEach(({ prices, variantId }) => { + prices.forEach((price) => { + if (price.region_id) { + const region = regionsMap.get(price.region_id)! + dataRegionPrices.push({ + variantId, + price: { + currency_code: region.currency_code, + region_id: price.region_id, + amount: price.amount, + }, + }) + } else { + dataCurrencyPrices.push({ + variantId, + price: { + ...price, + currency_code: price.currency_code!, + }, + }) + } + }) + }) + + const promises: Promise[] = [] + + if (dataRegionPrices.length) { + promises.push(this.upsertRegionPrices(dataRegionPrices)) + } + + if (dataCurrencyPrices.length) { + promises.push(this.upsertCurrencyPrices(dataCurrencyPrices)) + } + + await Promise.all(promises) + }) + } + + async upsertRegionPrices( + data: UpdateVariantRegionPriceData[] + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const moneyAmountRepo = manager.withRepository( + this.moneyAmountRepository_ + ) + + const where = data.map((data_) => ({ + variant_id: data_.variantId, + region_id: data_.price.region_id, + price_list_id: IsNull(), + })) + + const moneyAmounts = await moneyAmountRepo.find({ + where, + }) + + const moneyAmountsMapToVariantId = new Map() + moneyAmounts.map((d) => { + const moneyAmounts = moneyAmountsMapToVariantId.get(d.variant_id) ?? [] + moneyAmounts.push(d) + moneyAmountsMapToVariantId.set(d.variant_id, moneyAmounts) + }) + + const dataToCreate: QueryDeepPartialEntity[] = [] + const dataToUpdate: QueryDeepPartialEntity[] = [] + + data.forEach(({ price, variantId }) => { + const variantMoneyAmounts = + moneyAmountsMapToVariantId.get(variantId) ?? [] + + const moneyAmount: MoneyAmount = variantMoneyAmounts.find( + (ma) => ma.region_id === price.region_id + ) + + if (moneyAmount) { + // No need to update if the amount is the same + if (moneyAmount.amount !== price.amount) { + dataToUpdate.push({ + id: moneyAmount.id, + amount: price.amount, + }) + } } else { - await this.setCurrencyPrice(variantId, price) + dataToCreate.push( + moneyAmountRepo.create({ + ...price, + variant_id: variantId, + }) as QueryDeepPartialEntity + ) } + }) + + const promises: Promise[] = [] + + if (dataToCreate.length) { + promises.push(moneyAmountRepo.insertBulk(dataToCreate)) } - await this.priceSelectionStrategy_ - .withTransaction(manager) - .onVariantsPricesUpdate([variantId]) + if (dataToUpdate.length) { + dataToUpdate.forEach((data) => { + const { id, ...rest } = data + promises.push(moneyAmountRepo.update({ id: data.id as string }, rest)) + }) + } + + if (dataToCreate.length || dataToUpdate.length) { + promises.push( + this.priceSelectionStrategy_ + .withTransaction(manager) + .onVariantsPricesUpdate(data.map((d) => d.variantId)) + ) + } + + await Promise.all(promises) + }) + } + + async upsertCurrencyPrices( + data: { + variantId: string + price: WithRequiredProperty + }[] + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const moneyAmountRepo = manager.withRepository( + this.moneyAmountRepository_ + ) + + const where = data.map((data_) => ({ + variant_id: data_.variantId, + currency_code: data_.price.currency_code, + region_id: IsNull(), + price_list_id: IsNull(), + })) + + const moneyAmounts = await moneyAmountRepo.find({ + where, + }) + + const moneyAmountsMapToVariantId = new Map() + moneyAmounts.map((d) => { + const moneyAmounts = moneyAmountsMapToVariantId.get(d.variant_id) ?? [] + moneyAmounts.push(d) + moneyAmountsMapToVariantId.set(d.variant_id, moneyAmounts) + }) + + const dataToCreate: QueryDeepPartialEntity[] = [] + const dataToUpdate: QueryDeepPartialEntity[] = [] + + data.forEach(({ price, variantId }) => { + const variantMoneyAmounts = + moneyAmountsMapToVariantId.get(variantId) ?? [] + + const moneyAmount: MoneyAmount = variantMoneyAmounts.find( + (ma) => ma.currency_code === price.currency_code + ) + + if (moneyAmount) { + // No need to update if the amount is the same + if (moneyAmount.amount !== price.amount) { + dataToUpdate.push({ + id: moneyAmount.id, + amount: price.amount, + }) + } + } else { + dataToCreate.push( + moneyAmountRepo.create({ + ...price, + variant_id: variantId, + currency_code: price.currency_code.toLowerCase(), + }) as QueryDeepPartialEntity + ) + } + }) + + const promises: Promise[] = [] + + if (dataToCreate.length) { + promises.push(moneyAmountRepo.insertBulk(dataToCreate)) + } + + if (dataToUpdate.length) { + dataToUpdate.forEach((data) => { + const { id, ...rest } = data + promises.push(moneyAmountRepo.update({ id: data.id as string }, rest)) + }) + } + + if (dataToCreate.length || dataToUpdate.length) { + promises.push( + this.priceSelectionStrategy_ + .withTransaction(manager) + .onVariantsPricesUpdate(data.map((d) => d.variantId)) + ) + } + + await Promise.all(promises) }) } @@ -406,6 +735,7 @@ class ProductVariantService extends TransactionBaseService { } /** + * @deprecated use addOrUpdateRegionPrices instead * Sets the default price of a specific region * @param variantId - the id of the variant to update * @param price - the price for the variant. @@ -442,6 +772,7 @@ class ProductVariantService extends TransactionBaseService { } /** + * @deprecated use addOrUpdateCurrencyPrices instead * Sets the default price for the given currency. * @param variantId - the id of the variant to set prices for * @param price - the price for the variant @@ -670,34 +1001,41 @@ class ProductVariantService extends TransactionBaseService { } /** - * Deletes variant. + * Deletes variant or variants. * Will never fail due to delete being idempotent. - * @param variantId - the id of the variant to delete. Must be + * @param variantIds - the id of the variant to delete. Must be * castable as an ObjectId * @return empty promise */ - async delete(variantId: string): Promise { + async delete(variantIds: string | string[]): Promise { + const variantIds_ = isString(variantIds) ? [variantIds] : variantIds + return await this.atomicPhase_(async (manager: EntityManager) => { const variantRepo = manager.withRepository(this.productVariantRepository_) - const variant = await variantRepo.findOne({ - where: { id: variantId }, + const variants = await variantRepo.find({ + where: { id: In(variantIds_) }, relations: ["prices", "options", "inventory_items"], }) - if (!variant) { + if (!variants.length) { return Promise.resolve() } - await variantRepo.softRemove(variant) + await variantRepo.softRemove(variants) - await this.eventBus_ - .withTransaction(manager) - .emit(ProductVariantService.Events.DELETED, { - id: variant.id, - product_id: variant.product_id, - metadata: variant.metadata, - }) + const events = variants.map((variant) => { + return { + eventName: ProductVariantService.Events.DELETED, + data: { + id: variant.id, + product_id: variant.product_id, + metadata: variant.metadata, + }, + } + }) + + await this.eventBus_.withTransaction(manager).emit(events) }) } diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index 61fbf764cb217..0a0f16f27a925 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -517,20 +517,32 @@ class ProductService extends TransactionBaseService { product.thumbnail = images[0] } + const promises: Promise[] = [] + if (images) { - product.images = await imageRepo.upsertImages(images) + promises.push( + imageRepo + .upsertImages(images) + .then((image) => (product.images = image)) + ) } if (metadata) { product.metadata = setMetadata(product, metadata) } - if (typeof type !== `undefined`) { - product.type_id = (await productTypeRepo.upsertType(type))?.id || null + if (isDefined(type)) { + promises.push( + productTypeRepo + .upsertType(type) + .then((type) => (product.type_id = type?.id ?? null)) + ) } if (tags) { - product.tags = await productTagRepo.upsertTags(tags) + promises.push( + productTagRepo.upsertTags(tags).then((tags) => (product.tags = tags)) + ) } if (isDefined(categories)) { @@ -538,11 +550,9 @@ class ProductService extends TransactionBaseService { if (categories?.length) { const categoryIds = categories.map((c) => c.id) - const categoryRecords = categoryIds.map( + product.categories = categoryIds.map( (id) => ({ id } as ProductCategory) ) - - product.categories = categoryRecords } } @@ -566,6 +576,8 @@ class ProductService extends TransactionBaseService { } } + await Promise.all(promises) + const result = await productRepo.save(product) await this.eventBus_ @@ -574,6 +586,7 @@ class ProductService extends TransactionBaseService { id: result.id, fields: Object.keys(update), }) + return result }) } diff --git a/packages/medusa/src/strategies/batch-jobs/product/import.ts b/packages/medusa/src/strategies/batch-jobs/product/import.ts index 042e987bcb9cc..f16e00e8b7152 100644 --- a/packages/medusa/src/strategies/batch-jobs/product/import.ts +++ b/packages/medusa/src/strategies/batch-jobs/product/import.ts @@ -12,14 +12,11 @@ import { ProductVariantService, RegionService, SalesChannelService, - ShippingProfileService + ShippingProfileService, } from "../../../services" import CsvParser from "../../../services/csv-parser" import { CreateProductInput } from "../../../types/product" -import { - CreateProductVariantInput, - UpdateProductVariantInput -} from "../../../types/product-variant" +import { CreateProductVariantInput } from "../../../types/product-variant" import { FlagRouter } from "../../../utils/flag-router" import { OperationType, @@ -27,11 +24,11 @@ import { ProductImportCsvSchema, ProductImportInjectedProps, ProductImportJobContext, - TParsedProductImportRowData + TParsedProductImportRowData, } from "./types" import { productImportColumnsDefinition, - productImportSalesChannelsColumnsDefinition + productImportSalesChannelsColumnsDefinition, } from "./types/columns-definition" import { transformProductData, transformVariantData } from "./utils" @@ -584,12 +581,13 @@ class ProductImportStrategy extends AbstractBatchJobStrategy { await this.prepareVariantOptions(variantOp, product.id) + const updateData = transformVariantData(variantOp) + delete updateData.product + delete updateData["product.handle"] + await this.productVariantService_ .withTransaction(transactionManager) - .update( - variantOp["variant.id"] as string, - transformVariantData(variantOp) as UpdateProductVariantInput - ) + .update(variantOp["variant.id"] as string, updateData) } catch (e) { ProductImportStrategy.throwDescriptiveError(variantOp, e.message) } diff --git a/packages/medusa/src/types/product-variant.ts b/packages/medusa/src/types/product-variant.ts index 2407765801aab..3a098416794b9 100644 --- a/packages/medusa/src/types/product-variant.ts +++ b/packages/medusa/src/types/product-variant.ts @@ -12,8 +12,10 @@ import { DateComparisonOperator, NumericalComparisonOperator, StringComparisonOperator, + WithRequiredProperty, } from "./common" import { XorConstraint } from "./validators/xor" +import { ProductVariant } from "../models" export type ProductVariantPrice = { id?: string @@ -84,6 +86,30 @@ export type UpdateProductVariantInput = { metadata?: Record } +export type UpdateProductVariantData = { + variant: ProductVariant + updateData: UpdateProductVariantInput +} + +export type UpdateVariantPricesData = { + variantId: string + prices: ProductVariantPrice[] +} + +export type UpdateVariantRegionPriceData = { + variantId: string + price: { + currency_code: string + region_id: string + amount: number + } +} + +export type UpdateVariantCurrencyPriceData = { + variantId: string + price: WithRequiredProperty +} + export class FilterableProductVariantProps { @ValidateNested() @IsType([String, [String], StringComparisonOperator]) diff --git a/packages/medusa/src/utils/__tests__/has-changes.spec.ts b/packages/medusa/src/utils/__tests__/has-changes.spec.ts new file mode 100644 index 0000000000000..ac543f5fe5966 --- /dev/null +++ b/packages/medusa/src/utils/__tests__/has-changes.spec.ts @@ -0,0 +1,59 @@ +import { hasChanges } from "../has-changes" + +describe("hasChanges", function () { + it("should return true the data differ and false otherwise", () => { + const objToCompareTo = { + prop1: "test", + prop2: "test", + prop3: "test", + prop4: { + prop4_1: "test", + prop4_2: "test", + prop4_3: "test", + }, + } + + const obj = { + prop1: "test", + prop2: "test", + prop3: "test", + prop4: { + prop4_1: "test", + prop4_2: "test", + prop4_3: "test", + }, + } + + let res = hasChanges(objToCompareTo, obj) + expect(res).toBeFalsy() + + const obj2 = { + ...obj, + prop3: "tes", + } + + res = hasChanges(objToCompareTo, obj2) + expect(res).toBeTruthy() + + const obj3 = { + ...obj, + prop4: { + prop4_1: "", + prop4_2: "test", + prop4_3: "test", + }, + } + + res = hasChanges(objToCompareTo, obj3) + expect(res).toBeTruthy() + + const obj4 = { + ...obj, + } + /* @ts-ignore */ + delete obj4.prop4 + + res = hasChanges(objToCompareTo, obj4) + expect(res).toBeFalsy() + }) +}) diff --git a/packages/medusa/src/utils/has-changes.ts b/packages/medusa/src/utils/has-changes.ts new file mode 100644 index 0000000000000..c353149a3f49f --- /dev/null +++ b/packages/medusa/src/utils/has-changes.ts @@ -0,0 +1,23 @@ +import { isObject } from "./is-object" + +/** + * Compare two objects and return true if there is changes detected from obj2 compared to obj1 + * @param obj1 + * @param obj2 + */ +export function hasChanges( + obj1: T1, + obj2: T2 +): boolean { + for (const [key, value] of Object.entries(obj2)) { + if (isObject(obj1[key])) { + return hasChanges(obj1[key], value) + } + + if (obj1[key] !== value) { + return true + } + } + + return false +} diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts index 72d0370b73998..d8f7e0312e7a1 100644 --- a/packages/medusa/src/utils/index.ts +++ b/packages/medusa/src/utils/index.ts @@ -9,6 +9,7 @@ export * from "./is-object" export * from "./is-string" export * from "./product-category" export * from "./remove-undefined-properties" -export * from "./row-sql-results-to-entity-transformer" export * from "./set-metadata" export * from "./validate-id" +export * from "./is-object" +export * from "./has-changes" diff --git a/packages/medusa/src/utils/row-sql-results-to-entity-transformer.ts b/packages/medusa/src/utils/row-sql-results-to-entity-transformer.ts deleted file mode 100644 index a91e5ed46eb7d..0000000000000 --- a/packages/medusa/src/utils/row-sql-results-to-entity-transformer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { RelationIdLoader } from "typeorm/query-builder/relation-id/RelationIdLoader" -import { RawSqlResultsToEntityTransformer } from "typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer" -import { QueryBuilder, QueryRunner } from "typeorm" - -export async function rowSqlResultsToEntityTransformer( - rows: any[], - queryBuilder: QueryBuilder, - queryRunner: QueryRunner -): Promise { - const relationIdLoader = new RelationIdLoader( - queryBuilder.connection, - queryRunner, - queryBuilder.expressionMap.relationIdAttributes - ) - const transformer = new RawSqlResultsToEntityTransformer( - queryBuilder.expressionMap, - queryBuilder.connection.driver, - [], - [], - queryRunner - ) - - return transformer.transform( - rows, - queryBuilder.expressionMap.mainAlias! - ) as T[] -}