Browse Source

Merge branch 'minor' into major

Michael Bromley 3 năm trước cách đây
mục cha
commit
03b6357501
28 tập tin đã thay đổi với 361 bổ sung36 xóa
  1. 1 1
      docs/content/developer-guide/customizing-models.md
  2. 2 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss
  3. 11 0
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss
  4. 62 0
      packages/core/e2e/custom-fields.e2e-spec.ts
  5. 13 4
      packages/core/src/api/resolvers/admin/order.resolver.ts
  6. 2 0
      packages/core/src/config/config.module.ts
  7. 1 0
      packages/core/src/config/config.service.mock.ts
  8. 5 0
      packages/core/src/config/config.service.ts
  9. 3 0
      packages/core/src/config/custom-field/custom-field-types.ts
  10. 4 0
      packages/core/src/config/default-config.ts
  11. 1 0
      packages/core/src/config/index.ts
  12. 49 0
      packages/core/src/config/system/health-check-strategy.ts
  13. 28 0
      packages/core/src/config/vendure-config.ts
  14. 8 0
      packages/core/src/entity/register-custom-entity-fields.ts
  15. 7 1
      packages/core/src/health-check/health-check-registry.service.ts
  16. 7 4
      packages/core/src/health-check/health-check.module.ts
  17. 47 0
      packages/core/src/health-check/http-health-check-strategy.ts
  18. 2 0
      packages/core/src/health-check/index.ts
  19. 48 0
      packages/core/src/health-check/typeorm-health-check-strategy.ts
  20. 1 1
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  21. 5 1
      packages/core/src/service/services/collection.service.ts
  22. 14 10
      packages/core/src/service/services/order.service.ts
  23. 3 5
      packages/job-queue-plugin/src/bullmq/plugin.ts
  24. 15 0
      packages/job-queue-plugin/src/bullmq/redis-health-check-strategy.ts
  25. 1 1
      packages/job-queue-plugin/src/bullmq/redis-health-indicator.ts
  26. 5 5
      packages/payments-plugin/src/braintree/braintree.plugin.ts
  27. 15 2
      packages/payments-plugin/src/braintree/braintree.resolver.ts
  28. 1 1
      packages/ui-devkit/src/compiler/compile.ts

+ 1 - 1
docs/content/developer-guide/customizing-models.md

@@ -19,7 +19,7 @@ const config = {
       { name: 'shortName', type: 'localeString' },
     ],
     User: [
-      { name: 'socialLoginToken', type: 'string' },
+      { name: 'socialLoginToken', type: 'string', unique: true },
     ],
   },
 }

+ 2 - 0
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.scss

@@ -54,6 +54,8 @@ vdr-action-bar clr-toggle-wrapper {
 
 .channel-assignment {
     flex-wrap: wrap;
+    max-height: 144px;
+    overflow-y: auto;
 }
 
 .auto-rename-wrapper {

+ 11 - 0
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.scss

@@ -20,9 +20,11 @@
 .variant-container {
     transition: background-color 0.2s;
     min-height: 330px;
+
     &.disabled {
         background-color: var(--color-component-bg-200);
     }
+
     .header-row {
         display: flex;
         align-items: center;
@@ -46,10 +48,13 @@
             flex-direction: row;
             height: 36px;
         }
+
         .name {
             flex: 1;
+
             ::ng-deep .clr-control-container {
                 width: 100%;
+
                 input.clr-input {
                     min-width: 100%;
                 }
@@ -113,6 +118,7 @@
 
     .pricing {
         display: flex;
+
         > div {
             margin-right: 12px;
         }
@@ -138,6 +144,10 @@
 
 .channel-assignment {
     justify-content: flex-end;
+    flex-wrap: wrap;
+    max-height: 110px;
+    overflow-y: auto;
+
     .btn {
         margin: 6px 12px 6px 0;
     }
@@ -146,6 +156,7 @@
 .out-of-stock-threshold-wrapper {
     display: flex;
     flex-direction: column;
+
     clr-toggle-wrapper {
         margin-left: 24px;
     }

+ 62 - 0
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -1,6 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Asset, CustomFields, mergeConfig, TransactionalConnection } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
+import { fail } from 'assert';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -161,6 +162,11 @@ const customConfig = mergeConfig(testConfig(), {
                     }
                 },
             },
+            {
+                name: 'uniqueString',
+                type: 'string',
+                unique: true,
+            },
         ],
         Facet: [
             {
@@ -244,6 +250,7 @@ describe('Custom fields', () => {
                 { name: 'localeStringList', type: 'localeString', list: true },
                 { name: 'stringListWithDefault', type: 'string', list: true },
                 { name: 'intListWithValidation', type: 'int', list: true },
+                { name: 'uniqueString', type: 'string', list: false },
                 // The internal type should not be exposed at all
                 // { name: 'internalString', type: 'string' },
             ],
@@ -832,4 +839,59 @@ describe('Custom fields', () => {
             }, `Field "internalString" is not defined by type "ProductFilterParameter"`),
         );
     });
+
+    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
+                            }
+                        }
+                    }
+                `,
+            );
+
+            expect(result.updateProduct.customFields.uniqueString).toBe('foo');
+        });
+
+        it('setting conflicting value fails', async () => {
+            try {
+                await adminClient.query(gql`
+                    mutation {
+                        createProduct(
+                            input: {
+                                translations: [
+                                    { languageCode: en, name: "test 2", slug: "test-2", description: "" }
+                                ]
+                                customFields: { uniqueString: "foo" }
+                            }
+                        ) {
+                            id
+                        }
+                    }
+                `);
+                fail('Should have thrown');
+            } catch (e: any) {
+                let duplicateKeyErrMessage = 'unassigned';
+                switch (customConfig.dbConnectionOptions.type) {
+                    case 'mariadb':
+                    case 'mysql':
+                        duplicateKeyErrMessage = `ER_DUP_ENTRY: Duplicate entry 'foo' for key`;
+                        break;
+                    case 'postgres':
+                        duplicateKeyErrMessage = `duplicate key value violates unique constraint`;
+                        break;
+                    case 'sqlite':
+                    case 'sqljs':
+                        duplicateKeyErrMessage = `UNIQUE constraint failed: product.customFieldsUniquestring`;
+                        break;
+                }
+                expect(e.message).toContain(duplicateKeyErrMessage);
+            }
+        });
+    });
 });

+ 13 - 4
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -25,7 +25,8 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
-import { ErrorResultUnion } from '../../../common/error/error-result';
+import { ErrorResultUnion, isGraphQlErrorResult } from '../../../common/error/error-result';
+import { TransactionalConnection } from '../../../connection';
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
@@ -34,7 +35,6 @@ import { FulfillmentState } from '../../../service/helpers/fulfillment-state-mac
 import { OrderState } from '../../../service/helpers/order-state-machine/order-state';
 import { PaymentState } from '../../../service/helpers/payment-state-machine/payment-state';
 import { OrderService } from '../../../service/services/order.service';
-import { ShippingMethodService } from '../../../service/services/shipping-method.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
 import { RelationPaths, Relations } from '../../decorators/relations.decorator';
@@ -43,7 +43,7 @@ import { Transaction } from '../../decorators/transaction.decorator';
 
 @Resolver()
 export class OrderResolver {
-    constructor(private orderService: OrderService, private shippingMethodService: ShippingMethodService) {}
+    constructor(private orderService: OrderService, private connection: TransactionalConnection) {}
 
     @Query()
     @Allow(Permission.ReadOrder)
@@ -174,7 +174,16 @@ export class OrderResolver {
     @Mutation()
     @Allow(Permission.UpdateOrder)
     async modifyOrder(@Ctx() ctx: RequestContext, @Args() args: MutationModifyOrderArgs) {
-        return this.orderService.modifyOrder(ctx, args.input);
+        await this.connection.startTransaction(ctx);
+        const result = await this.orderService.modifyOrder(ctx, args.input);
+
+        if (args.input.dryRun || isGraphQlErrorResult(result)) {
+            await this.connection.rollBackTransaction(ctx);
+        } else {
+            await this.connection.commitOpenTransaction(ctx);
+        }
+
+        return result;
     }
 
     @Transaction()

+ 2 - 0
packages/core/src/config/config.module.ts

@@ -82,6 +82,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
         const { customPaymentProcess } = this.configService.paymentOptions;
         const { entityIdStrategy: entityIdStrategyDeprecated } = this.configService;
         const { entityIdStrategy } = this.configService.entityOptions;
+        const { healthChecks } = this.configService.systemOptions;
         return [
             ...adminAuthenticationStrategy,
             ...shopAuthenticationStrategy,
@@ -107,6 +108,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             ...customPaymentProcess,
             stockAllocationStrategy,
             stockDisplayStrategy,
+            ...healthChecks,
         ];
     }
 

+ 1 - 0
packages/core/src/config/config.service.mock.ts

@@ -47,6 +47,7 @@ export class MockConfigService implements MockClass<ConfigService> {
     plugins = [];
     logger = {} as any;
     jobQueueOptions = {};
+    systemOptions = {};
 }
 
 export const ENCODED = 'encoded';

+ 5 - 0
packages/core/src/config/config.service.ts

@@ -19,6 +19,7 @@ import {
     PromotionOptions,
     RuntimeVendureConfig,
     ShippingOptions,
+    SystemOptions,
     TaxOptions,
     VendureConfig,
 } from './vendure-config';
@@ -110,4 +111,8 @@ export class ConfigService implements VendureConfig {
     get jobQueueOptions(): Required<JobQueueOptions> {
         return this.activeConfig.jobQueueOptions;
     }
+
+    get systemOptions(): Required<SystemOptions> {
+        return this.activeConfig.systemOptions;
+    }
 }

+ 3 - 0
packages/core/src/config/custom-field/custom-field-types.ts

@@ -41,6 +41,7 @@ export type BaseTypedCustomFieldConfig<T extends CustomFieldType, C extends Cust
      */
     public?: boolean;
     nullable?: boolean;
+    unique?: boolean;
     ui?: UiComponentConfig<DefaultFormComponentId | string>;
 };
 
@@ -127,6 +128,8 @@ export type CustomFieldConfig =
  * * `internal?: boolean`: Whether or not the custom field is exposed at all via the GraphQL APIs. Defaults to `false`.
  * * `defaultValue?: any`: The default value when an Entity is created with this field.
  * * `nullable?: boolean`: Whether the field is nullable in the database. If set to `false`, then a `defaultValue` should be provided.
+ * * `unique?: boolean`: Whether the value of the field should be unique. When set to `true`, a UNIQUE constraint is added to the column. Defaults
+ *     to `false`.
  * * `validate?: (value: any) => string | LocalizedString[] | void`: A custom validation function. If the value is valid, then
  *     the function should not return a value. If a string or LocalizedString array is returned, this is interpreted as an error message.
  *

+ 4 - 0
packages/core/src/config/default-config.ts

@@ -5,6 +5,7 @@ import {
     SUPER_ADMIN_USER_PASSWORD,
 } from '@vendure/common/lib/shared-constants';
 
+import { TypeORMHealthCheckStrategy } from '../health-check/typeorm-health-check-strategy';
 import { InMemoryJobQueueStrategy } from '../job-queue/in-memory-job-queue-strategy';
 import { InMemoryJobBufferStorageStrategy } from '../job-queue/job-buffer/in-memory-job-buffer-storage-strategy';
 
@@ -183,4 +184,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         Zone: [],
     },
     plugins: [],
+    systemOptions: {
+        healthChecks: [new TypeORMHealthCheckStrategy()],
+    },
 };

+ 1 - 0
packages/core/src/config/index.ts

@@ -56,6 +56,7 @@ export * from './shipping-method/default-shipping-calculator';
 export * from './shipping-method/default-shipping-eligibility-checker';
 export * from './shipping-method/shipping-calculator';
 export * from './shipping-method/shipping-eligibility-checker';
+export * from './system/health-check-strategy';
 export * from './tax/default-tax-line-calculation-strategy';
 export * from './tax/default-tax-zone-strategy';
 export * from './tax/tax-line-calculation-strategy';

+ 49 - 0
packages/core/src/config/system/health-check-strategy.ts

@@ -0,0 +1,49 @@
+import { HealthIndicatorFunction } from '@nestjs/terminus';
+
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+
+/**
+ * @description
+ * This strategy defines health checks which are included as part of the
+ * `/health` endpoint. They should only be used to monitor _critical_ systems
+ * on which proper functioning of the Vendure server depends.
+ *
+ * For more information on the underlying mechanism, see the
+ * [NestJS Terminus module docs](https://docs.nestjs.com/recipes/terminus).
+ *
+ * Custom strategies should be added to the `systemOptions.healthChecks` array.
+ * By default, Vendure includes the `TypeORMHealthCheckStrategy`, so if you set the value of the `healthChecks`
+ * array, be sure to include it manually.
+ *
+ * Vendure also ships with the {@link HttpHealthCheckStrategy}, which is convenient
+ * for adding a health check dependent on an HTTP ping.
+ *
+ *
+ *
+ * @example
+ * ```TypeScript
+ * import { HttpHealthCheckStrategy, TypeORMHealthCheckStrategy } from '\@vendure/core';
+ * import { MyCustomHealthCheckStrategy } from './config/custom-health-check-strategy';
+ *
+ * export const config = {
+ *   // ...
+ *   systemOptions: {
+ *     healthChecks: [
+ *       new TypeORMHealthCheckStrategy(),
+ *       new HttpHealthCheckStrategy({ key: 'my-service', url: 'https://my-service.com' }),
+ *       new MyCustomHealthCheckStrategy(),
+ *     ],
+ *   },
+ * };
+ * ```
+ *
+ * @docsCategory health-check
+ */
+export interface HealthCheckStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Should return a `HealthIndicatorFunction`, as defined by the
+     * [NestJS Terminus module](https://docs.nestjs.com/recipes/terminus).
+     */
+    getHealthIndicator(): HealthIndicatorFunction;
+}

+ 28 - 0
packages/core/src/config/vendure-config.ts

@@ -41,6 +41,7 @@ import { PromotionCondition } from './promotion/promotion-condition';
 import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
 import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
+import { HealthCheckStrategy } from './system/health-check-strategy';
 import { TaxLineCalculationStrategy } from './tax/tax-line-calculation-strategy';
 import { TaxZoneStrategy } from './tax/tax-zone-strategy';
 
@@ -880,6 +881,25 @@ export interface EntityOptions {
     metadataModifiers?: EntityMetadataModifier[];
 }
 
+/**
+ * @description
+ * Options relating to system functions.
+ *
+ * @since 1.6.0
+ * @docsCategory configuration
+ */
+export interface SystemOptions {
+    /**
+     * @description
+     * Defines an array of {@link HealthCheckStrategy} instances which are used by the `/health` endpoint to verify
+     * that any critical systems which the Vendure server depends on are also healthy.
+     *
+     * @default [TypeORMHealthCheckStrategy]
+     * @since 1.6.0
+     */
+    healthChecks?: HealthCheckStrategy[];
+}
+
 /**
  * @description
  * All possible configuration options are defined by the
@@ -1001,6 +1021,13 @@ export interface VendureConfig {
      * Configures how the job queue is persisted and processed.
      */
     jobQueueOptions?: JobQueueOptions;
+    /**
+     * @description
+     * Configures system options
+     *
+     * @since 1.6.0
+     */
+    systemOptions?: SystemOptions;
 }
 
 /**
@@ -1023,6 +1050,7 @@ export interface RuntimeVendureConfig extends Required<VendureConfig> {
     promotionOptions: Required<PromotionOptions>;
     shippingOptions: Required<ShippingOptions>;
     taxOptions: Required<TaxOptions>;
+    systemOptions: Required<SystemOptions>;
 }
 
 type DeepPartialSimple<T> = {

+ 8 - 0
packages/core/src/entity/register-custom-entity-fields.ts

@@ -5,6 +5,7 @@ import {
     ColumnOptions,
     ColumnType,
     ConnectionOptions,
+    Index,
     JoinColumn,
     JoinTable,
     ManyToMany,
@@ -89,6 +90,7 @@ function registerCustomFieldsForEntity(
                         default: getDefault(customField, dbEngine),
                         name,
                         nullable: nullable === false ? false : true,
+                        unique: customField.unique ?? false,
                     };
                     if ((customField.type === 'string' || customField.type === 'localeString') && !list) {
                         const length = customField.length || 255;
@@ -114,6 +116,12 @@ function registerCustomFieldsForEntity(
                         options.precision = 6;
                     }
                     Column(options)(instance, name);
+                    if ((dbEngine === 'mysql' || dbEngine === 'mariadb') && customField.unique === true) {
+                        // The MySQL driver seems to work differently and will only apply a unique
+                        // constraint if an index is defined on the column. For postgres/sqlite it is
+                        // sufficient to add the `unique: true` property to the column options.
+                        Index({ unique: true })(instance, name);
+                    }
                 }
             };
 

+ 7 - 1
packages/core/src/health-check/health-check-registry.service.ts

@@ -14,7 +14,13 @@ import { HealthIndicatorFunction } from '@nestjs/terminus';
  * Plugins which rely on external services (web services, databases etc.) can make use of this
  * service to add a check for that dependency to the Vendure health check.
  *
- * To use it in your plugin, you'll need to import the {@link PluginCommonModule}:
+ *
+ * Since v1.6.0, the preferred way to implement a custom health check is by creating a new
+ * {@link HealthCheckStrategy} and then passing it to the `systemOptions.healthChecks` array.
+ * See the {@link HealthCheckStrategy} docs for an example configuration.
+ *
+ * The alternative way to register a health check is by injecting this service directly into your
+ * plugin module. To use it in your plugin, you'll need to import the {@link PluginCommonModule}:
  *
  * @example
  * ```TypeScript

+ 7 - 4
packages/core/src/health-check/health-check.module.ts

@@ -1,5 +1,5 @@
 import { Module } from '@nestjs/common';
-import { TerminusModule, TypeOrmHealthIndicator } from '@nestjs/terminus';
+import { TerminusModule } from '@nestjs/terminus';
 
 import { ConfigModule } from '../config/config.module';
 import { ConfigService } from '../config/config.service';
@@ -20,11 +20,14 @@ export class HealthCheckModule {
     constructor(
         private configService: ConfigService,
         private healthCheckRegistryService: HealthCheckRegistryService,
-        private typeOrm: TypeOrmHealthIndicator,
         private worker: WorkerHealthIndicator,
     ) {
-        // Register the default health checks for database and worker
-        this.healthCheckRegistryService.registerIndicatorFunction([() => this.typeOrm.pingCheck('database')]);
+        // Register all configured health checks
+        for (const strategy of this.configService.systemOptions.healthChecks) {
+            this.healthCheckRegistryService.registerIndicatorFunction(strategy.getHealthIndicator());
+        }
+
+        // TODO: Remove in v2
         const { enableWorkerHealthCheck, jobQueueStrategy } = this.configService.jobQueueOptions;
         if (enableWorkerHealthCheck && isInspectableJobQueueStrategy(jobQueueStrategy)) {
             this.healthCheckRegistryService.registerIndicatorFunction([() => this.worker.isHealthy()]);

+ 47 - 0
packages/core/src/health-check/http-health-check-strategy.ts

@@ -0,0 +1,47 @@
+import { HealthIndicatorFunction, HttpHealthIndicator } from '@nestjs/terminus';
+
+import { Injector } from '../common/index';
+import { HealthCheckStrategy } from '../config/system/health-check-strategy';
+
+let indicator: HttpHealthIndicator;
+
+export interface HttpHealthCheckOptions {
+    key: string;
+    url: string;
+    timeout?: number;
+}
+
+/**
+ * @description
+ * A {@link HealthCheckStrategy} used to check health by pinging a url. Internally it uses
+ * the [NestJS HttpHealthIndicator](https://docs.nestjs.com/recipes/terminus#http-healthcheck).
+ *
+ * @example
+ * ```TypeScript
+ * import { HttpHealthCheckStrategy, TypeORMHealthCheckStrategy } from '\@vendure/core';
+ *
+ * export const config = {
+ *   // ...
+ *   systemOptions: {
+ *     healthChecks: [
+ *       new TypeORMHealthCheckStrategy(),
+ *       new HttpHealthCheckStrategy({ key: 'my-service', url: 'https://my-service.com' }),
+ *     ]
+ *   },
+ * };
+ * ```
+ *
+ * @docsCategory health-check
+ */
+export class HttpHealthCheckStrategy implements HealthCheckStrategy {
+    constructor(private options: HttpHealthCheckOptions) {}
+
+    async init(injector: Injector) {
+        indicator = await injector.get(HttpHealthIndicator);
+    }
+
+    getHealthIndicator(): HealthIndicatorFunction {
+        const { key, url, timeout } = this.options;
+        return () => indicator.pingCheck(key, url, { timeout });
+    }
+}

+ 2 - 0
packages/core/src/health-check/index.ts

@@ -1,2 +1,4 @@
 export * from './constants';
 export * from './health-check-registry.service';
+export * from './typeorm-health-check-strategy';
+export * from './http-health-check-strategy';

+ 48 - 0
packages/core/src/health-check/typeorm-health-check-strategy.ts

@@ -0,0 +1,48 @@
+import { HealthIndicatorFunction, TypeOrmHealthIndicator } from '@nestjs/terminus';
+
+import { Injector } from '../common/index';
+import { HealthCheckStrategy } from '../config/system/health-check-strategy';
+
+let indicator: TypeOrmHealthIndicator;
+
+export interface TypeORMHealthCheckOptions {
+    key?: string;
+    timeout?: number;
+}
+
+/**
+ * @description
+ * A {@link HealthCheckStrategy} used to check the health of the database. This health
+ * check is included by default, but can be customized by explicitly adding it to the
+ * `systemOptions.healthChecks` array:
+ *
+ * @example
+ * ```TypeScript
+ * import { TypeORMHealthCheckStrategy } from '\@vendure/core';
+ *
+ * export const config = {
+ *   // ...
+ *   systemOptions: [
+ *     // The default key is "database" and the default timeout is 1000ms
+ *     // Sometimes this is too short and leads to false negatives in the
+ *     // /health endpoint.
+ *     new TypeORMHealthCheckStrategy({ key: 'postgres-db', timeout: 5000 }),
+ *   ]
+ * }
+ * ```
+ *
+ * @docsCategory health-check
+ */
+export class TypeORMHealthCheckStrategy implements HealthCheckStrategy {
+    constructor(private options?: TypeORMHealthCheckOptions) {}
+
+    async init(injector: Injector) {
+        indicator = await injector.resolve(TypeOrmHealthIndicator);
+    }
+
+    getHealthIndicator(): HealthIndicatorFunction {
+        const key = this.options?.key || 'database';
+        const timeout = this.options?.timeout ?? 1000;
+        return () => indicator.pingCheck(key, { timeout });
+    }
+}

+ 1 - 1
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -133,7 +133,7 @@ export class OrderModifier {
             new OrderLine({
                 productVariant,
                 taxCategory: productVariant.taxCategory,
-                featuredAsset: productVariant.product.featuredAsset,
+                featuredAsset: productVariant.featuredAsset ?? productVariant.product.featuredAsset,
                 customFields,
             }),
         );

+ 5 - 1
packages/core/src/service/services/collection.service.ts

@@ -89,7 +89,11 @@ export class CollectionService implements OnModuleInit {
         merge(productEvents$, variantEvents$)
             .pipe(debounceTime(50))
             .subscribe(async event => {
-                const collections = await this.connection.getRepository(Collection).find();
+                const collections = await this.connection
+                    .getRepository(Collection)
+                    .createQueryBuilder('collection')
+                    .select('collection.id', 'id')
+                    .getRawMany();
                 await this.applyFiltersQueue.add({
                     ctx: event.ctx.serialize(),
                     collectionIds: collections.map(c => c.id),

+ 14 - 10
packages/core/src/service/services/order.service.ts

@@ -908,23 +908,26 @@ export class OrderService {
      * * Shipping or billing address changes
      *
      * Setting the `dryRun` input property to `true` will apply all changes, including updating the price of the
-     * Order, but will not actually persist any of those changes to the database.
+     * Order, except history entry and additional payment actions.
+     *
+     * __Using dryRun option, you must wrap function call in transaction manually.__
+     *
      */
     async modifyOrder(
         ctx: RequestContext,
         input: ModifyOrderInput,
     ): Promise<ErrorResultUnion<ModifyOrderResult, Order>> {
-        await this.connection.startTransaction(ctx);
         const order = await this.getOrderOrThrow(ctx, input.orderId);
         const result = await this.orderModifier.modifyOrder(ctx, input, order);
-        if (input.dryRun) {
-            await this.connection.rollBackTransaction(ctx);
-            return isGraphQlErrorResult(result) ? result : result.order;
-        }
+
         if (isGraphQlErrorResult(result)) {
-            await this.connection.rollBackTransaction(ctx);
             return result;
         }
+
+        if (input.dryRun) {
+            return result.order;
+        }
+
         await this.historyService.createHistoryEntryForOrder({
             ctx,
             orderId: input.orderId,
@@ -933,7 +936,6 @@ export class OrderService {
                 modificationId: result.modification.id,
             },
         });
-        await this.connection.commitOpenTransaction(ctx);
         return this.getOrderOrThrow(ctx, input.orderId);
     }
 
@@ -1661,9 +1663,11 @@ export class OrderService {
     }
 
     /**
-     * Applies promotions, taxes and shipping to the Order.
+     * @description
+     * Applies promotions, taxes and shipping to the Order. If the `updatedOrderLines` argument is passed in,
+     * then all of those OrderLines will have their prices re-calculated using the configured {@link OrderItemPriceCalculationStrategy}.
      */
-    private async applyPriceAdjustments(
+    async applyPriceAdjustments(
         ctx: RequestContext,
         order: Order,
         updatedOrderLines?: OrderLine[],

+ 3 - 5
packages/job-queue-plugin/src/bullmq/plugin.ts

@@ -1,7 +1,8 @@
-import { HealthCheckRegistryService, PluginCommonModule, VendurePlugin } from '@vendure/core';
+import { PluginCommonModule, VendurePlugin } from '@vendure/core';
 
 import { BullMQJobQueueStrategy } from './bullmq-job-queue-strategy';
 import { BULLMQ_PLUGIN_OPTIONS } from './constants';
+import { RedisHealthCheckStrategy } from './redis-health-check-strategy';
 import { RedisHealthIndicator } from './redis-health-indicator';
 import { RedisJobBufferStorageStrategy } from './redis-job-buffer-storage-strategy';
 import { BullMQPluginOptions } from './types';
@@ -101,6 +102,7 @@ import { BullMQPluginOptions } from './types';
     configuration: config => {
         config.jobQueueOptions.jobQueueStrategy = new BullMQJobQueueStrategy();
         config.jobQueueOptions.jobBufferStorageStrategy = new RedisJobBufferStorageStrategy();
+        config.systemOptions.healthChecks.push(new RedisHealthCheckStrategy());
         return config;
     },
     providers: [
@@ -119,8 +121,4 @@ export class BullMQJobQueuePlugin {
         this.options = options;
         return this;
     }
-
-    constructor(private registry: HealthCheckRegistryService, private redis: RedisHealthIndicator) {
-        registry.registerIndicatorFunction(() => this.redis.isHealthy('redis (job queue)'));
-    }
 }

+ 15 - 0
packages/job-queue-plugin/src/bullmq/redis-health-check-strategy.ts

@@ -0,0 +1,15 @@
+import { HealthIndicatorFunction } from '@nestjs/terminus';
+import { HealthCheckStrategy, Injector } from '@vendure/core';
+
+import { RedisHealthIndicator } from './redis-health-indicator';
+
+let indicator: RedisHealthIndicator;
+
+export class RedisHealthCheckStrategy implements HealthCheckStrategy {
+    init(injector: Injector) {
+        indicator = injector.get(RedisHealthIndicator);
+    }
+    getHealthIndicator(): HealthIndicatorFunction {
+        return () => indicator.isHealthy('redis (job queue)');
+    }
+}

+ 1 - 1
packages/job-queue-plugin/src/bullmq/redis-health-indicator.ts

@@ -54,7 +54,7 @@ export class RedisHealthIndicator extends HealthIndicator {
 
         const result = this.getStatus(key, pingResult === 'PONG');
 
-        if (pingResult) {
+        if (pingResult === 'PONG') {
             return result;
         }
         throw new HealthCheckError('Redis failed', result);

+ 5 - 5
packages/payments-plugin/src/braintree/braintree.plugin.ts

@@ -128,12 +128,12 @@ import { BraintreePluginOptions } from './types';
  *   });
  * }
  *
- * async function generateClientToken(orderId: string) {
+ * async function generateClientToken() {
  *   const { generateBraintreeClientToken } = await graphQlClient.query(gql`
- *     query GenerateBraintreeClientToken($orderId: ID!) {
- *       generateBraintreeClientToken(orderId: $orderId)
+ *     query GenerateBraintreeClientToken {
+ *       generateBraintreeClientToken
  *     }
- *   `, { orderId });
+ *   `);
  *   return generateBraintreeClientToken;
  * }
  *
@@ -215,7 +215,7 @@ import { BraintreePluginOptions } from './types';
     shopApiExtensions: {
         schema: gql`
             extend type Query {
-                generateBraintreeClientToken(orderId: ID!): String!
+                generateBraintreeClientToken(orderId: ID): String!
             }
         `,
         resolvers: [BraintreeResolver],

+ 15 - 2
packages/payments-plugin/src/braintree/braintree.resolver.ts

@@ -1,6 +1,7 @@
 import { Inject } from '@nestjs/common';
 import { Args, Query, Resolver } from '@nestjs/graphql';
 import {
+    ActiveOrderService,
     Ctx,
     ID,
     InternalServerError,
@@ -21,12 +22,24 @@ export class BraintreeResolver {
     constructor(
         private connection: TransactionalConnection,
         private orderService: OrderService,
+        private activeOrderService: ActiveOrderService,
         @Inject(BRAINTREE_PLUGIN_OPTIONS) private options: BraintreePluginOptions,
     ) {}
 
     @Query()
-    async generateBraintreeClientToken(@Ctx() ctx: RequestContext, @Args() { orderId }: { orderId: ID }) {
-        const order = await this.orderService.findOne(ctx, orderId);
+    async generateBraintreeClientToken(@Ctx() ctx: RequestContext, @Args() { orderId }: { orderId?: ID }) {
+        if (orderId) {
+            Logger.warn(
+                `The orderId argument to the generateBraintreeClientToken mutation has been deprecated and may be omitted.`,
+            );
+        }
+        const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+        if (!sessionOrder) {
+            throw new InternalServerError(
+                `Cannot generate Braintree clientToken as there is no active Order.`,
+            );
+        }
+        const order = await this.orderService.findOne(ctx, sessionOrder.id);
         if (order && order.customer) {
             const customerId = order.customer.customFields.braintreeCustomerId ?? undefined;
             const args = await this.getPaymentMethodArgs(ctx);

+ 1 - 1
packages/ui-devkit/src/compiler/compile.ts

@@ -75,7 +75,7 @@ function runCompileMode(
             const commandArgs = [
                 'run',
                 'build',
-                `--outputPath=${distPath}`,
+                `--outputPath="${distPath}"`,
                 `--base-href=${baseHref}`,
                 ...buildProcessArguments(args),
             ];