Bläddra i källkod

Merge branch 'minor' into major

Michael Bromley 3 år sedan
förälder
incheckning
b882b91e70
40 ändrade filer med 1156 tillägg och 331 borttagningar
  1. 18 0
      CHANGELOG.md
  2. 20 0
      docs/content/plugins/extending-the-admin-ui/custom-form-inputs/_index.md
  3. 105 0
      docs/content/plugins/plugin-examples/defining-db-subscribers.md
  4. 119 0
      docs/content/storefront/configuring-a-graphql-client.md
  5. 2 0
      docs/content/storefront/managing-sessions.md
  6. 1 1
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts
  7. 1 1
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html
  8. 7 0
      packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts
  9. 25 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  10. 25 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  11. 25 0
      packages/common/src/generated-shop-types.ts
  12. 25 0
      packages/common/src/generated-types.ts
  13. 23 3
      packages/core/e2e/__snapshots__/import.e2e-spec.ts.snap
  14. 1 0
      packages/core/e2e/fixtures/e2e-products-promotions.csv
  15. 5 0
      packages/core/e2e/fixtures/product-import-option-values.csv
  16. 1 0
      packages/core/e2e/fixtures/product-import.csv
  17. 293 297
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  18. 25 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  19. 1 0
      packages/core/e2e/graphql/shared-definitions.ts
  20. 7 0
      packages/core/e2e/import.e2e-spec.ts
  21. 71 1
      packages/core/e2e/order-promotion.e2e-spec.ts
  22. 68 0
      packages/core/e2e/populate.e2e-spec.ts
  23. 53 1
      packages/core/e2e/stock-control.e2e-spec.ts
  24. 25 0
      packages/core/src/api/config/generate-permissions.ts
  25. 12 3
      packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts
  26. 4 1
      packages/core/src/bootstrap.ts
  27. 18 8
      packages/core/src/data-import/providers/importer/importer.ts
  28. 1 1
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  29. 12 0
      packages/core/src/service/helpers/order-calculator/prorate.spec.ts
  30. 1 1
      packages/core/src/service/helpers/order-calculator/prorate.ts
  31. 10 1
      packages/core/src/service/services/customer-group.service.ts
  32. 10 0
      packages/core/src/service/services/order.service.ts
  33. 19 1
      packages/core/src/service/services/product-variant.service.ts
  34. 3 1
      packages/core/src/service/services/promotion.service.ts
  35. 25 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  36. 2 0
      packages/elasticsearch-plugin/src/options.ts
  37. 25 0
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  38. 25 0
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  39. 25 0
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts
  40. 18 10
      packages/payments-plugin/src/stripe/stripe.controller.ts

+ 18 - 0
CHANGELOG.md

@@ -1,3 +1,21 @@
+## <small>1.5.1 (2022-03-31)</small>
+
+
+#### Fixes
+
+* **admin-ui** Allow stockOnHand to match outOfStockThreshold ([f89bfbe](https://github.com/vendure-ecommerce/vendure/commit/f89bfbe)), closes [#1483](https://github.com/vendure-ecommerce/vendure/issues/1483)
+* **admin-ui** Fix error with FacetValue localeString custom field ([80ef31a](https://github.com/vendure-ecommerce/vendure/commit/80ef31a)), closes [#1442](https://github.com/vendure-ecommerce/vendure/issues/1442)
+* **core** Add missing OrderLine.order field resolver (#1478) ([c6cf4d4](https://github.com/vendure-ecommerce/vendure/commit/c6cf4d4)), closes [#1478](https://github.com/vendure-ecommerce/vendure/issues/1478)
+* **core** Allow stockOnHand adjustments to match outOfStockThreshold ([77239b2](https://github.com/vendure-ecommerce/vendure/commit/77239b2)), closes [#1483](https://github.com/vendure-ecommerce/vendure/issues/1483)
+* **core** Correctly save relation custom fields on CustomerGroup ([1634ed9](https://github.com/vendure-ecommerce/vendure/commit/1634ed9)), closes [#1493](https://github.com/vendure-ecommerce/vendure/issues/1493)
+* **core** Fix error when pro-rating order with 0 price variant ([44cc46d](https://github.com/vendure-ecommerce/vendure/commit/44cc46d)), closes [#1492](https://github.com/vendure-ecommerce/vendure/issues/1492)
+* **core** Fix importing products when 2 options have same name ([316f5e9](https://github.com/vendure-ecommerce/vendure/commit/316f5e9)), closes [#1445](https://github.com/vendure-ecommerce/vendure/issues/1445)
+* **core** Promotion usage limits account for cancelled orders ([ce34f14](https://github.com/vendure-ecommerce/vendure/commit/ce34f14)), closes [#1466](https://github.com/vendure-ecommerce/vendure/issues/1466)
+* **core** Truthy check for custom fields in importer ([a8c44d1](https://github.com/vendure-ecommerce/vendure/commit/a8c44d1))
+* **core** Use subscribers passed in to the dbConnectionOptions ([ea63784](https://github.com/vendure-ecommerce/vendure/commit/ea63784))
+* **payments-plugin** Fix state transitioning error case in Stripe webhook (#1485) ([280d2e3](https://github.com/vendure-ecommerce/vendure/commit/280d2e3)), closes [#1485](https://github.com/vendure-ecommerce/vendure/issues/1485)
+* **payments-plugin** Send 200 response from Stripe webhook (#1487) ([4d55949](https://github.com/vendure-ecommerce/vendure/commit/4d55949)), closes [#1487](https://github.com/vendure-ecommerce/vendure/issues/1487)
+
 ## 1.5.0 (2022-03-15)
 
 

+ 20 - 0
docs/content/plugins/extending-the-admin-ui/custom-form-inputs/_index.md

@@ -28,6 +28,7 @@ By default, the "intensity" field will be displayed as a number input:
 But let's say we want to display a range slider instead. Here's how we can do this using our shared extension module combined with the [registerFormInputComponent function]({{< relref "register-form-input-component" >}}):
 
 ```TypeScript
+// project/ui-extensions/shared.module.ts
 import { NgModule, Component } from '@angular/core';
 import { FormControl } from '@angular/forms';
 import { CustomFieldConfig } from '@vendure/common/lib/generated-types';
@@ -58,6 +59,25 @@ export class SliderControl implements FormInputComponent<CustomFieldConfig> {
 })
 export class SharedExtensionModule {}
 ```
+The `SharedExtensionModule` is then passed to the `compileUiExtensions()` function as described in the [UI Extensions With Angular guide]({{< relref "using-angular" >}}#4-pass-the-extension-to-the-compileuiextensions-function):
+
+```TypeScript
+// project/vendure-config.ts
+AdminUiPlugin.init({
+  port: 5001,
+  app: compileUiExtensions({
+    outputPath: path.join(__dirname, 'admin-ui'),
+    extensions: [{
+      extensionPath: path.join(__dirname, 'ui-extensions'),
+      ngModules: [{
+        type: 'shared',
+        ngModuleFileName: 'shared.module.ts',
+        ngModuleName: 'SharedExtensionModule',
+      }],
+    }],
+  }),
+})
+```
 
 Once registered, this new slider input can be used in our custom field config:
 

+ 105 - 0
docs/content/plugins/plugin-examples/defining-db-subscribers.md

@@ -0,0 +1,105 @@
+---
+title: "Defining database subscribers"
+showtoc: true
+---
+
+# Defining database subscribers
+
+TypeORM allows us to define [subscribers](https://typeorm.io/listeners-and-subscribers#what-is-a-subscriber). With a subscriber, we can listen to specific entity events and take actions based on inserts, updates, deletions and more.
+
+If you need lower-level access to database changes that you get with the [Vendure EventBus system]({{< relref "event-bus" >}}), TypeORM subscribers can be useful.
+
+## Simple subscribers
+
+The simplest way to register a subscriber is to pass it to the `dbConnectionOptions.subscribers` array:
+
+```TypeScript
+import { Product, VendureConfig } from '@vendure/core';
+import { EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm';
+
+@EventSubscriber()
+export class ProductSubscriber implements EntitySubscriberInterface<Product> {
+  listenTo() {
+    return Product;
+  }
+  
+  beforeUpdate(event: UpdateEvent<Product>) {
+    console.log(`BEFORE PRODUCT UPDATED: `, event.entity);
+  }
+}
+
+// ...
+export const config: VendureConfig = {
+  dbConnectionOptions: {
+    // ...
+    subscribers: [ProductSubscriber],
+  }
+}
+```
+The limitation of this method is that the `ProductSubscriber` class cannot make use of dependency injection, since it is not known to the underlying NestJS application and is instead instantiated by TypeORM directly.
+
+If you need to make use of providers in your subscriber class, you'll need to use the following pattern.
+
+## Injectable subscribers
+
+By defining the subscriber as an injectable provider, and passing it to a Vendure plugin, you can take advantage of Nest's dependency injection inside the subscriber methods.
+
+```TypeScript
+import {
+  PluginCommonModule,
+  Product,
+  TransactionalConnection,
+  VendureConfig,
+  VendurePlugin,
+} from '@vendure/core';
+import { Injectable } from '@nestjs/common';
+import { EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm';
+import { MyService } from './services/my.service';
+
+@Injectable()
+@EventSubscriber()
+export class ProductSubscriber implements EntitySubscriberInterface<Product> {
+  constructor(private connection: TransactionalConnection,
+              private myService: MyService) {
+    // This is how we can dynamically register the subscriber
+    // with TypeORM
+    connection.rawConnection.subscribers.push(this);
+  }
+
+  listenTo() {
+    return Product;
+  }
+
+  async beforeUpdate(event: UpdateEvent<Product>) {
+    console.log(`BEFORE PRODUCT UPDATED: `, event.entity);
+    // Now we can make use of our injected provider
+    await this.myService.handleProductUpdate(event);
+  }
+}
+
+@VendurePlugin({
+  imports: [PluginCommonModule],
+  providers: [ProductSubscriber, MyService],
+})
+class MyPlugin {}
+
+// ...
+export const config: VendureConfig = {
+  dbConnectionOptions: {
+    // We no longer need to pass the subscriber here
+    // subscribers: [ProductSubscriber],
+  },
+  plugins: [
+    MyPlugin,  
+  ],
+}
+```
+
+## Troubleshooting subscribers
+
+An important factor when working with TypeORM subscribers is that they are very low-level and require some understanding of the Vendure schema.
+
+For example consider the `ProductSubscriber` above. If an admin changes a product's name in the Admin UI, this subscriber **will not fire**. The reason is that the `name` property is actually stored on the `ProductTranslation` entity, rather than on the `Product` entity.
+
+So if your subscribers do not seem to work as expected, check your database schema and make sure you are really targeting the correct entity which has the property that you are interested in.
+

+ 119 - 0
docs/content/storefront/configuring-a-graphql-client.md

@@ -0,0 +1,119 @@
+---
+title: "Configuring a GraphQL Client"
+weight: 2
+showtoc: true
+---
+
+# Configuring a GraphQL Client
+
+This guide provides examples of how to set up popular GraphQL clients to work with the Vendure Shop API. These examples are designed to work with both the `bearer` and `cookie` methods of [managing sessions]({{< relref "managing-sessions" >}}).
+
+## Apollo Client
+
+Here's an example configuration for [Apollo Client](https://www.apollographql.com/docs/react/) with a React app.
+
+```TypeScript
+import {
+  ApolloClient,
+  ApolloLink, 
+  HttpLink,
+  InMemoryCache,
+} from '@apollo/client'
+import { setContext } from '@apollo/client/link/context'
+
+const AUTH_TOKEN_KEY = 'auth_token';
+
+const httpLink = new HttpLink({
+  uri: `${process.env.NEXT_PUBLIC_URL_SHOP_API}/shop-api`,
+  withCredentials: true,
+});
+
+const afterwareLink = new ApolloLink((operation, forward) => {
+  return forward(operation).map((response) => {
+    const context = operation.getContext();
+    const authHeader = context.response.headers.get('vendure-auth-token');
+    if (authHeader) {
+      // If the auth token has been returned by the Vendure
+      // server, we store it in localStorage  
+      localStorage.setItem(AUTH_TOKEN_KEY, authHeader);
+    }
+    return response;
+  });
+});
+
+const client = new ApolloClient({
+  link: ApolloLink.from([
+    setContext(() => {
+      const authToken = localStorage.getItem(AUTH_TOKEN_KEY)
+      if (authToken) {
+        // If we have stored the authToken from a previous
+        // response, we attach it to all subsequent requests.  
+        return {
+          headers: {
+            authorization: `Bearer ${authToken}`,
+          },
+        }
+      }
+    }),
+    afterwareLink,
+    httpLink,
+  ]),
+  cache: new InMemoryCache(),
+})
+
+export default client;
+```
+
+## Urql
+
+Here's an example using the [urql](https://formidable.com/open-source/urql/) client:
+
+```tsx
+import * as React from "react"
+import { createClient, dedupExchange, fetchExchange, Provider } from "urql"
+import { cacheExchange} from "@urql/exchange-graphcache"
+import { makeOperation} from "@urql/core"
+
+const AUTH_TOKEN_KEY = "auth_token"
+
+const client = createClient({
+  fetch: (input, init) => {
+    const token = localStorage.getItem(AUTH_TOKEN_KEY)
+    if (token) {
+      const headers = input instanceof Request ? input.headers : init.headers;
+      headers['Authorization'] = `Bearer ${token}`;
+    }
+    return fetch(input, init).then(response => {
+      const token = response.headers.get("vendure-auth-token")
+      if (token) {
+        localStorage.setItem(AUTH_TOKEN_KEY, token)
+      }
+      return response
+    })
+  },
+  url: process.env.NEXT_PUBLIC_URL_SHOP_API,
+  exchanges: [
+    dedupExchange,
+    cacheExchange({
+      updates: {
+        Mutation: {
+          addItemToOrder: (parent, args, cache) => {
+            const activeOrder = cache.resolve('Query', 'activeOrder');
+            if (activeOrder == null) {
+              // The first time that the `addItemToOrder` mutation is called in a session,
+              // the `activeOrder` query needs to be manually updated to point to the newly-created
+              // Order type. From then on, the graphcache will handle keeping it up-to-date.
+              cache.link('Query', 'activeOrder', parent.addItemToOrder);
+            }
+          },
+        },
+      },
+    }),
+    fetchExchange,
+  ],
+})
+
+export const App = () => (
+  <Provider value={client}><YourRoutes /></Provider>
+)
+```

+ 2 - 0
docs/content/storefront/managing-sessions.md

@@ -73,3 +73,5 @@ export async function request(query: string, variables: any) {
      return response.data;
 }
 ```
+
+Real-world examples with specific clients can be found in the [Configuring a GraphQL Client guide]({{< relref "configuring-a-graphql-client" >}}).

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts

@@ -330,7 +330,7 @@ export class FacetDetailComponent
                         const key = fieldDef.name;
                         const fieldValue =
                             fieldDef.type === 'localeString'
-                                ? (valueTranslation as any).customFields[key]
+                                ? (valueTranslation as any | undefined)?.customFields?.[key]
                                 : (value as any).customFields[key];
                         const control = customValueFieldsGroup.get(key);
                         if (control) {

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.html

@@ -137,7 +137,7 @@
                                         [class.inventory-untracked]="inventoryIsNotTracked(formGroup)"
                                         clrInput
                                         type="number"
-                                        min="0"
+                                        [min]="getStockOnHandMinValue(formGroup)"
                                         step="1"
                                         formControlName="stockOnHand"
                                         [readonly]="!(updatePermission | hasPermission)"

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

@@ -142,6 +142,13 @@ export class ProductVariantsListComponent implements OnChanges, OnInit, OnDestro
         return '';
     }
 
+    getStockOnHandMinValue(variant: FormGroup) {
+        const effectiveOutOfStockThreshold = variant.get('useGlobalOutOfStockThreshold')?.value
+            ? this.globalOutOfStockThreshold
+            : variant.get('outOfStockThreshold')?.value;
+        return effectiveOutOfStockThreshold;
+    }
+
     getSaleableStockLevel(variant: ProductVariantFragment) {
         const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold
             ? this.globalOutOfStockThreshold

+ 25 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -3583,6 +3583,31 @@ export type PaymentStateTransitionError = ErrorResult & {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 25 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -3348,6 +3348,31 @@ export type PaymentStateTransitionError = ErrorResult & {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 25 - 0
packages/common/src/generated-shop-types.ts

@@ -2216,6 +2216,31 @@ export type PaymentMethodQuote = {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 25 - 0
packages/common/src/generated-types.ts

@@ -3520,6 +3520,31 @@ export type PaymentStateTransitionError = ErrorResult & {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 23 - 3
packages/core/e2e/__snapshots__/import.e2e-spec.ts.snap

@@ -368,6 +368,26 @@ Object {
       },
       "trackInventory": "FALSE",
     },
+    Object {
+      "assets": Array [],
+      "customFields": Object {
+        "weight": null,
+      },
+      "featuredAsset": null,
+      "id": "T_11",
+      "name": "Artists Smock large navy",
+      "price": 1199,
+      "sku": "10115",
+      "stockMovements": Object {
+        "items": Array [],
+      },
+      "stockOnHand": 0,
+      "taxCategory": Object {
+        "id": "T_2",
+        "name": "Reduced Tax",
+      },
+      "trackInventory": "FALSE",
+    },
   ],
 }
 `;
@@ -402,7 +422,7 @@ Object {
         "weight": 243,
       },
       "featuredAsset": null,
-      "id": "T_11",
+      "id": "T_12",
       "name": "奇妙的纸张拉伸器 半英制",
       "price": 4530,
       "sku": "PPS12",
@@ -428,7 +448,7 @@ Object {
         "weight": 344,
       },
       "featuredAsset": null,
-      "id": "T_12",
+      "id": "T_13",
       "name": "奇妙的纸张拉伸器 四分之一英制",
       "price": 3250,
       "sku": "PPS14",
@@ -454,7 +474,7 @@ Object {
         "weight": 656,
       },
       "featuredAsset": null,
-      "id": "T_13",
+      "id": "T_14",
       "name": "奇妙的纸张拉伸器 全英制",
       "price": 5950,
       "sku": "PPSF",

+ 1 - 0
packages/core/e2e/fixtures/e2e-products-promotions.csv

@@ -4,3 +4,4 @@ item-1000     ,item-1000     ,           ,      ,          ,            ,
 item-5000     ,item-5000     ,           ,      ,          ,            ,            ,I60 ,50.00,standard   ,100        ,false         ,             ,
 item-sale-100 ,item-sale-100 ,           ,      ,promo:sale,            ,            ,I10 ,1.00 ,standard   ,100        ,false         ,             ,
 item-sale-1000,item-sale-1000,           ,      ,promo:sale,            ,            ,I12S,10.00,standard   ,100        ,false         ,             ,
+item-0        ,item-0        ,           ,      ,          ,            ,            ,I0  ,0.00 ,standard   ,100        ,false         ,             ,

+ 5 - 0
packages/core/e2e/fixtures/product-import-option-values.csv

@@ -0,0 +1,5 @@
+name,slug,description,assets,facets,optionGroups,optionValues,sku         ,price ,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets
+Foo ,foo ,           ,      ,      ,Foo|Bar     ,fiz|fiz     ,foo-fiz-fiz ,420.69,standard   ,100        ,TRUE          ,             ,
+    ,    ,           ,      ,      ,            ,fiz|buz     ,foo-fiz-buz ,420.69,standard   ,100        ,TRUE          ,             ,
+    ,    ,           ,      ,      ,            ,fizz|fiz    ,foo-fizz-fiz,420.69,standard   ,100        ,TRUE          ,             ,
+    ,    ,           ,      ,      ,            ,fizz|buz    ,foo-fizz-buz,420.69,standard   ,100        ,TRUE          ,             ,

+ 1 - 0
packages/core/e2e/fixtures/product-import.csv

@@ -10,3 +10,4 @@ Artists Smock          ,                       ,Keeps the paint off the clothes
                        ,                       ,                                    ,                   ,                                 ,             ,"large|beige"   ,10113 ,11.99,reduced    ,           ,false         ,               ,                       ,default         ,500           ,"{""id"": 1}",,
                        ,                       ,                                    ,                   ,                                 ,             ,"small|navy"    ,10114 ,11.99,reduced    ,           ,false         ,               ,                       ,default         ,500           ,"{""id"": 1}",,
                        ,                       ,                                    ,                   ,                                 ,             ,"large|navy"    ,10115 ,11.99,reduced    ,           ,false         ,               ,                       ,default         ,500           ,"{""id"": 1}",,
+                       ,                       ,                                    ,                   ,                                 ,             ,"large|navy"    ,10115 ,11.99,reduced    ,           ,false         ,               ,                       ,default         ,              ,"{""id"": 1}",,

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 293 - 297
packages/core/e2e/graphql/generated-e2e-admin-types.ts


+ 25 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -2139,6 +2139,31 @@ export type PaymentMethodQuote = {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 1 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -712,6 +712,7 @@ export const CANCEL_ORDER = gql`
     }
     fragment CanceledOrder on Order {
         id
+        state
         lines {
             quantity
             items {

+ 7 - 0
packages/core/e2e/import.e2e-spec.ts

@@ -235,6 +235,13 @@ describe('Import resolver', () => {
         expect(pencils.customFields.owner.id).toBe('T_1');
         expect(smock.customFields.owner.id).toBe('T_1');
 
+        // Import non-list custom fields
+        expect(smock.variants[0].customFields.weight).toEqual(500);
+        expect(smock.variants[1].customFields.weight).toEqual(500);
+        expect(smock.variants[2].customFields.weight).toEqual(500);
+        expect(smock.variants[3].customFields.weight).toEqual(500);
+        expect(smock.variants[4].customFields.weight).toEqual(null);
+
         // Import list custom fields
         expect(paperStretcher.customFields.keywords).toEqual(['paper', 'stretching', 'watercolor']);
         expect(easel.customFields.keywords).toEqual([]);

+ 71 - 1
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -35,6 +35,7 @@ import * as CodegenShop from './graphql/generated-e2e-shop-types';
 import {
     ASSIGN_PRODUCT_TO_CHANNEL,
     ASSIGN_PROMOTIONS_TO_CHANNEL,
+    CANCEL_ORDER,
     CREATE_CHANNEL,
     CREATE_CUSTOMER_GROUP,
     CREATE_PROMOTION,
@@ -1407,6 +1408,8 @@ describe('Promotions applied to Orders', () => {
                 return shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
             }
 
+            let orderId: string;
+
             it('allows initial usage', async () => {
                 await logInAsRegisteredCustomer();
                 await createNewActiveOrder();
@@ -1422,6 +1425,7 @@ describe('Promotions applied to Orders', () => {
                 await proceedToArrangingPayment(shopClient);
                 const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
                 orderGuard.assertSuccess(order);
+                orderId = order.id;
 
                 expect(order.state).toBe('PaymentSettled');
                 expect(order.active).toBe(false);
@@ -1461,6 +1465,33 @@ describe('Promotions applied to Orders', () => {
                 expect(activeOrder!.totalWithTax).toBe(6000);
                 expect(activeOrder!.couponCodes).toEqual([]);
             });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1466
+            it('cancelled orders do not count against usage limit', async () => {
+                const { cancelOrder } = await adminClient.query<
+                    Codegen.CancelOrderMutation,
+                    Codegen.CancelOrderMutationVariables
+                >(CANCEL_ORDER, {
+                    input: {
+                        orderId,
+                        cancelShipping: true,
+                        reason: 'request',
+                    },
+                });
+                orderResultGuard.assertSuccess(cancelOrder);
+                expect(cancelOrder.state).toBe('Cancelled');
+
+                await logInAsRegisteredCustomer();
+                await createNewActiveOrder();
+                const { applyCouponCode } = await shopClient.query<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+                orderResultGuard.assertSuccess(applyCouponCode);
+
+                expect(applyCouponCode!.totalWithTax).toBe(0);
+                expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+            });
         });
     });
 
@@ -1519,6 +1550,44 @@ describe('Promotions applied to Orders', () => {
         expect(check2!.discounts.length).toBe(0);
     });
 
+    // https://github.com/vendure-ecommerce/vendure/issues/1492
+    it('correctly handles pro-ration of variants with 0 price', async () => {
+        const couponCode = '20%_off_order';
+        const promotion = await createPromotion({
+            enabled: true,
+            name: '20% discount on order',
+            couponCode,
+            conditions: [],
+            actions: [
+                {
+                    code: orderPercentageDiscount.code,
+                    arguments: [{ name: 'discount', value: '20' }],
+                },
+            ],
+        });
+        await shopClient.asAnonymousUser();
+        await shopClient.query<
+            CodegenShop.AddItemToOrderMutation,
+            CodegenShop.AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: getVariantBySlug('item-100').id,
+            quantity: 1,
+        });
+        await shopClient.query<
+            CodegenShop.AddItemToOrderMutation,
+            CodegenShop.AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: getVariantBySlug('item-0').id,
+            quantity: 1,
+        });
+        const { applyCouponCode } = await shopClient.query<
+            CodegenShop.ApplyCouponCodeMutation,
+            CodegenShop.ApplyCouponCodeMutationVariables
+        >(APPLY_COUPON_CODE, { couponCode });
+        orderResultGuard.assertSuccess(applyCouponCode);
+        expect(applyCouponCode.totalWithTax).toBe(96);
+    });
+
     async function getProducts() {
         const result = await adminClient.query<Codegen.GetProductsWithVariantPricesQuery>(
             GET_PRODUCTS_WITH_VARIANT_PRICES,
@@ -1531,6 +1600,7 @@ describe('Promotions applied to Orders', () => {
         );
         products = result.products.items;
     }
+
     async function createGlobalPromotions() {
         const { facets } = await adminClient.query<Codegen.GetFacetListQuery>(GET_FACET_LIST);
         const saleFacetValue = facets.items[0].values[0];
@@ -1562,7 +1632,7 @@ describe('Promotions applied to Orders', () => {
     }
 
     function getVariantBySlug(
-        slug: 'item-100' | 'item-1000' | 'item-5000' | 'item-sale-100' | 'item-sale-1000',
+        slug: 'item-100' | 'item-1000' | 'item-5000' | 'item-sale-100' | 'item-sale-1000' | 'item-0',
     ): Codegen.GetProductsWithVariantPricesQuery['products']['items'][number]['variants'][number] {
         return products.find(p => p.slug === slug)!.variants[0];
     }

+ 68 - 0
packages/core/e2e/populate.e2e-spec.ts

@@ -16,6 +16,9 @@ import {
     GetAssetListQuery,
     GetCollectionsQuery,
     GetProductListQuery,
+    GetProductListQueryVariables,
+    GetProductWithVariantsQuery,
+    GetProductWithVariantsQueryVariables,
     LanguageCode,
 } from './graphql/generated-e2e-admin-types';
 import {
@@ -23,6 +26,7 @@ import {
     GET_ASSET_LIST,
     GET_COLLECTIONS,
     GET_PRODUCT_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
 } from './graphql/shared-definitions';
 
 describe('populate() function', () => {
@@ -191,4 +195,68 @@ describe('populate() function', () => {
             expect(products.items.map(i => i.name).includes('Model Hand')).toBe(true);
         });
     });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1445
+    describe('clashing option names', () => {
+        let app: INestApplication;
+
+        beforeAll(async () => {
+            const initialDataForPopulate: InitialData = {
+                defaultLanguage: initialData.defaultLanguage,
+                defaultZone: initialData.defaultZone,
+                taxRates: [],
+                shippingMethods: [],
+                paymentMethods: [],
+                countries: [],
+                collections: [{ name: 'Collection 1', filters: [] }],
+            };
+            const csvFile = path.join(__dirname, 'fixtures', 'product-import-option-values.csv');
+            app = await populate(
+                async () => {
+                    await server.bootstrap();
+                    return server.app;
+                },
+                initialDataForPopulate,
+                csvFile,
+            );
+        }, TEST_SETUP_TIMEOUT_MS);
+
+        afterAll(async () => {
+            await app.close();
+        });
+
+        it('populates variants & options', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { products } = await adminClient.query<GetProductListQuery, GetProductListQueryVariables>(
+                GET_PRODUCT_LIST,
+                {
+                    options: {
+                        filter: {
+                            slug: { eq: 'foo' },
+                        },
+                    },
+                },
+            );
+            expect(products.totalItems).toBe(1);
+            const fooProduct = products.items[0];
+            expect(fooProduct.name).toBe('Foo');
+
+            const { product } = await adminClient.query<
+                GetProductWithVariantsQuery,
+                GetProductWithVariantsQueryVariables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: fooProduct.id,
+            });
+
+            expect(product?.variants.length).toBe(4);
+            expect(product?.optionGroups.map(og => og.name).sort()).toEqual(['Bar', 'Foo']);
+            expect(
+                product?.variants
+                    .find(v => v.sku === 'foo-fiz-buz')
+                    ?.options.map(o => o.name)
+                    .sort(),
+            ).toEqual(['buz', 'fiz']);
+        });
+    });
 });

+ 53 - 1
packages/core/e2e/stock-control.e2e-spec.ts

@@ -190,7 +190,7 @@ describe('Stock control', () => {
         });
 
         it(
-            'attempting to set a negative stockOnHand throws',
+            'attempting to set stockOnHand below saleable stock level throws',
             assertThrowsWithMessage(async () => {
                 const result = await adminClient.query<
                     Codegen.UpdateStockMutation,
@@ -956,6 +956,58 @@ describe('Stock control', () => {
             expect(variant.stockAllocated).toBe(0);
         });
 
+        describe('adjusting stockOnHand with negative outOfStockThreshold', () => {
+            const variant1Id = 'T_1';
+            beforeAll(async () => {
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                id: variant1Id,
+                                stockOnHand: 0,
+                                outOfStockThreshold: -20,
+                                trackInventory: GlobalFlag.TRUE,
+                                useGlobalOutOfStockThreshold: false,
+                            },
+                        ],
+                    },
+                );
+            });
+
+            it(
+                'attempting to set stockOnHand below outOfStockThreshold throws',
+                assertThrowsWithMessage(async () => {
+                    const result = await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(
+                        UPDATE_STOCK_ON_HAND,
+                        {
+                            input: [
+                                {
+                                    id: variant1Id,
+                                    stockOnHand: -21,
+                                },
+                            ] as UpdateProductVariantInput[],
+                        },
+                    );
+                }, 'stockOnHand cannot be a negative value'),
+            );
+
+            it('can set negative stockOnHand that is not less than outOfStockThreshold', async () => {
+                const result = await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(
+                    UPDATE_STOCK_ON_HAND,
+                    {
+                        input: [
+                            {
+                                id: variant1Id,
+                                stockOnHand: -10,
+                            },
+                        ] as UpdateProductVariantInput[],
+                    },
+                );
+                expect(result.updateProductVariants[0]!.stockOnHand).toBe(-10);
+            });
+        });
+
         describe('edge cases', () => {
             const variant5Id = 'T_5';
             const variant6Id = 'T_6';

+ 25 - 0
packages/core/src/api/config/generate-permissions.ts

@@ -9,6 +9,31 @@ const PERMISSION_DESCRIPTION = `@description
 Permissions for administrators and customers. Used to control access to
 GraphQL resolvers via the {@link Allow} decorator.
 
+## Understanding Permission.Owner
+
+\`Permission.Owner\` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+be accessible to the "owner" of that resource.
+
+For example, the Shop API \`activeCustomer\` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+based on the activeUserId of the current session. As a result, the resolver code looks like this:
+
+@example
+\`\`\`TypeScript
+\\@Query()
+\\@Allow(Permission.Owner)
+async activeCustomer(\\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+  const userId = ctx.activeUserId;
+  if (userId) {
+    return this.customerService.findOneByUserId(ctx, userId);
+  }
+}
+\`\`\`
+
+Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+nor statically encoded at build-time, any resolvers using \`Permission.Owner\` **must** include logic to enforce that only the owner
+of the resource has access. If not, then it is the equivalent of using \`Permission.Public\`.
+
+
 @docsCategory common`;
 
 /**

+ 12 - 3
packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts

@@ -1,13 +1,17 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
-import { Asset, OrderLine, ProductVariant } from '../../../entity';
-import { AssetService, ProductVariantService } from '../../../service';
+import { Asset, Order, OrderLine, ProductVariant } from '../../../entity';
+import { AssetService, OrderService, ProductVariantService } from '../../../service';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('OrderLine')
 export class OrderLineEntityResolver {
-    constructor(private productVariantService: ProductVariantService, private assetService: AssetService) {}
+    constructor(
+        private productVariantService: ProductVariantService,
+        private assetService: AssetService,
+        private orderService: OrderService,
+    ) {}
 
     @ResolveField()
     async productVariant(
@@ -31,4 +35,9 @@ export class OrderLineEntityResolver {
             return this.assetService.getFeaturedAsset(ctx, orderLine);
         }
     }
+
+    @ResolveField()
+    async order(@Ctx() ctx: RequestContext, @Parent() orderLine: OrderLine): Promise<Order | undefined> {
+        return this.orderService.findOneByOrderLineId(ctx, orderLine.id);
+    }
 }

+ 4 - 1
packages/core/src/bootstrap.ts

@@ -133,7 +133,10 @@ export async function preBootstrapConfig(
     setConfig({
         dbConnectionOptions: {
             entities,
-            subscribers: Object.values(coreSubscribersMap) as Array<Type<EntitySubscriberInterface>>,
+            subscribers: [
+                ...(userConfig.dbConnectionOptions?.subscribers ?? []),
+                ...(Object.values(coreSubscribersMap) as Array<Type<EntitySubscriberInterface>>),
+            ],
         },
     });
 

+ 18 - 8
packages/core/src/data-import/providers/importer/importer.ts

@@ -193,7 +193,9 @@ export class Importer {
             });
 
             const optionsMap: { [optionName: string]: ID } = {};
-            for (const optionGroup of product.optionGroups) {
+            for (const [optionGroup, optionGroupIndex] of product.optionGroups.map(
+                (group, i) => [group, i] as const,
+            )) {
                 const optionGroupMainTranslation = this.getTranslationByCodeOrFirst(
                     optionGroup.translations,
                     ctx.languageCode,
@@ -212,10 +214,12 @@ export class Importer {
                         };
                     }),
                 });
-                for (const optionIndex of optionGroupMainTranslation.values.map((value, index) => index)) {
+                for (const [optionIndex, value] of optionGroupMainTranslation.values.map(
+                    (val, index) => [index, val] as const,
+                )) {
                     const createdOptionId = await this.fastImporter.createProductOption({
                         productOptionGroupId: groupId,
-                        code: normalizeString(optionGroupMainTranslation.values[optionIndex], '-'),
+                        code: normalizeString(value, '-'),
                         translations: optionGroup.translations.map(translation => {
                             return {
                                 languageCode: translation.languageCode,
@@ -223,7 +227,7 @@ export class Importer {
                             };
                         }),
                     });
-                    optionsMap[optionGroupMainTranslation.values[optionIndex]] = createdOptionId;
+                    optionsMap[`${optionGroupIndex}_${value}`] = createdOptionId;
                 }
                 await this.fastImporter.addOptionGroupToProduct(createdProductId, groupId);
             }
@@ -246,6 +250,9 @@ export class Importer {
                     variantMainTranslation.customFields,
                     this.configService.customFields.ProductVariant,
                 );
+                const optionIds = variantMainTranslation.optionValues.map(
+                    (v, index) => optionsMap[`${index}_${v}`],
+                );
                 const createdVariant = await this.fastImporter.createProductVariant({
                     productId: createdProductId,
                     facetValueIds,
@@ -255,7 +262,7 @@ export class Importer {
                     taxCategoryId: this.getMatchingTaxCategoryId(variant.taxCategory, taxCategories),
                     stockOnHand: variant.stockOnHand,
                     trackInventory: variant.trackInventory,
-                    optionIds: variantMainTranslation.optionValues.map(v => optionsMap[v]),
+                    optionIds,
                     translations: variant.translations.map(translation => {
                         const productTranslation = product.translations.find(
                             t => t.languageCode === translation.languageCode,
@@ -356,11 +363,14 @@ export class Importer {
     }
 
     private processCustomFieldValues(customFields: { [field: string]: string }, config: CustomFieldConfig[]) {
-        const processed: { [field: string]: string | string[] } = {};
+        const processed: { [field: string]: string | string[] | undefined } = {};
         for (const fieldDef of config) {
             const value = customFields[fieldDef.name];
-            processed[fieldDef.name] =
-                fieldDef.list === true ? value?.split('|').filter(val => val.trim() !== '') : value;
+            if (fieldDef.list === true) {
+                processed[fieldDef.name] = value?.split('|').filter(val => val.trim() !== '');
+            } else {
+                processed[fieldDef.name] = value ? value : undefined;
+            }
         }
         return processed;
     }

+ 1 - 1
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -92,7 +92,7 @@ export type ExtendedListQueryOptions<T extends VendureEntity> = {
  * ```GraphQL
  * type BlogPost implements Node {
  *   id: ID!
- *   published: DataTime!
+ *   published: DateTime!
  *   title: String!
  *   body: String!
  * }

+ 12 - 0
packages/core/src/service/helpers/order-calculator/prorate.spec.ts

@@ -27,4 +27,16 @@ describe('prorate()', () => {
     it('many weights non-neatly divisible', () => {
         testProrate([10, 20, 10, 30, 50, 20, 10, 40], 93, [5, 10, 5, 15, 24, 10, 5, 19]);
     });
+    it('weights include zero', () => {
+        testProrate([10, 0], 40, [40, 0]);
+    });
+    it('all weights are zero', () => {
+        testProrate([0, 0], 10, [5, 5]);
+    });
+    it('all weights are zero with zero total', () => {
+        testProrate([0, 0], 0, [0, 0]);
+    });
+    it('amount is negative', () => {
+        testProrate([100, 100], -20, [-10, -10]);
+    });
 });

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

@@ -20,7 +20,7 @@ export function prorate(weights: number[], amount: number): number[] {
 
     let i = 0;
     for (const w of weights) {
-        actual[i] = amount * (w / totalWeight);
+        actual[i] = totalWeight === 0 ? amount / weights.length : amount * (w / totalWeight);
         rounded[i] = Math.floor(actual[i]);
         error[i] = actual[i] - rounded[i];
         added += rounded[i];

+ 10 - 1
packages/core/src/service/services/customer-group.service.ts

@@ -21,6 +21,7 @@ import { Customer } from '../../entity/customer/customer.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { CustomerGroupEntityEvent } from '../../event-bus/events/customer-group-entity-event';
 import { CustomerGroupChangeEvent, CustomerGroupEvent } from '../../event-bus/events/customer-group-event';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { patchEntity } from '../helpers/utils/patch-entity';
 
@@ -39,6 +40,7 @@ export class CustomerGroupService {
         private listQueryBuilder: ListQueryBuilder,
         private historyService: HistoryService,
         private eventBus: EventBus,
+        private customFieldRelationService: CustomFieldRelationService,
     ) {}
 
     findAll(ctx: RequestContext, options?: CustomerGroupListOptions): Promise<PaginatedList<CustomerGroup>> {
@@ -91,14 +93,21 @@ export class CustomerGroupService {
             await this.connection.getRepository(ctx, Customer).save(customers);
         }
         const savedCustomerGroup = await assertFound(this.findOne(ctx, newCustomerGroup.id));
+        await this.customFieldRelationService.updateRelations(ctx, CustomerGroup, input, savedCustomerGroup);
         this.eventBus.publish(new CustomerGroupEntityEvent(ctx, savedCustomerGroup, 'created', input));
-        return savedCustomerGroup;
+        return assertFound(this.findOne(ctx, savedCustomerGroup.id));
     }
 
     async update(ctx: RequestContext, input: UpdateCustomerGroupInput): Promise<CustomerGroup> {
         const customerGroup = await this.connection.getEntityOrThrow(ctx, CustomerGroup, input.id);
         const updatedCustomerGroup = patchEntity(customerGroup, input);
         await this.connection.getRepository(ctx, CustomerGroup).save(updatedCustomerGroup, { reload: false });
+        await this.customFieldRelationService.updateRelations(
+            ctx,
+            CustomerGroup,
+            input,
+            updatedCustomerGroup,
+        );
         this.eventBus.publish(new CustomerGroupEntityEvent(ctx, customerGroup, 'updated', input));
         return assertFound(this.findOne(ctx, customerGroup.id));
     }

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

@@ -249,6 +249,16 @@ export class OrderService {
         return order ? this.findOne(ctx, order.id) : undefined;
     }
 
+    async findOneByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise<Order | undefined> {
+        const order = await this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder('order')
+            .innerJoin('order.lines', 'line', 'line.id = :orderLineId', { orderLineId })
+            .getOne();
+
+        return order ? this.findOne(ctx, order.id) : undefined;
+    }
+
     async findByCustomerId(
         ctx: RequestContext,
         customerId: ID,

+ 19 - 1
packages/core/src/service/services/product-variant.service.ts

@@ -318,6 +318,23 @@ export class ProductVariantService {
         return variant.stockOnHand - variant.stockAllocated - effectiveOutOfStockThreshold;
     }
 
+    private async getOutOfStockThreshold(ctx: RequestContext, variant: ProductVariant): Promise<number> {
+        const { outOfStockThreshold, trackInventory } = await this.requestCache.get(
+            ctx,
+            'globalSettings',
+            () => this.globalSettingsService.getSettings(ctx),
+        );
+
+        const inventoryNotTracked =
+            variant.trackInventory === GlobalFlag.FALSE ||
+            (variant.trackInventory === GlobalFlag.INHERIT && trackInventory === false);
+        if (inventoryNotTracked) {
+            return 0;
+        } else {
+            return variant.useGlobalOutOfStockThreshold ? outOfStockThreshold : variant.outOfStockThreshold;
+        }
+    }
+
     /**
      * @description
      * Returns the stockLevel to display to the customer, as specified by the configured
@@ -446,7 +463,8 @@ export class ProductVariantService {
             channelId: ctx.channelId,
             relations: ['facetValues', 'facetValues.channels'],
         });
-        if (input.stockOnHand && input.stockOnHand < 0) {
+        const outOfStockThreshold = await this.getOutOfStockThreshold(ctx, existingVariant);
+        if (input.stockOnHand && input.stockOnHand < outOfStockThreshold) {
             throw new UserInputError('error.stockonhand-cannot-be-negative');
         }
         const inputWithoutPrice = {

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

@@ -38,6 +38,7 @@ import { EventBus } from '../../event-bus';
 import { PromotionEvent } from '../../event-bus/events/promotion-event';
 import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
+import { OrderState } from '../helpers/order-state-machine/order-state';
 import { patchEntity } from '../helpers/utils/patch-entity';
 
 import { ChannelService } from './channel.service';
@@ -258,7 +259,8 @@ export class PromotionService {
             .createQueryBuilder('order')
             .leftJoin('order.promotions', 'promotion')
             .where('promotion.id = :promotionId', { promotionId })
-            .andWhere('order.customer = :customerId', { customerId });
+            .andWhere('order.customer = :customerId', { customerId })
+            .andWhere('order.state != :state', { state: 'Cancelled' as OrderState });
 
         return qb.getCount();
     }

+ 25 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -3348,6 +3348,31 @@ export type PaymentStateTransitionError = ErrorResult & {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 2 - 0
packages/elasticsearch-plugin/src/options.ts

@@ -92,6 +92,8 @@ export interface ElasticsearchOptions {
      *   }
      * },
      * ```
+     * A more complete example can be found in the discussion thread
+     * [How to make elastic plugin to search by substring with stemming](https://github.com/vendure-ecommerce/vendure/discussions/1066).
      *
      * @since 1.2.0
      * @default

+ 25 - 0
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -3348,6 +3348,31 @@ export type PaymentStateTransitionError = ErrorResult & {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 25 - 0
packages/payments-plugin/e2e/graphql/generated-shop-types.ts

@@ -2139,6 +2139,31 @@ export type PaymentMethodQuote = {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 25 - 0
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -2238,6 +2238,31 @@ export type PaymentMethodQuote = {
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
  *
+ * ## Understanding Permission.Owner
+ *
+ * `Permission.Owner` is a special permission which is used in some of the Vendure resolvers to indicate that that resolver should only
+ * be accessible to the "owner" of that resource.
+ *
+ * For example, the Shop API `activeCustomer` query resolver should only return the Customer object for the "owner" of that Customer, i.e.
+ * based on the activeUserId of the current session. As a result, the resolver code looks like this:
+ *
+ * @example
+ * ```TypeScript
+ * \@Query()
+ * \@Allow(Permission.Owner)
+ * async activeCustomer(\@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+ *   const userId = ctx.activeUserId;
+ *   if (userId) {
+ *     return this.customerService.findOneByUserId(ctx, userId);
+ *   }
+ * }
+ * ```
+ *
+ * Here we can see that the "ownership" must be enforced by custom logic inside the resolver. Since "ownership" cannot be defined generally
+ * nor statically encoded at build-time, any resolvers using `Permission.Owner` **must** include logic to enforce that only the owner
+ * of the resource has access. If not, then it is the equivalent of using `Permission.Public`.
+ *
+ *
  * @docsCategory common
  */
 export enum Permission {

+ 18 - 10
packages/payments-plugin/src/stripe/stripe.controller.ts

@@ -77,18 +77,25 @@ export class StripeController {
 
         const ctx = await this.createContext(channelToken);
 
-        const transitionToStateResult = await this.orderService.transitionToState(
-            ctx,
-            orderId,
-            'ArrangingPayment',
-        );
+        const order = await this.orderService.findOneByCode(ctx, orderCode);
+        if (!order) {
+            throw Error(`Unable to find order ${orderCode}, unable to settle payment ${paymentIntent.id}!`);
+        }
 
-        if (transitionToStateResult instanceof OrderStateTransitionError) {
-            Logger.error(
-                `Error transitioning order ${orderCode} to ArrangingPayment state: ${transitionToStateResult.message}`,
-                loggerCtx,
+        if (order.state !== 'ArrangingPayment') {
+            const transitionToStateResult = await this.orderService.transitionToState(
+                ctx,
+                orderId,
+                'ArrangingPayment',
             );
-            return;
+
+            if (transitionToStateResult instanceof OrderStateTransitionError) {
+                Logger.error(
+                    `Error transitioning order ${orderCode} to ArrangingPayment state: ${transitionToStateResult.message}`,
+                    loggerCtx,
+                );
+                return;
+            }
         }
 
         const paymentMethod = await this.getPaymentMethod(ctx);
@@ -109,6 +116,7 @@ export class StripeController {
         }
 
         Logger.info(`Stripe payment intent id ${paymentIntent.id} added to order ${orderCode}`, loggerCtx);
+        response.status(HttpStatus.OK).send('Ok');
     }
 
     private async createContext(channelToken: string): Promise<RequestContext> {

Vissa filer visades inte eftersom för många filer har ändrats