diff --git a/CHANGELOG.md b/CHANGELOG.md index cc070f3ae0..48e4c9d252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## 3.0.2 (2024-09-10) + + +#### Fixes + +* **admin-ui** Fix removing coupon code from draft order ([04340f1](https://github.com/vendure-ecommerce/vendure/commit/04340f1)), closes [#2969](https://github.com/vendure-ecommerce/vendure/issues/2969) +* **core** Fix search indexing issue when working with multiple channels (#3041) ([75ed6e1](https://github.com/vendure-ecommerce/vendure/commit/75ed6e1)), closes [#3041](https://github.com/vendure-ecommerce/vendure/issues/3041) [#3012](https://github.com/vendure-ecommerce/vendure/issues/3012) +* **core** Prevent exposure of private custom fields via JSON type ([042abdb](https://github.com/vendure-ecommerce/vendure/commit/042abdb)), closes [#3049](https://github.com/vendure-ecommerce/vendure/issues/3049) +* **elasticsearch-plugin** Fix search multichannel indexing issue ([9d6f9cf](https://github.com/vendure-ecommerce/vendure/commit/9d6f9cf)), closes [#3012](https://github.com/vendure-ecommerce/vendure/issues/3012) + +#### Perf + +* **core** Fix slow `order` query for postgres v16 ([1baa8e7](https://github.com/vendure-ecommerce/vendure/commit/1baa8e7)), closes [#3037](https://github.com/vendure-ecommerce/vendure/issues/3037) +* **core** Omit ID encode/decode step if default EntityIdStrategy used ([ad30b55](https://github.com/vendure-ecommerce/vendure/commit/ad30b55)) +* **core** Optimizations to the addItemToOrder path ([70ad853](https://github.com/vendure-ecommerce/vendure/commit/70ad853)) +* **core** Optimize order operations ([e3d6c21](https://github.com/vendure-ecommerce/vendure/commit/e3d6c21)) +* **core** Optimize resolution of featuredAsset fields ([d7bd446](https://github.com/vendure-ecommerce/vendure/commit/d7bd446)) +* **core** Optimize setting active order on session ([c591432](https://github.com/vendure-ecommerce/vendure/commit/c591432)) + ## 3.0.1 (2024-08-21) diff --git a/docs/docs/guides/deployment/deploying-admin-ui.md b/docs/docs/guides/deployment/deploying-admin-ui.md index b63c1f8a0c..e394363ee3 100644 --- a/docs/docs/guides/deployment/deploying-admin-ui.md +++ b/docs/docs/guides/deployment/deploying-admin-ui.md @@ -3,8 +3,18 @@ title: "Deploying the Admin UI" showtoc: true --- +## Compiling the Admin UI -If you have customized the Admin UI with extensions, you should [compile your extensions ahead of time as part of the deployment process](/guides/extending-the-admin-ui/getting-started/#compiling-as-a-deployment-step). +If you have customized the Admin UI with extensions, you should compile your custom Admin UI app ahead of time +before deploying it. This will bundle the app into a set of static files which are then served by the AdminUiPlugin. + +- [Guide: Compiling the Admin UI as a deployment step](/guides/extending-the-admin-ui/getting-started/#compiling-as-a-deployment-step). + +:::warning + +It is not recommended to compile the Admin UI on the server at runtime, as this can be slow and resource-intensive. +Instead, compile the Admin UI ahead of time and deploy the compiled assets, as covered in the guide linked above. +::: ## Setting the API host & port diff --git a/docs/docs/guides/developer-guide/testing/index.md b/docs/docs/guides/developer-guide/testing/index.md index c0f0c93301..02cd50a0fa 100644 --- a/docs/docs/guides/developer-guide/testing/index.md +++ b/docs/docs/guides/developer-guide/testing/index.md @@ -195,21 +195,24 @@ All that's left is to run your tests to find out whether your code behaves as ex :::caution **Note:** When using **Vitest** with multiple test suites (multiple `.e2e-spec.ts` files), it will attempt to run them in parallel. If all the test servers are running -on the same port (the default in the `testConfig` is `3050`), then this will cause a port conflict. To avoid this, you can manually set a unique port for each test suite: +on the same port (the default in the `testConfig` is `3050`), then this will cause a port conflict. To avoid this, you can manually set a unique port for each test suite. Be aware that `mergeConfig` is used here: ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts" import { createTestEnvironment, testConfig } from '@vendure/testing'; +import { mergeConfig } from "@vendure/core"; import { describe } from 'vitest'; import { MyPlugin } from '../my-plugin.ts'; describe('my plugin', () => { - const {server, adminClient, shopClient} = createTestEnvironment({ - ...testConfig, - // highlight-next-line - port: 3051, + const {server, adminClient, shopClient} = createTestEnvironment(mergeConfig(testConfig, { + // highlight-start + apiOptions: { + port: 3051, + }, + // highlight-end plugins: [MyPlugin], - }); + })); }); ``` diff --git a/docs/docs/guides/how-to/publish-plugin/index.mdx b/docs/docs/guides/how-to/publish-plugin/index.mdx index fe3776430b..8e59a61b21 100644 --- a/docs/docs/guides/how-to/publish-plugin/index.mdx +++ b/docs/docs/guides/how-to/publish-plugin/index.mdx @@ -18,24 +18,21 @@ There are a couple of ways you can structure your plugin project: ### Repo structure -#### Stand-alone repo +We recommend that you use a "monorepo" structure to develop your plugins. This means that you have a single repository +which contains all your plugins, each in its own subdirectory. This makes it easy to manage dependencies between plugins, +and to share common code such as utility functions & dev tooling. -You can have a single repository for your plugin. For this scenario you can use -the [Vendure Plugin Template](https://github.com/vendure-ecommerce/plugin-template) as a starting point. +Even if you only have a single plugin at the moment, it's a good idea to set up your project in this way from the start. -**Pros**: simple to set up. +To that end, we provide a [monorepo plugin starter template](https://github.com/vendure-ecommerce/plugin-template) +which you can use as a starting point for your plugin development. -**Cons**: if you have multiple plugins, you'll have multiple repositories to manage with duplicated setup and configuration. +This starter template includes support for: -#### Monorepo - -If you have multiple plugins, you can use a monorepo setup. Tools such as [Lerna](https://lerna.js.org/) or -[Nx](https://nx.dev/) can help you manage multiple packages in a single repository. A good example of this approach -can be found in the [Pinelab plugins repo](https://github.com/Pinelab-studio/pinelab-vendure-plugins). - -**Pros**: single repository to manage; can scale to any number of plugins; can share configuration and tooling. - -**Cons**: Initial setup is more complex. +- Development & build scripts already set up +- Admin UI extensions already configured +- End-to-end testing infrastructure fully configured +- Code generation for your schema extensions ### Plugin naming diff --git a/docs/docs/guides/storefront/checkout-flow/index.mdx b/docs/docs/guides/storefront/checkout-flow/index.mdx index 26c8a5c554..4897500bd9 100644 --- a/docs/docs/guides/storefront/checkout-flow/index.mdx +++ b/docs/docs/guides/storefront/checkout-flow/index.mdx @@ -234,7 +234,7 @@ query GetShippingMethods { The results can then be displayed to the customer so they can choose the desired shipping method. If there is only a single result, then it can be automatically selected. -The desired shipping method's id is the passed to the [`setOrderShippingMethod`](/reference/graphql-api/shop/mutations/#setordershippingmethod) mutation. +The desired shipping method's id is then passed to the [`setOrderShippingMethod`](/reference/graphql-api/shop/mutations/#setordershippingmethod) mutation. ```graphql mutation SetShippingMethod($id: [ID!]!) { diff --git a/docs/docs/reference/typescript-api/orders/order-seller-strategy.md b/docs/docs/reference/typescript-api/orders/order-seller-strategy.md index d3fdfe3975..e2d6fd55bd 100644 --- a/docs/docs/reference/typescript-api/orders/order-seller-strategy.md +++ b/docs/docs/reference/typescript-api/orders/order-seller-strategy.md @@ -50,7 +50,7 @@ interface OrderSellerStrategy extends InjectableStrategy { This method is called whenever a new OrderLine is added to the Order via the `addItemToOrder` mutation or the underlying `addItemToOrder()` method of the OrderService. -It should return the ID of the Channel to which this OrderLine will be assigned, which will be used to set the +It should return the Channel to which this OrderLine will be assigned, which will be used to set the OrderLine `sellerChannel` property. ### splitOrder diff --git a/license/signatures/version1/cla.json b/license/signatures/version1/cla.json index 3c5537fa2f..ca817815a3 100644 --- a/license/signatures/version1/cla.json +++ b/license/signatures/version1/cla.json @@ -111,6 +111,30 @@ "created_at": "2024-08-13T15:47:42Z", "repoId": 136938012, "pullRequestNo": 3009 + }, + { + "name": "sphade", + "id": 85949974, + "comment_id": 2304127063, + "created_at": "2024-08-22T08:51:51Z", + "repoId": 136938012, + "pullRequestNo": 3027 + }, + { + "name": "hsensh", + "id": 23084617, + "comment_id": 2324473830, + "created_at": "2024-09-02T11:14:15Z", + "repoId": 136938012, + "pullRequestNo": 3041 + }, + { + "name": "neokim", + "id": 3601028, + "comment_id": 2339775064, + "created_at": "2024-09-10T06:26:52Z", + "repoId": 136938012, + "pullRequestNo": 3052 } ] } \ No newline at end of file diff --git a/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.html b/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.html index d02eefae04..c1156a2ba8 100644 --- a/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.html +++ b/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.html @@ -11,7 +11,7 @@ [typeahead]="couponCodeInput$" [formControl]="control" (add)="addCouponCode.emit($event.code)" - (remove)="removeCouponCode.emit($event.value?.code)" + (remove)="remove($event.code)" >
diff --git a/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts b/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts index 3631a48ca6..5aa9a913c6 100644 --- a/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts +++ b/packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts @@ -65,4 +65,8 @@ export class CouponCodeSelectorComponent implements OnInit { this.control = new UntypedFormControl(this.couponCodes ?? []); } } + + remove(code: string) { + this.removeCouponCode.emit(code); + } } diff --git a/packages/core/e2e/custom-fields.e2e-spec.ts b/packages/core/e2e/custom-fields.e2e-spec.ts index a39d7c7394..d436b8d557 100644 --- a/packages/core/e2e/custom-fields.e2e-spec.ts +++ b/packages/core/e2e/custom-fields.e2e-spec.ts @@ -183,6 +183,10 @@ const customConfig = mergeConfig(testConfig(), { readonly: true, }, ], + Collection: [ + { name: 'secretKey1', type: 'string', defaultValue: '', public: false, internal: true }, + { name: 'secretKey2', type: 'string', defaultValue: '', public: false, internal: false }, + ], OrderLine: [{ name: 'validateInt', type: 'int', min: 0, max: 10 }], } as CustomFields, }); @@ -942,6 +946,20 @@ describe('Custom fields', () => { `); }, 'Cannot query field "internalString" on type "ProductCustomFields"'), ); + + // https://github.com/vendure-ecommerce/vendure/issues/3049 + it('does not leak private fields via JSON type', async () => { + const { collection } = await shopClient.query(gql` + query { + collection(id: "T_1") { + id + customFields + } + } + `); + + expect(collection.customFields).toBe(null); + }); }); describe('sort & filter', () => { @@ -1087,18 +1105,16 @@ describe('Custom fields', () => { describe('unique constraint', () => { it('setting unique value works', async () => { - const result = await adminClient.query( - gql` - mutation { - updateProduct(input: { id: "T_1", customFields: { uniqueString: "foo" } }) { - id - customFields { - uniqueString - } + const result = await adminClient.query(gql` + mutation { + updateProduct(input: { id: "T_1", customFields: { uniqueString: "foo" } }) { + id + customFields { + uniqueString } } - `, - ); + } + `); expect(result.updateProduct.customFields.uniqueString).toBe('foo'); }); diff --git a/packages/core/e2e/draft-order.e2e-spec.ts b/packages/core/e2e/draft-order.e2e-spec.ts index e9759d7ed7..cf5ce03989 100644 --- a/packages/core/e2e/draft-order.e2e-spec.ts +++ b/packages/core/e2e/draft-order.e2e-spec.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { LanguageCode } from '@vendure/common/lib/generated-types'; import { - DefaultLogger, DefaultOrderPlacedStrategy, mergeConfig, Order, @@ -15,7 +14,7 @@ import path from 'path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { initialData } from '../../../e2e-common/e2e-initial-data'; -import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; +import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config'; import { singleStageRefundablePaymentMethod } from './fixtures/test-payment-methods'; import { ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments'; @@ -24,14 +23,12 @@ import { AddManualPaymentDocument, AdminTransitionDocument, CanceledOrderFragment, - GetOrderDocument, GetOrderPlacedAtDocument, OrderWithLinesFragment, } from './graphql/generated-e2e-admin-types'; import { GetActiveCustomerOrdersQuery, TestOrderFragmentFragment, - TransitionToStateDocument, UpdatedOrderFragment, } from './graphql/generated-e2e-shop-types'; import { CREATE_PROMOTION, GET_CUSTOMER_LIST } from './graphql/shared-definitions'; @@ -53,7 +50,6 @@ class TestOrderPlacedStrategy extends DefaultOrderPlacedStrategy { describe('Draft Orders resolver', () => { const { server, adminClient, shopClient } = createTestEnvironment( mergeConfig(testConfig(), { - logger: new DefaultLogger(), paymentOptions: { paymentMethodHandlers: [singleStageRefundablePaymentMethod], }, @@ -125,9 +121,8 @@ describe('Draft Orders resolver', () => { }); it('create draft order', async () => { - const { createDraftOrder } = await adminClient.query( - CREATE_DRAFT_ORDER, - ); + const { createDraftOrder } = + await adminClient.query(CREATE_DRAFT_ORDER); expect(createDraftOrder.state).toBe('Draft'); expect(createDraftOrder.active).toBe(false); @@ -213,9 +208,8 @@ describe('Draft Orders resolver', () => { it('custom does not see draft orders in history', async () => { await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test'); - const { activeCustomer } = await shopClient.query( - GET_ACTIVE_CUSTOMER_ORDERS, - ); + const { activeCustomer } = + await shopClient.query(GET_ACTIVE_CUSTOMER_ORDERS); expect(activeCustomer?.orders.totalItems).toBe(0); expect(activeCustomer?.orders.items.length).toBe(0); diff --git a/packages/core/src/api/config/configure-graphql-module.ts b/packages/core/src/api/config/configure-graphql-module.ts index bfe261c36d..2959b72637 100644 --- a/packages/core/src/api/config/configure-graphql-module.ts +++ b/packages/core/src/api/config/configure-graphql-module.ts @@ -7,6 +7,7 @@ import path from 'path'; import { ConfigModule } from '../../config/config.module'; import { ConfigService } from '../../config/config.service'; +import { AutoIncrementIdStrategy, EntityIdStrategy, UuidIdStrategy } from '../../config/index'; import { I18nModule } from '../../i18n/i18n.module'; import { I18nService } from '../../i18n/i18n.service'; import { getPluginAPIExtensions } from '../../plugin/plugin-metadata'; @@ -96,6 +97,24 @@ async function createGraphQLOptions( options.apiType, builtSchema, ); + + const apolloServerPlugins = [ + new TranslateErrorsPlugin(i18nService), + new AssetInterceptorPlugin(configService), + ...configService.apiOptions.apolloServerPlugins, + ]; + // We only need to add the IdCodecPlugin if the user has configured + // a non-default EntityIdStrategy. This is a performance optimization + // that prevents unnecessary traversal of each response when no + // actual encoding/decoding is taking place. + if ( + !isUsingDefaultEntityIdStrategy( + configService.entityOptions.entityIdStrategy ?? configService.entityIdStrategy, + ) + ) { + apolloServerPlugins.unshift(new IdCodecPlugin(idCodecService)); + } + return { path: '/' + options.apiPath, typeDefs: printSchema(builtSchema), @@ -112,12 +131,7 @@ async function createGraphQLOptions( context: (req: any) => req, // This is handled by the Express cors plugin cors: false, - plugins: [ - new IdCodecPlugin(idCodecService), - new TranslateErrorsPlugin(i18nService), - new AssetInterceptorPlugin(configService), - ...configService.apiOptions.apolloServerPlugins, - ], + plugins: apolloServerPlugins, validationRules: options.validationRules, introspection: configService.apiOptions.introspection ?? true, } as ApolloDriverConfig; @@ -165,3 +179,10 @@ async function createGraphQLOptions( return schema; } } + +function isUsingDefaultEntityIdStrategy(entityIdStrategy: EntityIdStrategy): boolean { + return ( + entityIdStrategy.constructor === AutoIncrementIdStrategy || + entityIdStrategy.constructor === UuidIdStrategy + ); +} diff --git a/packages/core/src/api/config/generate-resolvers.ts b/packages/core/src/api/config/generate-resolvers.ts index 2d72f8af57..0cb89df116 100644 --- a/packages/core/src/api/config/generate-resolvers.ts +++ b/packages/core/src/api/config/generate-resolvers.ts @@ -255,6 +255,16 @@ function generateCustomFieldRelationResolvers( } as any; } } + const allCustomFieldsAreNonPublic = + customFields.length && customFields.every(f => f.public === false || f.internal === true); + if (allCustomFieldsAreNonPublic) { + // When an entity has only non-public custom fields, the GraphQL type used for the + // customFields field is `JSON`. This type will simply return the full object, which + // will cause a leak of private data unless we force a `null` return value in the case + // that there are no public fields. + // See https://github.com/vendure-ecommerce/vendure/issues/3049 + shopResolvers[entityName] = { customFields: () => null }; + } } return { adminResolvers, shopResolvers }; } diff --git a/packages/core/src/api/resolvers/admin/order.resolver.ts b/packages/core/src/api/resolvers/admin/order.resolver.ts index d677652336..0d337708f1 100644 --- a/packages/core/src/api/resolvers/admin/order.resolver.ts +++ b/packages/core/src/api/resolvers/admin/order.resolver.ts @@ -46,7 +46,10 @@ import { Transaction } from '../../decorators/transaction.decorator'; @Resolver() export class OrderResolver { - constructor(private orderService: OrderService, private connection: TransactionalConnection) {} + constructor( + private orderService: OrderService, + private connection: TransactionalConnection, + ) {} @Query() @Allow(Permission.ReadOrder) @@ -63,7 +66,8 @@ export class OrderResolver { async order( @Ctx() ctx: RequestContext, @Args() args: QueryOrderArgs, - @Relations(Order) relations: RelationPaths, + @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] }) + relations: RelationPaths, ): Promise { return this.orderService.findOne(ctx, args.id, relations); } diff --git a/packages/core/src/api/resolvers/entity/collection-entity.resolver.ts b/packages/core/src/api/resolvers/entity/collection-entity.resolver.ts index 9d7383d66c..165d694bec 100644 --- a/packages/core/src/api/resolvers/entity/collection-entity.resolver.ts +++ b/packages/core/src/api/resolvers/entity/collection-entity.resolver.ts @@ -116,7 +116,7 @@ export class CollectionEntityResolver { @Ctx() ctx: RequestContext, @Parent() collection: Collection, ): Promise { - if (collection.featuredAsset) { + if (collection.featuredAsset !== undefined) { return collection.featuredAsset; } return this.assetService.getFeaturedAsset(ctx, collection); diff --git a/packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts b/packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts index bd57dd4339..e8cc63a348 100644 --- a/packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts +++ b/packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts @@ -31,7 +31,7 @@ export class OrderLineEntityResolver { @Ctx() ctx: RequestContext, @Parent() orderLine: OrderLine, ): Promise { - if (orderLine.featuredAsset) { + if (orderLine.featuredAsset !== undefined) { return orderLine.featuredAsset; } else { return this.assetService.getFeaturedAsset(ctx, orderLine); diff --git a/packages/core/src/api/resolvers/entity/product-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-entity.resolver.ts index 9086c3be31..f89b988293 100644 --- a/packages/core/src/api/resolvers/entity/product-entity.resolver.ts +++ b/packages/core/src/api/resolvers/entity/product-entity.resolver.ts @@ -129,7 +129,7 @@ export class ProductEntityResolver { @ResolveField() async featuredAsset(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise { - if (product.featuredAsset) { + if (product.featuredAsset !== undefined) { return product.featuredAsset; } return this.assetService.getFeaturedAsset(ctx, product); diff --git a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts b/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts index addcf02b32..ceea767662 100644 --- a/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts +++ b/packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts @@ -14,9 +14,9 @@ import { Asset, Channel, FacetValue, Product, ProductOption, StockLevel, TaxRate import { ProductVariant } from '../../../entity/product-variant/product-variant.entity'; import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity'; import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator'; -import { StockLevelService } from '../../../service/services/stock-level.service'; import { AssetService } from '../../../service/services/asset.service'; import { ProductVariantService } from '../../../service/services/product-variant.service'; +import { StockLevelService } from '../../../service/services/stock-level.service'; import { StockMovementService } from '../../../service/services/stock-movement.service'; import { ApiType } from '../../common/get-api-type'; import { RequestContext } from '../../common/request-context'; @@ -103,7 +103,7 @@ export class ProductVariantEntityResolver { @Ctx() ctx: RequestContext, @Parent() productVariant: ProductVariant, ): Promise { - if (productVariant.featuredAsset) { + if (productVariant.featuredAsset !== undefined) { return productVariant.featuredAsset; } return this.assetService.getFeaturedAsset(ctx, productVariant); diff --git a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts b/packages/core/src/api/resolvers/shop/shop-order.resolver.ts index d418034587..98758ce23f 100644 --- a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts +++ b/packages/core/src/api/resolvers/shop/shop-order.resolver.ts @@ -76,7 +76,8 @@ export class ShopOrderResolver { async order( @Ctx() ctx: RequestContext, @Args() args: QueryOrderArgs, - @Relations(Order) relations: RelationPaths, + @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] }) + relations: RelationPaths, ): Promise { const requiredRelations: RelationPaths = ['customer', 'customer.user']; const order = await this.orderService.findOne( @@ -98,7 +99,8 @@ export class ShopOrderResolver { @Allow(Permission.Owner) async activeOrder( @Ctx() ctx: RequestContext, - @Relations(Order) relations: RelationPaths, + @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] }) + relations: RelationPaths, @Args() args: ActiveOrderArgs, ): Promise { if (ctx.authorizedAsOwnerOnly) { @@ -107,7 +109,7 @@ export class ShopOrderResolver { args[ACTIVE_ORDER_INPUT_FIELD_NAME], ); if (sessionOrder) { - return this.orderService.findOne(ctx, sessionOrder.id); + return this.orderService.findOne(ctx, sessionOrder.id, relations); } else { return; } @@ -119,7 +121,8 @@ export class ShopOrderResolver { async orderByCode( @Ctx() ctx: RequestContext, @Args() args: QueryOrderByCodeArgs, - @Relations(Order) relations: RelationPaths, + @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] }) + relations: RelationPaths, ): Promise { if (ctx.authorizedAsOwnerOnly) { const requiredRelations: RelationPaths = ['customer', 'customer.user']; @@ -294,6 +297,8 @@ export class ShopOrderResolver { async addItemToOrder( @Ctx() ctx: RequestContext, @Args() args: MutationAddItemToOrderArgs & ActiveOrderArgs, + @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] }) + relations: RelationPaths, ): Promise> { const order = await this.activeOrderService.getActiveOrder( ctx, @@ -306,6 +311,7 @@ export class ShopOrderResolver { args.productVariantId, args.quantity, (args as any).customFields, + relations, ); } @@ -315,6 +321,8 @@ export class ShopOrderResolver { async adjustOrderLine( @Ctx() ctx: RequestContext, @Args() args: MutationAdjustOrderLineArgs & ActiveOrderArgs, + @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] }) + relations: RelationPaths, ): Promise> { if (args.quantity === 0) { return this.removeOrderLine(ctx, { orderLineId: args.orderLineId }); @@ -330,6 +338,7 @@ export class ShopOrderResolver { args.orderLineId, args.quantity, (args as any).customFields, + relations, ); } diff --git a/packages/core/src/config/config.module.ts b/packages/core/src/config/config.module.ts index 8c7cb88feb..26b9662268 100644 --- a/packages/core/src/config/config.module.ts +++ b/packages/core/src/config/config.module.ts @@ -106,10 +106,11 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo } = this.configService.shippingOptions; const { customPaymentProcess, process: paymentProcess } = this.configService.paymentOptions; const { entityIdStrategy: entityIdStrategyDeprecated } = this.configService; - const { entityIdStrategy } = this.configService.entityOptions; + const { entityIdStrategy: entityIdStrategyCurrent } = this.configService.entityOptions; const { healthChecks, errorHandlers } = this.configService.systemOptions; const { assetImportStrategy } = this.configService.importExportOptions; const { refundProcess: refundProcess } = this.configService.paymentOptions; + const entityIdStrategy = entityIdStrategyCurrent ?? entityIdStrategyDeprecated; return [ ...adminAuthenticationStrategy, ...shopAuthenticationStrategy, @@ -127,8 +128,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo checkoutMergeStrategy, orderCodeStrategy, orderByCodeAccessStrategy, - entityIdStrategyDeprecated, - ...[entityIdStrategy].filter(notNullOrUndefined), + entityIdStrategy, productVariantPriceCalculationStrategy, productVariantPriceUpdateStrategy, orderItemPriceCalculationStrategy, diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index e7b5f5b6dc..dcc0372c54 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -84,6 +84,7 @@ export const defaultConfig: RuntimeVendureConfig = { introspection: true, apolloServerPlugins: [], }, + entityIdStrategy: new AutoIncrementIdStrategy(), authOptions: { disableAuth: false, tokenMethod: 'cookie', @@ -118,7 +119,6 @@ export const defaultConfig: RuntimeVendureConfig = { stockDisplayStrategy: new DefaultStockDisplayStrategy(), stockLocationStrategy: new DefaultStockLocationStrategy(), }, - entityIdStrategy: new AutoIncrementIdStrategy(), assetOptions: { assetNamingStrategy: new DefaultAssetNamingStrategy(), assetStorageStrategy: new NoAssetStorageStrategy(), @@ -131,6 +131,7 @@ export const defaultConfig: RuntimeVendureConfig = { type: 'mysql', }, entityOptions: { + entityIdStrategy: new AutoIncrementIdStrategy(), moneyStrategy: new DefaultMoneyStrategy(), entityDuplicators: defaultEntityDuplicators, channelCacheTtl: 30000, diff --git a/packages/core/src/config/order/order-seller-strategy.ts b/packages/core/src/config/order/order-seller-strategy.ts index 371e79926a..59080493c4 100644 --- a/packages/core/src/config/order/order-seller-strategy.ts +++ b/packages/core/src/config/order/order-seller-strategy.ts @@ -46,7 +46,7 @@ export interface OrderSellerStrategy extends InjectableStrategy { * This method is called whenever a new OrderLine is added to the Order via the `addItemToOrder` mutation or the * underlying `addItemToOrder()` method of the {@link OrderService}. * - * It should return the ID of the Channel to which this OrderLine will be assigned, which will be used to set the + * It should return the Channel to which this OrderLine will be assigned, which will be used to set the * {@link OrderLine} `sellerChannel` property. */ setOrderLineSellerChannel?( diff --git a/packages/core/src/entity/order-line/order-line.entity.ts b/packages/core/src/entity/order-line/order-line.entity.ts index a9c44fc229..4f747ca67d 100644 --- a/packages/core/src/entity/order-line/order-line.entity.ts +++ b/packages/core/src/entity/order-line/order-line.entity.ts @@ -76,6 +76,9 @@ export class OrderLine extends VendureEntity implements HasCustomFields { @ManyToOne(type => TaxCategory) taxCategory: TaxCategory; + @EntityId({ nullable: true }) + taxCategoryId: ID; + @Index() @ManyToOne(type => Asset, asset => asset.featuredInVariants, { onDelete: 'SET NULL' }) featuredAsset: Asset; diff --git a/packages/core/src/entity/product-variant/product-variant.entity.ts b/packages/core/src/entity/product-variant/product-variant.entity.ts index 91226f07ff..2cc90ca184 100644 --- a/packages/core/src/entity/product-variant/product-variant.entity.ts +++ b/packages/core/src/entity/product-variant/product-variant.entity.ts @@ -107,6 +107,9 @@ export class ProductVariant @ManyToOne(type => Asset, asset => asset.featuredInVariants, { onDelete: 'SET NULL' }) featuredAsset: Asset; + @EntityId({ nullable: true }) + featuredAssetId: ID; + @OneToMany(type => ProductVariantAsset, productVariantAsset => productVariantAsset.productVariant, { onDelete: 'SET NULL', }) @@ -116,6 +119,9 @@ export class ProductVariant @ManyToOne(type => TaxCategory, taxCategory => taxCategory.productVariants) taxCategory: TaxCategory; + @EntityId({ nullable: true }) + taxCategoryId: ID; + @OneToMany(type => ProductVariantPrice, price => price.variant, { eager: true }) productVariantPrices: ProductVariantPrice[]; diff --git a/packages/core/src/entity/product/product.entity.ts b/packages/core/src/entity/product/product.entity.ts index 82968bcdf9..88a6e7c21a 100644 --- a/packages/core/src/entity/product/product.entity.ts +++ b/packages/core/src/entity/product/product.entity.ts @@ -1,4 +1,4 @@ -import { DeepPartial } from '@vendure/common/lib/shared-types'; +import { DeepPartial, ID } from '@vendure/common/lib/shared-types'; import { Column, Entity, Index, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; import { ChannelAware, SoftDeletable } from '../../common/types/common-types'; @@ -8,6 +8,7 @@ import { Asset } from '../asset/asset.entity'; import { VendureEntity } from '../base/base.entity'; import { Channel } from '../channel/channel.entity'; import { CustomProductFields } from '../custom-entity-fields'; +import { EntityId } from '../entity-id.decorator'; import { FacetValue } from '../facet-value/facet-value.entity'; import { ProductOptionGroup } from '../product-option-group/product-option-group.entity'; import { ProductVariant } from '../product-variant/product-variant.entity'; @@ -47,6 +48,9 @@ export class Product @ManyToOne(type => Asset, asset => asset.featuredInProducts, { onDelete: 'SET NULL' }) featuredAsset: Asset; + @EntityId({ nullable: true }) + featuredAssetId: ID; + @OneToMany(type => ProductAsset, productAsset => productAsset.product) assets: ProductAsset[]; diff --git a/packages/core/src/entity/tax-rate/tax-rate.entity.ts b/packages/core/src/entity/tax-rate/tax-rate.entity.ts index eeb2ea433a..630ef74de3 100644 --- a/packages/core/src/entity/tax-rate/tax-rate.entity.ts +++ b/packages/core/src/entity/tax-rate/tax-rate.entity.ts @@ -1,5 +1,5 @@ import { TaxLine } from '@vendure/common/lib/generated-types'; -import { DeepPartial } from '@vendure/common/lib/shared-types'; +import { DeepPartial, ID } from '@vendure/common/lib/shared-types'; import { Column, Entity, Index, ManyToOne } from 'typeorm'; import { grossPriceOf, netPriceOf, taxComponentOf, taxPayableOn } from '../../common/tax-utils'; @@ -8,6 +8,7 @@ import { HasCustomFields } from '../../config/custom-field/custom-field-types'; import { VendureEntity } from '../base/base.entity'; import { CustomTaxRateFields } from '../custom-entity-fields'; import { CustomerGroup } from '../customer-group/customer-group.entity'; +import { EntityId } from '../entity-id.decorator'; import { TaxCategory } from '../tax-category/tax-category.entity'; import { DecimalTransformer } from '../value-transformers'; import { Zone } from '../zone/zone.entity'; @@ -38,10 +39,16 @@ export class TaxRate extends VendureEntity implements HasCustomFields { @ManyToOne(type => TaxCategory, taxCategory => taxCategory.taxRates) category: TaxCategory; + @EntityId({ nullable: true }) + categoryId: ID; + @Index() @ManyToOne(type => Zone, zone => zone.taxRates) zone: Zone; + @EntityId({ nullable: true }) + zoneId: ID; + @Index() @ManyToOne(type => CustomerGroup, customerGroup => customerGroup.taxRates, { nullable: true }) customerGroup?: CustomerGroup; @@ -84,7 +91,13 @@ export class TaxRate extends VendureEntity implements HasCustomFields { }; } - test(zone: Zone, taxCategory: TaxCategory): boolean { - return idsAreEqual(taxCategory.id, this.category.id) && idsAreEqual(zone.id, this.zone.id); + test(zone: Zone | ID, taxCategory: TaxCategory | ID): boolean { + const taxCategoryId = this.isId(taxCategory) ? taxCategory : taxCategory.id; + const zoneId = this.isId(zone) ? zone : zone.id; + return idsAreEqual(taxCategoryId, this.categoryId) && idsAreEqual(zoneId, this.zoneId); + } + + private isId(entityOrId: T | ID): entityOrId is ID { + return typeof entityOrId === 'string' || typeof entityOrId === 'number'; } } diff --git a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts index a31635c3bf..b9ee716bb0 100644 --- a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts +++ b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts @@ -405,7 +405,9 @@ export class IndexerController { await this.removeSyntheticVariants(ctx, variants); const productMap = new Map(); + const originalChannel = ctx.channel; for (const variant of variants) { + ctx.setChannel(originalChannel); let product = productMap.get(variant.productId); if (!product) { product = await this.getProductInChannelQueryBuilder(ctx, variant.productId, ctx.channel); @@ -496,6 +498,7 @@ export class IndexerController { } } } + ctx.setChannel(originalChannel); await this.queue.push(() => this.connection.getRepository(ctx, SearchIndexItem).save(items, { chunk: 2500 }), diff --git a/packages/core/src/service/helpers/active-order/active-order.service.ts b/packages/core/src/service/helpers/active-order/active-order.service.ts index 2945f6d23a..8b5bf6fc9d 100644 --- a/packages/core/src/service/helpers/active-order/active-order.service.ts +++ b/packages/core/src/service/helpers/active-order/active-order.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { RequestContext } from '../../../api/common/request-context'; import { InternalServerError, UserInputError } from '../../../common/error/errors'; +import { idsAreEqual } from '../../../common/utils'; import { ConfigService } from '../../../config/config.service'; import { TransactionalConnection } from '../../../connection/transactional-connection'; import { Order } from '../../../entity/order/order.entity'; @@ -90,7 +91,7 @@ export class ActiveOrderService { input: { [strategyName: string]: Record | undefined } | undefined, createIfNotExists = false, ): Promise { - let order: any; + let order: Order | undefined; if (!order) { const { activeOrderStrategy } = this.configService.orderOptions; const strategyArray = Array.isArray(activeOrderStrategy) @@ -119,7 +120,11 @@ export class ActiveOrderService { } if (order && ctx.session) { - await this.sessionService.setActiveOrder(ctx, ctx.session, order); + const orderAlreadyAssignedToSession = + ctx.session.activeOrderId && idsAreEqual(ctx.session.activeOrderId, order.id); + if (!orderAlreadyAssignedToSession) { + await this.sessionService.setActiveOrder(ctx, ctx.session, order); + } } } return order || undefined; diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.ts index 64825dbce8..d9eb069bf7 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { filterAsync } from '@vendure/common/lib/filter-async'; import { AdjustmentType } from '@vendure/common/lib/generated-types'; +import { ID } from '@vendure/common/lib/shared-types'; import { RequestContext } from '../../../api/common/request-context'; import { RequestContextCacheService } from '../../../cache/request-context-cache.service'; @@ -8,7 +9,7 @@ import { CacheKey } from '../../../common/constants'; import { InternalServerError } from '../../../common/error/errors'; import { idsAreEqual } from '../../../common/utils'; import { ConfigService } from '../../../config/config.service'; -import { OrderLine, TaxCategory, TaxRate } from '../../../entity'; +import { OrderLine, TaxRate } from '../../../entity'; import { Order } from '../../../entity/order/order.entity'; import { Promotion } from '../../../entity/promotion/promotion.entity'; import { Zone } from '../../../entity/zone/zone.entity'; @@ -76,7 +77,6 @@ export class OrderCalculator { ctx, order, updatedOrderLine, - activeTaxZone, this.createTaxRateGetter(ctx, activeTaxZone), ); } @@ -113,7 +113,7 @@ export class OrderCalculator { private async applyTaxes(ctx: RequestContext, order: Order, activeZone: Zone) { const getTaxRate = this.createTaxRateGetter(ctx, activeZone); for (const line of order.lines) { - await this.applyTaxesToOrderLine(ctx, order, line, activeZone, getTaxRate); + await this.applyTaxesToOrderLine(ctx, order, line, getTaxRate); } this.calculateOrderTotals(order); } @@ -126,10 +126,9 @@ export class OrderCalculator { ctx: RequestContext, order: Order, line: OrderLine, - activeZone: Zone, - getTaxRate: (taxCategory: TaxCategory) => Promise, + getTaxRate: (taxCategoryId: ID) => Promise, ) { - const applicableTaxRate = await getTaxRate(line.taxCategory); + const applicableTaxRate = await getTaxRate(line.taxCategoryId); const { taxLineCalculationStrategy } = this.configService.taxOptions; line.taxLines = await taxLineCalculationStrategy.calculate({ ctx, @@ -147,16 +146,16 @@ export class OrderCalculator { private createTaxRateGetter( ctx: RequestContext, activeZone: Zone, - ): (taxCategory: TaxCategory) => Promise { - const taxRateCache = new Map(); + ): (taxCategoryId: ID) => Promise { + const taxRateCache = new Map(); - return async (taxCategory: TaxCategory): Promise => { - const cached = taxRateCache.get(taxCategory); + return async (taxCategoryId: ID): Promise => { + const cached = taxRateCache.get(taxCategoryId); if (cached) { return cached; } - const rate = await this.taxRateService.getApplicableTaxRate(ctx, activeZone, taxCategory); - taxRateCache.set(taxCategory, rate); + const rate = await this.taxRateService.getApplicableTaxRate(ctx, activeZone, taxCategoryId); + taxRateCache.set(taxCategoryId, rate); return rate; }; } diff --git a/packages/core/src/service/helpers/order-modifier/order-modifier.ts b/packages/core/src/service/helpers/order-modifier/order-modifier.ts index 934d2388c1..a5153e1319 100644 --- a/packages/core/src/service/helpers/order-modifier/order-modifier.ts +++ b/packages/core/src/service/helpers/order-modifier/order-modifier.ts @@ -10,6 +10,7 @@ import { } from '@vendure/common/lib/generated-types'; import { ID } from '@vendure/common/lib/shared-types'; import { getGraphQlInputName, summate } from '@vendure/common/lib/shared-utils'; +import { IsNull } from 'typeorm'; import { RequestContext } from '../../../api/common/request-context'; import { isGraphQlErrorResult, JustErrorResults } from '../../../common/error/error-result'; @@ -140,7 +141,7 @@ export class OrderModifier { ): Promise { for (const line of order.lines) { const match = - idsAreEqual(line.productVariant.id, productVariantId) && + idsAreEqual(line.productVariantId, productVariantId) && (await this.customFieldsAreEqual(ctx, line, customFields, line.customFields)); if (match) { return line; @@ -164,12 +165,13 @@ export class OrderModifier { return existingOrderLine; } - const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId); + const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId, order); + const featuredAssetId = productVariant.featuredAssetId ?? productVariant.featuredAssetId; const orderLine = await this.connection.getRepository(ctx, OrderLine).save( new OrderLine({ productVariant, taxCategory: productVariant.taxCategory, - featuredAsset: productVariant.featuredAsset ?? productVariant.product.featuredAsset, + featuredAsset: featuredAssetId ? { id: featuredAssetId } : undefined, listPrice: productVariant.listPrice, listPriceIncludesTax: productVariant.listPriceIncludesTax, adjustments: [], @@ -189,26 +191,15 @@ export class OrderModifier { .set(orderLine.sellerChannel); } await this.customFieldRelationService.updateRelations(ctx, OrderLine, { customFields }, orderLine); - const lineWithRelations = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLine.id, { - relations: [ - 'taxCategory', - 'productVariant', - 'productVariant.productVariantPrices', - 'productVariant.taxCategory', - ], - }); - lineWithRelations.productVariant = this.translator.translate( - await this.productVariantService.applyChannelPriceAndTax( - lineWithRelations.productVariant, - ctx, - order, - ), - ctx, - ); - order.lines.push(lineWithRelations); - await this.connection.getRepository(ctx, Order).save(order, { reload: false }); - await this.eventBus.publish(new OrderLineEvent(ctx, order, lineWithRelations, 'created')); - return lineWithRelations; + order.lines.push(orderLine); + await this.connection + .getRepository(ctx, Order) + .createQueryBuilder() + .relation('lines') + .of(order) + .add(orderLine); + await this.eventBus.publish(new OrderLineEvent(ctx, order, orderLine, 'created')); + return orderLine; } /** @@ -896,11 +887,24 @@ export class OrderModifier { private async getProductVariantOrThrow( ctx: RequestContext, productVariantId: ID, + order: Order, ): Promise { - const productVariant = await this.productVariantService.findOne(ctx, productVariantId); - if (!productVariant) { + const variant = await this.connection.findOneInChannel( + ctx, + ProductVariant, + productVariantId, + ctx.channelId, + { + relations: ['product', 'productVariantPrices', 'taxCategory'], + loadEagerRelations: false, + where: { deletedAt: IsNull() }, + }, + ); + + if (variant) { + return await this.productVariantService.applyChannelPriceAndTax(variant, ctx, order); + } else { throw new EntityNotFoundError('ProductVariant', productVariantId); } - return productVariant; } } diff --git a/packages/core/src/service/helpers/order-splitter/order-splitter.ts b/packages/core/src/service/helpers/order-splitter/order-splitter.ts index 9dc510bb46..d550e63723 100644 --- a/packages/core/src/service/helpers/order-splitter/order-splitter.ts +++ b/packages/core/src/service/helpers/order-splitter/order-splitter.ts @@ -90,7 +90,9 @@ export class OrderSplitter { ...pick(line, [ 'quantity', 'productVariant', + 'productVariantId', 'taxCategory', + 'taxCategoryId', 'featuredAsset', 'shippingLine', 'shippingLineId', diff --git a/packages/core/src/service/services/asset.service.ts b/packages/core/src/service/services/asset.service.ts index 234e504f21..b50b43f3a9 100644 --- a/packages/core/src/service/services/asset.service.ts +++ b/packages/core/src/service/services/asset.service.ts @@ -168,6 +168,7 @@ export class AssetService { ctx.channelId, { relations: ['featuredAsset'], + loadEagerRelations: false, }, ); } else { @@ -178,6 +179,7 @@ export class AssetService { relations: { featuredAsset: true, }, + loadEagerRelations: false, // TODO: satisfies } as FindOneOptions) .then(result => result ?? undefined); diff --git a/packages/core/src/service/services/order-testing.service.ts b/packages/core/src/service/services/order-testing.service.ts index 56603b46c2..b40973da94 100644 --- a/packages/core/src/service/services/order-testing.service.ts +++ b/packages/core/src/service/services/order-testing.service.ts @@ -130,6 +130,7 @@ export class OrderTestingService { taxLines: [], quantity: line.quantity, taxCategory: productVariant.taxCategory, + taxCategoryId: productVariant.taxCategoryId, }); mockOrder.lines.push(orderLine); diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index d3afe58ad8..180411c361 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -53,7 +53,6 @@ import { CancelPaymentError, EmptyOrderLineSelectionError, FulfillmentStateTransitionError, - RefundStateTransitionError, InsufficientStockOnHandError, ItemsAlreadyFulfilledError, ManualPaymentStateError, @@ -61,6 +60,7 @@ import { NothingToRefundError, PaymentOrderMismatchError, RefundOrderStateError, + RefundStateTransitionError, SettlePaymentError, } from '../../common/error/generated-graphql-admin-errors'; import { @@ -489,7 +489,7 @@ export class OrderService { * @since 2.2.0 */ async updateOrderCustomer(ctx: RequestContext, { customerId, orderId, note }: SetOrderCustomerInput) { - const order = await this.getOrderOrThrow(ctx, orderId); + const order = await this.getOrderOrThrow(ctx, orderId, ['channels', 'customer']); const currentCustomer = order.customer; if (currentCustomer?.id === customerId) { // No change in customer, so just return the order as-is @@ -539,6 +539,7 @@ export class OrderService { productVariantId: ID, quantity: number, customFields?: { [key: string]: any }, + relations?: RelationPaths, ): Promise> { const order = await this.getOrderOrThrow(ctx, orderId); const existingOrderLine = await this.orderModifier.getExistingOrderLine( @@ -561,6 +562,7 @@ export class OrderService { enabled: true, deletedAt: IsNull(), }, + loadEagerRelations: false, }); if (variant.product.enabled === false) { throw new EntityNotFoundError('ProductVariant', productVariantId); @@ -596,7 +598,7 @@ export class OrderService { await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, correctedQuantity, order); } const quantityWasAdjustedDown = correctedQuantity < quantity; - const updatedOrder = await this.applyPriceAdjustments(ctx, order, [orderLine]); + const updatedOrder = await this.applyPriceAdjustments(ctx, order, [orderLine], relations); if (quantityWasAdjustedDown) { return new InsufficientStockError({ quantityAvailable: correctedQuantity, order: updatedOrder }); } else { @@ -614,6 +616,7 @@ export class OrderService { orderLineId: ID, quantity: number, customFields?: { [key: string]: any }, + relations?: RelationPaths, ): Promise> { const order = await this.getOrderOrThrow(ctx, orderId); const orderLine = this.getOrderLineOrThrow(order, orderLineId); @@ -660,7 +663,7 @@ export class OrderService { await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, correctedQuantity, order); } const quantityWasAdjustedDown = correctedQuantity < quantity; - const updatedOrder = await this.applyPriceAdjustments(ctx, order, updatedOrderLines); + const updatedOrder = await this.applyPriceAdjustments(ctx, order, updatedOrderLines, relations); if (quantityWasAdjustedDown) { return new InsufficientStockError({ quantityAvailable: correctedQuantity, order: updatedOrder }); } else { @@ -1663,8 +1666,23 @@ export class OrderService { return order; } - private async getOrderOrThrow(ctx: RequestContext, orderId: ID): Promise { - const order = await this.findOne(ctx, orderId); + private async getOrderOrThrow( + ctx: RequestContext, + orderId: ID, + relations?: RelationPaths, + ): Promise { + const order = await this.findOne( + ctx, + orderId, + relations ?? [ + 'lines', + 'lines.productVariant', + 'lines.productVariant.productVariantPrices', + 'shippingLines', + 'surcharges', + 'customer', + ], + ); if (!order) { throw new EntityNotFoundError('Order', orderId); } @@ -1730,6 +1748,7 @@ export class OrderService { ctx: RequestContext, order: Order, updatedOrderLines?: OrderLine[], + relations?: RelationPaths, ): Promise { const promotions = await this.promotionService.getActivePromotionsInChannel(ctx); const activePromotionsPre = await this.promotionService.getActivePromotionsOnOrder(ctx, order.id); @@ -1776,22 +1795,77 @@ export class OrderService { } } + // Get the shipping line IDs before doing the order calculation + // step, which can in some cases change the applied shipping lines. + const shippingLineIdsPre = order.shippingLines.map(l => l.id); + const updatedOrder = await this.orderCalculator.applyPriceAdjustments( ctx, order, promotions, updatedOrderLines ?? [], ); + + const shippingLineIdsPost = updatedOrder.shippingLines.map(l => l.id); + await this.applyChangesToShippingLines(ctx, updatedOrder, shippingLineIdsPre, shippingLineIdsPost); + + // Explicitly omit the shippingAddress and billingAddress properties to avoid + // a race condition where changing one or the other in parallel can + // overwrite the other's changes. The other omissions prevent the save + // function from doing more work than necessary. await this.connection .getRepository(ctx, Order) - // Explicitly omit the shippingAddress and billingAddress properties to avoid - // a race condition where changing one or the other in parallel can - // overwrite the other's changes. - .save(omit(updatedOrder, ['shippingAddress', 'billingAddress']), { reload: false }); + .save( + omit(updatedOrder, [ + 'shippingAddress', + 'billingAddress', + 'lines', + 'shippingLines', + 'aggregateOrder', + 'sellerOrders', + 'customer', + 'modifications', + ]), + { + reload: false, + }, + ); await this.connection.getRepository(ctx, OrderLine).save(updatedOrder.lines, { reload: false }); await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false }); await this.promotionService.runPromotionSideEffects(ctx, order, activePromotionsPre); - return assertFound(this.findOne(ctx, order.id)); + return assertFound(this.findOne(ctx, order.id, relations)); + } + + /** + * Applies changes to the shipping lines of an order, adding or removing the relations + * in the database. + */ + private async applyChangesToShippingLines( + ctx: RequestContext, + order: Order, + shippingLineIdsPre: ID[], + shippingLineIdsPost: ID[], + ) { + const removedShippingLineIds = shippingLineIdsPre.filter(id => !shippingLineIdsPost.includes(id)); + const newlyAddedShippingLineIds = shippingLineIdsPost.filter(id => !shippingLineIdsPre.includes(id)); + + for (const idToRemove of removedShippingLineIds) { + await this.connection + .getRepository(ctx, Order) + .createQueryBuilder() + .relation('shippingLines') + .of(order) + .remove(idToRemove); + } + + for (const idToAdd of newlyAddedShippingLineIds) { + await this.connection + .getRepository(ctx, Order) + .createQueryBuilder() + .relation('shippingLines') + .of(order) + .add(idToAdd); + } } } diff --git a/packages/core/src/service/services/tax-rate.service.ts b/packages/core/src/service/services/tax-rate.service.ts index c0c2979d1a..cad588b3aa 100644 --- a/packages/core/src/service/services/tax-rate.service.ts +++ b/packages/core/src/service/services/tax-rate.service.ts @@ -164,7 +164,11 @@ export class TaxRateService { * Returns the applicable TaxRate based on the specified Zone and TaxCategory. Used when calculating Order * prices. */ - async getApplicableTaxRate(ctx: RequestContext, zone: Zone, taxCategory: TaxCategory): Promise { + async getApplicableTaxRate( + ctx: RequestContext, + zone: Zone | ID, + taxCategory: TaxCategory | ID, + ): Promise { const rate = (await this.getActiveTaxRates(ctx)).find(r => r.test(zone, taxCategory)); return rate || this.defaultTaxRate; } diff --git a/packages/core/src/testing/order-test-utils.ts b/packages/core/src/testing/order-test-utils.ts index 3d3c38a5f5..6471c0ead5 100644 --- a/packages/core/src/testing/order-test-utils.ts +++ b/packages/core/src/testing/order-test-utils.ts @@ -76,7 +76,9 @@ export const taxRateDefaultStandard = new TaxRate({ value: 20, enabled: true, zone: zoneDefault, + zoneId: zoneDefault.id, category: taxCategoryStandard, + categoryId: taxCategoryStandard.id, }); export const taxRateDefaultReduced = new TaxRate({ id: 'taxRateDefaultReduced', @@ -84,7 +86,9 @@ export const taxRateDefaultReduced = new TaxRate({ value: 10, enabled: true, zone: zoneDefault, + zoneId: zoneDefault.id, category: taxCategoryReduced, + categoryId: taxCategoryReduced.id, }); export const taxRateDefaultZero = new TaxRate({ id: 'taxRateDefaultZero', @@ -92,7 +96,9 @@ export const taxRateDefaultZero = new TaxRate({ value: 0, enabled: true, zone: zoneDefault, + zoneId: zoneDefault.id, category: taxCategoryZero, + categoryId: taxCategoryZero.id, }); export const taxRateOtherStandard = new TaxRate({ id: 'taxRateOtherStandard', @@ -100,7 +106,9 @@ export const taxRateOtherStandard = new TaxRate({ value: 15, enabled: true, zone: zoneOther, + zoneId: zoneOther.id, category: taxCategoryStandard, + categoryId: taxCategoryStandard.id, }); export const taxRateOtherReduced = new TaxRate({ id: 'taxRateOtherReduced', @@ -108,7 +116,9 @@ export const taxRateOtherReduced = new TaxRate({ value: 5, enabled: true, zone: zoneOther, + zoneId: zoneOther.id, category: taxCategoryReduced, + categoryId: taxCategoryReduced.id, }); export class MockTaxRateService { @@ -124,14 +134,18 @@ export class MockTaxRateService { /* noop */ } - async getApplicableTaxRate(ctx: RequestContext, zone: Zone, taxCategory: TaxCategory): Promise { + async getApplicableTaxRate( + ctx: RequestContext, + zone: Zone | ID, + taxCategory: TaxCategory | ID, + ): Promise { const rate = this.activeTaxRates.find(r => r.test(zone, taxCategory)); return rate || taxRateDefaultStandard; } } export function createOrder( - orderConfig: Partial> & { + orderConfig: Partial> & { ctx: RequestContext; lines: Array<{ listPrice: number; @@ -145,6 +159,7 @@ export function createOrder( ({ listPrice, taxCategory, quantity }) => new OrderLine({ taxCategory, + taxCategoryId: taxCategory.id, quantity, orderPlacedQuantity: 0, listPrice, diff --git a/packages/dev-server/index.ts b/packages/dev-server/index.ts index 845fa81e90..93a9aae1e7 100644 --- a/packages/dev-server/index.ts +++ b/packages/dev-server/index.ts @@ -8,7 +8,7 @@ import { devConfig } from './dev-config'; bootstrap(devConfig) .then(app => { if (process.env.RUN_JOB_QUEUE === '1') { - app.get(JobQueueService).start(); + return app.get(JobQueueService).start(); } }) .catch(err => { diff --git a/packages/dev-server/load-testing/init-load-test.ts b/packages/dev-server/load-testing/init-load-test.ts index 48f96b3fce..90946daabe 100644 --- a/packages/dev-server/load-testing/init-load-test.ts +++ b/packages/dev-server/load-testing/init-load-test.ts @@ -3,7 +3,7 @@ import { bootstrap, JobQueueService, Logger } from '@vendure/core'; import { populate } from '@vendure/core/cli/populate'; import { clearAllTables, populateCustomers, SimpleGraphQLClient } from '@vendure/testing'; -import stringify from 'csv-stringify'; +import { stringify } from 'csv-stringify'; import fs from 'fs'; import path from 'path'; @@ -100,6 +100,9 @@ async function isDatabasePopulated(databaseName: string): Promise { await client.connect(); try { const res = await client.query('SELECT COUNT(id) as prodCount FROM product'); + if (res.rows[0]?.prodcount < 1) { + return false; + } return true; } catch (e: any) { if (e.message === 'relation "product" does not exist') { diff --git a/packages/dev-server/load-testing/run-load-test.ts b/packages/dev-server/load-testing/run-load-test.ts index 4b062970a6..f928974022 100644 --- a/packages/dev-server/load-testing/run-load-test.ts +++ b/packages/dev-server/load-testing/run-load-test.ts @@ -2,7 +2,7 @@ import { INestApplication } from '@nestjs/common'; import { bootstrap, JobQueueService } from '@vendure/core'; import { spawn } from 'child_process'; -import stringify from 'csv-stringify'; +import { stringify } from 'csv-stringify'; import fs from 'fs'; import path from 'path'; diff --git a/packages/elasticsearch-plugin/src/indexing/indexer.controller.ts b/packages/elasticsearch-plugin/src/indexing/indexer.controller.ts index 328a3bdcdc..7eb65c62c4 100644 --- a/packages/elasticsearch-plugin/src/indexing/indexer.controller.ts +++ b/packages/elasticsearch-plugin/src/indexing/indexer.controller.ts @@ -511,7 +511,6 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes if (!product) { return; } - let updatedProductVariants: ProductVariant[] = []; try { updatedProductVariants = await this.connection.rawConnection.getRepository(ProductVariant).find({ @@ -541,6 +540,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes languageVariants.push(...variant.translations.map(t => t.languageCode)); const uniqueLanguageVariants = unique(languageVariants); + const originalChannel = ctx.channel; for (const channel of product.channels) { ctx.setChannel(channel); const variantsInChannel = updatedProductVariants.filter(v => @@ -623,6 +623,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes } } } + ctx.setChannel(originalChannel); // Because we can have a huge amount of variant for 1 product, we also chunk update operations await this.executeBulkOperationsByChunks( @@ -944,7 +945,13 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes const productCustomMappings = Object.entries(this.options.customProductMappings); for (const [name, def] of productCustomMappings) { - item[`product-${name}`] = await def.valueFn(v.product, variants, languageCode, this.injector, ctx); + item[`product-${name}`] = await def.valueFn( + v.product, + variants, + languageCode, + this.injector, + ctx, + ); } return item; } catch (err: any) {