Browse Source

Merge branch 'master' into minor

Michael Bromley 4 years ago
parent
commit
5b163ee42c
46 changed files with 669 additions and 167 deletions
  1. 34 0
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 3 3
      packages/admin-ui-plugin/package.json
  4. 0 1
      packages/admin-ui/angular.json
  5. 2 2
      packages/admin-ui/package.json
  6. 1 1
      packages/admin-ui/src/lib/core/src/app.component.ts
  7. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  8. 2 2
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  9. 1 1
      packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.html
  10. 1 0
      packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.ts
  11. 1 1
      packages/admin-ui/src/lib/settings/src/components/zone-detail-dialog/zone-detail-dialog.component.html
  12. 3 1
      packages/admin-ui/src/lib/static/styles/global/_utilities.scss
  13. 3 3
      packages/asset-server-plugin/package.json
  14. 1 1
      packages/asset-server-plugin/src/plugin.ts
  15. 1 1
      packages/common/package.json
  16. 43 0
      packages/core/e2e/administrator.e2e-spec.ts
  17. 23 3
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  18. 29 0
      packages/core/e2e/entity-hydrator.e2e-spec.ts
  19. 18 0
      packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts
  20. 277 86
      packages/core/e2e/order-modification.e2e-spec.ts
  21. 64 0
      packages/core/e2e/stock-control.e2e-spec.ts
  22. 2 2
      packages/core/package.json
  23. 1 1
      packages/core/src/api/config/graphql-custom-fields.ts
  24. 1 0
      packages/core/src/api/schema/common/payment-method.type.graphql
  25. 1 0
      packages/core/src/common/types/entity-relation-paths.ts
  26. 2 0
      packages/core/src/i18n/messages/en.json
  27. 1 1
      packages/core/src/job-queue/subscribable-job.ts
  28. 7 2
      packages/core/src/plugin/default-search-plugin/search-job-buffer/search-job-buffer.service.ts
  29. 7 7
      packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts
  30. 7 1
      packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts
  31. 13 2
      packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts
  32. 10 2
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  33. 60 1
      packages/core/src/service/services/administrator.service.ts
  34. 2 4
      packages/core/src/service/services/customer.service.ts
  35. 3 1
      packages/core/src/service/services/order.service.ts
  36. 3 3
      packages/create/package.json
  37. 9 9
      packages/dev-server/package.json
  38. 10 1
      packages/dev-server/test-plugins/entity-hydrate-plugin.ts
  39. 3 3
      packages/elasticsearch-plugin/package.json
  40. 3 3
      packages/email-plugin/package.json
  41. 3 3
      packages/job-queue-plugin/package.json
  42. 4 4
      packages/payments-plugin/package.json
  43. 3 3
      packages/testing/package.json
  44. 4 4
      packages/ui-devkit/package.json
  45. 0 1
      packages/ui-devkit/scaffold/angular.json
  46. 1 1
      scripts/changelogs/generate-changelog.ts

+ 34 - 0
CHANGELOG.md

@@ -1,3 +1,37 @@
+## <small>1.4.3 (2021-12-22)</small>
+
+
+#### Fixes
+
+* **admin-ui** Do not show cancelled orders in latest orders widget ([e842e6e](https://github.com/vendure-ecommerce/vendure/commit/e842e6e))
+* **admin-ui** Fix broken Zone creation dialog ([2bc6f4d](https://github.com/vendure-ecommerce/vendure/commit/2bc6f4d)), closes [#1309](https://github.com/vendure-ecommerce/vendure/issues/1309)
+* **core** Prevent removal of sole SuperAdmin ([a1debff](https://github.com/vendure-ecommerce/vendure/commit/a1debff)), closes [#1307](https://github.com/vendure-ecommerce/vendure/issues/1307)
+* **core** Restore deleted superadmin entities ([498a5c6](https://github.com/vendure-ecommerce/vendure/commit/498a5c6)), closes [#1307](https://github.com/vendure-ecommerce/vendure/issues/1307)
+
+## <small>1.4.2 (2021-12-20)</small>
+
+
+#### Fixes
+
+* **admin-ui** Allow CustomerGroup to be created ([2782df8](https://github.com/vendure-ecommerce/vendure/commit/2782df8)), closes [#1300](https://github.com/vendure-ecommerce/vendure/issues/1300)
+* **admin-ui** Correct the warning about the division in Sass (#1294) ([0e4e952](https://github.com/vendure-ecommerce/vendure/commit/0e4e952)), closes [#1294](https://github.com/vendure-ecommerce/vendure/issues/1294) [#1293](https://github.com/vendure-ecommerce/vendure/issues/1293)
+* **admin-ui** Fix cmd+u shortcut on macOS (#1291) ([0b74cc9](https://github.com/vendure-ecommerce/vendure/commit/0b74cc9)), closes [#1291](https://github.com/vendure-ecommerce/vendure/issues/1291)
+* **admin-ui** Fix null property access error for configurable args ([3f7d46d](https://github.com/vendure-ecommerce/vendure/commit/3f7d46d)), closes [#1296](https://github.com/vendure-ecommerce/vendure/issues/1296)
+* **admin-ui** Remove deprecated showCircularDependencies option ([a30d639](https://github.com/vendure-ecommerce/vendure/commit/a30d639))
+* **core** Correctly record stock movement when modifying orders ([a983f24](https://github.com/vendure-ecommerce/vendure/commit/a983f24)), closes [#1210](https://github.com/vendure-ecommerce/vendure/issues/1210)
+* **core** EntityHydrator correctly handles custom field relations ([fd3e642](https://github.com/vendure-ecommerce/vendure/commit/fd3e642)), closes [#1284](https://github.com/vendure-ecommerce/vendure/issues/1284)
+* **core** Fix email verification for already-verified accounts (#1304) ([2f17b9a](https://github.com/vendure-ecommerce/vendure/commit/2f17b9a)), closes [#1304](https://github.com/vendure-ecommerce/vendure/issues/1304) [#1303](https://github.com/vendure-ecommerce/vendure/issues/1303)
+* **core** Handle search job buffer timeout errors, increase timeout ([8797456](https://github.com/vendure-ecommerce/vendure/commit/8797456)), closes [#1287](https://github.com/vendure-ecommerce/vendure/issues/1287)
+* **core** Handle substring search terms for postgres & mysql ([81e3672](https://github.com/vendure-ecommerce/vendure/commit/81e3672)), closes [#1277](https://github.com/vendure-ecommerce/vendure/issues/1277)
+
+## <small>1.4.1 (2021-12-14)</small>
+
+
+#### Fixes
+
+* **core** Fix `Unknown type "ShippingMethodCustomFields"` error ([d810450](https://github.com/vendure-ecommerce/vendure/commit/d810450))
+* **core** Fix FK error with adjustOrderLine when zero saleable stock ([28aeddb](https://github.com/vendure-ecommerce/vendure/commit/28aeddb)), closes [#1273](https://github.com/vendure-ecommerce/vendure/issues/1273)
+
 ## 1.4.0 (2021-12-13)
 
 

+ 1 - 1
lerna.json

@@ -2,7 +2,7 @@
   "packages": [
     "packages/*"
   ],
-  "version": "1.4.0",
+  "version": "1.4.3",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "command": {

+ 3 - 3
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -21,8 +21,8 @@
   "devDependencies": {
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^1.4.0",
-    "@vendure/core": "^1.4.0",
+    "@vendure/common": "^1.4.3",
+    "@vendure/core": "^1.4.3",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"

+ 0 - 1
packages/admin-ui/angular.json

@@ -34,7 +34,6 @@
                 "./src/lib/static/styles"
               ]
             },
-            "showCircularDependencies": false,
             "allowedCommonJsDependencies": [
               "graphql-tag",
               "zen-observable",

+ 2 - 2
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "license": "MIT",
   "scripts": {
     "ng": "ng",
@@ -39,7 +39,7 @@
     "@ng-select/ng-select": "^7.2.0",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/http-loader": "^6.0.0",
-    "@vendure/common": "^1.4.0",
+    "@vendure/common": "^1.4.3",
     "@webcomponents/custom-elements": "^1.4.3",
     "apollo-angular": "^2.6.0",
     "apollo-upload-client": "^16.0.0",

+ 1 - 1
packages/admin-ui/src/lib/core/src/app.component.ts

@@ -74,7 +74,7 @@ export class AppComponent implements OnInit {
 
     @HostListener('window:keydown', ['$event'])
     handleGlobalHotkeys(event: KeyboardEvent) {
-        if (event.ctrlKey === true && event.key === 'u') {
+        if ((event.ctrlKey === true || event.metaKey === true) && event.key === 'u') {
             event.preventDefault();
             if (isDevMode()) {
                 this.dataService.client

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/version.ts

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '1.4.0';
+export const ADMIN_UI_VERSION = '1.4.3';

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts

@@ -192,7 +192,7 @@ export class DynamicFormInputComponent
 
     private updateBindings(changes: SimpleChanges, componentRef: ComponentRef<FormInputComponent>) {
         if ('def' in changes) {
-            componentRef.instance.config = this.isConfigArgDef(this.def) ? this.def.ui : this.def;
+            componentRef.instance.config = simpleDeepClone(this.def);
         }
         if ('readonly' in changes) {
             componentRef.instance.readonly = this.readonly;
@@ -241,7 +241,7 @@ export class DynamicFormInputComponent
     ) {
         const componentRef = viewContainerRef.createComponent(factory);
         const { instance } = componentRef;
-        instance.config = simpleDeepClone(this.isConfigArgDef(this.def) ? this.def.ui : this.def);
+        instance.config = simpleDeepClone(this.def);
         instance.formControl = formControl;
         instance.readonly = this.readonly;
         componentRef.injector.get(ChangeDetectorRef).markForCheck();

+ 1 - 1
packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.html

@@ -22,7 +22,7 @@
 </form>
 <ng-template vdrDialogButtons>
     <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
-    <button type="submit" (click)="save()" [disabled]="!group.name" class="btn btn-primary">
+    <button type="submit" (click)="save()" [disabled]="!form.valid" class="btn btn-primary">
         <span *ngIf="group.id">{{ 'customer.update-customer-group' | translate }}</span>
         <span *ngIf="!group.id">{{ 'customer.create-customer-group' | translate }}</span>
     </button>

+ 1 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.ts

@@ -18,6 +18,7 @@ export class LatestOrdersWidgetComponent implements OnInit {
                 take: 10,
                 filter: {
                     active: { eq: false },
+                    state: { notEq: 'Cancelled' },
                 },
                 sort: {
                     orderPlacedAt: SortOrder.DESC,

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/zone-detail-dialog/zone-detail-dialog.component.html

@@ -21,7 +21,7 @@
 
 <ng-template vdrDialogButtons>
     <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
-    <button type="submit" (click)="save()" [disabled]="!zone.name" class="btn btn-primary">
+    <button type="submit" (click)="save()" [disabled]="form.invalid" class="btn btn-primary">
         <span *ngIf="zone.id">{{ 'settings.update-zone' | translate }}</span>
         <span *ngIf="!zone.id">{{ 'settings.create-zone' | translate }}</span>
     </button>

+ 3 - 1
packages/admin-ui/src/lib/static/styles/global/_utilities.scss

@@ -1,7 +1,9 @@
+@use "sass:math";
+
 // spacing
 $space-unit: 6px;
 
-$space-1: $space-unit / 2;
+$space-1: math.div($space-unit, 2);
 $space-2: $space-unit;
 $space-3: $space-unit * 2;
 $space-4: $space-unit * 3;

+ 3 - 3
packages/asset-server-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/asset-server-plugin",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -24,8 +24,8 @@
     "@types/fs-extra": "^9.0.8",
     "@types/node-fetch": "^2.5.8",
     "@types/sharp": "^0.27.1",
-    "@vendure/common": "^1.4.0",
-    "@vendure/core": "^1.4.0",
+    "@vendure/common": "^1.4.3",
+    "@vendure/core": "^1.4.3",
     "aws-sdk": "^2.856.0",
     "express": "^4.17.1",
     "node-fetch": "^2.6.1",

+ 1 - 1
packages/asset-server-plugin/src/plugin.ts

@@ -88,7 +88,7 @@ import { AssetServerOptions, ImageTransformPreset } from './types';
  * For example, defining the following preset:
  *
  * ```ts
- * new AssetServerPlugin({
+ * AssetServerPlugin.init({
  *   // ...
  *   presets: [
  *     { name: 'my-preset', width: 85, height: 85, mode: 'crop' },

+ 1 - 1
packages/common/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/common",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "main": "index.js",
   "license": "MIT",
   "scripts": {

+ 43 - 0
packages/core/e2e/administrator.e2e-spec.ts

@@ -1,5 +1,6 @@
 import { SUPER_ADMIN_USER_IDENTIFIER } from '@vendure/common/lib/shared-constants';
 import { createTestEnvironment } from '@vendure/testing';
+import { fail } from 'assert';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -150,6 +151,48 @@ describe('Administrator resolver', () => {
         expect(after.totalItems).toBe(1);
     });
 
+    it('cannot delete sole SuperAdmin', async () => {
+        const { administrators: before } = await adminClient.query<
+            GetAdministrators.Query,
+            GetAdministrators.Variables
+        >(GET_ADMINISTRATORS);
+        expect(before.totalItems).toBe(1);
+        expect(before.items[0].emailAddress).toBe('superadmin');
+
+        try {
+            const { deleteAdministrator } = await adminClient.query<
+                DeleteAdministrator.Mutation,
+                DeleteAdministrator.Variables
+            >(DELETE_ADMINISTRATOR, {
+                id: before.items[0].id,
+            });
+            fail('Should have thrown');
+        } catch (e) {
+            expect(e.message).toBe('The sole SuperAdmin cannot be deleted');
+        }
+
+        const { administrators: after } = await adminClient.query<
+            GetAdministrators.Query,
+            GetAdministrators.Variables
+        >(GET_ADMINISTRATORS);
+        expect(after.totalItems).toBe(1);
+    });
+
+    it(
+        'cannot remove SuperAdmin role from sole SuperAdmin',
+        assertThrowsWithMessage(async () => {
+            const result = await adminClient.query<
+                UpdateAdministrator.Mutation,
+                UpdateAdministrator.Variables
+            >(UPDATE_ADMINISTRATOR, {
+                input: {
+                    id: 'T_1',
+                    roleIds: [],
+                },
+            });
+        }, 'Cannot remove the SuperAdmin role from the sole SuperAdmin'),
+    );
+
     it('cannot query a deleted Administrator', async () => {
         const { administrator } = await adminClient.query<GetAdministrator.Query, GetAdministrator.Variables>(
             GET_ADMINISTRATOR,

+ 23 - 3
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -18,7 +18,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
     AssignProductsToChannel,
@@ -52,8 +52,8 @@ import {
 } from './graphql/generated-e2e-admin-types';
 import { LogicalOperator, SearchProductsShop } from './graphql/generated-e2e-shop-types';
 import {
-    ASSIGN_PRODUCT_TO_CHANNEL,
     ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
+    ASSIGN_PRODUCT_TO_CHANNEL,
     CREATE_CHANNEL,
     CREATE_COLLECTION,
     CREATE_FACET,
@@ -62,8 +62,8 @@ import {
     DELETE_ASSET,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
-    REMOVE_PRODUCT_FROM_CHANNEL,
     REMOVE_PRODUCTVARIANT_FROM_CHANNEL,
+    REMOVE_PRODUCT_FROM_CHANNEL,
     UPDATE_ASSET,
     UPDATE_COLLECTION,
     UPDATE_PRODUCT,
@@ -209,6 +209,22 @@ describe('Default search plugin', () => {
         ]);
     }
 
+    async function testMatchPartialSearchTerm(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProductsShop.Query, SearchProductShopVariables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    term: 'lap',
+                    groupByProduct: true,
+                    sort: {
+                        name: SortOrder.ASC,
+                    },
+                },
+            },
+        );
+        expect(result.search.items.map(i => i.productName)).toEqual(['Laptop']);
+    }
+
     async function testMatchFacetIdsAnd(client: SimpleGraphQLClient) {
         const result = await client.query<SearchProductsShop.Query, SearchProductShopVariables>(
             SEARCH_PRODUCTS_SHOP,
@@ -468,6 +484,8 @@ describe('Default search plugin', () => {
 
         it('matches search term', () => testMatchSearchTerm(shopClient));
 
+        it('matches partial search term', () => testMatchPartialSearchTerm(shopClient));
+
         it('matches by facetId with AND operator', () => testMatchFacetIdsAnd(shopClient));
 
         it('matches by facetId with OR operator', () => testMatchFacetIdsOr(shopClient));
@@ -765,6 +783,8 @@ describe('Default search plugin', () => {
 
         it('matches search term', () => testMatchSearchTerm(adminClient));
 
+        it('matches partial search term', () => testMatchPartialSearchTerm(adminClient));
+
         it('matches by facetId with AND operator', () => testMatchFacetIdsAnd(adminClient));
 
         it('matches by facetId with OR operator', () => testMatchFacetIdsOr(adminClient));

+ 29 - 0
packages/core/e2e/entity-hydrator.e2e-spec.ts

@@ -8,7 +8,9 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { HydrationTestPlugin } from './fixtures/test-plugins/hydration-test-plugin';
+import { UpdateChannel } from './graphql/generated-e2e-admin-types';
 import { AddItemToOrder, UpdatedOrderFragment } from './graphql/generated-e2e-shop-types';
+import { UPDATE_CHANNEL } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 
 const orderResultGuard: ErrorResultGuard<UpdatedOrderFragment> = createErrorResultGuard(
@@ -188,6 +190,27 @@ describe('Entity hydration', () => {
 
         expect(hydrateOrderReturnQuantities).toEqual([2]);
     });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1284
+    it('hydrates custom field relations', async () => {
+        await adminClient.query<UpdateChannel.Mutation, UpdateChannel.Variables>(UPDATE_CHANNEL, {
+            input: {
+                id: 'T_1',
+                customFields: {
+                    thumbId: 'T_2',
+                },
+            },
+        });
+
+        const { hydrateChannel } = await adminClient.query<{
+            hydrateChannel: any;
+        }>(GET_HYDRATED_CHANNEL, {
+            id: 'T_1',
+        });
+
+        expect(hydrateChannel.customFields.thumb).toBeDefined();
+        expect(hydrateChannel.customFields.thumb.id).toBe('T_2');
+    });
 });
 
 function getVariantWithName(product: Product, name: string) {
@@ -221,3 +244,9 @@ const GET_HYDRATED_ORDER_QUANTITIES = gql`
         hydrateOrderReturnQuantities(id: $id)
     }
 `;
+
+const GET_HYDRATED_CHANNEL = gql`
+    query GetHydratedChannel($id: ID!) {
+        hydrateChannel(id: $id)
+    }
+`;

+ 18 - 0
packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts

@@ -1,6 +1,8 @@
 /* tslint:disable:no-non-null-assertion */
 import { Args, Query, Resolver } from '@nestjs/graphql';
 import {
+    Asset,
+    ChannelService,
     Ctx,
     EntityHydrator,
     ID,
@@ -19,6 +21,7 @@ export class TestAdminPluginResolver {
     constructor(
         private connection: TransactionalConnection,
         private orderService: OrderService,
+        private channelService: ChannelService,
         private productVariantService: ProductVariantService,
         private entityHydrator: EntityHydrator,
     ) {}
@@ -88,6 +91,16 @@ export class TestAdminPluginResolver {
         });
         return order?.lines.map(line => line.quantity);
     }
+
+    // Test case for https://github.com/vendure-ecommerce/vendure/issues/1284
+    @Query()
+    async hydrateChannel(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) {
+        const channel = await this.channelService.findOne(ctx, args.id);
+        await this.entityHydrator.hydrate(ctx, channel!, {
+            relations: ['customFields.thumb'],
+        });
+        return channel;
+    }
 }
 
 @VendurePlugin({
@@ -101,8 +114,13 @@ export class TestAdminPluginResolver {
                 hydrateProductVariant(id: ID!): JSON
                 hydrateOrder(id: ID!): JSON
                 hydrateOrderReturnQuantities(id: ID!): JSON
+                hydrateChannel(id: ID!): JSON
             }
         `,
     },
+    configuration: config => {
+        config.customFields.Channel.push({ name: 'thumb', type: 'relation', entity: Asset, nullable: true });
+        return config;
+    },
 })
 export class HydrationTestPlugin {}

+ 277 - 86
packages/core/e2e/order-modification.e2e-spec.ts

@@ -26,12 +26,14 @@ import {
 import {
     AddManualPayment,
     AdminTransition,
+    CreateFulfillment,
     CreatePromotion,
     CreateShippingMethod,
     ErrorCode,
     GetOrder,
     GetOrderHistory,
     GetOrderWithModifications,
+    GetStockMovement,
     GlobalFlag,
     HistoryEntryType,
     LanguageCode,
@@ -53,10 +55,13 @@ import {
 } from './graphql/generated-e2e-shop-types';
 import {
     ADMIN_TRANSITION_TO_STATE,
+    CREATE_FULFILLMENT,
     CREATE_PROMOTION,
     CREATE_SHIPPING_METHOD,
     GET_ORDER,
     GET_ORDER_HISTORY,
+    GET_PRODUCT_WITH_VARIANTS,
+    GET_STOCK_MOVEMENT,
     UPDATE_CHANNEL,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
@@ -233,13 +238,7 @@ describe('Order modification', () => {
     });
 
     it('transition to Modifying state', async () => {
-        const { transitionOrderToState } = await adminClient.query<
-            AdminTransition.Mutation,
-            AdminTransition.Variables
-        >(ADMIN_TRANSITION_TO_STATE, {
-            id: orderId,
-            state: 'Modifying',
-        });
+        const transitionOrderToState = await adminTransitionOrderToState(orderId, 'Modifying');
         orderGuard.assertSuccess(transitionOrderToState);
 
         expect(transitionOrderToState.state).toBe('Modifying');
@@ -1114,13 +1113,7 @@ describe('Order modification', () => {
         });
 
         it('cannot transition back to original state if no payment is set', async () => {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: orderId2,
-                state: 'PaymentSettled',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(orderId2, 'PaymentSettled');
             orderGuard.assertErrorResult(transitionOrderToState);
             expect(transitionOrderToState!.errorCode).toBe(ErrorCode.ORDER_STATE_TRANSITION_ERROR);
             expect(transitionOrderToState!.transitionError).toBe(
@@ -1129,25 +1122,16 @@ describe('Order modification', () => {
         });
 
         it('can transition to ArrangingAdditionalPayment state', async () => {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: orderId2,
-                state: 'ArrangingAdditionalPayment',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(
+                orderId2,
+                'ArrangingAdditionalPayment',
+            );
             orderGuard.assertSuccess(transitionOrderToState);
             expect(transitionOrderToState!.state).toBe('ArrangingAdditionalPayment');
         });
 
         it('cannot transition from ArrangingAdditionalPayment when total not covered by Payments', async () => {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: orderId2,
-                state: 'PaymentSettled',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(orderId2, 'PaymentSettled');
             orderGuard.assertErrorResult(transitionOrderToState);
             expect(transitionOrderToState!.errorCode).toBe(ErrorCode.ORDER_STATE_TRANSITION_ERROR);
             expect(transitionOrderToState!.transitionError).toBe(
@@ -1189,13 +1173,7 @@ describe('Order modification', () => {
         });
 
         it('transition back to original state', async () => {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: orderId2,
-                state: 'PaymentSettled',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(orderId2, 'PaymentSettled');
             orderGuard.assertSuccess(transitionOrderToState);
 
             expect(transitionOrderToState.state).toBe('PaymentSettled');
@@ -1250,13 +1228,10 @@ describe('Order modification', () => {
         });
 
         it('cannot transition to ArrangingAdditionalPayment state if no payment is needed', async () => {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: orderId3,
-                state: 'ArrangingAdditionalPayment',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(
+                orderId3,
+                'ArrangingAdditionalPayment',
+            );
             orderGuard.assertErrorResult(transitionOrderToState);
             expect(transitionOrderToState!.errorCode).toBe(ErrorCode.ORDER_STATE_TRANSITION_ERROR);
             expect(transitionOrderToState!.transitionError).toBe(
@@ -1265,13 +1240,7 @@ describe('Order modification', () => {
         });
 
         it('can transition to original state', async () => {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: orderId3,
-                state: 'PaymentSettled',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(orderId3, 'PaymentSettled');
             orderGuard.assertSuccess(transitionOrderToState);
             expect(transitionOrderToState!.state).toBe('PaymentSettled');
 
@@ -1316,13 +1285,7 @@ describe('Order modification', () => {
         const originalTotalWithTax = order.totalWithTax;
         const surcharge = 300;
 
-        const { transitionOrderToState } = await adminClient.query<
-            AdminTransition.Mutation,
-            AdminTransition.Variables
-        >(ADMIN_TRANSITION_TO_STATE, {
-            id: order.id,
-            state: 'Modifying',
-        });
+        const transitionOrderToState = await adminTransitionOrderToState(order.id, 'Modifying');
         orderGuard.assertSuccess(transitionOrderToState);
 
         expect(transitionOrderToState.state).toBe('Modifying');
@@ -1354,13 +1317,7 @@ describe('Order modification', () => {
     // https://github.com/vendure-ecommerce/vendure/issues/872
     describe('correct price calculations when prices include tax', () => {
         async function modifyOrderLineQuantity(order: TestOrderWithPaymentsFragment) {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: order.id,
-                state: 'Modifying',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(order.id, 'Modifying');
             orderGuard.assertSuccess(transitionOrderToState);
 
             expect(transitionOrderToState.state).toBe('Modifying');
@@ -1473,13 +1430,7 @@ describe('Order modification', () => {
 
             const originalTotalWithTax = order.totalWithTax;
 
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: order.id,
-                state: 'Modifying',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(order.id, 'Modifying');
             orderGuard.assertSuccess(transitionOrderToState);
 
             expect(transitionOrderToState.state).toBe('Modifying');
@@ -1542,13 +1493,7 @@ describe('Order modification', () => {
         });
 
         it('allows transition to PaymentSettled', async () => {
-            const { transitionOrderToState } = await adminClient.query<
-                AdminTransition.Mutation,
-                AdminTransition.Variables
-            >(ADMIN_TRANSITION_TO_STATE, {
-                id: order.id,
-                state: 'PaymentSettled',
-            });
+            const transitionOrderToState = await adminTransitionOrderToState(order.id, 'PaymentSettled');
 
             orderGuard.assertSuccess(transitionOrderToState);
 
@@ -1556,6 +1501,252 @@ describe('Order modification', () => {
         });
     });
 
+    // https://github.com/vendure-ecommerce/vendure/issues/1210
+    describe('updating stock levels', () => {
+        async function getVariant(id: 'T_1' | 'T_2' | 'T_3') {
+            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
+                GET_STOCK_MOVEMENT,
+                {
+                    id: 'T_1',
+                },
+            );
+            return product?.variants.find(v => v.id === id)!;
+        }
+
+        let orderId4: string;
+        let orderId5: string;
+
+        it('updates stock when increasing quantity before fulfillment', async () => {
+            const variant1 = await getVariant('T_2');
+            expect(variant1.stockOnHand).toBe(100);
+            expect(variant1.stockAllocated).toBe(0);
+
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_2',
+                    quantity: 1,
+                },
+            ]);
+            orderId4 = order.id;
+
+            const variant2 = await getVariant('T_2');
+            expect(variant2.stockOnHand).toBe(100);
+            expect(variant2.stockAllocated).toBe(1);
+
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        adjustOrderLines: [{ orderLineId: order.lines[0].id, quantity: 2 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const variant3 = await getVariant('T_2');
+            expect(variant3.stockOnHand).toBe(100);
+            expect(variant3.stockAllocated).toBe(2);
+        });
+
+        it('updates stock when increasing quantity after fulfillment', async () => {
+            const result = await adminTransitionOrderToState(orderId4, 'ArrangingAdditionalPayment');
+            orderGuard.assertSuccess(result);
+            expect(result!.state).toBe('ArrangingAdditionalPayment');
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId4,
+            });
+            const { addManualPaymentToOrder } = await adminClient.query<
+                AddManualPayment.Mutation,
+                AddManualPayment.Variables
+            >(ADD_MANUAL_PAYMENT, {
+                input: {
+                    orderId: orderId4,
+                    method: 'test',
+                    transactionId: 'ABC123',
+                    metadata: {
+                        foo: 'bar',
+                    },
+                },
+            });
+            orderGuard.assertSuccess(addManualPaymentToOrder);
+            await adminTransitionOrderToState(orderId4, 'PaymentSettled');
+            await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
+                CREATE_FULFILLMENT,
+                {
+                    input: {
+                        lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
+                        handler: {
+                            code: manualFulfillmentHandler.code,
+                            arguments: [
+                                { name: 'method', value: 'test method' },
+                                { name: 'trackingCode', value: 'ABC123' },
+                            ],
+                        },
+                    },
+                },
+            );
+
+            const variant1 = await getVariant('T_2');
+            expect(variant1.stockOnHand).toBe(98);
+            expect(variant1.stockAllocated).toBe(0);
+
+            await adminTransitionOrderToState(orderId4, 'Modifying');
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order!.id,
+                        adjustOrderLines: [{ orderLineId: order!.lines[0].id, quantity: 3 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const variant2 = await getVariant('T_2');
+            expect(variant2.stockOnHand).toBe(98);
+            expect(variant2.stockAllocated).toBe(1);
+
+            const { order: order2 } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId4,
+            });
+        });
+
+        it('updates stock when adding item before fulfillment', async () => {
+            const variant1 = await getVariant('T_3');
+            expect(variant1.stockOnHand).toBe(100);
+            expect(variant1.stockAllocated).toBe(0);
+
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_2',
+                    quantity: 1,
+                },
+            ]);
+            orderId5 = order.id;
+
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order!.id,
+                        addItems: [{ productVariantId: 'T_3', quantity: 1 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const variant2 = await getVariant('T_3');
+            expect(variant2.stockOnHand).toBe(100);
+            expect(variant2.stockAllocated).toBe(1);
+        });
+
+        it('updates stock when removing item before fulfillment', async () => {
+            const variant1 = await getVariant('T_3');
+            expect(variant1.stockOnHand).toBe(100);
+            expect(variant1.stockAllocated).toBe(1);
+
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId5,
+            });
+
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: orderId5,
+                        adjustOrderLines: [
+                            {
+                                orderLineId: order!.lines.find(l => l.productVariant.id === 'T_3')!.id,
+                                quantity: 0,
+                            },
+                        ],
+                        refund: {
+                            paymentId: order!.payments![0].id,
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const variant2 = await getVariant('T_3');
+            expect(variant2.stockOnHand).toBe(100);
+            expect(variant2.stockAllocated).toBe(0);
+        });
+
+        it('updates stock when removing item after fulfillment', async () => {
+            const variant1 = await getVariant('T_3');
+            expect(variant1.stockOnHand).toBe(100);
+            expect(variant1.stockAllocated).toBe(0);
+
+            const order = await createOrderAndCheckout([
+                {
+                    productVariantId: 'T_3',
+                    quantity: 1,
+                },
+            ]);
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'test method' },
+                            { name: 'trackingCode', value: 'ABC123' },
+                        ],
+                    },
+                },
+            });
+            orderGuard.assertSuccess(addFulfillmentToOrder);
+
+            const variant2 = await getVariant('T_3');
+            expect(variant2.stockOnHand).toBe(99);
+            expect(variant2.stockAllocated).toBe(0);
+
+            await adminTransitionOrderToState(order.id, 'Modifying');
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        adjustOrderLines: [
+                            {
+                                orderLineId: order!.lines.find(l => l.productVariant.id === 'T_3')!.id,
+                                quantity: 0,
+                            },
+                        ],
+                        refund: {
+                            paymentId: order!.payments![0].id,
+                        },
+                    },
+                },
+            );
+
+            const variant3 = await getVariant('T_3');
+            expect(variant3.stockOnHand).toBe(100);
+            expect(variant3.stockAllocated).toBe(0);
+        });
+    });
+
+    async function adminTransitionOrderToState(id: string, state: string) {
+        const result = await adminClient.query<AdminTransition.Mutation, AdminTransition.Variables>(
+            ADMIN_TRANSITION_TO_STATE,
+            {
+                id,
+                state,
+            },
+        );
+        return result.transitionOrderToState;
+    }
+
     async function assertOrderIsUnchanged(order: OrderWithLinesFragment) {
         const { order: order2 } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
             id: order.id,
@@ -1566,9 +1757,9 @@ describe('Order modification', () => {
         expect(order2!.totalQuantity).toBe(order!.totalQuantity);
     }
 
-    async function createOrderAndTransitionToModifyingState(
+    async function createOrderAndCheckout(
         items: Array<AddItemToOrderMutationVariables & { customFields?: any }>,
-    ): Promise<TestOrderWithPaymentsFragment> {
+    ) {
         await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
         for (const itemInput of items) {
             await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), itemInput);
@@ -1597,14 +1788,14 @@ describe('Order modification', () => {
 
         const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
         orderGuard.assertSuccess(order);
+        return order;
+    }
 
-        const { transitionOrderToState } = await adminClient.query<
-            AdminTransition.Mutation,
-            AdminTransition.Variables
-        >(ADMIN_TRANSITION_TO_STATE, {
-            id: order.id,
-            state: 'Modifying',
-        });
+    async function createOrderAndTransitionToModifyingState(
+        items: Array<AddItemToOrderMutationVariables & { customFields?: any }>,
+    ): Promise<TestOrderWithPaymentsFragment> {
+        const order = await createOrderAndCheckout(items);
+        await adminTransitionOrderToState(order.id, 'Modifying');
         return order;
     }
 });

+ 64 - 0
packages/core/e2e/stock-control.e2e-spec.ts

@@ -31,7 +31,9 @@ import {
 import {
     AddItemToOrder,
     AddPaymentToOrder,
+    AdjustItemQuantity,
     ErrorCode,
+    GetActiveOrder,
     GetProductStockLevel,
     GetShippingMethods,
     PaymentInput,
@@ -54,6 +56,8 @@ import {
 import {
     ADD_ITEM_TO_ORDER,
     ADD_PAYMENT,
+    ADJUST_ITEM_QUANTITY,
+    GET_ACTIVE_ORDER,
     GET_ELIGIBLE_SHIPPING_METHODS,
     GET_PRODUCT_WITH_STOCK_LEVEL,
     SET_SHIPPING_ADDRESS,
@@ -941,6 +945,7 @@ describe('Stock control', () => {
         describe('edge cases', () => {
             const variant5Id = 'T_5';
             const variant6Id = 'T_6';
+            const variant7Id = 'T_7';
 
             beforeAll(async () => {
                 // First place an order which creates a backorder (excess of allocated units)
@@ -962,6 +967,13 @@ describe('Stock control', () => {
                                 trackInventory: GlobalFlag.TRUE,
                                 useGlobalOutOfStockThreshold: false,
                             },
+                            {
+                                id: variant7Id,
+                                stockOnHand: 3,
+                                outOfStockThreshold: 0,
+                                trackInventory: GlobalFlag.TRUE,
+                                useGlobalOutOfStockThreshold: false,
+                            },
                         ],
                     },
                 );
@@ -1055,6 +1067,58 @@ describe('Stock control', () => {
                 expect((add2 as any).order.lines[0].productVariant.id).toBe(variant6Id);
                 expect((add2 as any).order.lines[0].quantity).toBe(3);
             });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1273
+            it('adjustOrderLine when saleable stock changes to zero', async () => {
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                id: variant7Id,
+                                stockOnHand: 10,
+                            },
+                        ],
+                    },
+                );
+
+                await shopClient.asAnonymousUser();
+                const { addItemToOrder: add1 } = await shopClient.query<
+                    AddItemToOrder.Mutation,
+                    AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: variant7Id,
+                    quantity: 1,
+                });
+                orderGuard.assertSuccess(add1);
+                expect(add1.lines.length).toBe(1);
+
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                id: variant7Id,
+                                stockOnHand: 0,
+                            },
+                        ],
+                    },
+                );
+
+                const { adjustOrderLine: add2 } = await shopClient.query<
+                    AdjustItemQuantity.Mutation,
+                    AdjustItemQuantity.Variables
+                >(ADJUST_ITEM_QUANTITY, {
+                    orderLineId: add1.lines[0].id,
+                    quantity: 2,
+                });
+                orderGuard.assertErrorResult(add2);
+
+                expect(add2.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+
+                const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+                expect(activeOrder!.lines.length).toBe(0);
+            });
         });
     });
 

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/core",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "description": "A modern, headless ecommerce framework",
   "repository": {
     "type": "git",
@@ -49,7 +49,7 @@
     "@nestjs/testing": "7.6.17",
     "@nestjs/typeorm": "7.1.5",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^1.4.0",
+    "@vendure/common": "^1.4.3",
     "apollo-server-express": "2.24.1",
     "bcrypt": "^5.0.0",
     "body-parser": "^1.19.0",

+ 1 - 1
packages/core/src/api/config/graphql-custom-fields.ts

@@ -418,7 +418,7 @@ export function addPaymentMethodQuoteCustomFields(
     if (0 < publicCustomFields.length) {
         customFieldTypeDefs = `
             extend type PaymentMethodQuote {
-                customFields: ShippingMethodCustomFields
+                customFields: PaymentMethodCustomFields
             }
         `;
     } else {

+ 1 - 0
packages/core/src/api/schema/admin-api/payment-method.type.graphql → packages/core/src/api/schema/common/payment-method.type.graphql

@@ -9,3 +9,4 @@ type PaymentMethod implements Node {
     checker: ConfigurableOperation
     handler: ConfigurableOperation!
 }
+

+ 1 - 0
packages/core/src/common/types/entity-relation-paths.ts

@@ -21,6 +21,7 @@ import { VendureEntity } from '../../entity/base/base.entity';
  * @docsCategory Common
  */
 export type EntityRelationPaths<T extends VendureEntity> =
+    | `customFields.${string}`
     | PathsToStringProps1<T>
     | Join<PathsToStringProps2<T>, '.'>
     | TripleDotPath;

+ 2 - 0
packages/core/src/i18n/messages/en.json

@@ -1,6 +1,7 @@
 {
   "error": {
     "cannot-delete-role": "The role '{ roleCode }' cannot be deleted",
+    "cannot-delete-sole-superadmin": "The sole SuperAdmin cannot be deleted",
     "cannot-locate-customer-for-user": "Cannot locate a Customer for the user",
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
     "cannot-create-sales-for-active-order": "Cannot create a Sale for an Order which is still active",
@@ -43,6 +44,7 @@
     "product-variant-options-combination-already-exists": "A ProductVariant with the selected options already exists: {variantName}",
     "promotion-channels-can-only-be-changed-from-default-channel": "Promotions channels may only be changed from the Default Channel",
     "stockonhand-cannot-be-negative": "stockOnHand cannot be a negative value",
+    "superadmin-must-have-superadmin-role": "Cannot remove the SuperAdmin role from the sole SuperAdmin",
     "unauthorized": "The credentials did not match. Please check and try again"
   },
   "errorResult": {

+ 1 - 1
packages/core/src/job-queue/subscribable-job.ts

@@ -69,7 +69,7 @@ export class SubscribableJob<T extends JobData<T> = any> extends Job<T> {
                 tap(i => {
                     if (timeoutMs < i * pollInterval) {
                         throw new Error(
-                            `Job ${this.id} update polling timed out after ${timeoutMs}ms. The job may still be running.`,
+                            `Job ${this.id} SubscribableJob update polling timed out after ${timeoutMs}ms. The job may still be running.`,
                         );
                     }
                 }),

+ 7 - 2
packages/core/src/plugin/default-search-plugin/search-job-buffer/search-job-buffer.service.ts

@@ -3,6 +3,7 @@ import { forkJoin } from 'rxjs';
 
 import { ConfigService } from '../../../config/config.service';
 import { isInspectableJobQueueStrategy } from '../../../config/job-queue/inspectable-job-queue-strategy';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { JobQueueService } from '../../../job-queue/job-queue.service';
 import { SubscribableJob } from '../../../job-queue/subscribable-job';
 import { BUFFER_SEARCH_INDEX_UPDATES } from '../constants';
@@ -54,9 +55,13 @@ export class SearchJobBufferService implements OnApplicationBootstrap {
             );
             await forkJoin(
                 ...subscribableCollectionJobs.map(sj =>
-                    sj.updates({ pollInterval: 500, timeoutMs: 3 * 60 * 1000 }),
+                    sj.updates({ pollInterval: 500, timeoutMs: 15 * 60 * 1000 }),
                 ),
-            ).toPromise();
+            )
+                .toPromise()
+                .catch(err => {
+                    Logger.error(err.message);
+                });
         }
         await this.jobQueueService.flush(this.searchIndexJobBuffer);
     }

+ 7 - 7
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -151,21 +151,21 @@ export class MysqlSearchStrategy implements SearchStrategy {
                 .addSelect(`IF (sku LIKE :like_term, 10, 0)`, 'sku_score')
                 .addSelect(
                     `(SELECT sku_score) +
-                     MATCH (productName) AGAINST (:term) * 2 +
-                     MATCH (productVariantName) AGAINST (:term) * 1.5 +
-                     MATCH (description) AGAINST (:term)* 1`,
+                     MATCH (productName) AGAINST (:term IN BOOLEAN MODE) * 2 +
+                     MATCH (productVariantName) AGAINST (:term IN BOOLEAN MODE) * 1.5 +
+                     MATCH (description) AGAINST (:term IN BOOLEAN MODE) * 1`,
                     'score',
                 )
                 .where(
                     new Brackets(qb1 => {
                         qb1.where('sku LIKE :like_term')
-                            .orWhere('MATCH (productName) AGAINST (:term)')
-                            .orWhere('MATCH (productVariantName) AGAINST (:term)')
-                            .orWhere('MATCH (description) AGAINST (:term)');
+                            .orWhere('MATCH (productName) AGAINST (:term IN BOOLEAN MODE)')
+                            .orWhere('MATCH (productVariantName) AGAINST (:term IN BOOLEAN MODE)')
+                            .orWhere('MATCH (description) AGAINST (:term IN BOOLEAN MODE)');
                     }),
                 )
                 .andWhere('channelId = :channelId')
-                .setParameters({ term, like_term: `%${term}%`, channelId: ctx.channelId });
+                .setParameters({ term: `${term}*`, like_term: `%${term}%`, channelId: ctx.channelId });
 
             qb.innerJoin(`(${termScoreQuery.getQuery()})`, 'term_result', 'inner_productId = si.productId')
                 .addSelect(input.groupByProduct ? 'MAX(term_result.score)' : 'term_result.score', 'score')

+ 7 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -145,7 +145,13 @@ export class PostgresSearchStrategy implements SearchStrategy {
         const { term, facetValueFilters, facetValueIds, facetValueOperator, collectionId, collectionSlug } =
             input;
         // join multiple words with the logical AND operator
-        const termLogicalAnd = term ? term.trim().replace(/\s+/g, ' & ') : '';
+        const termLogicalAnd = term
+            ? term
+                  .trim()
+                  .split(/\s+/g)
+                  .map(t => `${t}:*`)
+                  .join(' & ')
+            : '';
 
         qb.where('1 = 1');
         if (term && term.length > this.minTermLength) {

+ 13 - 2
packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts

@@ -22,7 +22,7 @@ import { HydrateOptions } from './entity-hydrator-types';
  *
  * @example
  * ```TypeScript
- * const product = this.productVariantService
+ * const product = await this.productVariantService
  *   .getProductForVariant(ctx, variantId);
  *
  * await this.entityHydrator
@@ -37,6 +37,17 @@ import { HydrateOptions } from './entity-hydrator-types';
  * options is used (see {@link HydrateOptions}), any related ProductVariant will have the correct
  * Channel-specific prices applied to them.
  *
+ * Custom field relations may also be hydrated:
+ *
+ * @example
+ * ```TypeScript
+ * const customer = await this.customerService
+ *   .findOne(ctx, id);
+ *
+ * await this.entityHydrator
+ *   .hydrate(ctx, customer, { relations: ['customFields.avatar' ]});
+ * ```
+ *
  * @docsCategory data-access
  * @since 1.3.0
  */
@@ -156,7 +167,7 @@ export class EntityHydrator {
         const missingRelations: string[] = [];
         for (const relation of options.relations.slice().sort()) {
             if (typeof relation === 'string') {
-                const parts = relation.split('.');
+                const parts = !relation.startsWith('customFields') ? relation.split('.') : [relation];
                 let entity: Record<string, any> | undefined = target;
                 const path = [];
                 for (const part of parts) {

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

@@ -182,6 +182,14 @@ export class OrderModifier {
             orderLine.items = await this.connection
                 .getRepository(ctx, OrderItem)
                 .find({ where: { line: orderLine } });
+            if (!order.active) {
+                await this.stockMovementService.createAllocationsForOrderLines(ctx, [
+                    {
+                        orderLine,
+                        quantity: quantity - currentQuantity,
+                    },
+                ]);
+            }
         } else if (quantity < currentQuantity) {
             if (order.active) {
                 // When an Order is still active, it is fine to just delete
@@ -199,9 +207,9 @@ export class OrderModifier {
                 // When an Order is not active (i.e. Customer checked out), then we don't want to just
                 // delete the OrderItems - instead we will cancel them
                 const toSetAsCancelled = orderLine.items.filter(i => !i.cancelled).slice(quantity);
-                const soldItems = toSetAsCancelled.filter(i => !!i.fulfillment);
+                const fulfilledItems = toSetAsCancelled.filter(i => !!i.fulfillment);
                 const allocatedItems = toSetAsCancelled.filter(i => !i.fulfillment);
-                await this.stockMovementService.createCancellationsForOrderItems(ctx, soldItems);
+                await this.stockMovementService.createCancellationsForOrderItems(ctx, fulfilledItems);
                 await this.stockMovementService.createReleasesForOrderItems(ctx, allocatedItems);
                 toSetAsCancelled.forEach(i => (i.cancelled = true));
                 await this.connection.getRepository(ctx, OrderItem).save(toSetAsCancelled, { reload: false });

+ 60 - 1
packages/core/src/service/services/administrator.service.ts

@@ -4,10 +4,12 @@ import {
     DeletionResult,
     UpdateAdministratorInput,
 } from '@vendure/common/lib/generated-types';
+import { SUPER_ADMIN_ROLE_CODE } from '@vendure/common/lib/shared-constants';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError } from '../../common/error/errors';
+import { EntityNotFoundError, InternalServerError } from '../../common/error/errors';
+import { idsAreEqual } from '../../common/index';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { ConfigService } from '../../config';
 import { TransactionalConnection } from '../../connection/transactional-connection';
@@ -144,6 +146,13 @@ export class AdministratorService {
             }
         }
         if (input.roleIds) {
+            const isSoleSuperAdmin = await this.isSoleSuperadmin(ctx, input.id);
+            if (isSoleSuperAdmin) {
+                const superAdminRole = await this.roleService.getSuperAdminRole();
+                if (!input.roleIds.find(id => idsAreEqual(id, superAdminRole.id))) {
+                    throw new InternalServerError('error.superadmin-must-have-superadmin-role');
+                }
+            }
             const removeIds = administrator.user.roles
                 .map(role => role.id)
                 .filter(roleId => (input.roleIds as ID[]).indexOf(roleId) === -1);
@@ -196,6 +205,10 @@ export class AdministratorService {
         const administrator = await this.connection.getEntityOrThrow(ctx, Administrator, id, {
             relations: ['user'],
         });
+        const isSoleSuperadmin = await this.isSoleSuperadmin(ctx, id);
+        if (isSoleSuperadmin) {
+            throw new InternalServerError('error.cannot-delete-sole-superadmin');
+        }
         await this.connection.getRepository(ctx, Administrator).update({ id }, { deletedAt: new Date() });
         // tslint:disable-next-line:no-non-null-assertion
         await this.userService.softDelete(ctx, administrator.user!.id);
@@ -205,6 +218,26 @@ export class AdministratorService {
         };
     }
 
+    /**
+     * @description
+     * Resolves to `true` if the administrator ID belongs to the only Administrator
+     * with SuperAdmin permissions.
+     */
+    private async isSoleSuperadmin(ctx: RequestContext, id: ID) {
+        const superAdminRole = await this.roleService.getSuperAdminRole();
+        const allAdmins = await this.connection.getRepository(ctx, Administrator).find({
+            relations: ['user', 'user.roles'],
+        });
+        const superAdmins = allAdmins.filter(
+            admin => !!admin.user.roles.find(r => r.id === superAdminRole.id),
+        );
+        if (1 < superAdmins.length) {
+            return false;
+        } else {
+            return idsAreEqual(superAdmins[0].id, id);
+        }
+    }
+
     /**
      * @description
      * There must always exist a SuperAdmin, otherwise full administration via API will
@@ -230,6 +263,32 @@ export class AdministratorService {
                 lastName: 'Admin',
                 roleIds: [superAdminRole.id],
             });
+        } else {
+            const superAdministrator = await this.connection.getRepository(Administrator).findOne({
+                where: {
+                    user: superAdminUser,
+                },
+            });
+            if (!superAdministrator) {
+                const administrator = new Administrator({
+                    emailAddress: superadminCredentials.identifier,
+                    firstName: 'Super',
+                    lastName: 'Admin',
+                });
+                const createdAdministrator = await this.connection
+                    .getRepository(Administrator)
+                    .save(administrator);
+                createdAdministrator.user = superAdminUser;
+                await this.connection.getRepository(Administrator).save(createdAdministrator);
+            } else if (superAdministrator.deletedAt != null) {
+                superAdministrator.deletedAt = null;
+                await this.connection.getRepository(Administrator).save(superAdministrator);
+            }
+
+            if (superAdminUser.deletedAt != null) {
+                superAdminUser.deletedAt = null;
+                await this.connection.getRepository(User).save(superAdminUser);
+            }
         }
     }
 }

+ 2 - 4
packages/core/src/service/services/customer.service.ts

@@ -424,11 +424,9 @@ export class CustomerService {
      */
     async refreshVerificationToken(ctx: RequestContext, emailAddress: string): Promise<void> {
         const user = await this.userService.getUserByEmailAddress(ctx, emailAddress);
-        if (user) {
+        if (user && !user.verified) {
             await this.userService.setVerificationToken(ctx, user);
-            if (!user.verified) {
-                this.eventBus.publish(new AccountRegistrationEvent(ctx, user));
-            }
+            this.eventBus.publish(new AccountRegistrationEvent(ctx, user));
         }
     }
 

+ 3 - 1
packages/core/src/service/services/order.service.ts

@@ -509,14 +509,16 @@ export class OrderService {
             orderLine.productVariant,
             quantity,
         );
+        let updatedOrderLines = [orderLine];
         if (correctedQuantity === 0) {
             order.lines = order.lines.filter(l => !idsAreEqual(l.id, orderLine.id));
             await this.connection.getRepository(ctx, OrderLine).remove(orderLine);
+            updatedOrderLines = [];
         } else {
             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, updatedOrderLines);
         if (quantityWasAdjustedDown) {
             return new InsufficientStockError(correctedQuantity, updatedOrder);
         } else {

+ 3 - 3
packages/create/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/create",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "license": "MIT",
   "bin": {
     "create": "./index.js"
@@ -28,13 +28,13 @@
     "@types/handlebars": "^4.1.0",
     "@types/listr": "^0.14.2",
     "@types/semver": "^6.2.2",
-    "@vendure/core": "^1.4.0",
+    "@vendure/core": "^1.4.3",
     "rimraf": "^3.0.2",
     "ts-node": "^10.2.1",
     "typescript": "4.3.5"
   },
   "dependencies": {
-    "@vendure/common": "^1.4.0",
+    "@vendure/common": "^1.4.3",
     "chalk": "^4.1.0",
     "commander": "^7.1.0",
     "cross-spawn": "^7.0.3",

+ 9 - 9
packages/dev-server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dev-server",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "main": "index.js",
   "license": "MIT",
   "private": true,
@@ -14,18 +14,18 @@
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   "dependencies": {
-    "@vendure/admin-ui-plugin": "^1.4.0",
-    "@vendure/asset-server-plugin": "^1.4.0",
-    "@vendure/common": "^1.4.0",
-    "@vendure/core": "^1.4.0",
-    "@vendure/elasticsearch-plugin": "^1.4.0",
-    "@vendure/email-plugin": "^1.4.0",
+    "@vendure/admin-ui-plugin": "^1.4.3",
+    "@vendure/asset-server-plugin": "^1.4.3",
+    "@vendure/common": "^1.4.3",
+    "@vendure/core": "^1.4.3",
+    "@vendure/elasticsearch-plugin": "^1.4.3",
+    "@vendure/email-plugin": "^1.4.3",
     "typescript": "4.3.5"
   },
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
-    "@vendure/testing": "^1.4.0",
-    "@vendure/ui-devkit": "^1.4.0",
+    "@vendure/testing": "^1.4.3",
+    "@vendure/ui-devkit": "^1.4.3",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3"
   }

+ 10 - 1
packages/dev-server/test-plugins/entity-hydrate-plugin.ts

@@ -1,6 +1,7 @@
 /* tslint:disable:no-non-null-assertion */
 import { Args, Query, Resolver } from '@nestjs/graphql';
 import {
+    Asset,
     Ctx,
     EntityHydrator,
     ID,
@@ -28,7 +29,7 @@ class TestResolver {
             relations: ['featuredAsset'],
         });
         await this.entityHydrator.hydrate(ctx, product!, {
-            relations: ['facetValues.facet', 'variants.options', 'assets'],
+            relations: ['facetValues.facet', 'customFields.thumb'],
         });
         return product;
     }
@@ -45,5 +46,13 @@ class TestResolver {
         `,
         resolvers: [TestResolver],
     },
+    configuration: config => {
+        config.customFields.Product.push({
+            name: 'thumb',
+            type: 'relation',
+            entity: Asset,
+        });
+        return config;
+    },
 })
 export class EntityHydratePlugin {}

+ 3 - 3
packages/elasticsearch-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/elasticsearch-plugin",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -25,8 +25,8 @@
     "fast-deep-equal": "^3.1.3"
   },
   "devDependencies": {
-    "@vendure/common": "^1.4.0",
-    "@vendure/core": "^1.4.0",
+    "@vendure/common": "^1.4.3",
+    "@vendure/core": "^1.4.3",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"
   }

+ 3 - 3
packages/email-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/email-plugin",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -35,8 +35,8 @@
     "@types/fs-extra": "^9.0.1",
     "@types/handlebars": "^4.1.0",
     "@types/mjml": "^4.0.4",
-    "@vendure/common": "^1.4.0",
-    "@vendure/core": "^1.4.0",
+    "@vendure/common": "^1.4.3",
+    "@vendure/core": "^1.4.3",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"
   }

+ 3 - 3
packages/job-queue-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/job-queue-plugin",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "license": "MIT",
   "main": "package/index.js",
   "types": "package/index.d.ts",
@@ -24,8 +24,8 @@
   "devDependencies": {
     "@google-cloud/pubsub": "^2.8.0",
     "@types/redis": "^2.8.28",
-    "@vendure/common": "^1.4.0",
-    "@vendure/core": "^1.4.0",
+    "@vendure/common": "^1.4.3",
+    "@vendure/core": "^1.4.3",
     "bullmq": "^1.40.1",
     "redis": "^3.0.2",
     "rimraf": "^3.0.2",

+ 4 - 4
packages/payments-plugin/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/payments-plugin",
-    "version": "1.4.0",
+    "version": "1.4.3",
     "license": "MIT",
     "main": "package/index.js",
     "types": "package/index.d.ts",
@@ -27,9 +27,9 @@
     "devDependencies": {
         "@mollie/api-client": "^3.5.1",
         "@types/braintree": "^2.22.15",
-        "@vendure/common": "^1.4.0",
-        "@vendure/core": "^1.4.0",
-        "@vendure/testing": "^1.4.0",
+        "@vendure/common": "^1.4.3",
+        "@vendure/core": "^1.4.3",
+        "@vendure/testing": "^1.4.3",
         "braintree": "^3.0.0",
         "nock": "^13.1.4",
         "rimraf": "^3.0.2",

+ 3 - 3
packages/testing/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/testing",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "description": "End-to-end testing tools for Vendure projects",
   "keywords": [
     "vendure",
@@ -34,7 +34,7 @@
   },
   "dependencies": {
     "@types/node-fetch": "^2.5.4",
-    "@vendure/common": "^1.4.0",
+    "@vendure/common": "^1.4.3",
     "faker": "^4.1.0",
     "form-data": "^3.0.0",
     "graphql": "15.5.1",
@@ -45,7 +45,7 @@
   "devDependencies": {
     "@types/mysql": "^2.15.15",
     "@types/pg": "^7.14.5",
-    "@vendure/core": "^1.4.0",
+    "@vendure/core": "^1.4.3",
     "mysql": "^2.18.1",
     "pg": "^8.4.0",
     "rimraf": "^3.0.0",

+ 4 - 4
packages/ui-devkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/ui-devkit",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
     "vendure",
@@ -40,8 +40,8 @@
     "@angular/cli": "12.2.2",
     "@angular/compiler": "12.2.2",
     "@angular/compiler-cli": "12.2.2",
-    "@vendure/admin-ui": "^1.4.0",
-    "@vendure/common": "^1.4.0",
+    "@vendure/admin-ui": "^1.4.3",
+    "@vendure/common": "^1.4.3",
     "chalk": "^4.1.0",
     "chokidar": "^3.5.1",
     "fs-extra": "^10.0.0",
@@ -52,7 +52,7 @@
     "@rollup/plugin-node-resolve": "^11.2.0",
     "@types/fs-extra": "^9.0.8",
     "@types/glob": "^7.1.3",
-    "@vendure/core": "^1.4.0",
+    "@vendure/core": "^1.4.3",
     "rimraf": "^3.0.2",
     "rollup": "^2.40.0",
     "rollup-plugin-terser": "^7.0.2",

+ 0 - 1
packages/ui-devkit/scaffold/angular.json

@@ -53,7 +53,6 @@
                 "./src/styles"
               ]
             },
-            "showCircularDependencies": false,
             "allowedCommonJsDependencies": [
               "graphql-tag",
               "zen-observable",

+ 1 - 1
scripts/changelogs/generate-changelog.ts

@@ -57,7 +57,7 @@ function generateChangelogForPackage() {
                     return context(null, null);
                 }
             },
-            releaseCount: 2,
+            releaseCount: 1,
             outputUnreleased: true,
         },
         {