Răsfoiți Sursa

Merge branch 'split-apis'

Michael Bromley 7 ani în urmă
părinte
comite
4e0b0492f3
100 a modificat fișierele cu 3831 adăugiri și 2042 ștergeri
  1. 2 0
      .gitignore
  2. 1 1
      admin-ui/src/app/customer/components/address-card/address-card.component.ts
  3. 2 2
      admin-ui/src/app/customer/components/customer-detail/customer-detail.component.ts
  4. 2 2
      admin-ui/src/app/data/data.module.ts
  5. 7 5
      admin-ui/src/app/data/definitions/settings-definitions.ts
  6. 1 1
      codegen/client-schema.ts
  7. 4 5
      codegen/download-introspection-schema.ts
  8. 21 4
      codegen/generate-api-docs.ts
  9. 40 13
      codegen/generate-graphql-types.ts
  10. 1 0
      docs/content/docs/graphql-api/_index.md
  11. 1 1
      docs/content/docs/plugins/default-search-plugin.md
  12. 49 31
      docs/content/docs/plugins/writing-a-vendure-plugin.md
  13. 1 1
      package.json
  14. 0 0
      schema-admin.json
  15. 0 0
      schema-shop.json
  16. 0 0
      schema.json
  17. 21 20
      server/dev-config.ts
  18. 2 2
      server/e2e/administrator.e2e-spec.ts
  19. 6 330
      server/e2e/auth.e2e-spec.ts
  20. 3 2
      server/e2e/config/test-config.ts
  21. 2 11
      server/e2e/country.e2e-spec.ts
  22. 25 26
      server/e2e/customer.e2e-spec.ts
  23. 103 0
      server/e2e/default-search-plugin.e2e-spec.ts
  24. 2 2
      server/e2e/facet.e2e-spec.ts
  25. 94 0
      server/e2e/fixtures/test-plugins.ts
  26. 2 2
      server/e2e/import.e2e-spec.ts
  27. 38 803
      server/e2e/order.e2e-spec.ts
  28. 77 0
      server/e2e/plugin.e2e-spec.ts
  29. 2 2
      server/e2e/product-category.e2e-spec.ts
  30. 2 2
      server/e2e/product.e2e-spec.ts
  31. 2 2
      server/e2e/promotion.e2e-spec.ts
  32. 2 2
      server/e2e/role.e2e-spec.ts
  33. 420 0
      server/e2e/shop-auth.e2e-spec.ts
  34. 867 0
      server/e2e/shop-order.e2e-spec.ts
  35. 17 3
      server/e2e/test-client.ts
  36. 2 1
      server/e2e/test-server.ts
  37. 2 2
      server/e2e/zone.e2e-spec.ts
  38. 1 1
      server/mock-data/populate.ts
  39. 12 6
      server/package.json
  40. 79 33
      server/src/api/api.module.ts
  41. 102 0
      server/src/api/config/configure-graphql-module.ts
  42. 308 0
      server/src/api/config/generate-list-options.spec.ts
  43. 196 0
      server/src/api/config/generate-list-options.ts
  44. 0 75
      server/src/api/config/graphql-config.service.ts
  45. 5 2
      server/src/api/config/graphql-custom-fields.ts
  46. 6 6
      server/src/api/resolvers/admin/administrator.resolver.ts
  47. 6 15
      server/src/api/resolvers/admin/asset.resolver.ts
  48. 43 0
      server/src/api/resolvers/admin/auth.resolver.ts
  49. 8 8
      server/src/api/resolvers/admin/channel.resolver.ts
  50. 8 27
      server/src/api/resolvers/admin/country.resolver.ts
  51. 7 7
      server/src/api/resolvers/admin/customer-group.resolver.ts
  52. 85 0
      server/src/api/resolvers/admin/customer.resolver.ts
  53. 13 13
      server/src/api/resolvers/admin/facet.resolver.ts
  54. 5 5
      server/src/api/resolvers/admin/global-settings.resolver.ts
  55. 5 5
      server/src/api/resolvers/admin/import.resolver.ts
  56. 32 0
      server/src/api/resolvers/admin/order.resolver.ts
  57. 5 5
      server/src/api/resolvers/admin/payment-method.resolver.ts
  58. 15 35
      server/src/api/resolvers/admin/product-category.resolver.ts
  59. 9 23
      server/src/api/resolvers/admin/product-option.resolver.ts
  60. 17 28
      server/src/api/resolvers/admin/product.resolver.ts
  61. 7 7
      server/src/api/resolvers/admin/promotion.resolver.ts
  62. 5 5
      server/src/api/resolvers/admin/role.resolver.ts
  63. 7 7
      server/src/api/resolvers/admin/search.resolver.ts
  64. 5 5
      server/src/api/resolvers/admin/shipping-method.resolver.ts
  65. 6 6
      server/src/api/resolvers/admin/tax-category.resolver.ts
  66. 8 8
      server/src/api/resolvers/admin/tax-rate.resolver.ts
  67. 7 7
      server/src/api/resolvers/admin/zone.resolver.ts
  68. 0 162
      server/src/api/resolvers/auth.resolver.ts
  69. 95 0
      server/src/api/resolvers/base/base-auth.resolver.ts
  70. 0 131
      server/src/api/resolvers/customer.resolver.ts
  71. 36 0
      server/src/api/resolvers/entity/customer-entity.resolver.ts
  72. 33 0
      server/src/api/resolvers/entity/order-entity.resolver.ts
  73. 26 0
      server/src/api/resolvers/entity/order-line-entity.resolver.ts
  74. 39 0
      server/src/api/resolvers/entity/product-category-entity.resolver.ts
  75. 26 0
      server/src/api/resolvers/entity/product-entity.resolver.ts
  76. 28 0
      server/src/api/resolvers/entity/product-option-group-entity.resolver.ts
  77. 102 0
      server/src/api/resolvers/shop/shop-auth.resolver.ts
  78. 72 0
      server/src/api/resolvers/shop/shop-customer.resolver.ts
  79. 36 44
      server/src/api/resolvers/shop/shop-order.resolver.ts
  80. 70 0
      server/src/api/resolvers/shop/shop-products.resolver.ts
  81. 33 0
      server/src/api/schema/admin-api/administrator.api.graphql
  82. 17 0
      server/src/api/schema/admin-api/asset.api.graphql
  83. 8 0
      server/src/api/schema/admin-api/auth.api.graphql
  84. 12 11
      server/src/api/schema/admin-api/channel.api.graphql
  85. 1 1
      server/src/api/schema/admin-api/config.api.graphql
  86. 38 0
      server/src/api/schema/admin-api/country.api.graphql
  87. 10 0
      server/src/api/schema/admin-api/customer-group.api.graphql
  88. 2 31
      server/src/api/schema/admin-api/customer.api.graphql
  89. 67 0
      server/src/api/schema/admin-api/facet.api.graphql
  90. 4 0
      server/src/api/schema/admin-api/global-settings.api.graphql
  91. 3 0
      server/src/api/schema/admin-api/import.api.graphql
  92. 7 0
      server/src/api/schema/admin-api/order.api.graphql
  93. 24 0
      server/src/api/schema/admin-api/payment-method.api.graphql
  94. 24 24
      server/src/api/schema/admin-api/product-category.api.graphql
  95. 40 0
      server/src/api/schema/admin-api/product-option-group.api.graphql
  96. 7 0
      server/src/api/schema/admin-api/product-search.api.graphql
  97. 48 21
      server/src/api/schema/admin-api/product.api.graphql
  98. 29 0
      server/src/api/schema/admin-api/promotion.api.graphql
  99. 28 0
      server/src/api/schema/admin-api/role.api.graphql
  100. 31 0
      server/src/api/schema/admin-api/shipping-method.api.graphql

+ 2 - 0
.gitignore

@@ -404,3 +404,5 @@ docs/content/docs/configuration/*
 !docs/content/docs/configuration/_index.md
 docs/content/docs/graphql-api/*
 !docs/content/docs/graphql-api/_index.md
+!docs/content/docs/graphql-api/shop/_index.md
+!docs/content/docs/graphql-api/admin/_index.md

+ 1 - 1
admin-ui/src/app/customer/components/address-card/address-card.component.ts

@@ -11,7 +11,7 @@ import { GetAvailableCountries } from 'shared/generated-types';
 export class AddressCardComponent implements OnInit {
     editing = false;
     @Input() addressForm: FormGroup;
-    @Input() availableCountries: GetAvailableCountries.AvailableCountries[] = [];
+    @Input() availableCountries: GetAvailableCountries.Items[] = [];
     @Input() isDefaultBilling: string;
     @Input() isDefaultShipping: string;
     @Output() setAsDefaultShipping = new EventEmitter<string>();

+ 2 - 2
admin-ui/src/app/customer/components/customer-detail/customer-detail.component.ts

@@ -30,7 +30,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<GetCustomer.Cus
     implements OnInit, OnDestroy {
     detailForm: FormGroup;
     customFields: CustomFieldConfig[];
-    availableCountries$: Observable<GetAvailableCountries.AvailableCountries[]>;
+    availableCountries$: Observable<GetAvailableCountries.Items[]>;
     orders$: Observable<GetCustomer.Items[]>;
     ordersCount$: Observable<number>;
     defaultShippingAddressId: string;
@@ -72,7 +72,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<GetCustomer.Cus
         this.init();
         this.availableCountries$ = this.dataService.settings
             .getAvailableCountries()
-            .mapSingle(result => result.availableCountries)
+            .mapSingle(result => result.countries.items)
             .pipe(shareReplay(1));
 
         const customerWithUpdates$ = this.entity$.pipe(merge(this.orderListUpdates$));

+ 2 - 2
admin-ui/src/app/data/data.module.ts

@@ -7,7 +7,7 @@ import { ApolloLink } from 'apollo-link';
 import { setContext } from 'apollo-link-context';
 import { withClientState } from 'apollo-link-state';
 import { createUploadLink } from 'apollo-upload-client';
-import { API_PATH } from 'shared/shared-constants';
+import { ADMIN_API_PATH } from 'shared/shared-constants';
 
 import { environment } from '../../environments/environment';
 import { API_URL } from '../app.config';
@@ -54,7 +54,7 @@ export function createApollo(
                 }
             }),
             createUploadLink({
-                uri: `${API_URL}/${API_PATH}`,
+                uri: `${API_URL}/${ADMIN_API_PATH}`,
                 fetch: fetchAdapter.fetch,
             }),
         ]),

+ 7 - 5
admin-ui/src/app/data/definitions/settings-definitions.ts

@@ -30,11 +30,13 @@ export const GET_COUNTRY_LIST = gql`
 
 export const GET_AVAILABLE_COUNTRIES = gql`
     query GetAvailableCountries {
-        availableCountries {
-            id
-            code
-            name
-            enabled
+        countries(options: { filter: { enabled: { eq: true } } }) {
+            items {
+                id
+                code
+                name
+                enabled
+            }
         }
     }
 `;

+ 1 - 1
codegen/client-schema.ts

@@ -3,7 +3,7 @@ import { makeExecutableSchema } from 'graphql-tools';
 import path from 'path';
 
 const CLIENT_SCHEMA_FILE = '../admin-ui/src/app/data/client-state/client-types.graphql';
-const LANGUAGE_CODE_FILE = '../server/src/common/types/language-code.graphql';
+const LANGUAGE_CODE_FILE = '../server/src/api/schema/common/language-code.graphql';
 
 function loadGraphQL(file: string): string {
     const filePath = path.join(__dirname, file);

+ 4 - 5
codegen/download-introspection-schema.ts

@@ -1,9 +1,8 @@
 import fs from 'fs';
 import { introspectionQuery } from 'graphql';
 import http from 'http';
-import path from 'path';
 
-import { API_PATH, API_PORT } from '../shared/shared-constants';
+import { ADMIN_API_PATH, API_PORT } from '../shared/shared-constants';
 
 // tslint:disable:no-console
 
@@ -13,7 +12,7 @@ import { API_PATH, API_PORT } from '../shared/shared-constants';
  *
  * If there is an error connecting to the server, the promise resolves to false.
  */
-export function downloadIntrospectionSchema(outputFilePath: string): Promise<boolean> {
+export function downloadIntrospectionSchema(apiPath: string, outputFilePath: string): Promise<boolean> {
     const body = JSON.stringify({ query: introspectionQuery });
 
     return new Promise((resolve, reject) => {
@@ -22,7 +21,7 @@ export function downloadIntrospectionSchema(outputFilePath: string): Promise<boo
                 method: 'post',
                 host: 'localhost',
                 port: API_PORT,
-                path: '/' + API_PATH,
+                path: '/' + apiPath,
                 headers: {
                     'Content-Type': 'application/json',
                     'Content-Length': Buffer.byteLength(body),
@@ -40,7 +39,7 @@ export function downloadIntrospectionSchema(outputFilePath: string): Promise<boo
         request.on('error', (err: any) => {
             if (err.code === 'ECONNREFUSED') {
                 console.error(
-                    `ERROR: Could not connect to the Vendure server at http://localhost:${API_PORT}/${API_PATH}`,
+                    `ERROR: Could not connect to the Vendure server at http://localhost:${API_PORT}/${apiPath}`,
                 );
                 resolve(false);
             }

+ 21 - 4
codegen/generate-api-docs.ts

@@ -18,12 +18,16 @@ import { deleteGeneratedDocs, generateFrontMatter } from './docgen-utils';
 
 // tslint:disable:no-console
 
+type TargetApi = 'shop' | 'admin';
+
+const targetApi: TargetApi = getTargetApiFromArgs();
+
 // The path to the introspection schema json file
-const SCHEMA_FILE = path.join(__dirname, '../schema.json');
+const SCHEMA_FILE = path.join(__dirname, `../schema-${targetApi}.json`);
 // The absolute URL to the generated api docs section
-const docsUrl = '/docs/graphql-api/';
+const docsUrl = `/docs/graphql-api/${targetApi}/`;
 // The directory in which the markdown files will be saved
-const outputPath = path.join(__dirname, '../docs/content/docs/graphql-api');
+const outputPath = path.join(__dirname, `../docs/content/docs/graphql-api/${targetApi}`);
 
 const enum FileName {
     ENUM = 'enums',
@@ -35,7 +39,7 @@ const enum FileName {
 
 const schemaJson = fs.readFileSync(SCHEMA_FILE, 'utf8');
 const parsed = JSON.parse(schemaJson);
-const schema = buildClientSchema(parsed.data);
+const schema = buildClientSchema(parsed.data ? parsed.data : parsed);
 
 deleteGeneratedDocs(outputPath);
 generateApiDocs(outputPath);
@@ -57,6 +61,9 @@ function generateApiDocs(hugoOutputPath: string) {
         if (isObjectType(type)) {
             if (type.name === 'Query') {
                 for (const field of Object.values(type.getFields())) {
+                    if (field.name === 'temp__') {
+                        continue;
+                    }
                     queriesOutput += `## ${field.name}\n`;
                     queriesOutput += renderDescription(field);
                     queriesOutput += renderFields([field], false) + '\n\n';
@@ -172,3 +179,13 @@ function unwrapType(type: GraphQLType): GraphQLNamedType {
     }
     return innerType;
 }
+
+function getTargetApiFromArgs(): TargetApi {
+    const apiArg = process.argv.find(arg => /--api=(shop|admin)/.test(arg));
+    if (!apiArg) {
+        console.error(`\nPlease specify which GraphQL API to generate docs for: --api=<shop|admin>\n`);
+        process.exit(1);
+        return null as never;
+    }
+    return apiArg === '--api=shop' ? 'shop' : 'admin';
+}

+ 40 - 13
codegen/generate-graphql-types.ts

@@ -1,38 +1,65 @@
+import fs from 'fs';
+import { buildClientSchema, graphqlSync, introspectionQuery } from 'graphql';
 import { generate } from 'graphql-code-generator';
 import { TypeScriptNamingConventionMap } from 'graphql-codegen-typescript-common';
+import { mergeSchemas } from 'graphql-tools';
 import path from 'path';
 
-import { API_PATH, API_PORT } from '../shared/shared-constants';
+import { ADMIN_API_PATH, API_PORT, SHOP_API_PATH } from '../shared/shared-constants';
 
 import { downloadIntrospectionSchema } from './download-introspection-schema';
 
 const CLIENT_QUERY_FILES = path.join(__dirname, '../admin-ui/src/app/data/definitions/**/*.ts');
-const SCHEMA_OUTPUT_FILE = path.join(__dirname, '../schema.json');
+const ADMIN_SCHEMA_OUTPUT_FILE = path.join(__dirname, '../schema-admin.json');
+const SHOP_SCHEMA_OUTPUT_FILE = path.join(__dirname, '../schema-shop.json');
 
 // tslint:disable:no-console
 
-downloadIntrospectionSchema(SCHEMA_OUTPUT_FILE)
-    .then(downloaded => {
-        if (!downloaded) {
-            console.log('Attempting to generate types from existing schema.json...');
+Promise.all([
+    downloadIntrospectionSchema(ADMIN_API_PATH, ADMIN_SCHEMA_OUTPUT_FILE),
+    downloadIntrospectionSchema(SHOP_API_PATH, SHOP_SCHEMA_OUTPUT_FILE),
+])
+    .then(([adminSchemaSuccess, shopSchemaSuccess]) => {
+        if (!adminSchemaSuccess || !shopSchemaSuccess) {
+            console.log('Attempting to generate types from existing schema json files...');
         }
+
+        const adminSchemaJson = JSON.parse(fs.readFileSync(ADMIN_SCHEMA_OUTPUT_FILE, 'utf-8'));
+        const shopSchemaJson = JSON.parse(fs.readFileSync(SHOP_SCHEMA_OUTPUT_FILE, 'utf-8'));
+        const adminSchema = buildClientSchema(adminSchemaJson.data);
+        const shopSchema = buildClientSchema(shopSchemaJson.data);
+        const combinedSchemas = mergeSchemas({ schemas: [adminSchema, shopSchema]});
+
+        const combinedJson = graphqlSync(combinedSchemas, introspectionQuery).data;
+        fs.writeFileSync(path.join(__dirname, '../schema.json'), JSON.stringify(combinedJson, null, 2));
+
+        const namingConventionConfig = {
+            namingConvention: {
+                enumValues: 'keep',
+            } as TypeScriptNamingConventionMap,
+        };
         return generate({
-            schema: [SCHEMA_OUTPUT_FILE, path.join(__dirname, 'client-schema.ts')],
             overwrite: true,
-            documents: CLIENT_QUERY_FILES,
             generates: {
                 [path.join(__dirname, '../shared/generated-types.ts')]: {
+                    schema: [ADMIN_SCHEMA_OUTPUT_FILE, path.join(__dirname, 'client-schema.ts')],
+                    documents: CLIENT_QUERY_FILES,
                     plugins: [
                         { add: '// tslint:disable' },
                         'time',
                         'typescript-common',
                         'typescript-client',
                         'typescript-server'],
-                    config: {
-                        namingConvention: {
-                            enumValues: 'keep',
-                        } as TypeScriptNamingConventionMap,
-                    },
+                    config: namingConventionConfig,
+                },
+                [path.join(__dirname, '../shared/generated-shop-types.ts')]: {
+                    schema: [SHOP_SCHEMA_OUTPUT_FILE],
+                    plugins: [
+                        { add: '// tslint:disable' },
+                        'time',
+                        'typescript-common',
+                        'typescript-server'],
+                    config: namingConventionConfig,
                 },
             },
         });

+ 1 - 0
docs/content/docs/graphql-api/_index.md

@@ -8,6 +8,7 @@ showtoc: false
 
 This section contains a description of all queries, mutations and related types available in the Vendure GraphQL API.
 
+The API is split into two distinct endpoints: *Shop* and *Admin*. The Shop API is for storefront client applications, whereas the Admin API is used for administrative tasks.
 {{% alert %}}
 All documentation in this section is auto-generated from the Vendure GraphQL schema.
 {{% /alert %}}

+ 1 - 1
docs/content/docs/plugins/default-search-plugin.md

@@ -16,5 +16,5 @@ const config: VendureConfig = {
 ```
 
 {{% alert "warning" %}}
-Note that the current implementation of the DefaultSearchPlugin is only implemented and tested against a MySQL/MariaDB database. In addition, the search result quality has not yet been optimized.
+Note that the quality of the fulltext search capabilities varies depending on the underlying database being used. For example, the MySQL & Postgres implementations will typically yield better results than the SQLite implementation.
 {{% /alert %}}

+ 49 - 31
docs/content/docs/plugins/writing-a-vendure-plugin.md

@@ -68,29 +68,29 @@ The `@Injectable()` decorator is part of the underlying [Nest framework](https:/
 To use decorators with TypeScript, you must set the "emitDecoratorMetadata" and "experimentalDecorators" compiler options to `true` in your tsconfig.json file.
 {{% /alert %}}
 
-### Step 3: Extend the GraphQL API
+### Step 3: Define the new mutation
 
-Next we will extend the Vendure GraphQL API to add our new mutation. This is done by implementing the [`defineGraphQlTypes` method](({{< relref "vendure-plugin" >}}#definegraphqltypes)) in our plugin.
+Next we will define how the GraphQL API should be extended:
 
 ```ts 
 import gql from 'graphql-tag';
 
 export class RandomCatPlugin implements VendurePlugin {
 
+    private schemaExtension = gql`
+        extend type Mutation {
+            addRandomCat(id: ID!): Product!
+        }
+    `;
+
     configure(config) {
         // as above
     }
-
-    defineGraphQlTypes() {
-        return gql`
-            extend type Mutation {
-                addRandomCat(id: ID!): Product!
-            }
-        `;
-    }
 }
 ```
 
+We will use this private `schemaExtension` variable in a later step.
+
 ### Step 4: Create a resolver
 
 Now that we've defined the new mutation, we'll need a resolver function to handle it. To do this, we'll create a new resolver class, following the [Nest GraphQL resolver architecture](https://docs.nestjs.com/graphql/resolvers-map). In short, this will be a class which has the `@Resolver()` decorator and features a method to handle our new mutation.
@@ -121,32 +121,44 @@ Some explanations of this code are in order:
 * The `@Resolver()` decorator tells Nest that this class contains GraphQL resolvers.
 * We are able to use Nest's dependency injection to inject an instance of our `CatFetcher` class into the constructor of the resolver. We are also injecting an instance of the built-in `ProductService` class, which is responsible for operations on Products.
 * We use the `@Mutation()` decorator to mark this method as a resolver for a mutation with the corresponding name.
-* The `@Allow()` decorator enables us to define permissions restrictions on the mutation. Only those users whose permissions include `UpdateCatalog` may perform this operation. For a full list of available permissions, see the [Permission enum]({{< relref "enums" >}}#permission).
+* The `@Allow()` decorator enables us to define permissions restrictions on the mutation. Only those users whose permissions include `UpdateCatalog` may perform this operation. For a full list of available permissions, see the [Permission enum]({{< relref "../graphql-api/admin/enums" >}}#permission).
 * The `@Ctx()` decorator injects the current `RequestContext` into the resolver. This provides information about the current request such as the current Session, User and Channel. It is required by most of the internal service methods.
 * The `@Args()` decorator injects the arguments passed to the mutation as an object.
 
-### Step 5: Export the providers
+### Step 5: Export any providers used in the resolver
 
-In order that the Vendure server (and the underlying Nest framework) is able to use the `CatFetcher` and `RandomCatResolver` classes, we must export them via the [`defineProviders` method](({{< relref "vendure-plugin" >}}#defineproviders)) in our plugin:
+In order that out resolver is able to use Nest's dependency injection to inject and instance of `CatFetcher`, we must export it via the [`defineProviders` method](({{< relref "vendure-plugin" >}}#defineproviders)) in our plugin:
 
 ```ts 
 export class RandomCatPlugin implements VendurePlugin {
 
-    configure(config) {
-        // as above
-    }
+    // ...
 
-    defineGraphQlTypes() {
-        // as above
+    defineProviders() {
+        return [CatFetcher];
     }
+}
+```
 
-    defineProviders() {
-        return [CatFetcher, RandomCatResolver];
+### Step 6: Extend the GraphQL API
+
+Now that we've defined the new mutation and we have a resolver capable of handling it, we just need to tell Vendure to extend the API. This is done with the [`extendAdminAPI` method]({{< relref "vendure-plugin" >}}#extendadminapi). If we wanted to extend the Shop API, we'd use the [`extendShopAPI` method]({{< relref "vendure-plugin" >}}#extendshopapi) method instead.
+
+```ts 
+export class RandomCatPlugin implements VendurePlugin {
+
+    // ...
+    
+    extendAdminAPI() {
+        return {
+            schema: this.schemaExtension,
+            resolvers: [RandomCatResolver],
+        };
     }
 }
 ```
 
-### Step 6: Add the plugin to the Vendure config
+### Step 7: Add the plugin to the Vendure config
 
 Finally we need to add an instance of our plugin to the config object with which we bootstrap out Vendure server:
 
@@ -161,9 +173,9 @@ bootstrap({
 });
 ```
 
-### Step 7: Test the plugin
+### Step 8: Test the plugin
 
-Once we have started the Vendure server with the new config, we should be able to send the following GraphQL query:
+Once we have started the Vendure server with the new config, we should be able to send the following GraphQL query to the Admin API:
 
 ```GraphQL
 mutation {
@@ -203,6 +215,13 @@ import http from 'http';
 import { Allow, Ctx, Permission, ProductService, RequestContext, VendureConfig, VendurePlugin } from '@vendure/core';
 
 export class RandomCatPlugin implements VendurePlugin {
+
+    private schemaExtension = gql`
+        extend type Mutation {
+            addRandomCat(id: ID!): Product!
+        }
+    `;
+
     configure(config: Required<VendureConfig>) {
         config.customFields.Product.push({
             type: 'string',
@@ -211,16 +230,15 @@ export class RandomCatPlugin implements VendurePlugin {
         return config;
     }
 
-    defineGraphQlTypes() {
-        return gql`
-            extend type Mutation {
-                addRandomCat(id: ID!): Product!
-            }
-        `;
+    defineProviders() {
+        return [CatFetcher];
     }
 
-    defineProviders() {
-        return [CatFetcher, RandomCatResolver];
+    extendAdminAPI() {
+        return {
+            schema: this.schemaExtension,
+            resolvers: [RandomCatResolver],
+        };
     }
 }
 

+ 1 - 1
package.json

@@ -8,7 +8,7 @@
     "docs:deploy": "cd docs && yarn && cd .. && yarn docs:build",
     "generate-gql-types": "ts-node ./codegen/generate-graphql-types.ts",
     "generate-config-docs": "ts-node ./codegen/generate-config-docs.ts",
-    "generate-api-docs": "ts-node ./codegen/generate-api-docs.ts",
+    "generate-api-docs": "ts-node ./codegen/generate-api-docs.ts --api=shop && ts-node ./codegen/generate-api-docs.ts --api=admin",
     "test": "cd admin-ui && yarn test --watch=false --browsers=ChromeHeadlessCI --progress=false && cd ../server && yarn test && yarn test:e2e",
     "format": "prettier --write --html-whitespace-sensitivity ignore",
     "lint:server": "cd server && yarn lint --fix",

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-admin.json


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-shop.json


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema.json


+ 21 - 20
server/dev-config.ts

@@ -1,6 +1,6 @@
 import path from 'path';
 
-import { API_PATH, API_PORT } from '../shared/shared-constants';
+import { ADMIN_API_PATH, API_PORT, SHOP_API_PATH } from '../shared/shared-constants';
 
 import { examplePaymentHandler } from './src/config/payment-method/example-payment-method-config';
 import { OrderProcessOptions, VendureConfig } from './src/config/vendure-config';
@@ -17,30 +17,31 @@ export const devConfig: VendureConfig = {
     authOptions: {
         disableAuth: false,
         sessionSecret: 'some-secret',
+        requireVerification: false,
     },
     port: API_PORT,
-    apiPath: API_PATH,
+    adminApiPath: ADMIN_API_PATH,
+    shopApiPath: SHOP_API_PATH,
     dbConnectionOptions: {
-        synchronize: true,
+        synchronize: false,
         logging: false,
 
-        // type: 'mysql',
-        // host: '192.168.99.100',
-        // port: 3306,
-        // username: 'root',
-        // password: '',
-        // database: 'vendure-dev',
+        type: 'mysql',
+        host: '192.168.99.100',
+        port: 3306,
+        username: 'root',
+        password: '',
+        database: 'vendure-dev',
 
-        // type: 'sqljs',
-        // database: new Uint8Array([]),
-        // location:  path.join(__dirname, 'vendure.sqlite'),
+        // type: 'sqlite',
+        // database:  path.join(__dirname, 'vendure.sqlite'),
 
-        type: 'postgres',
-        host: '127.0.0.1',
-        port: 5432,
-        username: 'postgres',
-        password: 'Be70',
-        database: 'vendure',
+        // type: 'postgres',
+        // host: '127.0.0.1',
+        // port: 5432,
+        // username: 'postgres',
+        // password: 'Be70',
+        // database: 'vendure',
     },
     orderProcessOptions: {} as OrderProcessOptions<any>,
     paymentOptions: {
@@ -64,11 +65,11 @@ export const devConfig: VendureConfig = {
         new DefaultAssetServerPlugin({
             route: 'assets',
             assetUploadDir: path.join(__dirname, 'assets'),
-            port: 3002,
+            port: 5002,
         }),
         new DefaultSearchPlugin(),
         new AdminUiPlugin({
-            port: 3001,
+            port: 5001,
         }),
     ],
 };

+ 2 - 2
server/e2e/administrator.e2e-spec.ts

@@ -15,12 +15,12 @@ import {
 } from '../../shared/generated-types';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './test-utils';
 
 describe('Administrator resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
     let createdAdmin: Administrator.Fragment;
 

+ 6 - 330
server/e2e/auth.e2e-spec.ts

@@ -18,43 +18,23 @@ import {
     CreateRole,
     LoginMutationArgs,
     Permission,
-    RegisterCustomerInput,
     UpdateProductMutationArgs,
 } from '../../shared/generated-types';
 import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '../../shared/shared-constants';
-import { NoopEmailGenerator } from '../src/config/email/noop-email-generator';
-import { defaultEmailTypes } from '../src/email/default-email-types';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
-import { assertThrowsWithMessage } from './test-utils';
-
-let sendEmailFn: jest.Mock;
-const emailOptions = {
-    emailTemplatePath: 'src/email/templates',
-    emailTypes: defaultEmailTypes,
-    generator: new NoopEmailGenerator(),
-    transport: {
-        type: 'testing' as 'testing',
-        onSend: ctx => sendEmailFn(ctx),
-    },
-};
 
 describe('Authorization & permissions', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                emailOptions,
-            },
-        );
+        const token = await server.init({
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
         await client.init();
     }, TEST_SETUP_TIMEOUT_MS);
 
@@ -145,147 +125,6 @@ describe('Authorization & permissions', () => {
         });
     });
 
-    describe('customer account creation', () => {
-        const password = 'password';
-        const emailAddress = 'test1@test.com';
-        let verificationToken: string;
-
-        beforeEach(() => {
-            sendEmailFn = jest.fn();
-        });
-
-        it(
-            'errors if a password is provided',
-            assertThrowsWithMessage(async () => {
-                const input: RegisterCustomerInput = {
-                    firstName: 'Sofia',
-                    lastName: 'Green',
-                    emailAddress: 'sofia.green@test.com',
-                    password: 'test',
-                };
-                const result = await client.query(REGISTER_ACCOUNT, { input });
-            }, 'Do not provide a password when `authOptions.requireVerification` is set to "true"'),
-        );
-
-        it('register a new account', async () => {
-            const verificationTokenPromise = getVerificationTokenPromise();
-            const input: RegisterCustomerInput = {
-                firstName: 'Sean',
-                lastName: 'Tester',
-                emailAddress,
-            };
-            const result = await client.query(REGISTER_ACCOUNT, { input });
-
-            verificationToken = await verificationTokenPromise;
-
-            expect(result.registerCustomerAccount).toBe(true);
-            expect(sendEmailFn).toHaveBeenCalled();
-            expect(verificationToken).toBeDefined();
-        });
-
-        it('issues a new token if attempting to register a second time', async () => {
-            const sendEmail = new Promise<string>(resolve => {
-                sendEmailFn.mockImplementation(ctx => {
-                    resolve(ctx.event.user.verificationToken);
-                });
-            });
-            const input: RegisterCustomerInput = {
-                firstName: 'Sean',
-                lastName: 'Tester',
-                emailAddress,
-            };
-            const result = await client.query(REGISTER_ACCOUNT, { input });
-
-            const newVerificationToken = await sendEmail;
-
-            expect(result.registerCustomerAccount).toBe(true);
-            expect(sendEmailFn).toHaveBeenCalled();
-            expect(newVerificationToken).not.toBe(verificationToken);
-
-            verificationToken = newVerificationToken;
-        });
-
-        it('refreshCustomerVerification issues a new token', async () => {
-            const sendEmail = new Promise<string>(resolve => {
-                sendEmailFn.mockImplementation(ctx => {
-                    resolve(ctx.event.user.verificationToken);
-                });
-            });
-            const result = await client.query(REFRESH_TOKEN, { emailAddress });
-
-            const newVerificationToken = await sendEmail;
-
-            expect(result.refreshCustomerVerification).toBe(true);
-            expect(sendEmailFn).toHaveBeenCalled();
-            expect(newVerificationToken).not.toBe(verificationToken);
-
-            verificationToken = newVerificationToken;
-        });
-
-        it('refreshCustomerVerification does nothing with an unrecognized emailAddress', async () => {
-            const result = await client.query(REFRESH_TOKEN, {
-                emailAddress: 'never-been-registered@test.com',
-            });
-            await waitForSendEmailFn();
-            expect(result.refreshCustomerVerification).toBe(true);
-            expect(sendEmailFn).not.toHaveBeenCalled();
-        });
-
-        it('login fails before verification', async () => {
-            try {
-                await client.asUserWithCredentials(emailAddress, '');
-                fail('should have thrown');
-            } catch (err) {
-                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
-            }
-        });
-
-        it(
-            'verification fails with wrong token',
-            assertThrowsWithMessage(
-                () =>
-                    client.query(VERIFY_EMAIL, {
-                        password,
-                        token: 'bad-token',
-                    }),
-                `Verification token not recognized`,
-            ),
-        );
-
-        it('verification succeeds with correct token', async () => {
-            const result = await client.query(VERIFY_EMAIL, {
-                password,
-                token: verificationToken,
-            });
-
-            expect(result.verifyCustomerAccount.user.identifier).toBe('test1@test.com');
-        });
-
-        it('registration silently fails if attempting to register an email already verified', async () => {
-            const input: RegisterCustomerInput = {
-                firstName: 'Dodgy',
-                lastName: 'Hacker',
-                emailAddress,
-            };
-            const result = await client.query(REGISTER_ACCOUNT, { input });
-            await waitForSendEmailFn();
-            expect(result.registerCustomerAccount).toBe(true);
-            expect(sendEmailFn).not.toHaveBeenCalled();
-        });
-
-        it(
-            'verification fails if attempted a second time',
-            assertThrowsWithMessage(
-                () =>
-                    client.query(VERIFY_EMAIL, {
-                        password,
-                        token: verificationToken,
-                    }),
-                `Verification token not recognized`,
-            ),
-        );
-    });
-
     async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
         try {
             const status = await client.queryStatus(operation, variables);
@@ -351,167 +190,4 @@ describe('Authorization & permissions', () => {
             password,
         };
     }
-
-    /**
-     * A "sleep" function which allows the sendEmailFn time to get called.
-     */
-    function waitForSendEmailFn() {
-        return new Promise(resolve => setTimeout(resolve, 10));
-    }
-});
-
-describe('Expiring registration token', () => {
-    const client = new TestClient();
-    const server = new TestServer();
-
-    beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                emailOptions,
-                authOptions: {
-                    verificationTokenDuration: '1ms',
-                },
-            },
-        );
-        await client.init();
-    }, TEST_SETUP_TIMEOUT_MS);
-
-    beforeEach(() => {
-        sendEmailFn = jest.fn();
-    });
-
-    afterAll(async () => {
-        await server.destroy();
-    });
-
-    it(
-        'attempting to verify after token has expired throws',
-        assertThrowsWithMessage(async () => {
-            const verificationTokenPromise = getVerificationTokenPromise();
-            const input: RegisterCustomerInput = {
-                firstName: 'Barry',
-                lastName: 'Wallace',
-                emailAddress: 'barry.wallace@test.com',
-            };
-            const result = await client.query(REGISTER_ACCOUNT, { input });
-
-            const verificationToken = await verificationTokenPromise;
-
-            expect(result.registerCustomerAccount).toBe(true);
-            expect(sendEmailFn).toHaveBeenCalledTimes(1);
-            expect(verificationToken).toBeDefined();
-
-            await new Promise(resolve => setTimeout(resolve, 3));
-
-            return client.query(VERIFY_EMAIL, {
-                password: 'test',
-                token: verificationToken,
-            });
-        }, `Verification token has expired. Use refreshCustomerVerification to send a new token.`),
-    );
 });
-
-describe('Registration without email verification', () => {
-    const client = new TestClient();
-    const server = new TestServer();
-    const userEmailAddress = 'glen.beardsley@test.com';
-
-    beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                emailOptions,
-                authOptions: {
-                    requireVerification: false,
-                },
-            },
-        );
-        await client.init();
-    }, TEST_SETUP_TIMEOUT_MS);
-
-    beforeEach(() => {
-        sendEmailFn = jest.fn();
-    });
-
-    afterAll(async () => {
-        await server.destroy();
-    });
-
-    it(
-        'errors if no password is provided',
-        assertThrowsWithMessage(async () => {
-            const input: RegisterCustomerInput = {
-                firstName: 'Glen',
-                lastName: 'Beardsley',
-                emailAddress: userEmailAddress,
-            };
-            const result = await client.query(REGISTER_ACCOUNT, { input });
-        }, 'A password must be provided when `authOptions.requireVerification` is set to "false"'),
-    );
-
-    it('register a new account with password', async () => {
-        const input: RegisterCustomerInput = {
-            firstName: 'Glen',
-            lastName: 'Beardsley',
-            emailAddress: userEmailAddress,
-            password: 'test',
-        };
-        const result = await client.query(REGISTER_ACCOUNT, { input });
-
-        expect(result.registerCustomerAccount).toBe(true);
-        expect(sendEmailFn).not.toHaveBeenCalled();
-    });
-
-    it('can login after registering', async () => {
-        await client.asUserWithCredentials(userEmailAddress, 'test');
-
-        const result = await client.query(
-            gql`
-                query {
-                    me {
-                        identifier
-                    }
-                }
-            `,
-        );
-        expect(result.me.identifier).toBe(userEmailAddress);
-    });
-});
-
-function getVerificationTokenPromise(): Promise<string> {
-    return new Promise<string>(resolve => {
-        sendEmailFn.mockImplementation(ctx => {
-            resolve(ctx.event.user.verificationToken);
-        });
-    });
-}
-
-const REGISTER_ACCOUNT = gql`
-    mutation Register($input: RegisterCustomerInput!) {
-        registerCustomerAccount(input: $input)
-    }
-`;
-
-const VERIFY_EMAIL = gql`
-    mutation Verify($password: String!, $token: String!) {
-        verifyCustomerAccount(password: $password, token: $token) {
-            user {
-                id
-                identifier
-            }
-        }
-    }
-`;
-
-const REFRESH_TOKEN = gql`
-    mutation RefreshToken($emailAddress: String!) {
-        refreshCustomerVerification(emailAddress: $emailAddress)
-    }
-`;

+ 3 - 2
server/e2e/config/test-config.ts

@@ -1,6 +1,6 @@
 import path from 'path';
 
-import { API_PATH } from '../../../shared/shared-constants';
+import { ADMIN_API_PATH, SHOP_API_PATH } from '../../../shared/shared-constants';
 import { DefaultAssetNamingStrategy } from '../../src/config/asset-naming-strategy/default-asset-naming-strategy';
 import { VendureConfig } from '../../src/config/vendure-config';
 
@@ -20,7 +20,8 @@ export const TEST_SETUP_TIMEOUT_MS = 120000;
  */
 export const testConfig: VendureConfig = {
     port: 3050,
-    apiPath: API_PATH,
+    adminApiPath: ADMIN_API_PATH,
+    shopApiPath: SHOP_API_PATH,
     cors: true,
     defaultChannelToken: 'e2e-default-channel',
     authOptions: {

+ 2 - 11
server/e2e/country.e2e-spec.ts

@@ -3,7 +3,6 @@ import path from 'path';
 
 import {
     CREATE_COUNTRY,
-    GET_AVAILABLE_COUNTRIES,
     GET_COUNTRY,
     GET_COUNTRY_LIST,
     UPDATE_COUNTRY,
@@ -11,7 +10,6 @@ import {
 import {
     CreateCountry,
     DeletionResult,
-    GetAvailableCountries,
     GetCountry,
     GetCountryList,
     LanguageCode,
@@ -19,13 +17,13 @@ import {
 } from '../../shared/generated-types';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Facet resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
     let countries: GetCountryList.Items[];
     let GB: GetCountryList.Items;
@@ -71,13 +69,6 @@ describe('Facet resolver', () => {
         expect(result.updateCountry.enabled).toBe(false);
     });
 
-    it('availableCountries returns enabled countries', async () => {
-        const result = await client.query<GetAvailableCountries.Query>(GET_AVAILABLE_COUNTRIES);
-
-        expect(result.availableCountries.length).toBe(countries.length - 1);
-        expect(result.availableCountries.find(c => c.id === AT.id)).toBeUndefined();
-    });
-
     it('createCountry', async () => {
         const result = await client.query<CreateCountry.Mutation, CreateCountry.Variables>(CREATE_COUNTRY, {
             input: {

+ 25 - 26
server/e2e/customer.e2e-spec.ts

@@ -19,14 +19,15 @@ import {
 import { omit } from '../../shared/omit';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient, TestShopClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './test-utils';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Customer resolver', () => {
-    const client = new TestClient();
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
     const server = new TestServer();
     let firstCustomer: GetCustomerList.Items;
     let secondCustomer: GetCustomerList.Items;
@@ -37,7 +38,7 @@ describe('Customer resolver', () => {
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             customerCount: 5,
         });
-        await client.init();
+        await adminClient.init();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -45,7 +46,7 @@ describe('Customer resolver', () => {
     });
 
     it('customers list', async () => {
-        const result = await client.query<GetCustomerList.Query, GetCustomerList.Variables>(
+        const result = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
             GET_CUSTOMER_LIST,
         );
 
@@ -63,7 +64,7 @@ describe('Customer resolver', () => {
             'createCustomerAddress throws on invalid countryCode',
             assertThrowsWithMessage(
                 () =>
-                    client.query(CREATE_ADDRESS, {
+                    adminClient.query(CREATE_ADDRESS, {
                         id: firstCustomer.id,
                         input: {
                             streetLine1: 'streetLine1',
@@ -75,7 +76,7 @@ describe('Customer resolver', () => {
         );
 
         it('createCustomerAddress creates a new address', async () => {
-            const result = await client.query(CREATE_ADDRESS, {
+            const result = await adminClient.query(CREATE_ADDRESS, {
                 id: firstCustomer.id,
                 input: {
                     fullName: 'fullName',
@@ -110,7 +111,7 @@ describe('Customer resolver', () => {
         });
 
         it('customer query returns addresses', async () => {
-            const result = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: firstCustomer.id,
             });
 
@@ -120,7 +121,7 @@ describe('Customer resolver', () => {
 
         it('updateCustomerAddress allows only a single default address', async () => {
             // set the first customer's second address to be default
-            const result1 = await client.query(UPDATE_ADDRESS, {
+            const result1 = await adminClient.query(UPDATE_ADDRESS, {
                 input: {
                     id: firstCustomerAddressIds[1],
                     defaultShippingAddress: true,
@@ -131,14 +132,14 @@ describe('Customer resolver', () => {
             expect(result1.updateCustomerAddress.defaultBillingAddress).toBe(true);
 
             // assert the first customer's first address is not default
-            const result2 = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            const result2 = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: firstCustomer.id,
             });
             expect(result2.customer!.addresses![0].defaultShippingAddress).toBe(false);
             expect(result2.customer!.addresses![0].defaultBillingAddress).toBe(false);
 
             // set the first customer's first address to be default
-            const result3 = await client.query(UPDATE_ADDRESS, {
+            const result3 = await adminClient.query(UPDATE_ADDRESS, {
                 input: {
                     id: firstCustomerAddressIds[0],
                     defaultShippingAddress: true,
@@ -149,20 +150,20 @@ describe('Customer resolver', () => {
             expect(result3.updateCustomerAddress.defaultBillingAddress).toBe(true);
 
             // assert the first customer's second address is not default
-            const result4 = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            const result4 = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: firstCustomer.id,
             });
             expect(result4.customer!.addresses![1].defaultShippingAddress).toBe(false);
             expect(result4.customer!.addresses![1].defaultBillingAddress).toBe(false);
 
             // get the second customer's address id
-            const result5 = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            const result5 = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: secondCustomer.id,
             });
             const secondCustomerAddressId = result5.customer!.addresses![0].id;
 
             // set the second customer's address to be default
-            const result6 = await client.query(UPDATE_ADDRESS, {
+            const result6 = await adminClient.query(UPDATE_ADDRESS, {
                 input: {
                     id: secondCustomerAddressId,
                     defaultShippingAddress: true,
@@ -173,7 +174,7 @@ describe('Customer resolver', () => {
             expect(result6.updateCustomerAddress.defaultBillingAddress).toBe(true);
 
             // assets the first customer's address defaults are unchanged
-            const result7 = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            const result7 = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: firstCustomer.id,
             });
             expect(result7.customer!.addresses![0].defaultShippingAddress).toBe(true);
@@ -183,7 +184,7 @@ describe('Customer resolver', () => {
         });
 
         it('createCustomerAddress with true defaults unsets existing defaults', async () => {
-            const result1 = await client.query(CREATE_ADDRESS, {
+            const result1 = await adminClient.query(CREATE_ADDRESS, {
                 id: firstCustomer.id,
                 input: {
                     streetLine1: 'new default streetline',
@@ -209,7 +210,7 @@ describe('Customer resolver', () => {
                 defaultBillingAddress: true,
             });
 
-            const result2 = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            const result2 = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: firstCustomer.id,
             });
             expect(result2.customer!.addresses![0].defaultShippingAddress).toBe(false);
@@ -224,16 +225,14 @@ describe('Customer resolver', () => {
     describe('orders', () => {
         it(`lists that user\'s orders`, async () => {
             // log in as first customer
-            await client.asUserWithCredentials(firstCustomer.emailAddress, 'test');
+            await shopClient.asUserWithCredentials(firstCustomer.emailAddress, 'test');
             // add an item to the order to create an order
-            const result1 = await client.query(ADD_ITEM_TO_ORDER, {
+            const result1 = await shopClient.query(ADD_ITEM_TO_ORDER, {
                 productVariantId: 'T_1',
                 quantity: 1,
             });
 
-            await client.asSuperAdmin();
-
-            const result2 = await client.query(GET_CUSTOMER_ORDERS, { id: firstCustomer.id });
+            const result2 = await adminClient.query(GET_CUSTOMER_ORDERS, { id: firstCustomer.id });
 
             expect(result2.customer.orders.totalItems).toBe(1);
             expect(result2.customer.orders.items[0].id).toBe(result1.addItemToOrder.id);
@@ -242,13 +241,13 @@ describe('Customer resolver', () => {
 
     describe('deletion', () => {
         it('deletes a customer', async () => {
-            const result = await client.query(DELETE_CUSTOMER, { id: thirdCustomer.id });
+            const result = await adminClient.query(DELETE_CUSTOMER, { id: thirdCustomer.id });
 
             expect(result.deleteCustomer).toEqual({ result: DeletionResult.DELETED });
         });
 
         it('cannot get a deleted customer', async () => {
-            const result = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: thirdCustomer.id,
             });
 
@@ -256,7 +255,7 @@ describe('Customer resolver', () => {
         });
 
         it('deleted customer omitted from list', async () => {
-            const result = await client.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            const result = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
                 GET_CUSTOMER_LIST,
             );
 
@@ -267,7 +266,7 @@ describe('Customer resolver', () => {
             'updateCustomer throws for deleted customer',
             assertThrowsWithMessage(
                 () =>
-                    client.query<UpdateCustomer.Mutation, UpdateCustomer.Variables>(UPDATE_CUSTOMER, {
+                    adminClient.query<UpdateCustomer.Mutation, UpdateCustomer.Variables>(UPDATE_CUSTOMER, {
                         input: {
                             id: thirdCustomer.id,
                             firstName: 'updated',
@@ -281,7 +280,7 @@ describe('Customer resolver', () => {
             'createCustomerAddress throws for deleted customer',
             assertThrowsWithMessage(
                 () =>
-                    client.query<CreateCustomerAddress.Mutation, CreateCustomerAddress.Variables>(
+                    adminClient.query<CreateCustomerAddress.Mutation, CreateCustomerAddress.Variables>(
                         CREATE_CUSTOMER_ADDRESS,
                         {
                             customerId: thirdCustomer.id,

+ 103 - 0
server/e2e/default-search-plugin.e2e-spec.ts

@@ -0,0 +1,103 @@
+import path from 'path';
+
+import { SEARCH_PRODUCTS } from '../../admin-ui/src/app/data/definitions/product-definitions';
+import { SearchProducts } from '../../shared/generated-types';
+import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
+import { DefaultSearchPlugin } from '../src/plugin';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Default search plugin', () => {
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+                customerCount: 1,
+            },
+            {
+                plugins: [new DefaultSearchPlugin()],
+            },
+        );
+        await adminClient.init();
+        await shopClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    async function testGroupByProduct(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                groupByProduct: true,
+            },
+        });
+        expect(result.search.totalItems).toBe(20);
+    }
+
+    async function testNoGrouping(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                groupByProduct: false,
+            },
+        });
+        expect(result.search.totalItems).toBe(34);
+    }
+
+    async function testMatchSearchTerm(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                term: 'camera',
+                groupByProduct: true,
+            },
+        });
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Instant Camera',
+            'Camera Lens',
+            'SLR Camera',
+        ]);
+    }
+
+    async function testMatchFacetIds(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                facetIds: ['T_1', 'T_2'],
+                groupByProduct: true,
+            },
+        });
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Laptop',
+            'Curvy Monitor',
+            'Gaming PC',
+            'Hard Drive',
+            'Clacky Keyboard',
+            'USB Cable',
+        ]);
+    }
+
+    describe('admin api', () => {
+        it('group by product', () => testGroupByProduct(adminClient));
+
+        it('no grouping', () => testNoGrouping(adminClient));
+
+        it('matches search term', () => testMatchSearchTerm(adminClient));
+
+        it('matches by facetId', () => testMatchFacetIds(adminClient));
+    });
+
+    describe('shop api', () => {
+        it('group by product', () => testGroupByProduct(shopClient));
+
+        it('no grouping', () => testNoGrouping(shopClient));
+
+        it('matches search term', () => testMatchSearchTerm(shopClient));
+
+        it('matches by facetId', () => testMatchFacetIds(shopClient));
+    });
+});

+ 2 - 2
server/e2e/facet.e2e-spec.ts

@@ -32,13 +32,13 @@ import {
 } from '../../shared/generated-types';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Facet resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
     let brandFacet: FacetWithValues.Fragment;
     let speakerTypeFacet: FacetWithValues.Fragment;

+ 94 - 0
server/e2e/fixtures/test-plugins.ts

@@ -0,0 +1,94 @@
+import { Query, Resolver } from '@nestjs/graphql';
+import gql from 'graphql-tag';
+
+import { LanguageCode } from '../../src';
+import { APIExtensionDefinition, InjectorFn, VendureConfig, VendurePlugin } from '../../src/config';
+import { ConfigService } from '../../src/config/config.service';
+
+export class TestAPIExtensionPlugin implements VendurePlugin {
+    extendShopAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [TestShopPluginResolver],
+            schema: gql`
+                extend type Query {
+                    baz: [String]!
+                }
+            `,
+        };
+    }
+
+    extendAdminAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [TestAdminPluginResolver],
+            schema: gql`
+                extend type Query {
+                    foo: [String]!
+                }
+            `,
+        };
+    }
+}
+
+@Resolver()
+export class TestAdminPluginResolver {
+    @Query()
+    foo() {
+        return ['bar'];
+    }
+}
+
+@Resolver()
+export class TestShopPluginResolver {
+    @Query()
+    baz() {
+        return ['quux'];
+    }
+}
+
+export class TestPluginWithProvider implements VendurePlugin {
+    extendShopAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [TestResolverWithInjection],
+            schema: gql`
+                extend type Query {
+                    names: [String]!
+                }
+            `,
+        };
+    }
+
+    defineProviders() {
+        return [NameService];
+    }
+}
+
+export class NameService {
+    getNames(): string[] {
+        return ['seon', 'linda', 'hong'];
+    }
+}
+
+@Resolver()
+export class TestResolverWithInjection {
+    constructor(private nameService: NameService) {}
+
+    @Query()
+    names() {
+        return this.nameService.getNames();
+    }
+}
+
+export class TestPluginWithConfigAndBootstrap implements VendurePlugin {
+    constructor(private boostrapWasCalled: (arg: any) => void) {}
+
+    configure(config: Required<VendureConfig>): Required<VendureConfig> {
+        // tslint:disable-next-line:no-non-null-assertion
+        config.defaultLanguageCode = LanguageCode.zh;
+        return config;
+    }
+
+    onBootstrap(inject: InjectorFn) {
+        const configService = inject(ConfigService);
+        this.boostrapWasCalled(configService);
+    }
+}

+ 2 - 2
server/e2e/import.e2e-spec.ts

@@ -2,11 +2,11 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 
 describe('Import resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
 
     beforeAll(async () => {

+ 38 - 803
server/e2e/order.e2e-spec.ts

@@ -1,835 +1,70 @@
+/* tslint:disable:no-non-null-assertion */
 import gql from 'graphql-tag';
 import path from 'path';
 
-import {
-    GET_CUSTOMER,
-    GET_CUSTOMER_LIST,
-} from '../../admin-ui/src/app/data/definitions/customer-definitions';
-import { CreateAddressInput, GetCustomer, GetCustomerList } from '../../shared/generated-types';
-import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
+import { GET_CUSTOMER_LIST } from '../../admin-ui/src/app/data/definitions/customer-definitions';
+import { GET_ORDER, GET_ORDERS_LIST } from '../../admin-ui/src/app/data/definitions/order-definitions';
+import { GetCustomerList, GetOrder, GetOrderList } from '../../shared/generated-types';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient, TestShopClient } from './test-client';
 import { TestServer } from './test-server';
-import { assertThrowsWithMessage } from './test-utils';
 
-describe('Orders', () => {
-    const client = new TestClient();
+describe('Orders resolver', () => {
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
     const server = new TestServer();
+    let customers: GetCustomerList.Items[];
+    const password = 'test';
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-                customerCount: 2,
-            },
+        const token = await server.init({
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 2,
+        });
+        await adminClient.init();
+
+        // Create a couple of orders to be queried
+        const result = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            GET_CUSTOMER_LIST,
             {
-                paymentOptions: {
-                    paymentMethodHandlers: [testPaymentMethod, testFailingPaymentMethod],
+                options: {
+                    take: 2,
                 },
             },
         );
-        await client.init();
+        customers = result.customers.items;
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, password);
+        await shopClient.query(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 1,
+        });
+        await shopClient.asUserWithCredentials(customers[1].emailAddress, password);
+        await shopClient.query(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_2',
+            quantity: 1,
+        });
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
         await server.destroy();
     });
 
-    describe('as anonymous user', () => {
-        let firstOrderItemId: string;
-        let createdCustomerId: string;
-        let orderCode: string;
-
-        beforeAll(async () => {
-            await client.asAnonymousUser();
-        });
-
-        it('addItemToOrder starts with no session token', () => {
-            expect(client.getAuthToken()).toBe('');
-        });
-
-        it('activeOrder returns null before any items have been added', async () => {
-            const result = await client.query(GET_ACTIVE_ORDER);
-            expect(result.activeOrder).toBeNull();
-        });
-
-        it('activeOrder creates an anonymous session', () => {
-            expect(client.getAuthToken()).not.toBe('');
-        });
-
-        it('addItemToOrder creates a new Order with an item', async () => {
-            const result = await client.query(ADD_ITEM_TO_ORDER, {
-                productVariantId: 'T_1',
-                quantity: 1,
-            });
-
-            expect(result.addItemToOrder.lines.length).toBe(1);
-            expect(result.addItemToOrder.lines[0].quantity).toBe(1);
-            expect(result.addItemToOrder.lines[0].productVariant.id).toBe('T_1');
-            expect(result.addItemToOrder.lines[0].id).toBe('T_1');
-            firstOrderItemId = result.addItemToOrder.lines[0].id;
-            orderCode = result.addItemToOrder.code;
-        });
-
-        it(
-            'addItemToOrder errors with an invalid productVariantId',
-            assertThrowsWithMessage(
-                () =>
-                    client.query(ADD_ITEM_TO_ORDER, {
-                        productVariantId: 'T_999',
-                        quantity: 1,
-                    }),
-                `No ProductVariant with the id '999' could be found`,
-            ),
-        );
-
-        it(
-            'addItemToOrder errors with a negative quantity',
-            assertThrowsWithMessage(
-                () =>
-                    client.query(ADD_ITEM_TO_ORDER, {
-                        productVariantId: 'T_999',
-                        quantity: -3,
-                    }),
-                `-3 is not a valid quantity for an OrderItem`,
-            ),
-        );
-
-        it('addItemToOrder with an existing productVariantId adds quantity to the existing OrderLine', async () => {
-            const result = await client.query(ADD_ITEM_TO_ORDER, {
-                productVariantId: 'T_1',
-                quantity: 2,
-            });
-
-            expect(result.addItemToOrder.lines.length).toBe(1);
-            expect(result.addItemToOrder.lines[0].quantity).toBe(3);
-        });
-
-        it('adjustItemQuantity adjusts the quantity', async () => {
-            const result = await client.query(ADJUST_ITEM_QUENTITY, {
-                orderItemId: firstOrderItemId,
-                quantity: 50,
-            });
-
-            expect(result.adjustItemQuantity.lines.length).toBe(1);
-            expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
-        });
-
-        it(
-            'adjustItemQuantity errors with a negative quantity',
-            assertThrowsWithMessage(
-                () =>
-                    client.query(ADJUST_ITEM_QUENTITY, {
-                        orderItemId: firstOrderItemId,
-                        quantity: -3,
-                    }),
-                `-3 is not a valid quantity for an OrderItem`,
-            ),
-        );
-
-        it(
-            'adjustItemQuantity errors with an invalid orderItemId',
-            assertThrowsWithMessage(
-                () =>
-                    client.query(ADJUST_ITEM_QUENTITY, {
-                        orderItemId: 'T_999',
-                        quantity: 5,
-                    }),
-                `This order does not contain an OrderLine with the id 999`,
-            ),
-        );
-
-        it('removeItemFromOrder removes the correct item', async () => {
-            const result1 = await client.query(ADD_ITEM_TO_ORDER, {
-                productVariantId: 'T_3',
-                quantity: 3,
-            });
-            expect(result1.addItemToOrder.lines.length).toBe(2);
-            expect(result1.addItemToOrder.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
-
-            const result2 = await client.query(REMOVE_ITEM_FROM_ORDER, {
-                orderItemId: firstOrderItemId,
-            });
-            expect(result2.removeItemFromOrder.lines.length).toBe(1);
-            expect(result2.removeItemFromOrder.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
-        });
-
-        it(
-            'removeItemFromOrder errors with an invalid orderItemId',
-            assertThrowsWithMessage(
-                () =>
-                    client.query(REMOVE_ITEM_FROM_ORDER, {
-                        orderItemId: 'T_999',
-                    }),
-                `This order does not contain an OrderLine with the id 999`,
-            ),
-        );
-
-        it('nextOrderStates returns next valid states', async () => {
-            const result = await client.query(gql`
-                query {
-                    nextOrderStates
-                }
-            `);
-
-            expect(result.nextOrderStates).toEqual(['ArrangingPayment']);
-        });
-
-        it(
-            'transitionOrderToState throws for an invalid state',
-            assertThrowsWithMessage(
-                () => client.query(TRANSITION_TO_STATE, { state: 'Completed' }),
-                `Cannot transition Order from "AddingItems" to "Completed"`,
-            ),
-        );
-
-        it(
-            'attempting to transition to ArrangingPayment throws when Order has no Customer',
-            assertThrowsWithMessage(
-                () => client.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' }),
-                `Cannot transition Order to the "ArrangingPayment" state without Customer details`,
-            ),
-        );
-
-        it('setCustomerForOrder creates a new Customer and associates it with the Order', async () => {
-            const result = await client.query(SET_CUSTOMER, {
-                input: {
-                    emailAddress: 'test@test.com',
-                    firstName: 'Test',
-                    lastName: 'Person',
-                },
-            });
-
-            const customer = result.setCustomerForOrder.customer;
-            expect(customer.firstName).toBe('Test');
-            expect(customer.lastName).toBe('Person');
-            expect(customer.emailAddress).toBe('test@test.com');
-            createdCustomerId = customer.id;
-        });
-
-        it('setCustomerForOrder updates the existing customer if Customer already set', async () => {
-            const result = await client.query(SET_CUSTOMER, {
-                input: {
-                    emailAddress: 'test@test.com',
-                    firstName: 'Changed',
-                    lastName: 'Person',
-                },
-            });
-
-            const customer = result.setCustomerForOrder.customer;
-            expect(customer.firstName).toBe('Changed');
-            expect(customer.lastName).toBe('Person');
-            expect(customer.emailAddress).toBe('test@test.com');
-            expect(customer.id).toBe(createdCustomerId);
-        });
-
-        it('setOrderShippingAddress sets shipping address', async () => {
-            const address: CreateAddressInput = {
-                fullName: 'name',
-                company: 'company',
-                streetLine1: '12 the street',
-                streetLine2: 'line 2',
-                city: 'foo',
-                province: 'bar',
-                postalCode: '123456',
-                countryCode: 'US',
-                phoneNumber: '4444444',
-            };
-            const result = await client.query(SET_SHIPPING_ADDRESS, {
-                input: address,
-            });
-
-            expect(result.setOrderShippingAddress.shippingAddress).toEqual({
-                fullName: 'name',
-                company: 'company',
-                streetLine1: '12 the street',
-                streetLine2: 'line 2',
-                city: 'foo',
-                province: 'bar',
-                postalCode: '123456',
-                country: 'United States of America',
-                phoneNumber: '4444444',
-            });
-        });
-
-        it('customer default Addresses are not updated before payment', async () => {
-            const result = await client.query(gql`
-                query {
-                    activeOrder {
-                        customer {
-                            addresses {
-                                id
-                            }
-                        }
-                    }
-                }
-            `);
-
-            expect(result.activeOrder.customer.addresses).toEqual([]);
-        });
-
-        it('can transition to ArrangingPayment once Customer has been set', async () => {
-            const result = await client.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
-
-            expect(result.transitionOrderToState).toEqual({ id: 'T_1', state: 'ArrangingPayment' });
-        });
-
-        it('adds a successful payment and transitions Order state', async () => {
-            const result = await client.query(ADD_PAYMENT, {
-                input: {
-                    method: testPaymentMethod.code,
-                    metadata: {},
-                },
-            });
-
-            const payment = result.addPaymentToOrder.payments[0];
-            expect(result.addPaymentToOrder.state).toBe('PaymentSettled');
-            expect(result.addPaymentToOrder.active).toBe(false);
-            expect(result.addPaymentToOrder.payments.length).toBe(1);
-            expect(payment.method).toBe(testPaymentMethod.code);
-            expect(payment.state).toBe('Settled');
-        });
-
-        it('activeOrder is null after payment', async () => {
-            const result = await client.query(GET_ACTIVE_ORDER);
-
-            expect(result.activeOrder).toBeNull();
-        });
-
-        it('customer default Addresses are updated after payment', async () => {
-            await client.asSuperAdmin();
-
-            const result = await client.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
-                id: createdCustomerId,
-            });
-
-            // tslint:disable-next-line:no-non-null-assertion
-            const address = result.customer!.addresses![0];
-            expect(address.streetLine1).toBe('12 the street');
-            expect(address.postalCode).toBe('123456');
-            expect(address.defaultBillingAddress).toBe(true);
-            expect(address.defaultShippingAddress).toBe(true);
-        });
+    it('orders', async () => {
+        const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
+        expect(result.orders.items.map(o => o.id)).toEqual(['T_1', 'T_2']);
     });
 
-    describe('as authenticated user', () => {
-        let firstOrderItemId: string;
-        let activeOrder: any;
-        let authenticatedUserEmailAddress: string;
-        let customers: GetCustomerList.Items[];
-        const password = 'test';
-
-        beforeAll(async () => {
-            await client.asSuperAdmin();
-            const result = await client.query<GetCustomerList.Query, GetCustomerList.Variables>(
-                GET_CUSTOMER_LIST,
-                {
-                    options: {
-                        take: 2,
-                    },
-                },
-            );
-            customers = result.customers.items;
-            authenticatedUserEmailAddress = customers[0].emailAddress;
-            await client.asUserWithCredentials(authenticatedUserEmailAddress, password);
-        });
-
-        it('activeOrder returns null before any items have been added', async () => {
-            const result = await client.query(GET_ACTIVE_ORDER);
-            expect(result.activeOrder).toBeNull();
-        });
-
-        it('addItemToOrder creates a new Order with an item', async () => {
-            const result = await client.query(ADD_ITEM_TO_ORDER, {
-                productVariantId: 'T_1',
-                quantity: 1,
-            });
-
-            expect(result.addItemToOrder.lines.length).toBe(1);
-            expect(result.addItemToOrder.lines[0].quantity).toBe(1);
-            expect(result.addItemToOrder.lines[0].productVariant.id).toBe('T_1');
-            activeOrder = result.addItemToOrder;
-            firstOrderItemId = result.addItemToOrder.lines[0].id;
-        });
-
-        it('activeOrder returns order after item has been added', async () => {
-            const result = await client.query(GET_ACTIVE_ORDER);
-            expect(result.activeOrder.id).toBe(activeOrder.id);
-            expect(result.activeOrder.state).toBe('AddingItems');
-        });
-
-        it('addItemToOrder with an existing productVariantId adds quantity to the existing OrderLine', async () => {
-            const result = await client.query(ADD_ITEM_TO_ORDER, {
-                productVariantId: 'T_1',
-                quantity: 2,
-            });
-
-            expect(result.addItemToOrder.lines.length).toBe(1);
-            expect(result.addItemToOrder.lines[0].quantity).toBe(3);
-        });
-
-        it('adjustItemQuantity adjusts the quantity', async () => {
-            const result = await client.query(ADJUST_ITEM_QUENTITY, {
-                orderItemId: firstOrderItemId,
-                quantity: 50,
-            });
-
-            expect(result.adjustItemQuantity.lines.length).toBe(1);
-            expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
-        });
-
-        it('removeItemFromOrder removes the correct item', async () => {
-            const result1 = await client.query(ADD_ITEM_TO_ORDER, {
-                productVariantId: 'T_3',
-                quantity: 3,
-            });
-            expect(result1.addItemToOrder.lines.length).toBe(2);
-            expect(result1.addItemToOrder.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
-
-            const result2 = await client.query(REMOVE_ITEM_FROM_ORDER, {
-                orderItemId: firstOrderItemId,
-            });
-            expect(result2.removeItemFromOrder.lines.length).toBe(1);
-            expect(result2.removeItemFromOrder.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
-        });
-
-        it('nextOrderStates returns next valid states', async () => {
-            const result = await client.query(GET_NEXT_STATES);
-
-            expect(result.nextOrderStates).toEqual(['ArrangingPayment']);
-        });
-
-        it('logging out and back in again resumes the last active order', async () => {
-            await client.asAnonymousUser();
-            const result1 = await client.query(GET_ACTIVE_ORDER);
-            expect(result1.activeOrder).toBeNull();
-
-            await client.asUserWithCredentials(authenticatedUserEmailAddress, password);
-            const result2 = await client.query(GET_ACTIVE_ORDER);
-            expect(result2.activeOrder.id).toBe(activeOrder.id);
-        });
-
-        describe('shipping', () => {
-            let shippingMethods: any;
-
-            it(
-                'setOrderShippingAddress throws with invalid countryCode',
-                assertThrowsWithMessage(() => {
-                    const address: CreateAddressInput = {
-                        streetLine1: '12 the street',
-                        countryCode: 'INVALID',
-                    };
-
-                    return client.query(SET_SHIPPING_ADDRESS, {
-                        input: address,
-                    });
-                }, `The countryCode "INVALID" was not recognized`),
-            );
-
-            it('setOrderShippingAddress sets shipping address', async () => {
-                const address: CreateAddressInput = {
-                    fullName: 'name',
-                    company: 'company',
-                    streetLine1: '12 the street',
-                    streetLine2: 'line 2',
-                    city: 'foo',
-                    province: 'bar',
-                    postalCode: '123456',
-                    countryCode: 'US',
-                    phoneNumber: '4444444',
-                };
-                const result = await client.query(SET_SHIPPING_ADDRESS, {
-                    input: address,
-                });
-
-                expect(result.setOrderShippingAddress.shippingAddress).toEqual({
-                    fullName: 'name',
-                    company: 'company',
-                    streetLine1: '12 the street',
-                    streetLine2: 'line 2',
-                    city: 'foo',
-                    province: 'bar',
-                    postalCode: '123456',
-                    country: 'United States of America',
-                    phoneNumber: '4444444',
-                });
-            });
-
-            it('eligibleShippingMethods lists shipping methods', async () => {
-                const result = await client.query(GET_ELIGIBLE_SHIPPING_METHODS);
-
-                shippingMethods = result.eligibleShippingMethods;
-
-                expect(shippingMethods).toEqual([
-                    { id: 'T_1', price: 500, description: 'Standard Shipping' },
-                    { id: 'T_2', price: 1000, description: 'Express Shipping' },
-                ]);
-            });
-
-            it('shipping is initially unset', async () => {
-                const result = await client.query(GET_ACTIVE_ORDER);
-
-                expect(result.activeOrder.shipping).toEqual(0);
-                expect(result.activeOrder.shippingMethod).toEqual(null);
-            });
-
-            it('setOrderShippingMethod sets the shipping method', async () => {
-                const result = await client.query(SET_SHIPPING_METHOD, {
-                    id: shippingMethods[1].id,
-                });
-
-                const activeOrderResult = await client.query(GET_ACTIVE_ORDER);
-
-                const order = activeOrderResult.activeOrder;
-
-                expect(order.shipping).toBe(shippingMethods[1].price);
-                expect(order.shippingMethod.id).toBe(shippingMethods[1].id);
-                expect(order.shippingMethod.description).toBe(shippingMethods[1].description);
-            });
-
-            it('shipping method is preserved after adjustItemQuantity', async () => {
-                const activeOrderResult = await client.query(GET_ACTIVE_ORDER);
-                activeOrder = activeOrderResult.activeOrder;
-                const result = await client.query(ADJUST_ITEM_QUENTITY, {
-                    orderItemId: activeOrder.lines[0].id,
-                    quantity: 10,
-                });
-
-                expect(result.adjustItemQuantity.shipping).toBe(shippingMethods[1].price);
-                expect(result.adjustItemQuantity.shippingMethod.id).toBe(shippingMethods[1].id);
-                expect(result.adjustItemQuantity.shippingMethod.description).toBe(
-                    shippingMethods[1].description,
-                );
-            });
-        });
-
-        describe('payment', () => {
-            it(
-                'attempting add a Payment throws error when in AddingItems state',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query(ADD_PAYMENT, {
-                            input: {
-                                method: testPaymentMethod.code,
-                                metadata: {},
-                            },
-                        }),
-                    `A Payment may only be added when Order is in "ArrangingPayment" state`,
-                ),
-            );
-
-            it('transitions to the ArrangingPayment state', async () => {
-                const result = await client.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
-                expect(result.transitionOrderToState).toEqual({
-                    id: activeOrder.id,
-                    state: 'ArrangingPayment',
-                });
-            });
-
-            it(
-                'attempting to add an item throws error when in ArrangingPayment state',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query(ADD_ITEM_TO_ORDER, {
-                            productVariantId: 'T_4',
-                            quantity: 1,
-                        }),
-                    `Order contents may only be modified when in the "AddingItems" state`,
-                ),
-            );
-
-            it(
-                'attempting to modify item quantity throws error when in ArrangingPayment state',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query(ADJUST_ITEM_QUENTITY, {
-                            orderItemId: activeOrder.lines[0].id,
-                            quantity: 12,
-                        }),
-                    `Order contents may only be modified when in the "AddingItems" state`,
-                ),
-            );
-
-            it(
-                'attempting to remove an item throws error when in ArrangingPayment state',
-                assertThrowsWithMessage(
-                    () =>
-                        client.query(REMOVE_ITEM_FROM_ORDER, {
-                            orderItemId: activeOrder.lines[0].id,
-                        }),
-                    `Order contents may only be modified when in the "AddingItems" state`,
-                ),
-            );
-
-            it(
-                'attempting to setOrderShippingMethod throws error when in ArrangingPayment state',
-                assertThrowsWithMessage(async () => {
-                    const shippingMethodsResult = await client.query(GET_ELIGIBLE_SHIPPING_METHODS);
-                    const shippingMethods = shippingMethodsResult.eligibleShippingMethods;
-                    return client.query(SET_SHIPPING_METHOD, {
-                        id: shippingMethods[0].id,
-                    });
-                }, `Order contents may only be modified when in the "AddingItems" state`),
-            );
-
-            it('adds a declined payment', async () => {
-                const result = await client.query(ADD_PAYMENT, {
-                    input: {
-                        method: testFailingPaymentMethod.code,
-                        metadata: {
-                            foo: 'bar',
-                        },
-                    },
-                });
-
-                const payment = result.addPaymentToOrder.payments[0];
-                expect(result.addPaymentToOrder.payments.length).toBe(1);
-                expect(payment.method).toBe(testFailingPaymentMethod.code);
-                expect(payment.state).toBe('Declined');
-                expect(payment.transactionId).toBe(null);
-                expect(payment.metadata).toEqual({
-                    foo: 'bar',
-                });
-            });
-
-            it('adds a successful payment and transitions Order state', async () => {
-                const result = await client.query(ADD_PAYMENT, {
-                    input: {
-                        method: testPaymentMethod.code,
-                        metadata: {
-                            baz: 'quux',
-                        },
-                    },
-                });
-
-                const payment = result.addPaymentToOrder.payments[0];
-                expect(result.addPaymentToOrder.state).toBe('PaymentSettled');
-                expect(result.addPaymentToOrder.active).toBe(false);
-                expect(result.addPaymentToOrder.payments.length).toBe(1);
-                expect(payment.method).toBe(testPaymentMethod.code);
-                expect(payment.state).toBe('Settled');
-                expect(payment.transactionId).toBe('12345');
-                expect(payment.metadata).toEqual({
-                    baz: 'quux',
-                });
-            });
-        });
-
-        describe('orderByCode', () => {
-            describe('immediately after Order is placed', () => {
-                it('works when authenticated', async () => {
-                    const result = await client.query(GET_ORDER_BY_CODE, {
-                        code: activeOrder.code,
-                    });
-
-                    expect(result.orderByCode.id).toBe(activeOrder.id);
-                });
-
-                it('works when anonymous', async () => {
-                    await client.asAnonymousUser();
-                    const result = await client.query(GET_ORDER_BY_CODE, {
-                        code: activeOrder.code,
-                    });
-
-                    expect(result.orderByCode.id).toBe(activeOrder.id);
-                });
-
-                it(
-                    `throws error for another user's Order`,
-                    assertThrowsWithMessage(async () => {
-                        authenticatedUserEmailAddress = customers[1].emailAddress;
-                        await client.asUserWithCredentials(authenticatedUserEmailAddress, password);
-                        return client.query(GET_ORDER_BY_CODE, {
-                            code: activeOrder.code,
-                        });
-                    }, `You are not currently authorized to perform this action`),
-                );
-            });
-        });
+    it('order', async () => {
+        const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, { id: 'T_2' });
+        expect(result.order!.id).toBe('T_2');
     });
 });
 
-const testPaymentMethod = new PaymentMethodHandler({
-    code: 'test-payment-method',
-    name: 'Test Payment Method',
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Settled',
-            transactionId: '12345',
-            metadata,
-        };
-    },
-});
-
-const testFailingPaymentMethod = new PaymentMethodHandler({
-    code: 'test-failing-payment-method',
-    name: 'Test Failing Payment Method',
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Declined',
-            metadata,
-        };
-    },
-});
-
-const TEST_ORDER_FRAGMENT = gql`
-    fragment TestOrderFragment on Order {
-        id
-        code
-        state
-        active
-        lines {
-            id
-            quantity
-            productVariant {
-                id
-            }
-        }
-        shipping
-        shippingMethod {
-            id
-            code
-            description
-        }
-        customer {
-            id
-        }
-    }
-`;
-
-const GET_ACTIVE_ORDER = gql`
-    query {
-        activeOrder {
-            ...TestOrderFragment
-        }
-    }
-    ${TEST_ORDER_FRAGMENT}
-`;
-
 const ADD_ITEM_TO_ORDER = gql`
     mutation AddItemToOrder($productVariantId: ID!, $quantity: Int!) {
         addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
-            ...TestOrderFragment
-        }
-    }
-    ${TEST_ORDER_FRAGMENT}
-`;
-
-const ADJUST_ITEM_QUENTITY = gql`
-    mutation AdjustItemQuantity($orderItemId: ID!, $quantity: Int!) {
-        adjustItemQuantity(orderItemId: $orderItemId, quantity: $quantity) {
-            ...TestOrderFragment
-        }
-    }
-    ${TEST_ORDER_FRAGMENT}
-`;
-
-const REMOVE_ITEM_FROM_ORDER = gql`
-    mutation RemoveItemFromOrder($orderItemId: ID!) {
-        removeItemFromOrder(orderItemId: $orderItemId) {
-            ...TestOrderFragment
-        }
-    }
-    ${TEST_ORDER_FRAGMENT}
-`;
-
-const GET_NEXT_STATES = gql`
-    query {
-        nextOrderStates
-    }
-`;
-
-const TRANSITION_TO_STATE = gql`
-    mutation TransitionToState($state: String!) {
-        transitionOrderToState(state: $state) {
             id
-            state
-        }
-    }
-`;
-
-const GET_ELIGIBLE_SHIPPING_METHODS = gql`
-    query {
-        eligibleShippingMethods {
-            id
-            price
-            description
-        }
-    }
-`;
-
-const SET_SHIPPING_ADDRESS = gql`
-    mutation SetShippingAddress($input: CreateAddressInput!) {
-        setOrderShippingAddress(input: $input) {
-            shippingAddress {
-                fullName
-                company
-                streetLine1
-                streetLine2
-                city
-                province
-                postalCode
-                country
-                phoneNumber
-            }
-        }
-    }
-`;
-
-const SET_SHIPPING_METHOD = gql`
-    mutation SetShippingMethod($id: ID!) {
-        setOrderShippingMethod(shippingMethodId: $id) {
-            shipping
-            shippingMethod {
-                id
-                code
-                description
-            }
-        }
-    }
-`;
-
-const ADD_PAYMENT = gql`
-    mutation AddPaymentToOrder($input: PaymentInput!) {
-        addPaymentToOrder(input: $input) {
-            ...TestOrderFragment
-            payments {
-                id
-                transactionId
-                method
-                amount
-                state
-                metadata
-            }
-        }
-    }
-    ${TEST_ORDER_FRAGMENT}
-`;
-
-const SET_CUSTOMER = gql`
-    mutation SetCustomerForOrder($input: CreateCustomerInput!) {
-        setCustomerForOrder(input: $input) {
-            id
-            customer {
-                id
-                emailAddress
-                firstName
-                lastName
-            }
-        }
-    }
-`;
-
-const GET_ORDER_BY_CODE = gql`
-    query GetOrderByCode($code: String!) {
-        orderByCode(code: $code) {
-            ...TestOrderFragment
         }
     }
-    ${TEST_ORDER_FRAGMENT}
 `;

+ 77 - 0
server/e2e/plugin.e2e-spec.ts

@@ -0,0 +1,77 @@
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { LanguageCode } from '../../shared/generated-types';
+import { ConfigService } from '../src/config/config.service';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import {
+    TestAPIExtensionPlugin,
+    TestPluginWithConfigAndBootstrap,
+    TestPluginWithProvider,
+} from './fixtures/test-plugins';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Plugins', () => {
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+    const bootstrapMockFn = jest.fn();
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+                customerCount: 1,
+            },
+            {
+                plugins: [
+                    new TestPluginWithConfigAndBootstrap(bootstrapMockFn),
+                    new TestAPIExtensionPlugin(),
+                    new TestPluginWithProvider(),
+                ],
+            },
+        );
+        await adminClient.init();
+        await shopClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('can modify the config in configure() and inject in onBootstrap()', () => {
+        expect(bootstrapMockFn).toHaveBeenCalled();
+        const configService: ConfigService = bootstrapMockFn.mock.calls[0][0];
+        expect(configService instanceof ConfigService).toBe(true);
+        expect(configService.defaultLanguageCode).toBe(LanguageCode.zh);
+    });
+
+    it('extends the admin API', async () => {
+        const result = await adminClient.query(gql`
+            query {
+                foo
+            }
+        `);
+        expect(result.foo).toEqual(['bar']);
+    });
+
+    it('extends the shop API', async () => {
+        const result = await shopClient.query(gql`
+            query {
+                baz
+            }
+        `);
+        expect(result.baz).toEqual(['quux']);
+    });
+
+    it('DI works with defined providers', async () => {
+        const result = await shopClient.query(gql`
+            query {
+                names
+            }
+        `);
+        expect(result.names).toEqual(['seon', 'linda', 'hong']);
+    });
+});

+ 2 - 2
server/e2e/product-category.e2e-spec.ts

@@ -21,12 +21,12 @@ import {
 import { ROOT_CATEGORY_NAME } from '../../shared/shared-constants';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './test-utils';
 
 describe('ProductCategory resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
     let assets: GetAssetList.Items[];
     let electronicsCategory: ProductCategory.Fragment;

+ 2 - 2
server/e2e/product.e2e-spec.ts

@@ -30,14 +30,14 @@ import {
 import { omit } from '../../shared/omit';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './test-utils';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Product resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
 
     beforeAll(async () => {

+ 2 - 2
server/e2e/promotion.e2e-spec.ts

@@ -22,14 +22,14 @@ import { PromotionAction, PromotionOrderAction } from '../src/config/promotion/p
 import { PromotionCondition } from '../src/config/promotion/promotion-condition';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './test-utils';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Promotion resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
 
     const promoCondition = generateTestCondition('promo_condition');

+ 2 - 2
server/e2e/role.e2e-spec.ts

@@ -11,12 +11,12 @@ import { omit } from '../../shared/omit';
 import { CUSTOMER_ROLE_CODE, SUPER_ADMIN_ROLE_CODE } from '../../shared/shared-constants';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './test-utils';
 
 describe('Role resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
     let createdRole: Role.Fragment;
     let defaultRoles: Role.Fragment[];

+ 420 - 0
server/e2e/shop-auth.e2e-spec.ts

@@ -0,0 +1,420 @@
+import { DocumentNode } from 'graphql';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import {
+    CREATE_ADMINISTRATOR,
+    CREATE_ROLE,
+} from '../../admin-ui/src/app/data/definitions/administrator-definitions';
+import { RegisterCustomerInput } from '../../shared/generated-shop-types';
+import { CreateAdministrator, CreateRole, Permission } from '../../shared/generated-types';
+import { NoopEmailGenerator } from '../src/config/email/noop-email-generator';
+import { defaultEmailTypes } from '../src/email/default-email-types';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
+
+let sendEmailFn: jest.Mock;
+const emailOptions = {
+    emailTemplatePath: 'src/email/templates',
+    emailTypes: defaultEmailTypes,
+    generator: new NoopEmailGenerator(),
+    transport: {
+        type: 'testing' as 'testing',
+        onSend: ctx => sendEmailFn(ctx),
+    },
+};
+
+describe('Shop auth & accounts', () => {
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
+            },
+            {
+                emailOptions,
+            },
+        );
+        await shopClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('customer account creation', () => {
+        const password = 'password';
+        const emailAddress = 'test1@test.com';
+        let verificationToken: string;
+
+        beforeEach(() => {
+            sendEmailFn = jest.fn();
+        });
+
+        it(
+            'errors if a password is provided',
+            assertThrowsWithMessage(async () => {
+                const input: RegisterCustomerInput = {
+                    firstName: 'Sofia',
+                    lastName: 'Green',
+                    emailAddress: 'sofia.green@test.com',
+                    password: 'test',
+                };
+                const result = await shopClient.query(REGISTER_ACCOUNT, { input });
+            }, 'Do not provide a password when `authOptions.requireVerification` is set to "true"'),
+        );
+
+        it('register a new account', async () => {
+            const verificationTokenPromise = getVerificationTokenPromise();
+            const input: RegisterCustomerInput = {
+                firstName: 'Sean',
+                lastName: 'Tester',
+                emailAddress,
+            };
+            const result = await shopClient.query(REGISTER_ACCOUNT, { input });
+
+            verificationToken = await verificationTokenPromise;
+
+            expect(result.registerCustomerAccount).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalled();
+            expect(verificationToken).toBeDefined();
+        });
+
+        it('issues a new token if attempting to register a second time', async () => {
+            const sendEmail = new Promise<string>(resolve => {
+                sendEmailFn.mockImplementation(ctx => {
+                    resolve(ctx.event.user.verificationToken);
+                });
+            });
+            const input: RegisterCustomerInput = {
+                firstName: 'Sean',
+                lastName: 'Tester',
+                emailAddress,
+            };
+            const result = await shopClient.query(REGISTER_ACCOUNT, { input });
+
+            const newVerificationToken = await sendEmail;
+
+            expect(result.registerCustomerAccount).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalled();
+            expect(newVerificationToken).not.toBe(verificationToken);
+
+            verificationToken = newVerificationToken;
+        });
+
+        it('refreshCustomerVerification issues a new token', async () => {
+            const sendEmail = new Promise<string>(resolve => {
+                sendEmailFn.mockImplementation(ctx => {
+                    resolve(ctx.event.user.verificationToken);
+                });
+            });
+            const result = await shopClient.query(REFRESH_TOKEN, { emailAddress });
+
+            const newVerificationToken = await sendEmail;
+
+            expect(result.refreshCustomerVerification).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalled();
+            expect(newVerificationToken).not.toBe(verificationToken);
+
+            verificationToken = newVerificationToken;
+        });
+
+        it('refreshCustomerVerification does nothing with an unrecognized emailAddress', async () => {
+            const result = await shopClient.query(REFRESH_TOKEN, {
+                emailAddress: 'never-been-registered@test.com',
+            });
+            await waitForSendEmailFn();
+            expect(result.refreshCustomerVerification).toBe(true);
+            expect(sendEmailFn).not.toHaveBeenCalled();
+        });
+
+        it('login fails before verification', async () => {
+            try {
+                await shopClient.asUserWithCredentials(emailAddress, '');
+                fail('should have thrown');
+            } catch (err) {
+                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
+            }
+        });
+
+        it(
+            'verification fails with wrong token',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(VERIFY_EMAIL, {
+                        password,
+                        token: 'bad-token',
+                    }),
+                `Verification token not recognized`,
+            ),
+        );
+
+        it('verification succeeds with correct token', async () => {
+            const result = await shopClient.query(VERIFY_EMAIL, {
+                password,
+                token: verificationToken,
+            });
+
+            expect(result.verifyCustomerAccount.user.identifier).toBe('test1@test.com');
+        });
+
+        it('registration silently fails if attempting to register an email already verified', async () => {
+            const input: RegisterCustomerInput = {
+                firstName: 'Dodgy',
+                lastName: 'Hacker',
+                emailAddress,
+            };
+            const result = await shopClient.query(REGISTER_ACCOUNT, { input });
+            await waitForSendEmailFn();
+            expect(result.registerCustomerAccount).toBe(true);
+            expect(sendEmailFn).not.toHaveBeenCalled();
+        });
+
+        it(
+            'verification fails if attempted a second time',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(VERIFY_EMAIL, {
+                        password,
+                        token: verificationToken,
+                    }),
+                `Verification token not recognized`,
+            ),
+        );
+    });
+
+    async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
+        try {
+            const status = await shopClient.queryStatus(operation, variables);
+            expect(status).toBe(200);
+        } catch (e) {
+            const errorCode = getErrorCode(e);
+            if (!errorCode) {
+                fail(`Unexpected failure: ${e}`);
+            } else {
+                fail(`Operation should be allowed, got status ${getErrorCode(e)}`);
+            }
+        }
+    }
+
+    async function assertRequestForbidden<V>(operation: DocumentNode, variables: V) {
+        try {
+            const status = await shopClient.query(operation, variables);
+            fail(`Should have thrown`);
+        } catch (e) {
+            expect(getErrorCode(e)).toBe('FORBIDDEN');
+        }
+    }
+
+    function getErrorCode(err: any): string {
+        return err.response.errors[0].extensions.code;
+    }
+
+    async function createAdministratorWithPermissions(
+        code: string,
+        permissions: Permission[],
+    ): Promise<{ identifier: string; password: string }> {
+        const roleResult = await shopClient.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
+            input: {
+                code,
+                description: '',
+                permissions,
+            },
+        });
+
+        const role = roleResult.createRole;
+
+        const identifier = `${code}@${Math.random()
+            .toString(16)
+            .substr(2, 8)}`;
+        const password = `test`;
+
+        const adminResult = await shopClient.query<
+            CreateAdministrator.Mutation,
+            CreateAdministrator.Variables
+        >(CREATE_ADMINISTRATOR, {
+            input: {
+                emailAddress: identifier,
+                firstName: code,
+                lastName: 'Admin',
+                password,
+                roleIds: [role.id],
+            },
+        });
+        const admin = adminResult.createAdministrator;
+
+        return {
+            identifier,
+            password,
+        };
+    }
+
+    /**
+     * A "sleep" function which allows the sendEmailFn time to get called.
+     */
+    function waitForSendEmailFn() {
+        return new Promise(resolve => setTimeout(resolve, 10));
+    }
+});
+
+describe('Expiring registration token', () => {
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
+            },
+            {
+                emailOptions,
+                authOptions: {
+                    verificationTokenDuration: '1ms',
+                },
+            },
+        );
+        await shopClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    beforeEach(() => {
+        sendEmailFn = jest.fn();
+    });
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it(
+        'attempting to verify after token has expired throws',
+        assertThrowsWithMessage(async () => {
+            const verificationTokenPromise = getVerificationTokenPromise();
+            const input: RegisterCustomerInput = {
+                firstName: 'Barry',
+                lastName: 'Wallace',
+                emailAddress: 'barry.wallace@test.com',
+            };
+            const result = await shopClient.query(REGISTER_ACCOUNT, { input });
+
+            const verificationToken = await verificationTokenPromise;
+
+            expect(result.registerCustomerAccount).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalledTimes(1);
+            expect(verificationToken).toBeDefined();
+
+            await new Promise(resolve => setTimeout(resolve, 3));
+
+            return shopClient.query(VERIFY_EMAIL, {
+                password: 'test',
+                token: verificationToken,
+            });
+        }, `Verification token has expired. Use refreshCustomerVerification to send a new token.`),
+    );
+});
+
+describe('Registration without email verification', () => {
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+    const userEmailAddress = 'glen.beardsley@test.com';
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
+            },
+            {
+                emailOptions,
+                authOptions: {
+                    requireVerification: false,
+                },
+            },
+        );
+        await shopClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    beforeEach(() => {
+        sendEmailFn = jest.fn();
+    });
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it(
+        'errors if no password is provided',
+        assertThrowsWithMessage(async () => {
+            const input: RegisterCustomerInput = {
+                firstName: 'Glen',
+                lastName: 'Beardsley',
+                emailAddress: userEmailAddress,
+            };
+            const result = await shopClient.query(REGISTER_ACCOUNT, { input });
+        }, 'A password must be provided when `authOptions.requireVerification` is set to "false"'),
+    );
+
+    it('register a new account with password', async () => {
+        const input: RegisterCustomerInput = {
+            firstName: 'Glen',
+            lastName: 'Beardsley',
+            emailAddress: userEmailAddress,
+            password: 'test',
+        };
+        const result = await shopClient.query(REGISTER_ACCOUNT, { input });
+
+        expect(result.registerCustomerAccount).toBe(true);
+        expect(sendEmailFn).not.toHaveBeenCalled();
+    });
+
+    it('can login after registering', async () => {
+        await shopClient.asUserWithCredentials(userEmailAddress, 'test');
+
+        const result = await shopClient.query(
+            gql`
+                query {
+                    me {
+                        identifier
+                    }
+                }
+            `,
+        );
+        expect(result.me.identifier).toBe(userEmailAddress);
+    });
+});
+
+function getVerificationTokenPromise(): Promise<string> {
+    return new Promise<string>(resolve => {
+        sendEmailFn.mockImplementation(ctx => {
+            resolve(ctx.event.user.verificationToken);
+        });
+    });
+}
+
+const REGISTER_ACCOUNT = gql`
+    mutation Register($input: RegisterCustomerInput!) {
+        registerCustomerAccount(input: $input)
+    }
+`;
+
+const VERIFY_EMAIL = gql`
+    mutation Verify($password: String!, $token: String!) {
+        verifyCustomerAccount(password: $password, token: $token) {
+            user {
+                id
+                identifier
+            }
+        }
+    }
+`;
+
+const REFRESH_TOKEN = gql`
+    mutation RefreshToken($emailAddress: String!) {
+        refreshCustomerVerification(emailAddress: $emailAddress)
+    }
+`;

+ 867 - 0
server/e2e/shop-order.e2e-spec.ts

@@ -0,0 +1,867 @@
+/* tslint:disable:no-non-null-assertion */
+import gql from 'graphql-tag';
+import path from 'path';
+
+import {
+    GET_CUSTOMER,
+    GET_CUSTOMER_LIST,
+} from '../../admin-ui/src/app/data/definitions/customer-definitions';
+import {
+    GET_COUNTRY_LIST,
+    UPDATE_COUNTRY,
+} from '../../admin-ui/src/app/data/definitions/settings-definitions';
+import {
+    CreateAddressInput,
+    GetCountryList,
+    GetCustomer,
+    GetCustomerList,
+    UpdateCountry,
+} from '../../shared/generated-types';
+import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
+
+describe('Orders', () => {
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+                customerCount: 2,
+            },
+            {
+                paymentOptions: {
+                    paymentMethodHandlers: [testPaymentMethod, testFailingPaymentMethod],
+                },
+            },
+        );
+        await shopClient.init();
+        await adminClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('availableCountries returns enabled countries', async () => {
+        // disable Austria
+        const { countries } = await adminClient.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+        const AT = countries.items.find(c => c.code === 'AT')!;
+        await adminClient.query<UpdateCountry.Mutation, UpdateCountry.Variables>(UPDATE_COUNTRY, {
+            input: {
+                id: AT.id,
+                enabled: false,
+            },
+        });
+
+        const result = await shopClient.query(GET_AVAILABLE_COUNTRIES);
+        expect(result.availableCountries.length).toBe(countries.items.length - 1);
+        expect(result.availableCountries.find(c => c.id === AT.id)).toBeUndefined();
+    });
+
+    describe('ordering as anonymous user', () => {
+        let firstOrderItemId: string;
+        let createdCustomerId: string;
+        let orderCode: string;
+
+        it('addItemToOrder starts with no session token', () => {
+            expect(shopClient.getAuthToken()).toBeFalsy();
+        });
+
+        it('activeOrder returns null before any items have been added', async () => {
+            const result = await shopClient.query(GET_ACTIVE_ORDER);
+            expect(result.activeOrder).toBeNull();
+        });
+
+        it('activeOrder creates an anonymous session', () => {
+            expect(shopClient.getAuthToken()).not.toBe('');
+        });
+
+        it('addItemToOrder creates a new Order with an item', async () => {
+            const result = await shopClient.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
+
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(1);
+            expect(result.addItemToOrder.lines[0].productVariant.id).toBe('T_1');
+            expect(result.addItemToOrder.lines[0].id).toBe('T_1');
+            firstOrderItemId = result.addItemToOrder.lines[0].id;
+            orderCode = result.addItemToOrder.code;
+        });
+
+        it(
+            'addItemToOrder errors with an invalid productVariantId',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(ADD_ITEM_TO_ORDER, {
+                        productVariantId: 'T_999',
+                        quantity: 1,
+                    }),
+                `No ProductVariant with the id '999' could be found`,
+            ),
+        );
+
+        it(
+            'addItemToOrder errors with a negative quantity',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(ADD_ITEM_TO_ORDER, {
+                        productVariantId: 'T_999',
+                        quantity: -3,
+                    }),
+                `-3 is not a valid quantity for an OrderItem`,
+            ),
+        );
+
+        it('addItemToOrder with an existing productVariantId adds quantity to the existing OrderLine', async () => {
+            const result = await shopClient.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 2,
+            });
+
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(3);
+        });
+
+        it('adjustItemQuantity adjusts the quantity', async () => {
+            const result = await shopClient.query(ADJUST_ITEM_QUENTITY, {
+                orderItemId: firstOrderItemId,
+                quantity: 50,
+            });
+
+            expect(result.adjustItemQuantity.lines.length).toBe(1);
+            expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
+        });
+
+        it(
+            'adjustItemQuantity errors with a negative quantity',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(ADJUST_ITEM_QUENTITY, {
+                        orderItemId: firstOrderItemId,
+                        quantity: -3,
+                    }),
+                `-3 is not a valid quantity for an OrderItem`,
+            ),
+        );
+
+        it(
+            'adjustItemQuantity errors with an invalid orderItemId',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(ADJUST_ITEM_QUENTITY, {
+                        orderItemId: 'T_999',
+                        quantity: 5,
+                    }),
+                `This order does not contain an OrderLine with the id 999`,
+            ),
+        );
+
+        it('removeItemFromOrder removes the correct item', async () => {
+            const result1 = await shopClient.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_3',
+                quantity: 3,
+            });
+            expect(result1.addItemToOrder.lines.length).toBe(2);
+            expect(result1.addItemToOrder.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+
+            const result2 = await shopClient.query(REMOVE_ITEM_FROM_ORDER, {
+                orderItemId: firstOrderItemId,
+            });
+            expect(result2.removeItemFromOrder.lines.length).toBe(1);
+            expect(result2.removeItemFromOrder.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
+        });
+
+        it(
+            'removeItemFromOrder errors with an invalid orderItemId',
+            assertThrowsWithMessage(
+                () =>
+                    shopClient.query(REMOVE_ITEM_FROM_ORDER, {
+                        orderItemId: 'T_999',
+                    }),
+                `This order does not contain an OrderLine with the id 999`,
+            ),
+        );
+
+        it('nextOrderStates returns next valid states', async () => {
+            const result = await shopClient.query(gql`
+                query {
+                    nextOrderStates
+                }
+            `);
+
+            expect(result.nextOrderStates).toEqual(['ArrangingPayment']);
+        });
+
+        it(
+            'transitionOrderToState throws for an invalid state',
+            assertThrowsWithMessage(
+                () => shopClient.query(TRANSITION_TO_STATE, { state: 'Completed' }),
+                `Cannot transition Order from "AddingItems" to "Completed"`,
+            ),
+        );
+
+        it(
+            'attempting to transition to ArrangingPayment throws when Order has no Customer',
+            assertThrowsWithMessage(
+                () => shopClient.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' }),
+                `Cannot transition Order to the "ArrangingPayment" state without Customer details`,
+            ),
+        );
+
+        it('setCustomerForOrder creates a new Customer and associates it with the Order', async () => {
+            const result = await shopClient.query(SET_CUSTOMER, {
+                input: {
+                    emailAddress: 'test@test.com',
+                    firstName: 'Test',
+                    lastName: 'Person',
+                },
+            });
+
+            const customer = result.setCustomerForOrder.customer;
+            expect(customer.firstName).toBe('Test');
+            expect(customer.lastName).toBe('Person');
+            expect(customer.emailAddress).toBe('test@test.com');
+            createdCustomerId = customer.id;
+        });
+
+        it('setCustomerForOrder updates the existing customer if Customer already set', async () => {
+            const result = await shopClient.query(SET_CUSTOMER, {
+                input: {
+                    emailAddress: 'test@test.com',
+                    firstName: 'Changed',
+                    lastName: 'Person',
+                },
+            });
+
+            const customer = result.setCustomerForOrder.customer;
+            expect(customer.firstName).toBe('Changed');
+            expect(customer.lastName).toBe('Person');
+            expect(customer.emailAddress).toBe('test@test.com');
+            expect(customer.id).toBe(createdCustomerId);
+        });
+
+        it('setOrderShippingAddress sets shipping address', async () => {
+            const address: CreateAddressInput = {
+                fullName: 'name',
+                company: 'company',
+                streetLine1: '12 the street',
+                streetLine2: 'line 2',
+                city: 'foo',
+                province: 'bar',
+                postalCode: '123456',
+                countryCode: 'US',
+                phoneNumber: '4444444',
+            };
+            const result = await shopClient.query(SET_SHIPPING_ADDRESS, {
+                input: address,
+            });
+
+            expect(result.setOrderShippingAddress.shippingAddress).toEqual({
+                fullName: 'name',
+                company: 'company',
+                streetLine1: '12 the street',
+                streetLine2: 'line 2',
+                city: 'foo',
+                province: 'bar',
+                postalCode: '123456',
+                country: 'United States of America',
+                phoneNumber: '4444444',
+            });
+        });
+
+        it('customer default Addresses are not updated before payment', async () => {
+            const result = await shopClient.query(gql`
+                query {
+                    activeOrder {
+                        customer {
+                            addresses {
+                                id
+                            }
+                        }
+                    }
+                }
+            `);
+
+            expect(result.activeOrder.customer.addresses).toEqual([]);
+        });
+
+        it('can transition to ArrangingPayment once Customer has been set', async () => {
+            const result = await shopClient.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
+
+            expect(result.transitionOrderToState).toEqual({ id: 'T_1', state: 'ArrangingPayment' });
+        });
+
+        it('adds a successful payment and transitions Order state', async () => {
+            const result = await shopClient.query(ADD_PAYMENT, {
+                input: {
+                    method: testPaymentMethod.code,
+                    metadata: {},
+                },
+            });
+
+            const payment = result.addPaymentToOrder.payments[0];
+            expect(result.addPaymentToOrder.state).toBe('PaymentSettled');
+            expect(result.addPaymentToOrder.active).toBe(false);
+            expect(result.addPaymentToOrder.payments.length).toBe(1);
+            expect(payment.method).toBe(testPaymentMethod.code);
+            expect(payment.state).toBe('Settled');
+        });
+
+        it('activeOrder is null after payment', async () => {
+            const result = await shopClient.query(GET_ACTIVE_ORDER);
+
+            expect(result.activeOrder).toBeNull();
+        });
+
+        it('customer default Addresses are updated after payment', async () => {
+            const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+                id: createdCustomerId,
+            });
+
+            // tslint:disable-next-line:no-non-null-assertion
+            const address = result.customer!.addresses![0];
+            expect(address.streetLine1).toBe('12 the street');
+            expect(address.postalCode).toBe('123456');
+            expect(address.defaultBillingAddress).toBe(true);
+            expect(address.defaultShippingAddress).toBe(true);
+        });
+    });
+
+    describe('ordering as authenticated user', () => {
+        let firstOrderItemId: string;
+        let activeOrder: any;
+        let authenticatedUserEmailAddress: string;
+        let customers: GetCustomerList.Items[];
+        const password = 'test';
+
+        beforeAll(async () => {
+            await adminClient.asSuperAdmin();
+            const result = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+                {
+                    options: {
+                        take: 2,
+                    },
+                },
+            );
+            customers = result.customers.items;
+            authenticatedUserEmailAddress = customers[0].emailAddress;
+            await shopClient.asUserWithCredentials(authenticatedUserEmailAddress, password);
+        });
+
+        it('activeOrder returns null before any items have been added', async () => {
+            const result = await shopClient.query(GET_ACTIVE_ORDER);
+            expect(result.activeOrder).toBeNull();
+        });
+
+        it('addItemToOrder creates a new Order with an item', async () => {
+            const result = await shopClient.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
+
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(1);
+            expect(result.addItemToOrder.lines[0].productVariant.id).toBe('T_1');
+            activeOrder = result.addItemToOrder;
+            firstOrderItemId = result.addItemToOrder.lines[0].id;
+        });
+
+        it('activeOrder returns order after item has been added', async () => {
+            const result = await shopClient.query(GET_ACTIVE_ORDER);
+            expect(result.activeOrder.id).toBe(activeOrder.id);
+            expect(result.activeOrder.state).toBe('AddingItems');
+        });
+
+        it('addItemToOrder with an existing productVariantId adds quantity to the existing OrderLine', async () => {
+            const result = await shopClient.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 2,
+            });
+
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(3);
+        });
+
+        it('adjustItemQuantity adjusts the quantity', async () => {
+            const result = await shopClient.query(ADJUST_ITEM_QUENTITY, {
+                orderItemId: firstOrderItemId,
+                quantity: 50,
+            });
+
+            expect(result.adjustItemQuantity.lines.length).toBe(1);
+            expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
+        });
+
+        it('removeItemFromOrder removes the correct item', async () => {
+            const result1 = await shopClient.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_3',
+                quantity: 3,
+            });
+            expect(result1.addItemToOrder.lines.length).toBe(2);
+            expect(result1.addItemToOrder.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+
+            const result2 = await shopClient.query(REMOVE_ITEM_FROM_ORDER, {
+                orderItemId: firstOrderItemId,
+            });
+            expect(result2.removeItemFromOrder.lines.length).toBe(1);
+            expect(result2.removeItemFromOrder.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
+        });
+
+        it('nextOrderStates returns next valid states', async () => {
+            const result = await shopClient.query(GET_NEXT_STATES);
+
+            expect(result.nextOrderStates).toEqual(['ArrangingPayment']);
+        });
+
+        it('logging out and back in again resumes the last active order', async () => {
+            await shopClient.asAnonymousUser();
+            const result1 = await shopClient.query(GET_ACTIVE_ORDER);
+            expect(result1.activeOrder).toBeNull();
+
+            await shopClient.asUserWithCredentials(authenticatedUserEmailAddress, password);
+            const result2 = await shopClient.query(GET_ACTIVE_ORDER);
+            expect(result2.activeOrder.id).toBe(activeOrder.id);
+        });
+
+        describe('shipping', () => {
+            let shippingMethods: any;
+
+            it(
+                'setOrderShippingAddress throws with invalid countryCode',
+                assertThrowsWithMessage(() => {
+                    const address: CreateAddressInput = {
+                        streetLine1: '12 the street',
+                        countryCode: 'INVALID',
+                    };
+
+                    return shopClient.query(SET_SHIPPING_ADDRESS, {
+                        input: address,
+                    });
+                }, `The countryCode "INVALID" was not recognized`),
+            );
+
+            it('setOrderShippingAddress sets shipping address', async () => {
+                const address: CreateAddressInput = {
+                    fullName: 'name',
+                    company: 'company',
+                    streetLine1: '12 the street',
+                    streetLine2: 'line 2',
+                    city: 'foo',
+                    province: 'bar',
+                    postalCode: '123456',
+                    countryCode: 'US',
+                    phoneNumber: '4444444',
+                };
+                const result = await shopClient.query(SET_SHIPPING_ADDRESS, {
+                    input: address,
+                });
+
+                expect(result.setOrderShippingAddress.shippingAddress).toEqual({
+                    fullName: 'name',
+                    company: 'company',
+                    streetLine1: '12 the street',
+                    streetLine2: 'line 2',
+                    city: 'foo',
+                    province: 'bar',
+                    postalCode: '123456',
+                    country: 'United States of America',
+                    phoneNumber: '4444444',
+                });
+            });
+
+            it('eligibleShippingMethods lists shipping methods', async () => {
+                const result = await shopClient.query(GET_ELIGIBLE_SHIPPING_METHODS);
+
+                shippingMethods = result.eligibleShippingMethods;
+
+                expect(shippingMethods).toEqual([
+                    { id: 'T_1', price: 500, description: 'Standard Shipping' },
+                    { id: 'T_2', price: 1000, description: 'Express Shipping' },
+                ]);
+            });
+
+            it('shipping is initially unset', async () => {
+                const result = await shopClient.query(GET_ACTIVE_ORDER);
+
+                expect(result.activeOrder.shipping).toEqual(0);
+                expect(result.activeOrder.shippingMethod).toEqual(null);
+            });
+
+            it('setOrderShippingMethod sets the shipping method', async () => {
+                const result = await shopClient.query(SET_SHIPPING_METHOD, {
+                    id: shippingMethods[1].id,
+                });
+
+                const activeOrderResult = await shopClient.query(GET_ACTIVE_ORDER);
+
+                const order = activeOrderResult.activeOrder;
+
+                expect(order.shipping).toBe(shippingMethods[1].price);
+                expect(order.shippingMethod.id).toBe(shippingMethods[1].id);
+                expect(order.shippingMethod.description).toBe(shippingMethods[1].description);
+            });
+
+            it('shipping method is preserved after adjustItemQuantity', async () => {
+                const activeOrderResult = await shopClient.query(GET_ACTIVE_ORDER);
+                activeOrder = activeOrderResult.activeOrder;
+                const result = await shopClient.query(ADJUST_ITEM_QUENTITY, {
+                    orderItemId: activeOrder.lines[0].id,
+                    quantity: 10,
+                });
+
+                expect(result.adjustItemQuantity.shipping).toBe(shippingMethods[1].price);
+                expect(result.adjustItemQuantity.shippingMethod.id).toBe(shippingMethods[1].id);
+                expect(result.adjustItemQuantity.shippingMethod.description).toBe(
+                    shippingMethods[1].description,
+                );
+            });
+        });
+
+        describe('payment', () => {
+            it(
+                'attempting add a Payment throws error when in AddingItems state',
+                assertThrowsWithMessage(
+                    () =>
+                        shopClient.query(ADD_PAYMENT, {
+                            input: {
+                                method: testPaymentMethod.code,
+                                metadata: {},
+                            },
+                        }),
+                    `A Payment may only be added when Order is in "ArrangingPayment" state`,
+                ),
+            );
+
+            it('transitions to the ArrangingPayment state', async () => {
+                const result = await shopClient.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
+                expect(result.transitionOrderToState).toEqual({
+                    id: activeOrder.id,
+                    state: 'ArrangingPayment',
+                });
+            });
+
+            it(
+                'attempting to add an item throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(
+                    () =>
+                        shopClient.query(ADD_ITEM_TO_ORDER, {
+                            productVariantId: 'T_4',
+                            quantity: 1,
+                        }),
+                    `Order contents may only be modified when in the "AddingItems" state`,
+                ),
+            );
+
+            it(
+                'attempting to modify item quantity throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(
+                    () =>
+                        shopClient.query(ADJUST_ITEM_QUENTITY, {
+                            orderItemId: activeOrder.lines[0].id,
+                            quantity: 12,
+                        }),
+                    `Order contents may only be modified when in the "AddingItems" state`,
+                ),
+            );
+
+            it(
+                'attempting to remove an item throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(
+                    () =>
+                        shopClient.query(REMOVE_ITEM_FROM_ORDER, {
+                            orderItemId: activeOrder.lines[0].id,
+                        }),
+                    `Order contents may only be modified when in the "AddingItems" state`,
+                ),
+            );
+
+            it(
+                'attempting to setOrderShippingMethod throws error when in ArrangingPayment state',
+                assertThrowsWithMessage(async () => {
+                    const shippingMethodsResult = await shopClient.query(GET_ELIGIBLE_SHIPPING_METHODS);
+                    const shippingMethods = shippingMethodsResult.eligibleShippingMethods;
+                    return shopClient.query(SET_SHIPPING_METHOD, {
+                        id: shippingMethods[0].id,
+                    });
+                }, `Order contents may only be modified when in the "AddingItems" state`),
+            );
+
+            it('adds a declined payment', async () => {
+                const result = await shopClient.query(ADD_PAYMENT, {
+                    input: {
+                        method: testFailingPaymentMethod.code,
+                        metadata: {
+                            foo: 'bar',
+                        },
+                    },
+                });
+
+                const payment = result.addPaymentToOrder.payments[0];
+                expect(result.addPaymentToOrder.payments.length).toBe(1);
+                expect(payment.method).toBe(testFailingPaymentMethod.code);
+                expect(payment.state).toBe('Declined');
+                expect(payment.transactionId).toBe(null);
+                expect(payment.metadata).toEqual({
+                    foo: 'bar',
+                });
+            });
+
+            it('adds a successful payment and transitions Order state', async () => {
+                const result = await shopClient.query(ADD_PAYMENT, {
+                    input: {
+                        method: testPaymentMethod.code,
+                        metadata: {
+                            baz: 'quux',
+                        },
+                    },
+                });
+
+                const payment = result.addPaymentToOrder.payments[0];
+                expect(result.addPaymentToOrder.state).toBe('PaymentSettled');
+                expect(result.addPaymentToOrder.active).toBe(false);
+                expect(result.addPaymentToOrder.payments.length).toBe(1);
+                expect(payment.method).toBe(testPaymentMethod.code);
+                expect(payment.state).toBe('Settled');
+                expect(payment.transactionId).toBe('12345');
+                expect(payment.metadata).toEqual({
+                    baz: 'quux',
+                });
+            });
+        });
+
+        describe('orderByCode', () => {
+            describe('immediately after Order is placed', () => {
+                it('works when authenticated', async () => {
+                    const result = await shopClient.query(GET_ORDER_BY_CODE, {
+                        code: activeOrder.code,
+                    });
+
+                    expect(result.orderByCode.id).toBe(activeOrder.id);
+                });
+
+                it('works when anonymous', async () => {
+                    await shopClient.asAnonymousUser();
+                    const result = await shopClient.query(GET_ORDER_BY_CODE, {
+                        code: activeOrder.code,
+                    });
+
+                    expect(result.orderByCode.id).toBe(activeOrder.id);
+                });
+
+                it(
+                    `throws error for another user's Order`,
+                    assertThrowsWithMessage(async () => {
+                        authenticatedUserEmailAddress = customers[1].emailAddress;
+                        await shopClient.asUserWithCredentials(authenticatedUserEmailAddress, password);
+                        return shopClient.query(GET_ORDER_BY_CODE, {
+                            code: activeOrder.code,
+                        });
+                    }, `You are not currently authorized to perform this action`),
+                );
+            });
+        });
+    });
+});
+
+const testPaymentMethod = new PaymentMethodHandler({
+    code: 'test-payment-method',
+    name: 'Test Payment Method',
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Settled',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+});
+
+const testFailingPaymentMethod = new PaymentMethodHandler({
+    code: 'test-failing-payment-method',
+    name: 'Test Failing Payment Method',
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Declined',
+            metadata,
+        };
+    },
+});
+
+const TEST_ORDER_FRAGMENT = gql`
+    fragment TestOrderFragment on Order {
+        id
+        code
+        state
+        active
+        lines {
+            id
+            quantity
+            productVariant {
+                id
+            }
+        }
+        shipping
+        shippingMethod {
+            id
+            code
+            description
+        }
+        customer {
+            id
+        }
+    }
+`;
+
+const GET_ACTIVE_ORDER = gql`
+    query {
+        activeOrder {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+const ADD_ITEM_TO_ORDER = gql`
+    mutation AddItemToOrder($productVariantId: ID!, $quantity: Int!) {
+        addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+const ADJUST_ITEM_QUENTITY = gql`
+    mutation AdjustItemQuantity($orderItemId: ID!, $quantity: Int!) {
+        adjustItemQuantity(orderItemId: $orderItemId, quantity: $quantity) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+const REMOVE_ITEM_FROM_ORDER = gql`
+    mutation RemoveItemFromOrder($orderItemId: ID!) {
+        removeItemFromOrder(orderItemId: $orderItemId) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+const GET_NEXT_STATES = gql`
+    query {
+        nextOrderStates
+    }
+`;
+
+const TRANSITION_TO_STATE = gql`
+    mutation TransitionToState($state: String!) {
+        transitionOrderToState(state: $state) {
+            id
+            state
+        }
+    }
+`;
+
+const GET_ELIGIBLE_SHIPPING_METHODS = gql`
+    query {
+        eligibleShippingMethods {
+            id
+            price
+            description
+        }
+    }
+`;
+
+const SET_SHIPPING_ADDRESS = gql`
+    mutation SetShippingAddress($input: CreateAddressInput!) {
+        setOrderShippingAddress(input: $input) {
+            shippingAddress {
+                fullName
+                company
+                streetLine1
+                streetLine2
+                city
+                province
+                postalCode
+                country
+                phoneNumber
+            }
+        }
+    }
+`;
+
+const SET_SHIPPING_METHOD = gql`
+    mutation SetShippingMethod($id: ID!) {
+        setOrderShippingMethod(shippingMethodId: $id) {
+            shipping
+            shippingMethod {
+                id
+                code
+                description
+            }
+        }
+    }
+`;
+
+const ADD_PAYMENT = gql`
+    mutation AddPaymentToOrder($input: PaymentInput!) {
+        addPaymentToOrder(input: $input) {
+            ...TestOrderFragment
+            payments {
+                id
+                transactionId
+                method
+                amount
+                state
+                metadata
+            }
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+const SET_CUSTOMER = gql`
+    mutation SetCustomerForOrder($input: CreateCustomerInput!) {
+        setCustomerForOrder(input: $input) {
+            id
+            customer {
+                id
+                emailAddress
+                firstName
+                lastName
+            }
+        }
+    }
+`;
+
+const GET_ORDER_BY_CODE = gql`
+    query GetOrderByCode($code: String!) {
+        orderByCode(code: $code) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+const GET_AVAILABLE_COUNTRIES = gql`
+    query {
+        availableCountries {
+            id
+            code
+        }
+    }
+`;

+ 17 - 3
server/e2e/test-client.ts

@@ -5,11 +5,11 @@ import { testConfig } from './config/test-config';
 
 // tslint:disable:no-console
 /**
- * A GraphQL client for use in e2e tests configured to use the test server endpoint.
+ * A GraphQL client for use in e2e tests configured to use the test admin server endpoint.
  */
-export class TestClient extends SimpleGraphQLClient {
+export class TestAdminClient extends SimpleGraphQLClient {
     constructor() {
-        super(`http://localhost:${testConfig.port}/${testConfig.apiPath}`);
+        super(`http://localhost:${testConfig.port}/${testConfig.adminApiPath}`);
     }
 
     async init() {
@@ -18,3 +18,17 @@ export class TestClient extends SimpleGraphQLClient {
         await this.asSuperAdmin();
     }
 }
+
+/**
+ * A GraphQL client for use in e2e tests configured to use the test shop server endpoint.
+ */
+export class TestShopClient extends SimpleGraphQLClient {
+    constructor() {
+        super(`http://localhost:${testConfig.port}/${testConfig.shopApiPath}`);
+    }
+
+    async init() {
+        const token = await getDefaultChannelToken(false);
+        this.setChannelToken(token);
+    }
+}

+ 2 - 1
server/e2e/test-server.ts

@@ -7,7 +7,7 @@ import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOpti
 
 import { Omit } from '../../shared/omit';
 import { populate, PopulateOptions } from '../mock-data/populate';
-import { preBootstrapConfig } from '../src/bootstrap';
+import { preBootstrapConfig, runPluginOnBootstrapMethods } from '../src/bootstrap';
 import { Mutable } from '../src/common/types/common-types';
 import { VendureConfig } from '../src/config/vendure-config';
 
@@ -91,6 +91,7 @@ export class TestServer {
         const config = await preBootstrapConfig(userConfig);
         const appModule = await import('../src/app.module');
         const app = await NestFactory.create(appModule.AppModule, { cors: config.cors, logger: false });
+        await runPluginOnBootstrapMethods(config, app);
         await app.listen(config.port);
         return app;
     }

+ 2 - 2
server/e2e/zone.e2e-spec.ts

@@ -20,13 +20,13 @@ import {
 } from '../../shared/generated-types';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestClient } from './test-client';
+import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Facet resolver', () => {
-    const client = new TestClient();
+    const client = new TestAdminClient();
     const server = new TestServer();
     let countries: GetCountryList.Items[];
     let zones: Array<{ id: string; name: string }>;

+ 1 - 1
server/mock-data/populate.ts

@@ -42,7 +42,7 @@ export async function populate(
     await populateProducts(app, options.productsCsvPath, logging);
 
     const defaultChannelToken = await getDefaultChannelToken(logging);
-    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.apiPath}`);
+    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.adminApiPath}`);
     client.setChannelToken(defaultChannelToken);
     await client.asSuperAdmin();
     const mockDataService = new MockDataService(client, logging);

+ 12 - 6
server/package.json

@@ -6,7 +6,13 @@
     "type": "git",
     "url": "https://github.com/vendure-ecommerce/vendure/"
   },
-  "keywords": ["vendure", "ecommerce", "headless", "graphql", "typescript"],
+  "keywords": [
+    "vendure",
+    "ecommerce",
+    "headless",
+    "graphql",
+    "typescript"
+  ],
   "readme": "README.md",
   "private": false,
   "license": "MIT",
@@ -31,11 +37,11 @@
     "dist/**/*"
   ],
   "dependencies": {
-    "@nestjs/common": "5.5.0",
-    "@nestjs/core": "5.5.0",
-    "@nestjs/graphql": "5.5.0",
-    "@nestjs/testing": "5.5.0",
-    "@nestjs/typeorm": "^5.2.2",
+    "@nestjs/common": "5.7.2",
+    "@nestjs/core": "5.7.2",
+    "@nestjs/graphql": "5.5.3",
+    "@nestjs/testing": "5.7.2",
+    "@nestjs/typeorm": "^5.3.0",
     "@types/progress": "^2.0.3",
     "apollo-server-express": "^2.4.0",
     "bcrypt": "^3.0.3",

+ 79 - 33
server/src/api/api.module.ts

@@ -1,43 +1,51 @@
 import { Module } from '@nestjs/common';
 import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
-import { GraphQLModule } from '@nestjs/graphql';
+import path from 'path';
 
-import { ConfigModule } from '../config/config.module';
 import { DataImportModule } from '../data-import/data-import.module';
-import { I18nModule } from '../i18n/i18n.module';
 import { PluginModule } from '../plugin/plugin.module';
 import { ServiceModule } from '../service/service.module';
 
 import { IdCodecService } from './common/id-codec.service';
 import { RequestContextService } from './common/request-context.service';
-import { GraphqlConfigService } from './config/graphql-config.service';
+import { configureGraphQLModule } from './config/configure-graphql-module';
 import { AssetInterceptor } from './middleware/asset-interceptor';
 import { AuthGuard } from './middleware/auth-guard';
 import { IdInterceptor } from './middleware/id-interceptor';
-import { AdministratorResolver } from './resolvers/administrator.resolver';
-import { AssetResolver } from './resolvers/asset.resolver';
-import { AuthResolver } from './resolvers/auth.resolver';
-import { ChannelResolver } from './resolvers/channel.resolver';
-import { CountryResolver } from './resolvers/country.resolver';
-import { CustomerGroupResolver } from './resolvers/customer-group.resolver';
-import { CustomerResolver } from './resolvers/customer.resolver';
-import { FacetResolver } from './resolvers/facet.resolver';
-import { GlobalSettingsResolver } from './resolvers/global-settings.resolver';
-import { ImportResolver } from './resolvers/import.resolver';
-import { OrderResolver } from './resolvers/order.resolver';
-import { PaymentMethodResolver } from './resolvers/payment-method.resolver';
-import { ProductCategoryResolver } from './resolvers/product-category.resolver';
-import { ProductOptionResolver } from './resolvers/product-option.resolver';
-import { ProductResolver } from './resolvers/product.resolver';
-import { PromotionResolver } from './resolvers/promotion.resolver';
-import { RoleResolver } from './resolvers/role.resolver';
-import { SearchResolver } from './resolvers/search.resolver';
-import { ShippingMethodResolver } from './resolvers/shipping-method.resolver';
-import { TaxCategoryResolver } from './resolvers/tax-category.resolver';
-import { TaxRateResolver } from './resolvers/tax-rate.resolver';
-import { ZoneResolver } from './resolvers/zone.resolver';
+import { AdministratorResolver } from './resolvers/admin/administrator.resolver';
+import { AssetResolver } from './resolvers/admin/asset.resolver';
+import { AuthResolver } from './resolvers/admin/auth.resolver';
+import { ChannelResolver } from './resolvers/admin/channel.resolver';
+import { CountryResolver } from './resolvers/admin/country.resolver';
+import { CustomerGroupResolver } from './resolvers/admin/customer-group.resolver';
+import { CustomerResolver } from './resolvers/admin/customer.resolver';
+import { FacetResolver } from './resolvers/admin/facet.resolver';
+import { GlobalSettingsResolver } from './resolvers/admin/global-settings.resolver';
+import { ImportResolver } from './resolvers/admin/import.resolver';
+import { OrderResolver } from './resolvers/admin/order.resolver';
+import { PaymentMethodResolver } from './resolvers/admin/payment-method.resolver';
+import { ProductCategoryResolver } from './resolvers/admin/product-category.resolver';
+import { ProductOptionResolver } from './resolvers/admin/product-option.resolver';
+import { ProductResolver } from './resolvers/admin/product.resolver';
+import { PromotionResolver } from './resolvers/admin/promotion.resolver';
+import { RoleResolver } from './resolvers/admin/role.resolver';
+import { SearchResolver } from './resolvers/admin/search.resolver';
+import { ShippingMethodResolver } from './resolvers/admin/shipping-method.resolver';
+import { TaxCategoryResolver } from './resolvers/admin/tax-category.resolver';
+import { TaxRateResolver } from './resolvers/admin/tax-rate.resolver';
+import { ZoneResolver } from './resolvers/admin/zone.resolver';
+import { CustomerEntityResolver } from './resolvers/entity/customer-entity.resolver';
+import { OrderEntityResolver } from './resolvers/entity/order-entity.resolver';
+import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.resolver';
+import { ProductCategoryEntityResolver } from './resolvers/entity/product-category-entity.resolver';
+import { ProductEntityResolver } from './resolvers/entity/product-entity.resolver';
+import { ProductOptionGroupEntityResolver } from './resolvers/entity/product-option-group-entity.resolver';
+import { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
+import { ShopCustomerResolver } from './resolvers/shop/shop-customer.resolver';
+import { ShopOrderResolver } from './resolvers/shop/shop-order.resolver';
+import { ShopProductsResolver } from './resolvers/shop/shop-products.resolver';
 
-const resolvers = [
+const adminResolvers = [
     AdministratorResolver,
     AssetResolver,
     AuthResolver,
@@ -62,6 +70,31 @@ const resolvers = [
     ZoneResolver,
 ];
 
+const shopResolvers = [ShopAuthResolver, ShopCustomerResolver, ShopOrderResolver, ShopProductsResolver];
+
+const entityResolvers = [
+    CustomerEntityResolver,
+    OrderEntityResolver,
+    OrderLineEntityResolver,
+    ProductCategoryEntityResolver,
+    ProductEntityResolver,
+    ProductOptionGroupEntityResolver,
+];
+
+@Module({
+    imports: [PluginModule, ServiceModule, DataImportModule],
+    providers: [IdCodecService, ...adminResolvers, ...entityResolvers, ...PluginModule.adminApiResolvers()],
+    exports: adminResolvers,
+})
+class AdminApiModule {}
+
+@Module({
+    imports: [PluginModule, ServiceModule],
+    providers: [IdCodecService, ...shopResolvers, ...entityResolvers, ...PluginModule.shopApiResolvers()],
+    exports: shopResolvers,
+})
+class ShopApiModule {}
+
 /**
  * The ApiModule is responsible for the public API of the application. This is where requests
  * come in, are parsed and then handed over to the ServiceModule classes which take care
@@ -71,14 +104,27 @@ const resolvers = [
     imports: [
         ServiceModule,
         DataImportModule,
-        GraphQLModule.forRootAsync({
-            useClass: GraphqlConfigService,
-            imports: [ConfigModule, I18nModule],
-        }),
-        PluginModule,
+        AdminApiModule,
+        ShopApiModule,
+        configureGraphQLModule(configService => ({
+            apiType: 'shop',
+            apiPath: configService.shopApiPath,
+            typePaths: ['type', 'shop-api', 'common'].map(p =>
+                path.join(__dirname, 'schema', p, '*.graphql'),
+            ),
+            resolverModule: ShopApiModule,
+        })),
+        configureGraphQLModule(configService => ({
+            apiType: 'admin',
+            apiPath: configService.adminApiPath,
+            typePaths: ['type', 'admin-api', 'common'].map(p =>
+                path.join(__dirname, 'schema', p, '*.graphql'),
+            ),
+            resolverModule: AdminApiModule,
+        })),
     ],
     providers: [
-        ...resolvers,
+        ...entityResolvers,
         RequestContextService,
         IdCodecService,
         {

+ 102 - 0
server/src/api/config/configure-graphql-module.ts

@@ -0,0 +1,102 @@
+import { DynamicModule } from '@nestjs/common';
+import { GqlModuleOptions, GraphQLModule, GraphQLTypesLoader } from '@nestjs/graphql';
+import { GraphQLUpload } from 'apollo-server-core';
+import { extendSchema, printSchema } from 'graphql';
+import { GraphQLDateTime } from 'graphql-iso-date';
+import GraphQLJSON from 'graphql-type-json';
+
+import { notNullOrUndefined } from '../../../../shared/shared-utils';
+import { ConfigModule } from '../../config/config.module';
+import { ConfigService } from '../../config/config.service';
+import { I18nModule } from '../../i18n/i18n.module';
+import { I18nService } from '../../i18n/i18n.service';
+import { getPluginAPIExtensions } from '../../plugin/plugin-utils';
+import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
+
+import { generateListOptions } from './generate-list-options';
+import { addGraphQLCustomFields } from './graphql-custom-fields';
+
+export interface GraphQLApiOptions {
+    apiType: 'shop' | 'admin';
+    typePaths: string[];
+    apiPath: string;
+    // tslint:disable-next-line:ban-types
+    resolverModule: Function;
+}
+
+/**
+ * Dynamically generates a GraphQLModule according to the given config options.
+ */
+export function configureGraphQLModule(
+    getOptions: (configService: ConfigService) => GraphQLApiOptions,
+): DynamicModule {
+    return GraphQLModule.forRootAsync({
+        useFactory: (
+            configService: ConfigService,
+            i18nService: I18nService,
+            typesLoader: GraphQLTypesLoader,
+        ) => {
+            return createGraphQLOptions(i18nService, configService, typesLoader, getOptions(configService));
+        },
+        inject: [ConfigService, I18nService, GraphQLTypesLoader],
+        imports: [ConfigModule, I18nModule],
+    });
+}
+
+function createGraphQLOptions(
+    i18nService: I18nService,
+    configService: ConfigService,
+    typesLoader: GraphQLTypesLoader,
+    options: GraphQLApiOptions,
+): GqlModuleOptions {
+    // Prevent `Type "Node" is missing a "resolveType" resolver.` warnings.
+    // See https://github.com/apollographql/apollo-server/issues/1075
+    const dummyResolveType = {
+        __resolveType() {
+            return null;
+        },
+    };
+
+    return {
+        path: '/' + options.apiPath,
+        typeDefs: createTypeDefs(options.apiType),
+        include: [options.resolverModule],
+        resolvers: {
+            JSON: GraphQLJSON,
+            DateTime: GraphQLDateTime,
+            Node: dummyResolveType,
+            PaginatedList: dummyResolveType,
+            Upload: GraphQLUpload || dummyResolveType,
+        },
+        uploads: {
+            maxFileSize: configService.assetOptions.uploadMaxFileSize,
+        },
+        playground: true,
+        debug: true,
+        context: (req: any) => req,
+        extensions: [() => new TranslateErrorExtension(i18nService)],
+        // This is handled by the Express cors plugin
+        cors: false,
+    };
+
+    /**
+     * Generates the server's GraphQL schema by combining:
+     * 1. the default schema as defined in the source .graphql files specified by `typePaths`
+     * 2. any custom fields defined in the config
+     * 3. any schema extensions defined by plugins
+     */
+    function createTypeDefs(apiType: 'shop' | 'admin'): string {
+        const customFields = configService.customFields;
+        const typeDefs = typesLoader.mergeTypesByPaths(...options.typePaths);
+        let schema = generateListOptions(typeDefs);
+        schema = addGraphQLCustomFields(schema, customFields);
+        const pluginSchemaExtensions = getPluginAPIExtensions(configService.plugins, apiType).map(
+            e => e.schema,
+        );
+
+        for (const documentNode of pluginSchemaExtensions) {
+            schema = extendSchema(schema, documentNode);
+        }
+        return printSchema(schema);
+    }
+}

+ 308 - 0
server/src/api/config/generate-list-options.spec.ts

@@ -0,0 +1,308 @@
+import { buildSchema, printType } from 'graphql';
+
+import { CustomFields } from '../../../../shared/shared-types';
+
+import { generateListOptions } from './generate-list-options';
+// tslint:disable:no-non-null-assertion
+
+describe('generateListOptions()', () => {
+    const COMMON_TYPES = `
+    scalar JSON
+    scalar DateTime
+
+    interface PaginatedList {
+        items: [Node!]!
+        totalItems: Int!
+    }
+
+    interface Node {
+        id: ID!
+    }
+
+    enum SortOrder {
+        ASC
+        DESC
+    }
+
+    input StringOperators { dummy: String }
+
+    input BooleanOperators { dummy: String }
+
+    input NumberRange { dummy: String }
+
+    input NumberOperators { dummy: String }
+
+    input DateRange { dummy: String }
+
+    input DateOperators { dummy: String }
+
+    type PersonList implements PaginatedList {
+        items: [Person!]!
+        totalItems: Int!
+    }
+    `;
+
+    const removeLeadingWhitespace = s => {
+        const indent = s.match(/^\s+/m)[0].replace(/\n/, '');
+        return s.replace(new RegExp(`^${indent}`, 'gm'), '').trim();
+    };
+
+    it('creates the required input types', () => {
+        const input = `
+                ${COMMON_TYPES}
+               type Query {
+                   people(options: PersonListOptions): PersonList
+               }
+
+               type Person {
+                   name: String!
+                   age: Int!
+               }
+
+               # Generated at runtime
+               input PersonListOptions
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonListOptions')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonListOptions {
+                     skip: Int
+                     take: Int
+                     sort: PersonSortParameter
+                     filter: PersonFilterParameter
+                   }`),
+        );
+
+        expect(printType(result.getType('PersonSortParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonSortParameter {
+                     name: SortOrder
+                     age: SortOrder
+                   }`),
+        );
+
+        expect(printType(result.getType('PersonFilterParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonFilterParameter {
+                     name: StringOperators
+                     age: NumberOperators
+                   }`),
+        );
+    });
+
+    it('works with a non-nullabel list type', () => {
+        const input = `
+                ${COMMON_TYPES}
+               type Query {
+                   people: PersonList!
+               }
+
+               type Person {
+                   name: String!
+                   age: Int!
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(result.getType('PersonListOptions')).toBeTruthy();
+    });
+
+    it('uses the correct filter operators', () => {
+        const input = `
+                ${COMMON_TYPES}
+               type Query {
+                   people(options: PersonListOptions): PersonList
+               }
+
+               type Person {
+                   name: String!
+                   age: Int!
+                   updatedAt: DateTime!
+                   admin: Boolean!
+                   score: Float
+                   personType: PersonType!
+               }
+
+               enum PersonType {
+                   TABS
+                   SPACES
+               }
+
+               # Generated at runtime
+               input PersonListOptions
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonFilterParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonFilterParameter {
+                     name: StringOperators
+                     age: NumberOperators
+                     updatedAt: DateOperators
+                     admin: BooleanOperators
+                     score: NumberOperators
+                     personType: StringOperators
+                   }`),
+        );
+    });
+
+    it('creates the ListOptions interface and argument if not defined', () => {
+        const input = `
+               ${COMMON_TYPES}
+               type Query {
+                   people: PersonList
+               }
+
+               type Person {
+                   name: String!
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonListOptions')!)).toBe(
+            removeLeadingWhitespace(`
+                    input PersonListOptions {
+                      skip: Int
+                      take: Int
+                      sort: PersonSortParameter
+                      filter: PersonFilterParameter
+                    }`),
+        );
+
+        const args = result.getQueryType()!.getFields().people.args;
+        expect(args.length).toBe(1);
+        expect(args[0].name).toBe('options');
+        expect(args[0].type.toString()).toBe('PersonListOptions');
+    });
+
+    it('extends the ListOptions interface if already defined', () => {
+        const input = `
+               ${COMMON_TYPES}
+               type Query {
+                   people(options: PersonListOptions): PersonList
+               }
+
+               type Person {
+                   name: String!
+               }
+
+               input PersonListOptions {
+                   categoryId: ID
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonListOptions')!)).toBe(
+            removeLeadingWhitespace(`
+                    input PersonListOptions {
+                      skip: Int
+                      take: Int
+                      sort: PersonSortParameter
+                      filter: PersonFilterParameter
+                      categoryId: ID
+                    }`),
+        );
+
+        const args = result.getQueryType()!.getFields().people.args;
+        expect(args.length).toBe(1);
+        expect(args[0].name).toBe('options');
+        expect(args[0].type.toString()).toBe('PersonListOptions');
+    });
+
+    it('ignores properties with types which cannot be sorted or filtered', () => {
+        const input = `
+               ${COMMON_TYPES}
+               type Query {
+                   people: PersonList
+               }
+
+               type Person {
+                   id: ID!
+                   name: String!
+                   vitals: [Int]
+                   meta: JSON
+                   user: User!
+               }
+
+               type User {
+                   identifier: String!
+               }
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('PersonSortParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonSortParameter {
+                     id: SortOrder
+                     name: SortOrder
+                   }`),
+        );
+
+        expect(printType(result.getType('PersonFilterParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input PersonFilterParameter {
+                     name: StringOperators
+                   }`),
+        );
+    });
+
+    it('generates ListOptions for nested list queries', () => {
+        const input = `
+               ${COMMON_TYPES}
+               type Query {
+                   people: PersonList
+               }
+
+               type Person {
+                   id: ID!
+                   orders(options: OrderListOptions): OrderList
+               }
+
+               type OrderList implements PaginatedList {
+                   items: [Order!]!
+                   totalItems: Int!
+               }
+
+               type Order {
+                   id: ID!
+                   code: String!
+               }
+
+               # Generated at runtime
+               input OrderListOptions
+           `;
+
+        const result = generateListOptions(buildSchema(input));
+
+        expect(printType(result.getType('OrderListOptions')!)).toBe(
+            removeLeadingWhitespace(`
+                   input OrderListOptions {
+                     skip: Int
+                     take: Int
+                     sort: OrderSortParameter
+                     filter: OrderFilterParameter
+                   }`),
+        );
+        expect(printType(result.getType('OrderSortParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input OrderSortParameter {
+                     id: SortOrder
+                     code: SortOrder
+                   }`),
+        );
+
+        expect(printType(result.getType('OrderFilterParameter')!)).toBe(
+            removeLeadingWhitespace(`
+                   input OrderFilterParameter {
+                     code: StringOperators
+                   }`),
+        );
+    });
+});

+ 196 - 0
server/src/api/config/generate-list-options.ts

@@ -0,0 +1,196 @@
+import {
+    buildSchema,
+    extendSchema,
+    GraphQLEnumType,
+    GraphQLField,
+    GraphQLInputFieldConfig,
+    GraphQLInputFieldConfigMap,
+    GraphQLInputObjectType,
+    GraphQLInputType,
+    GraphQLInt,
+    GraphQLNamedType,
+    GraphQLObjectType,
+    GraphQLOutputType,
+    GraphQLSchema,
+    isEnumType,
+    isListType,
+    isNonNullType,
+    isObjectType,
+} from 'graphql';
+import { mergeSchemas } from 'graphql-tools';
+
+/**
+ * Generates ListOptions inputs for queries which return PaginatedList types.
+ */
+export function generateListOptions(typeDefsOrSchema: string | GraphQLSchema): GraphQLSchema {
+    const schema = typeof typeDefsOrSchema === 'string' ? buildSchema(typeDefsOrSchema) : typeDefsOrSchema;
+    const queryType = schema.getQueryType();
+    if (!queryType) {
+        return schema;
+    }
+    const objectTypes = Object.values(schema.getTypeMap()).filter(isObjectType);
+    const allFields = objectTypes.reduce(
+        (fields, type) => {
+            const typeFields = Object.values(type.getFields()).filter(f => isListQueryType(f.type));
+            return [...fields, ...typeFields];
+        },
+        [] as Array<GraphQLField<any, any>>,
+    );
+    const generatedTypes: GraphQLNamedType[] = [];
+
+    for (const query of allFields) {
+        const targetTypeName = unwrapNonNullType(query.type)
+            .toString()
+            .replace(/List$/, '');
+        const targetType = schema.getType(targetTypeName);
+        if (targetType && isObjectType(targetType)) {
+            const sortParameter = createSortParameter(schema, targetType);
+            const filterParameter = createFilterParameter(schema, targetType);
+            const existingListOptions = schema.getType(
+                `${targetTypeName}ListOptions`,
+            ) as GraphQLInputObjectType | null;
+            const generatedListOptions = new GraphQLInputObjectType({
+                name: `${targetTypeName}ListOptions`,
+                fields: {
+                    skip: { type: GraphQLInt },
+                    take: { type: GraphQLInt },
+                    sort: { type: sortParameter },
+                    filter: { type: filterParameter },
+                    ...(existingListOptions ? existingListOptions.getFields() : {}),
+                },
+            });
+            let listOptionsInput: GraphQLInputObjectType;
+
+            listOptionsInput = generatedListOptions;
+
+            if (!query.args.find(a => a.type.toString() === `${targetTypeName}ListOptions`)) {
+                query.args.push({
+                    name: 'options',
+                    type: listOptionsInput,
+                });
+            }
+
+            generatedTypes.push(filterParameter);
+            generatedTypes.push(sortParameter);
+            generatedTypes.push(listOptionsInput);
+        }
+    }
+    return mergeSchemas({ schemas: [schema, generatedTypes] });
+}
+
+function isListQueryType(type: GraphQLOutputType): type is GraphQLObjectType {
+    const innerType = unwrapNonNullType(type);
+    return isObjectType(innerType) && !!innerType.getInterfaces().find(i => i.name === 'PaginatedList');
+}
+
+function createSortParameter(schema: GraphQLSchema, targetType: GraphQLObjectType) {
+    const fields = Object.values(targetType.getFields());
+    const targetTypeName = targetType.name;
+    const SortOrder = schema.getType('SortOrder') as GraphQLEnumType;
+
+    const sortableTypes = ['ID', 'String', 'Int', 'Float', 'DateTime'];
+    return new GraphQLInputObjectType({
+        name: `${targetTypeName}SortParameter`,
+        fields: fields
+            .filter(field => sortableTypes.includes(unwrapNonNullType(field.type).name))
+            .reduce(
+                (result, field) => {
+                    const fieldConfig: GraphQLInputFieldConfig = {
+                        type: SortOrder,
+                    };
+                    return {
+                        ...result,
+                        [field.name]: fieldConfig,
+                    };
+                },
+                {} as GraphQLInputFieldConfigMap,
+            ),
+    });
+}
+
+function createFilterParameter(schema: GraphQLSchema, targetType: GraphQLObjectType): GraphQLInputObjectType {
+    const fields = Object.values(targetType.getFields());
+    const targetTypeName = targetType.name;
+    const { StringOperators, BooleanOperators, NumberOperators, DateOperators } = getCommonTypes(schema);
+
+    return new GraphQLInputObjectType({
+        name: `${targetTypeName}FilterParameter`,
+        fields: fields.reduce(
+            (result, field) => {
+                const filterType = getFilterType(field);
+                if (!filterType) {
+                    return result;
+                }
+                const fieldConfig: GraphQLInputFieldConfig = {
+                    type: filterType,
+                };
+                return {
+                    ...result,
+                    [field.name]: fieldConfig,
+                };
+            },
+            {} as GraphQLInputFieldConfigMap,
+        ),
+    });
+
+    function getFilterType(field: GraphQLField<any, any>): GraphQLInputType | undefined {
+        if (isListType(field.type)) {
+            return;
+        }
+        const innerType = unwrapNonNullType(field.type);
+        if (isEnumType(innerType)) {
+            return StringOperators;
+        }
+        switch (innerType.name) {
+            case 'String':
+                return StringOperators;
+            case 'Boolean':
+                return BooleanOperators;
+            case 'Int':
+            case 'Float':
+                return NumberOperators;
+            case 'DateTime':
+                return DateOperators;
+            default:
+                return;
+        }
+    }
+}
+
+function getCommonTypes(schema: GraphQLSchema) {
+    const SortOrder = schema.getType('SortOrder') as GraphQLEnumType | null;
+    const StringOperators = schema.getType('StringOperators') as GraphQLInputType | null;
+    const BooleanOperators = schema.getType('BooleanOperators') as GraphQLInputType | null;
+    const NumberRange = schema.getType('NumberRange') as GraphQLInputType | null;
+    const NumberOperators = schema.getType('NumberOperators') as GraphQLInputType | null;
+    const DateRange = schema.getType('DateRange') as GraphQLInputType | null;
+    const DateOperators = schema.getType('DateOperators') as GraphQLInputType | null;
+    if (
+        !SortOrder ||
+        !StringOperators ||
+        !BooleanOperators ||
+        !NumberRange ||
+        !NumberOperators ||
+        !DateRange ||
+        !DateOperators
+    ) {
+        throw new Error(`A common type was not defined`);
+    }
+    return {
+        SortOrder,
+        StringOperators,
+        BooleanOperators,
+        NumberOperators,
+        DateOperators,
+    };
+}
+
+/**
+ * Unwraps the inner type if it is inside a non-nullable type
+ */
+function unwrapNonNullType(type: GraphQLOutputType): GraphQLNamedType {
+    if (isNonNullType(type)) {
+        return type.ofType;
+    }
+    return type;
+}

+ 0 - 75
server/src/api/config/graphql-config.service.ts

@@ -1,75 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { GqlModuleOptions, GqlOptionsFactory, GraphQLTypesLoader } from '@nestjs/graphql';
-import { GraphQLUpload } from 'apollo-server-core';
-import { extendSchema, printSchema } from 'graphql';
-import { GraphQLDateTime } from 'graphql-iso-date';
-import GraphQLJSON from 'graphql-type-json';
-import path from 'path';
-
-import { notNullOrUndefined } from '../../../../shared/shared-utils';
-import { ConfigService } from '../../config/config.service';
-import { I18nService } from '../../i18n/i18n.service';
-import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
-
-import { addGraphQLCustomFields } from './graphql-custom-fields';
-
-@Injectable()
-export class GraphqlConfigService implements GqlOptionsFactory {
-    readonly typePaths = path.join(__dirname, '/../../**/*.graphql');
-
-    constructor(
-        private i18nService: I18nService,
-        private configService: ConfigService,
-        private typesLoader: GraphQLTypesLoader,
-    ) {}
-
-    createGqlOptions(): GqlModuleOptions {
-        // Prevent `Type "Node" is missing a "resolveType" resolver.` warnings.
-        // See https://github.com/apollographql/apollo-server/issues/1075
-        const dummyResolveType = {
-            __resolveType() {
-                return null;
-            },
-        };
-
-        return {
-            path: '/' + this.configService.apiPath,
-            typeDefs: this.createTypeDefs(),
-            resolvers: {
-                JSON: GraphQLJSON,
-                DateTime: GraphQLDateTime,
-                Node: dummyResolveType,
-                PaginatedList: dummyResolveType,
-                Upload: GraphQLUpload || dummyResolveType,
-            },
-            uploads: {
-                maxFileSize: this.configService.assetOptions.uploadMaxFileSize,
-            },
-            playground: true,
-            debug: true,
-            context: req => req,
-            extensions: [() => new TranslateErrorExtension(this.i18nService)],
-            // This is handled by the Express cors plugin
-            cors: false,
-        };
-    }
-
-    /**
-     * Generates the server's GraphQL schema by combining:
-     * 1. the default schema as defined in the source .graphql files specified by `typePaths`
-     * 2. any custom fields defined in the config
-     * 3. any schema extensions defined by plugins
-     */
-    private createTypeDefs(): string {
-        const customFields = this.configService.customFields;
-        const typeDefs = this.typesLoader.mergeTypesByPaths(this.typePaths);
-        let schema = addGraphQLCustomFields(typeDefs, customFields);
-        const pluginTypes = this.configService.plugins
-            .map(p => (p.defineGraphQlTypes ? p.defineGraphQlTypes() : undefined))
-            .filter(notNullOrUndefined);
-        for (const types of pluginTypes) {
-            schema = extendSchema(schema, types);
-        }
-        return printSchema(schema);
-    }
-}

+ 5 - 2
server/src/api/config/graphql-custom-fields.ts

@@ -8,8 +8,11 @@ import { assertNever } from '../../../../shared/shared-utils';
  * types with a customFields property for all entities, translations and inputs for which
  * custom fields are defined.
  */
-export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: CustomFields): GraphQLSchema {
-    const schema = buildSchema(typeDefs);
+export function addGraphQLCustomFields(
+    typeDefsOrSchema: string | GraphQLSchema,
+    customFieldConfig: CustomFields,
+): GraphQLSchema {
+    const schema = typeof typeDefsOrSchema === 'string' ? buildSchema(typeDefsOrSchema) : typeDefsOrSchema;
 
     let customFieldTypeDefs = '';
 

+ 6 - 6
server/src/api/resolvers/administrator.resolver.ts → server/src/api/resolvers/admin/administrator.resolver.ts

@@ -8,12 +8,12 @@ import {
     CreateAdministratorMutationArgs,
     Permission,
     UpdateAdministratorMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Administrator } from '../../entity/administrator/administrator.entity';
-import { AdministratorService } from '../../service/services/administrator.service';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Administrator } from '../../../entity/administrator/administrator.entity';
+import { AdministratorService } from '../../../service/services/administrator.service';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
 
 @Resolver('Administrator')
 export class AdministratorResolver {

+ 6 - 15
server/src/api/resolvers/asset.resolver.ts → server/src/api/resolvers/admin/asset.resolver.ts

@@ -5,37 +5,28 @@ import {
     AssetsQueryArgs,
     CreateAssetsMutationArgs,
     Permission,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Asset } from '../../entity/asset/asset.entity';
-import { AssetService } from '../../service/services/asset.service';
-import { Allow } from '../decorators/allow.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Asset } from '../../../entity/asset/asset.entity';
+import { AssetService } from '../../../service/services/asset.service';
+import { Allow } from '../../decorators/allow.decorator';
 
-@Resolver('Assets')
+@Resolver('Asset')
 export class AssetResolver {
     constructor(private assetService: AssetService) {}
 
-    /**
-     * Returns a list of Assets
-     */
     @Query()
     @Allow(Permission.ReadCatalog)
     async asset(@Args() args: AssetQueryArgs): Promise<Asset | undefined> {
         return this.assetService.findOne(args.id);
     }
 
-    /**
-     * Returns a list of Assets
-     */
     @Query()
     @Allow(Permission.ReadCatalog)
     async assets(@Args() args: AssetsQueryArgs): Promise<PaginatedList<Asset>> {
         return this.assetService.findAll(args.options || undefined);
     }
 
-    /**
-     * Create a new Asset
-     */
     @Mutation()
     @Allow(Permission.CreateCatalog)
     async createAssets(@Args() args: CreateAssetsMutationArgs): Promise<Asset[]> {

+ 43 - 0
server/src/api/resolvers/admin/auth.resolver.ts

@@ -0,0 +1,43 @@
+import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
+import { Request, Response } from 'express';
+
+import { LoginMutationArgs, LoginResult, Permission } from '../../../../../shared/generated-types';
+import { ConfigService } from '../../../config/config.service';
+import { AuthService } from '../../../service/services/auth.service';
+import { ChannelService } from '../../../service/services/channel.service';
+import { CustomerService } from '../../../service/services/customer.service';
+import { UserService } from '../../../service/services/user.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
+import { BaseAuthResolver } from '../base/base-auth.resolver';
+
+@Resolver()
+export class AuthResolver extends BaseAuthResolver {
+    constructor(authService: AuthService, userService: UserService, configService: ConfigService) {
+        super(authService, userService, configService);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    login(
+        @Args() args: LoginMutationArgs,
+        @Ctx() ctx: RequestContext,
+        @Context('req') req: Request,
+        @Context('res') res: Response,
+    ): Promise<LoginResult> {
+        return super.login(args, ctx, req, res);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    logout(@Context('req') req: Request, @Context('res') res: Response): Promise<boolean> {
+        return super.logout(req, res);
+    }
+
+    @Query()
+    @Allow(Permission.Authenticated)
+    me(@Ctx() ctx: RequestContext) {
+        return super.me(ctx);
+    }
+}

+ 8 - 8
server/src/api/resolvers/channel.resolver.ts → server/src/api/resolvers/admin/channel.resolver.ts

@@ -5,13 +5,13 @@ import {
     CreateChannelMutationArgs,
     Permission,
     UpdateChannelMutationArgs,
-} from '../../../../shared/generated-types';
-import { Channel } from '../../entity/channel/channel.entity';
-import { ChannelService } from '../../service/services/channel.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { Channel } from '../../../entity/channel/channel.entity';
+import { ChannelService } from '../../../service/services/channel.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Channel')
 export class ChannelResolver {
@@ -30,7 +30,7 @@ export class ChannelResolver {
     }
 
     @Query()
-    @Allow(Permission.Public)
+    @Allow(Permission.Authenticated)
     async activeChannel(@Ctx() ctx: RequestContext): Promise<Channel> {
         return ctx.channel;
     }

+ 8 - 27
server/src/api/resolvers/country.resolver.ts → server/src/api/resolvers/admin/country.resolver.ts

@@ -8,14 +8,14 @@ import {
     DeletionResponse,
     Permission,
     UpdateCountryMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Translated } from '../../common/types/locale-types';
-import { Country } from '../../entity/country/country.entity';
-import { CountryService } from '../../service/services/country.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Translated } from '../../../common/types/locale-types';
+import { Country } from '../../../entity/country/country.entity';
+import { CountryService } from '../../../service/services/country.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Country')
 export class CountryResolver {
@@ -30,25 +30,6 @@ export class CountryResolver {
         return this.countryService.findAll(ctx, args.options || undefined);
     }
 
-    @Query()
-    @Allow(Permission.Public)
-    availableCountries(
-        @Ctx() ctx: RequestContext,
-        @Args() args: CountriesQueryArgs,
-    ): Promise<Array<Translated<Country>>> {
-        return this.countryService
-            .findAll(ctx, {
-                filter: {
-                    enabled: {
-                        eq: true,
-                    },
-                },
-                skip: 0,
-                take: 99999,
-            })
-            .then(data => data.items);
-    }
-
     @Query()
     @Allow(Permission.ReadSettings)
     async country(

+ 7 - 7
server/src/api/resolvers/customer-group.resolver.ts → server/src/api/resolvers/admin/customer-group.resolver.ts

@@ -7,13 +7,13 @@ import {
     Permission,
     RemoveCustomersFromGroupMutationArgs,
     UpdateCustomerGroupMutationArgs,
-} from '../../../../shared/generated-types';
-import { CustomerGroup } from '../../entity/customer-group/customer-group.entity';
-import { CustomerGroupService } from '../../service/services/customer-group.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { CustomerGroup } from '../../../entity/customer-group/customer-group.entity';
+import { CustomerGroupService } from '../../../service/services/customer-group.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('CustomerGroup')
 export class CustomerGroupResolver {

+ 85 - 0
server/src/api/resolvers/admin/customer.resolver.ts

@@ -0,0 +1,85 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+
+import {
+    CreateCustomerAddressMutationArgs,
+    CreateCustomerMutationArgs,
+    CustomerQueryArgs,
+    CustomersQueryArgs,
+    DeleteCustomerMutationArgs,
+    DeletionResponse,
+    Permission,
+    UpdateCustomerAddressMutationArgs,
+    UpdateCustomerMutationArgs,
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Address } from '../../../entity/address/address.entity';
+import { Customer } from '../../../entity/customer/customer.entity';
+import { CustomerService } from '../../../service/services/customer.service';
+import { OrderService } from '../../../service/services/order.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver()
+export class CustomerResolver {
+    constructor(
+        private customerService: CustomerService,
+        private orderService: OrderService,
+        private idCodecService: IdCodecService,
+    ) {}
+
+    @Query()
+    @Allow(Permission.ReadCustomer)
+    async customers(@Args() args: CustomersQueryArgs): Promise<PaginatedList<Customer>> {
+        return this.customerService.findAll(args.options || undefined);
+    }
+
+    @Query()
+    @Allow(Permission.ReadCustomer)
+    async customer(@Args() args: CustomerQueryArgs): Promise<Customer | undefined> {
+        return this.customerService.findOne(args.id);
+    }
+
+    @Mutation()
+    @Allow(Permission.CreateCustomer)
+    async createCustomer(@Args() args: CreateCustomerMutationArgs): Promise<Customer> {
+        const { input, password } = args;
+        return this.customerService.create(input, password || undefined);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateCustomer)
+    async updateCustomer(@Args() args: UpdateCustomerMutationArgs): Promise<Customer> {
+        const { input } = args;
+        return this.customerService.update(input);
+    }
+
+    @Mutation()
+    @Allow(Permission.CreateCustomer)
+    @Decode('customerId')
+    async createCustomerAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: CreateCustomerAddressMutationArgs,
+    ): Promise<Address> {
+        const { customerId, input } = args;
+        return this.customerService.createAddress(ctx, customerId, input);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateCustomer)
+    async updateCustomerAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: UpdateCustomerAddressMutationArgs,
+    ): Promise<Address> {
+        const { input } = args;
+        return this.customerService.updateAddress(ctx, input);
+    }
+
+    @Mutation()
+    @Allow(Permission.DeleteCustomer)
+    async deleteCustomer(@Args() args: DeleteCustomerMutationArgs): Promise<DeletionResponse> {
+        return this.customerService.softDelete(args.id);
+    }
+}

+ 13 - 13
server/src/api/resolvers/facet.resolver.ts → server/src/api/resolvers/admin/facet.resolver.ts

@@ -11,19 +11,19 @@ import {
     Permission,
     UpdateFacetMutationArgs,
     UpdateFacetValuesMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
-import { EntityNotFoundError } from '../../common/error/errors';
-import { Translated } from '../../common/types/locale-types';
-import { FacetValue } from '../../entity/facet-value/facet-value.entity';
-import { Facet } from '../../entity/facet/facet.entity';
-import { FacetValueService } from '../../service/services/facet-value.service';
-import { FacetService } from '../../service/services/facet.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { DEFAULT_LANGUAGE_CODE } from '../../../common/constants';
+import { EntityNotFoundError } from '../../../common/error/errors';
+import { Translated } from '../../../common/types/locale-types';
+import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
+import { Facet } from '../../../entity/facet/facet.entity';
+import { FacetValueService } from '../../../service/services/facet-value.service';
+import { FacetService } from '../../../service/services/facet.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Facet')
 export class FacetResolver {

+ 5 - 5
server/src/api/resolvers/global-settings.resolver.ts → server/src/api/resolvers/admin/global-settings.resolver.ts

@@ -1,10 +1,10 @@
 import { Args, Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
 
-import { Permission, UpdateGlobalSettingsMutationArgs } from '../../../../shared/generated-types';
-import { VendureConfig } from '../../config';
-import { ConfigService } from '../../config/config.service';
-import { GlobalSettingsService } from '../../service/services/global-settings.service';
-import { Allow } from '../decorators/allow.decorator';
+import { Permission, UpdateGlobalSettingsMutationArgs } from '../../../../../shared/generated-types';
+import { VendureConfig } from '../../../config';
+import { ConfigService } from '../../../config/config.service';
+import { GlobalSettingsService } from '../../../service/services/global-settings.service';
+import { Allow } from '../../decorators/allow.decorator';
 
 @Resolver('GlobalSettings')
 export class GlobalSettingsResolver {

+ 5 - 5
server/src/api/resolvers/import.resolver.ts → server/src/api/resolvers/admin/import.resolver.ts

@@ -1,10 +1,10 @@
 import { Args, Mutation, Resolver } from '@nestjs/graphql';
 
-import { ImportInfo, ImportProductsMutationArgs, Permission } from '../../../../shared/generated-types';
-import { Importer } from '../../data-import/providers/importer/importer';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+import { ImportInfo, ImportProductsMutationArgs, Permission } from '../../../../../shared/generated-types';
+import { Importer } from '../../../data-import/providers/importer/importer';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Import')
 export class ImportResolver {

+ 32 - 0
server/src/api/resolvers/admin/order.resolver.ts

@@ -0,0 +1,32 @@
+import { Args, Query, Resolver } from '@nestjs/graphql';
+
+import { OrderQueryArgs, OrdersQueryArgs, Permission } from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Order } from '../../../entity/order/order.entity';
+import { OrderService } from '../../../service/services/order.service';
+import { ShippingMethodService } from '../../../service/services/shipping-method.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver()
+export class OrderResolver {
+    constructor(
+        private orderService: OrderService,
+        private shippingMethodService: ShippingMethodService,
+        private idCodecService: IdCodecService,
+    ) {}
+
+    @Query()
+    @Allow(Permission.ReadOrder)
+    orders(@Ctx() ctx: RequestContext, @Args() args: OrdersQueryArgs): Promise<PaginatedList<Order>> {
+        return this.orderService.findAll(ctx, args.options || undefined);
+    }
+
+    @Query()
+    @Allow(Permission.ReadOrder)
+    async order(@Ctx() ctx: RequestContext, @Args() args: OrderQueryArgs): Promise<Order | undefined> {
+        return this.orderService.findOne(ctx, args.id);
+    }
+}

+ 5 - 5
server/src/api/resolvers/payment-method.resolver.ts → server/src/api/resolvers/admin/payment-method.resolver.ts

@@ -5,11 +5,11 @@ import {
     PaymentMethodsQueryArgs,
     Permission,
     UpdatePaymentMethodMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { PaymentMethod } from '../../entity/payment-method/payment-method.entity';
-import { PaymentMethodService } from '../../service/services/payment-method.service';
-import { Allow } from '../decorators/allow.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { PaymentMethod } from '../../../entity/payment-method/payment-method.entity';
+import { PaymentMethodService } from '../../../service/services/payment-method.service';
+import { Allow } from '../../decorators/allow.decorator';
 
 @Resolver('PaymentMethod')
 export class PaymentMethodResolver {

+ 15 - 35
server/src/api/resolvers/product-category.resolver.ts → server/src/api/resolvers/admin/product-category.resolver.ts

@@ -7,20 +7,20 @@ import {
     ProductCategoriesQueryArgs,
     ProductCategoryQueryArgs,
     UpdateProductCategoryMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Translated } from '../../common/types/locale-types';
-import { FacetValue } from '../../entity/facet-value/facet-value.entity';
-import { ProductCategory } from '../../entity/product-category/product-category.entity';
-import { FacetValueService } from '../../service/services/facet-value.service';
-import { ProductCategoryService } from '../../service/services/product-category.service';
-import { IdCodecService } from '../common/id-codec.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Translated } from '../../../common/types/locale-types';
+import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
+import { ProductCategory } from '../../../entity/product-category/product-category.entity';
+import { FacetValueService } from '../../../service/services/facet-value.service';
+import { ProductCategoryService } from '../../../service/services/product-category.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
-@Resolver('ProductCategory')
+@Resolver()
 export class ProductCategoryResolver {
     constructor(
         private productCategoryService: ProductCategoryService,
@@ -29,7 +29,7 @@ export class ProductCategoryResolver {
     ) {}
 
     @Query()
-    @Allow(Permission.ReadCatalog, Permission.Public)
+    @Allow(Permission.ReadCatalog)
     async productCategories(
         @Ctx() ctx: RequestContext,
         @Args() args: ProductCategoriesQueryArgs,
@@ -38,7 +38,7 @@ export class ProductCategoryResolver {
     }
 
     @Query()
-    @Allow(Permission.ReadCatalog, Permission.Public)
+    @Allow(Permission.ReadCatalog)
     async productCategory(
         @Ctx() ctx: RequestContext,
         @Args() args: ProductCategoryQueryArgs,
@@ -46,26 +46,6 @@ export class ProductCategoryResolver {
         return this.productCategoryService.findOne(ctx, args.id);
     }
 
-    @ResolveProperty()
-    async descendantFacetValues(
-        @Ctx() ctx: RequestContext,
-        @Parent() category: ProductCategory,
-    ): Promise<Array<Translated<FacetValue>>> {
-        const categoryId = this.idCodecService.decode(category.id);
-        const descendants = await this.productCategoryService.getDescendants(ctx, categoryId);
-        return this.facetValueService.findByCategoryIds(ctx, descendants.map(d => d.id));
-    }
-
-    @ResolveProperty()
-    async ancestorFacetValues(
-        @Ctx() ctx: RequestContext,
-        @Parent() category: ProductCategory,
-    ): Promise<Array<Translated<FacetValue>>> {
-        const categoryId = this.idCodecService.decode(category.id);
-        const ancestors = await this.productCategoryService.getAncestors(categoryId, ctx);
-        return this.facetValueService.findByCategoryIds(ctx, ancestors.map(d => d.id));
-    }
-
     @Mutation()
     @Allow(Permission.CreateCatalog)
     @Decode('assetIds', 'featuredAssetId', 'parentId', 'facetValueIds')

+ 9 - 23
server/src/api/resolvers/product-option.resolver.ts → server/src/api/resolvers/admin/product-option.resolver.ts

@@ -1,4 +1,4 @@
-import { Args, Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 
 import {
     CreateProductOptionGroupMutationArgs,
@@ -6,23 +6,20 @@ import {
     ProductOptionGroupQueryArgs,
     ProductOptionGroupsQueryArgs,
     UpdateProductOptionGroupMutationArgs,
-} from '../../../../shared/generated-types';
-import { Translated } from '../../common/types/locale-types';
-import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
-import { ProductOption } from '../../entity/product-option/product-option.entity';
-import { ProductOptionGroupService } from '../../service/services/product-option-group.service';
-import { ProductOptionService } from '../../service/services/product-option.service';
-import { IdCodecService } from '../common/id-codec.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { Translated } from '../../../common/types/locale-types';
+import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
+import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
+import { ProductOptionService } from '../../../service/services/product-option.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('ProductOptionGroup')
 export class ProductOptionResolver {
     constructor(
         private productOptionGroupService: ProductOptionGroupService,
         private productOptionService: ProductOptionService,
-        private idCodecService: IdCodecService,
     ) {}
 
     @Query()
@@ -43,17 +40,6 @@ export class ProductOptionResolver {
         return this.productOptionGroupService.findOne(args.id, ctx.languageCode);
     }
 
-    @ResolveProperty()
-    @Allow(Permission.ReadCatalog, Permission.Public)
-    async options(optionGroup: Translated<ProductOptionGroup>): Promise<Array<Translated<ProductOption>>> {
-        if (optionGroup.options) {
-            return Promise.resolve(optionGroup.options);
-        }
-        const id = this.idCodecService.decode(optionGroup.id);
-        const group = await this.productOptionGroupService.findOne(id, optionGroup.languageCode);
-        return group ? group.options : [];
-    }
-
     @Mutation()
     @Allow(Permission.CreateCatalog)
     async createProductOptionGroup(

+ 17 - 28
server/src/api/resolvers/product.resolver.ts → server/src/api/resolvers/admin/product.resolver.ts

@@ -1,4 +1,4 @@
-import { Args, Mutation, Parent, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 
 import {
     AddOptionGroupToProductMutationArgs,
@@ -12,32 +12,30 @@ import {
     RemoveOptionGroupFromProductMutationArgs,
     UpdateProductMutationArgs,
     UpdateProductVariantsMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Translated } from '../../common/types/locale-types';
-import { assertFound } from '../../common/utils';
-import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
-import { Product } from '../../entity/product/product.entity';
-import { FacetValueService } from '../../service/services/facet-value.service';
-import { ProductVariantService } from '../../service/services/product-variant.service';
-import { ProductService } from '../../service/services/product.service';
-import { IdCodecService } from '../common/id-codec.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Translated } from '../../../common/types/locale-types';
+import { assertFound } from '../../../common/utils';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { Product } from '../../../entity/product/product.entity';
+import { FacetValueService } from '../../../service/services/facet-value.service';
+import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { ProductService } from '../../../service/services/product.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
-@Resolver('Product')
+@Resolver()
 export class ProductResolver {
     constructor(
         private productService: ProductService,
         private productVariantService: ProductVariantService,
         private facetValueService: FacetValueService,
-        private idCodecService: IdCodecService,
     ) {}
 
     @Query()
-    @Allow(Permission.ReadCatalog, Permission.Public)
+    @Allow(Permission.ReadCatalog)
     async products(
         @Ctx() ctx: RequestContext,
         @Args() args: ProductsQueryArgs,
@@ -46,7 +44,7 @@ export class ProductResolver {
     }
 
     @Query()
-    @Allow(Permission.ReadCatalog, Permission.Public)
+    @Allow(Permission.ReadCatalog)
     async product(
         @Ctx() ctx: RequestContext,
         @Args() args: ProductQueryArgs,
@@ -54,15 +52,6 @@ export class ProductResolver {
         return this.productService.findOne(ctx, args.id);
     }
 
-    @ResolveProperty()
-    async variants(
-        @Ctx() ctx: RequestContext,
-        @Parent() product: Product,
-    ): Promise<Array<Translated<ProductVariant>>> {
-        const productId = this.idCodecService.decode(product.id);
-        return this.productVariantService.getVariantsByProductId(ctx, productId);
-    }
-
     @Mutation()
     @Allow(Permission.CreateCatalog)
     @Decode('assetIds', 'featuredAssetId', 'facetValueIds')

+ 7 - 7
server/src/api/resolvers/promotion.resolver.ts → server/src/api/resolvers/admin/promotion.resolver.ts

@@ -8,13 +8,13 @@ import {
     PromotionQueryArgs,
     PromotionsQueryArgs,
     UpdatePromotionMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Promotion } from '../../entity/promotion/promotion.entity';
-import { PromotionService } from '../../service/services/promotion.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Promotion } from '../../../entity/promotion/promotion.entity';
+import { PromotionService } from '../../../service/services/promotion.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Promotion')
 export class PromotionResolver {

+ 5 - 5
server/src/api/resolvers/role.resolver.ts → server/src/api/resolvers/admin/role.resolver.ts

@@ -6,11 +6,11 @@ import {
     RoleQueryArgs,
     RolesQueryArgs,
     UpdateRoleMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Role } from '../../entity/role/role.entity';
-import { RoleService } from '../../service/services/role.service';
-import { Allow } from '../decorators/allow.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Role } from '../../../entity/role/role.entity';
+import { RoleService } from '../../../service/services/role.service';
+import { Allow } from '../../decorators/allow.decorator';
 
 @Resolver('Roles')
 export class RoleResolver {

+ 7 - 7
server/src/api/resolvers/search.resolver.ts → server/src/api/resolvers/admin/search.resolver.ts

@@ -1,16 +1,16 @@
 import { Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
 
-import { Permission, SearchResponse } from '../../../../shared/generated-types';
-import { Omit } from '../../../../shared/omit';
-import { Allow } from '../../api/decorators/allow.decorator';
-import { InternalServerError } from '../../common/error/errors';
-import { Translated } from '../../common/types/locale-types';
-import { FacetValue } from '../../entity';
+import { Permission, SearchResponse } from '../../../../../shared/generated-types';
+import { Omit } from '../../../../../shared/omit';
+import { InternalServerError } from '../../../common/error/errors';
+import { Translated } from '../../../common/types/locale-types';
+import { FacetValue } from '../../../entity';
+import { Allow } from '../../decorators/allow.decorator';
 
 @Resolver()
 export class SearchResolver {
     @Query()
-    @Allow(Permission.Public)
+    @Allow(Permission.ReadCatalog)
     async search(...args: any): Promise<Omit<SearchResponse, 'facetValues'>> {
         throw new InternalServerError(`error.no-search-plugin-configured`);
     }

+ 5 - 5
server/src/api/resolvers/shipping-method.resolver.ts → server/src/api/resolvers/admin/shipping-method.resolver.ts

@@ -7,11 +7,11 @@ import {
     ShippingMethodQueryArgs,
     ShippingMethodsQueryArgs,
     UpdateShippingMethodMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity';
-import { ShippingMethodService } from '../../service/services/shipping-method.service';
-import { Allow } from '../decorators/allow.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { ShippingMethod } from '../../../entity/shipping-method/shipping-method.entity';
+import { ShippingMethodService } from '../../../service/services/shipping-method.service';
+import { Allow } from '../../decorators/allow.decorator';
 
 @Resolver('ShippingMethod')
 export class ShippingMethodResolver {

+ 6 - 6
server/src/api/resolvers/tax-category.resolver.ts → server/src/api/resolvers/admin/tax-category.resolver.ts

@@ -5,12 +5,12 @@ import {
     Permission,
     TaxCategoryQueryArgs,
     UpdateTaxCategoryMutationArgs,
-} from '../../../../shared/generated-types';
-import { TaxCategory } from '../../entity/tax-category/tax-category.entity';
-import { TaxCategoryService } from '../../service/services/tax-category.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { TaxCategory } from '../../../entity/tax-category/tax-category.entity';
+import { TaxCategoryService } from '../../../service/services/tax-category.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('TaxCategory')
 export class TaxCategoryResolver {

+ 8 - 8
server/src/api/resolvers/tax-rate.resolver.ts → server/src/api/resolvers/admin/tax-rate.resolver.ts

@@ -6,14 +6,14 @@ import {
     TaxRateQueryArgs,
     TaxRatesQueryArgs,
     UpdateTaxRateMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { TaxRate } from '../../entity/tax-rate/tax-rate.entity';
-import { TaxRateService } from '../../service/services/tax-rate.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { TaxRate } from '../../../entity/tax-rate/tax-rate.entity';
+import { TaxRateService } from '../../../service/services/tax-rate.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('TaxRate')
 export class TaxRateResolver {

+ 7 - 7
server/src/api/resolvers/zone.resolver.ts → server/src/api/resolvers/admin/zone.resolver.ts

@@ -9,13 +9,13 @@ import {
     RemoveMembersFromZoneMutationArgs,
     UpdateZoneMutationArgs,
     ZoneQueryArgs,
-} from '../../../../shared/generated-types';
-import { Zone } from '../../entity/zone/zone.entity';
-import { ZoneService } from '../../service/services/zone.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-types';
+import { Zone } from '../../../entity/zone/zone.entity';
+import { ZoneService } from '../../../service/services/zone.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Zone')
 export class ZoneResolver {

+ 0 - 162
server/src/api/resolvers/auth.resolver.ts

@@ -1,162 +0,0 @@
-import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
-import { Request, Response } from 'express';
-
-import {
-    LoginMutationArgs,
-    LoginResult,
-    Permission,
-    RefreshCustomerVerificationMutationArgs,
-    RegisterCustomerAccountMutationArgs,
-    VerifyCustomerAccountMutationArgs,
-} from '../../../../shared/generated-types';
-import { VerificationTokenError } from '../../common/error/errors';
-import { ConfigService } from '../../config/config.service';
-import { User } from '../../entity/user/user.entity';
-import { AuthService } from '../../service/services/auth.service';
-import { ChannelService } from '../../service/services/channel.service';
-import { CustomerService } from '../../service/services/customer.service';
-import { UserService } from '../../service/services/user.service';
-import { extractAuthToken } from '../common/extract-auth-token';
-import { RequestContext } from '../common/request-context';
-import { setAuthToken } from '../common/set-auth-token';
-import { Allow } from '../decorators/allow.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
-
-@Resolver('Auth')
-export class AuthResolver {
-    constructor(
-        private authService: AuthService,
-        private userService: UserService,
-        private channelService: ChannelService,
-        private customerService: CustomerService,
-        private configService: ConfigService,
-    ) {}
-
-    /**
-     * Attempts a login given the username and password of a user. If successful, returns
-     * the user data and returns the token either in a cookie or in the response body.
-     */
-    @Mutation()
-    @Allow(Permission.Public)
-    async login(
-        @Args() args: LoginMutationArgs,
-        @Ctx() ctx: RequestContext,
-        @Context('req') req: Request,
-        @Context('res') res: Response,
-    ): Promise<LoginResult> {
-        return await this.createAuthenticatedSession(ctx, args, req, res);
-    }
-
-    @Mutation()
-    @Allow(Permission.Public)
-    async logout(@Context('req') req: Request, @Context('res') res: Response): Promise<boolean> {
-        const token = extractAuthToken(req, this.configService.authOptions.tokenMethod);
-        if (!token) {
-            return false;
-        }
-        await this.authService.deleteSessionByToken(token);
-        setAuthToken({
-            req,
-            res,
-            authOptions: this.configService.authOptions,
-            rememberMe: false,
-            authToken: '',
-        });
-        return true;
-    }
-
-    @Mutation()
-    @Allow(Permission.Public)
-    async registerCustomerAccount(
-        @Ctx() ctx: RequestContext,
-        @Args() args: RegisterCustomerAccountMutationArgs,
-    ) {
-        return this.customerService.registerCustomerAccount(ctx, args.input).then(() => true);
-    }
-
-    @Mutation()
-    @Allow(Permission.Public)
-    async verifyCustomerAccount(
-        @Ctx() ctx: RequestContext,
-        @Args() args: VerifyCustomerAccountMutationArgs,
-        @Context('req') req: Request,
-        @Context('res') res: Response,
-    ) {
-        const customer = await this.customerService.verifyCustomerEmailAddress(
-            ctx,
-            args.token,
-            args.password,
-        );
-        if (customer && customer.user) {
-            return this.createAuthenticatedSession(
-                ctx,
-                {
-                    username: customer.user.identifier,
-                    password: args.password,
-                    rememberMe: true,
-                },
-                req,
-                res,
-            );
-        } else {
-            throw new VerificationTokenError();
-        }
-    }
-
-    @Mutation()
-    @Allow(Permission.Public)
-    async refreshCustomerVerification(
-        @Ctx() ctx: RequestContext,
-        @Args() args: RefreshCustomerVerificationMutationArgs,
-    ) {
-        return this.customerService.refreshVerificationToken(ctx, args.emailAddress).then(() => true);
-    }
-
-    /**
-     * Returns information about the current authenticated user.
-     */
-    @Query()
-    @Allow(Permission.Authenticated)
-    async me(@Ctx() ctx: RequestContext) {
-        const userId = ctx.activeUserId;
-        const user = userId && (await this.userService.getUserById(userId));
-        return user ? this.publiclyAccessibleUser(user) : null;
-    }
-
-    /**
-     * Creates an authenticated session and sets the session token.
-     */
-    private async createAuthenticatedSession(
-        ctx: RequestContext,
-        args: LoginMutationArgs,
-        req: Request,
-        res: Response,
-    ) {
-        const session = await this.authService.authenticate(ctx, args.username, args.password);
-        setAuthToken({
-            req,
-            res,
-            authOptions: this.configService.authOptions,
-            rememberMe: args.rememberMe || false,
-            authToken: session.token,
-        });
-        return {
-            user: this.publiclyAccessibleUser(session.user),
-        };
-    }
-
-    /**
-     * Exposes a subset of the User properties which we want to expose to the public API.
-     */
-    private publiclyAccessibleUser(user: User): any {
-        return {
-            id: user.id,
-            identifier: user.identifier,
-            channelTokens: this.getAvailableChannelTokens(user),
-        };
-    }
-
-    private getAvailableChannelTokens(user: User): string[] {
-        return user.roles.reduce((tokens, role) => role.channels.map(c => c.token), [] as string[]);
-    }
-}

+ 95 - 0
server/src/api/resolvers/base/base-auth.resolver.ts

@@ -0,0 +1,95 @@
+import { Request, Response } from 'express';
+
+import { LoginMutationArgs, LoginResult } from '../../../../../shared/generated-types';
+import { ConfigService } from '../../../config/config.service';
+import { User } from '../../../entity/user/user.entity';
+import { AuthService } from '../../../service/services/auth.service';
+import { ChannelService } from '../../../service/services/channel.service';
+import { CustomerService } from '../../../service/services/customer.service';
+import { UserService } from '../../../service/services/user.service';
+import { extractAuthToken } from '../../common/extract-auth-token';
+import { RequestContext } from '../../common/request-context';
+import { setAuthToken } from '../../common/set-auth-token';
+
+export class BaseAuthResolver {
+    constructor(
+        protected authService: AuthService,
+        protected userService: UserService,
+        protected configService: ConfigService,
+    ) {}
+
+    /**
+     * Attempts a login given the username and password of a user. If successful, returns
+     * the user data and returns the token either in a cookie or in the response body.
+     */
+    async login(
+        args: LoginMutationArgs,
+        ctx: RequestContext,
+        req: Request,
+        res: Response,
+    ): Promise<LoginResult> {
+        return await this.createAuthenticatedSession(ctx, args, req, res);
+    }
+
+    async logout(req: Request, res: Response): Promise<boolean> {
+        const token = extractAuthToken(req, this.configService.authOptions.tokenMethod);
+        if (!token) {
+            return false;
+        }
+        await this.authService.deleteSessionByToken(token);
+        setAuthToken({
+            req,
+            res,
+            authOptions: this.configService.authOptions,
+            rememberMe: false,
+            authToken: '',
+        });
+        return true;
+    }
+
+    /**
+     * Returns information about the current authenticated user.
+     */
+    async me(ctx: RequestContext) {
+        const userId = ctx.activeUserId;
+        const user = userId && (await this.userService.getUserById(userId));
+        return user ? this.publiclyAccessibleUser(user) : null;
+    }
+
+    /**
+     * Creates an authenticated session and sets the session token.
+     */
+    protected async createAuthenticatedSession(
+        ctx: RequestContext,
+        args: LoginMutationArgs,
+        req: Request,
+        res: Response,
+    ) {
+        const session = await this.authService.authenticate(ctx, args.username, args.password);
+        setAuthToken({
+            req,
+            res,
+            authOptions: this.configService.authOptions,
+            rememberMe: args.rememberMe || false,
+            authToken: session.token,
+        });
+        return {
+            user: this.publiclyAccessibleUser(session.user),
+        };
+    }
+
+    /**
+     * Exposes a subset of the User properties which we want to expose to the public API.
+     */
+    private publiclyAccessibleUser(user: User): any {
+        return {
+            id: user.id,
+            identifier: user.identifier,
+            channelTokens: this.getAvailableChannelTokens(user),
+        };
+    }
+
+    private getAvailableChannelTokens(user: User): string[] {
+        return user.roles.reduce((tokens, role) => role.channels.map(c => c.token), [] as string[]);
+    }
+}

+ 0 - 131
server/src/api/resolvers/customer.resolver.ts

@@ -1,131 +0,0 @@
-import { Args, Mutation, Parent, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
-
-import {
-    CreateCustomerAddressMutationArgs,
-    CreateCustomerMutationArgs,
-    CustomerQueryArgs,
-    CustomersQueryArgs,
-    DeleteCustomerMutationArgs,
-    DeletionResponse,
-    OrdersCustomerArgs,
-    Permission,
-    UpdateCustomerAddressMutationArgs,
-    UpdateCustomerMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { UnauthorizedError } from '../../common/error/errors';
-import { idsAreEqual } from '../../common/utils';
-import { Address } from '../../entity/address/address.entity';
-import { Customer } from '../../entity/customer/customer.entity';
-import { Order } from '../../entity/order/order.entity';
-import { CustomerService } from '../../service/services/customer.service';
-import { OrderService } from '../../service/services/order.service';
-import { IdCodecService } from '../common/id-codec.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
-
-@Resolver('Customer')
-export class CustomerResolver {
-    constructor(
-        private customerService: CustomerService,
-        private orderService: OrderService,
-        private idCodecService: IdCodecService,
-    ) {}
-
-    @Query()
-    @Allow(Permission.ReadCustomer)
-    async customers(@Args() args: CustomersQueryArgs): Promise<PaginatedList<Customer>> {
-        return this.customerService.findAll(args.options || undefined);
-    }
-
-    @Query()
-    @Allow(Permission.ReadCustomer)
-    async customer(@Args() args: CustomerQueryArgs): Promise<Customer | undefined> {
-        return this.customerService.findOne(args.id);
-    }
-
-    @Query()
-    @Allow(Permission.Owner)
-    async activeCustomer(@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
-        const userId = ctx.activeUserId;
-        if (userId) {
-            return this.customerService.findOneByUserId(userId);
-        }
-    }
-
-    @ResolveProperty()
-    @Allow(Permission.ReadCustomer, Permission.Owner)
-    async addresses(@Ctx() ctx: RequestContext, @Parent() customer: Customer): Promise<Address[]> {
-        this.checkOwnerPermissions(ctx, customer);
-        const customerId = this.idCodecService.decode(customer.id);
-        return this.customerService.findAddressesByCustomerId(ctx, customerId);
-    }
-
-    @ResolveProperty()
-    @Allow(Permission.ReadOrder, Permission.Owner)
-    async orders(
-        @Ctx() ctx: RequestContext,
-        @Parent() customer: Customer,
-        @Args() args: OrdersCustomerArgs,
-    ): Promise<PaginatedList<Order>> {
-        this.checkOwnerPermissions(ctx, customer);
-        const customerId = this.idCodecService.decode(customer.id);
-        return this.orderService.findByCustomerId(ctx, customerId, args.options || undefined);
-    }
-
-    @Mutation()
-    @Allow(Permission.CreateCustomer)
-    async createCustomer(@Args() args: CreateCustomerMutationArgs): Promise<Customer> {
-        const { input, password } = args;
-        return this.customerService.create(input, password || undefined);
-    }
-
-    @Mutation()
-    @Allow(Permission.UpdateCustomer)
-    async updateCustomer(@Args() args: UpdateCustomerMutationArgs): Promise<Customer> {
-        const { input } = args;
-        return this.customerService.update(input);
-    }
-
-    @Mutation()
-    @Allow(Permission.CreateCustomer)
-    @Decode('customerId')
-    async createCustomerAddress(
-        @Ctx() ctx: RequestContext,
-        @Args() args: CreateCustomerAddressMutationArgs,
-    ): Promise<Address> {
-        const { customerId, input } = args;
-        return this.customerService.createAddress(ctx, customerId, input);
-    }
-
-    @Mutation()
-    @Allow(Permission.UpdateCustomer)
-    async updateCustomerAddress(
-        @Ctx() ctx: RequestContext,
-        @Args() args: UpdateCustomerAddressMutationArgs,
-    ): Promise<Address> {
-        const { input } = args;
-        return this.customerService.updateAddress(ctx, input);
-    }
-
-    @Mutation()
-    @Allow(Permission.DeleteCustomer)
-    async deleteCustomer(@Args() args: DeleteCustomerMutationArgs): Promise<DeletionResponse> {
-        return this.customerService.softDelete(args.id);
-    }
-
-    /**
-     * If the current request is authorized as the Owner, ensure that the userId matches that
-     * of the Customer data being requested.
-     */
-    private checkOwnerPermissions(ctx: RequestContext, customer: Customer) {
-        if (ctx.authorizedAsOwnerOnly) {
-            const userId = customer.user && this.idCodecService.decode(customer.user.id);
-            if (userId && !idsAreEqual(userId, ctx.activeUserId)) {
-                throw new UnauthorizedError();
-            }
-        }
-    }
-}

+ 36 - 0
server/src/api/resolvers/entity/customer-entity.resolver.ts

@@ -0,0 +1,36 @@
+import { Args, Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { OrdersCustomerArgs } from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { Address } from '../../../entity/address/address.entity';
+import { Customer } from '../../../entity/customer/customer.entity';
+import { Order } from '../../../entity/order/order.entity';
+import { CustomerService } from '../../../service/services/customer.service';
+import { OrderService } from '../../../service/services/order.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('Customer')
+export class CustomerEntityResolver {
+    constructor(
+        private customerService: CustomerService,
+        private orderService: OrderService,
+        private idCodecService: IdCodecService,
+    ) {}
+    @ResolveProperty()
+    async addresses(@Ctx() ctx: RequestContext, @Parent() customer: Customer): Promise<Address[]> {
+        const customerId = this.idCodecService.decode(customer.id);
+        return this.customerService.findAddressesByCustomerId(ctx, customerId);
+    }
+
+    @ResolveProperty()
+    async orders(
+        @Ctx() ctx: RequestContext,
+        @Parent() customer: Customer,
+        @Args() args: OrdersCustomerArgs,
+    ): Promise<PaginatedList<Order>> {
+        const customerId = this.idCodecService.decode(customer.id);
+        return this.orderService.findByCustomerId(ctx, customerId, args.options || undefined);
+    }
+}

+ 33 - 0
server/src/api/resolvers/entity/order-entity.resolver.ts

@@ -0,0 +1,33 @@
+import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Order } from '../../../entity/order/order.entity';
+import { OrderService } from '../../../service/services/order.service';
+import { ShippingMethodService } from '../../../service/services/shipping-method.service';
+import { IdCodecService } from '../../common/id-codec.service';
+
+@Resolver('Order')
+export class OrderEntityResolver {
+    constructor(
+        private orderService: OrderService,
+        private shippingMethodService: ShippingMethodService,
+        private idCodecService: IdCodecService,
+    ) {}
+
+    @ResolveProperty()
+    async payments(@Parent() order: Order) {
+        const orderId = this.idCodecService.decode(order.id);
+        return this.orderService.getOrderPayments(orderId);
+    }
+
+    @ResolveProperty()
+    async shippingMethod(@Parent() order: Order) {
+        if (order.shippingMethodId) {
+            // Does not need to be decoded because it is an internal property
+            // which is never exposed to the outside world.
+            const shippingMethodId = order.shippingMethodId;
+            return this.shippingMethodService.findOne(shippingMethodId);
+        } else {
+            return null;
+        }
+    }
+}

+ 26 - 0
server/src/api/resolvers/entity/order-line-entity.resolver.ts

@@ -0,0 +1,26 @@
+import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Translated } from '../../../common/types/locale-types';
+import { assertFound } from '../../../common/utils';
+import { OrderLine, ProductVariant } from '../../../entity';
+import { ProductVariantService } from '../../../service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('OrderLine')
+export class OrderLineEntityResolver {
+    constructor(
+        private productVariantService: ProductVariantService,
+        private idCodecService: IdCodecService,
+    ) {}
+
+    @ResolveProperty()
+    async productVariant(
+        @Ctx() ctx: RequestContext,
+        @Parent() orderLine: OrderLine,
+    ): Promise<Translated<ProductVariant>> {
+        const id = this.idCodecService.decode(orderLine.productVariant.id);
+        return assertFound(this.productVariantService.findOne(ctx, id));
+    }
+}

+ 39 - 0
server/src/api/resolvers/entity/product-category-entity.resolver.ts

@@ -0,0 +1,39 @@
+import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Translated } from '../../../common/types/locale-types';
+import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
+import { ProductCategory } from '../../../entity/product-category/product-category.entity';
+import { FacetValueService } from '../../../service/services/facet-value.service';
+import { ProductCategoryService } from '../../../service/services/product-category.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('ProductCategory')
+export class ProductCategoryEntityResolver {
+    constructor(
+        private productCategoryService: ProductCategoryService,
+        private facetValueService: FacetValueService,
+        private idCodecService: IdCodecService,
+    ) {}
+
+    @ResolveProperty()
+    async descendantFacetValues(
+        @Ctx() ctx: RequestContext,
+        @Parent() category: ProductCategory,
+    ): Promise<Array<Translated<FacetValue>>> {
+        const categoryId = this.idCodecService.decode(category.id);
+        const descendants = await this.productCategoryService.getDescendants(ctx, categoryId);
+        return this.facetValueService.findByCategoryIds(ctx, descendants.map(d => d.id));
+    }
+
+    @ResolveProperty()
+    async ancestorFacetValues(
+        @Ctx() ctx: RequestContext,
+        @Parent() category: ProductCategory,
+    ): Promise<Array<Translated<FacetValue>>> {
+        const categoryId = this.idCodecService.decode(category.id);
+        const ancestors = await this.productCategoryService.getAncestors(categoryId, ctx);
+        return this.facetValueService.findByCategoryIds(ctx, ancestors.map(d => d.id));
+    }
+}

+ 26 - 0
server/src/api/resolvers/entity/product-entity.resolver.ts

@@ -0,0 +1,26 @@
+import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Translated } from '../../../common/types/locale-types';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { Product } from '../../../entity/product/product.entity';
+import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('Product')
+export class ProductEntityResolver {
+    constructor(
+        private productVariantService: ProductVariantService,
+        private idCodecService: IdCodecService,
+    ) {}
+
+    @ResolveProperty()
+    async variants(
+        @Ctx() ctx: RequestContext,
+        @Parent() product: Product,
+    ): Promise<Array<Translated<ProductVariant>>> {
+        const productId = this.idCodecService.decode(product.id);
+        return this.productVariantService.getVariantsByProductId(ctx, productId);
+    }
+}

+ 28 - 0
server/src/api/resolvers/entity/product-option-group-entity.resolver.ts

@@ -0,0 +1,28 @@
+import { ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Permission } from '../../../../../shared/generated-types';
+import { Translated } from '../../../common/types/locale-types';
+import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
+import { ProductOption } from '../../../entity/product-option/product-option.entity';
+import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { Allow } from '../../decorators/allow.decorator';
+
+@Resolver('ProductOptionGroup')
+export class ProductOptionGroupEntityResolver {
+    constructor(
+        private productOptionGroupService: ProductOptionGroupService,
+        private idCodecService: IdCodecService,
+    ) {}
+
+    @ResolveProperty()
+    @Allow(Permission.ReadCatalog, Permission.Public)
+    async options(optionGroup: Translated<ProductOptionGroup>): Promise<Array<Translated<ProductOption>>> {
+        if (optionGroup.options) {
+            return Promise.resolve(optionGroup.options);
+        }
+        const id = this.idCodecService.decode(optionGroup.id);
+        const group = await this.productOptionGroupService.findOne(id, optionGroup.languageCode);
+        return group ? group.options : [];
+    }
+}

+ 102 - 0
server/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -0,0 +1,102 @@
+import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
+import { Request, Response } from 'express';
+
+import {
+    LoginMutationArgs,
+    LoginResult,
+    Permission,
+    RefreshCustomerVerificationMutationArgs,
+    RegisterCustomerAccountMutationArgs,
+    VerifyCustomerAccountMutationArgs,
+} from '../../../../../shared/generated-shop-types';
+import { VerificationTokenError } from '../../../common/error/errors';
+import { ConfigService } from '../../../config/config.service';
+import { AuthService } from '../../../service/services/auth.service';
+import { CustomerService } from '../../../service/services/customer.service';
+import { UserService } from '../../../service/services/user.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
+import { BaseAuthResolver } from '../base/base-auth.resolver';
+
+@Resolver()
+export class ShopAuthResolver extends BaseAuthResolver {
+    constructor(
+        protected authService: AuthService,
+        protected userService: UserService,
+        protected customerService: CustomerService,
+        protected configService: ConfigService,
+    ) {
+        super(authService, userService, configService);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    login(
+        @Args() args: LoginMutationArgs,
+        @Ctx() ctx: RequestContext,
+        @Context('req') req: Request,
+        @Context('res') res: Response,
+    ): Promise<LoginResult> {
+        return super.login(args, ctx, req, res);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    logout(@Context('req') req: Request, @Context('res') res: Response): Promise<boolean> {
+        return super.logout(req, res);
+    }
+
+    @Query()
+    @Allow(Permission.Authenticated)
+    me(@Ctx() ctx: RequestContext) {
+        return super.me(ctx);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    async registerCustomerAccount(
+        @Ctx() ctx: RequestContext,
+        @Args() args: RegisterCustomerAccountMutationArgs,
+    ) {
+        return this.customerService.registerCustomerAccount(ctx, args.input).then(() => true);
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    async verifyCustomerAccount(
+        @Ctx() ctx: RequestContext,
+        @Args() args: VerifyCustomerAccountMutationArgs,
+        @Context('req') req: Request,
+        @Context('res') res: Response,
+    ) {
+        const customer = await this.customerService.verifyCustomerEmailAddress(
+            ctx,
+            args.token,
+            args.password,
+        );
+        if (customer && customer.user) {
+            return super.createAuthenticatedSession(
+                ctx,
+                {
+                    username: customer.user.identifier,
+                    password: args.password,
+                    rememberMe: true,
+                },
+                req,
+                res,
+            );
+        } else {
+            throw new VerificationTokenError();
+        }
+    }
+
+    @Mutation()
+    @Allow(Permission.Public)
+    async refreshCustomerVerification(
+        @Ctx() ctx: RequestContext,
+        @Args() args: RefreshCustomerVerificationMutationArgs,
+    ) {
+        return this.customerService.refreshVerificationToken(ctx, args.emailAddress).then(() => true);
+    }
+}

+ 72 - 0
server/src/api/resolvers/shop/shop-customer.resolver.ts

@@ -0,0 +1,72 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+
+import { Decode } from '../..';
+import {
+    CreateCustomerAddressMutationArgs,
+    Permission,
+    UpdateCustomerAddressMutationArgs,
+    UpdateCustomerMutationArgs,
+} from '../../../../../shared/generated-types';
+import { UnauthorizedError } from '../../../common/error/errors';
+import { idsAreEqual } from '../../../common/utils';
+import { Address, Customer } from '../../../entity';
+import { CustomerService } from '../../../service/services/customer.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver()
+export class ShopCustomerResolver {
+    constructor(private customerService: CustomerService, private idCodecService: IdCodecService) {}
+
+    @Query()
+    @Allow(Permission.Owner)
+    async activeCustomer(@Ctx() ctx: RequestContext): Promise<Customer | undefined> {
+        const userId = ctx.activeUserId;
+        if (userId) {
+            return this.customerService.findOneByUserId(userId);
+        }
+    }
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    async updateCustomer(@Args() args: UpdateCustomerMutationArgs): Promise<Customer> {
+        // TODO: implement for owner
+        return null as any;
+    }
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    @Decode('customerId')
+    async createCustomerAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: CreateCustomerAddressMutationArgs,
+    ): Promise<Address> {
+        // TODO: implement for owner
+        return null as any;
+    }
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    async updateCustomerAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: UpdateCustomerAddressMutationArgs,
+    ): Promise<Address> {
+        // TODO: implement for owner
+        return null as any;
+    }
+
+    /**
+     * If the current request is authorized as the Owner, ensure that the userId matches that
+     * of the Customer data being requested.
+     */
+    private checkOwnerPermissions(ctx: RequestContext, customer: Customer) {
+        if (ctx.authorizedAsOwnerOnly) {
+            const userId = customer.user && this.idCodecService.decode(customer.user.id);
+            if (userId && !idsAreEqual(userId, ctx.activeUserId)) {
+                throw new UnauthorizedError();
+            }
+        }
+    }
+}

+ 36 - 44
server/src/api/resolvers/order.resolver.ts → server/src/api/resolvers/shop/shop-order.resolver.ts

@@ -1,4 +1,4 @@
-import { Args, Mutation, Parent, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import ms from 'ms';
 
 import {
@@ -7,7 +7,6 @@ import {
     AdjustItemQuantityMutationArgs,
     OrderByCodeQueryArgs,
     OrderQueryArgs,
-    OrdersQueryArgs,
     Permission,
     RemoveItemFromOrderMutationArgs,
     SetCustomerForOrderMutationArgs,
@@ -15,40 +14,52 @@ import {
     SetOrderShippingMethodMutationArgs,
     ShippingMethodQuote,
     TransitionOrderToStateMutationArgs,
-} from '../../../../shared/generated-types';
-import { PaginatedList } from '../../../../shared/shared-types';
-import { ForbiddenError, InternalServerError } from '../../common/error/errors';
-import { idsAreEqual } from '../../common/utils';
-import { Order } from '../../entity/order/order.entity';
-import { OrderState } from '../../service/helpers/order-state-machine/order-state';
-import { AuthService } from '../../service/services/auth.service';
-import { CustomerService } from '../../service/services/customer.service';
-import { OrderService } from '../../service/services/order.service';
-import { ShippingMethodService } from '../../service/services/shipping-method.service';
-import { IdCodecService } from '../common/id-codec.service';
-import { RequestContext } from '../common/request-context';
-import { Allow } from '../decorators/allow.decorator';
-import { Decode } from '../decorators/decode.decorator';
-import { Ctx } from '../decorators/request-context.decorator';
+} from '../../../../../shared/generated-shop-types';
+import { CountriesQueryArgs } from '../../../../../shared/generated-types';
+import { ForbiddenError, InternalServerError } from '../../../common/error/errors';
+import { Translated } from '../../../common/types/locale-types';
+import { idsAreEqual } from '../../../common/utils';
+import { Country } from '../../../entity';
+import { Order } from '../../../entity/order/order.entity';
+import { CountryService } from '../../../service';
+import { OrderState } from '../../../service/helpers/order-state-machine/order-state';
+import { AuthService } from '../../../service/services/auth.service';
+import { CustomerService } from '../../../service/services/customer.service';
+import { OrderService } from '../../../service/services/order.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Decode } from '../../decorators/decode.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
 
-@Resolver('Order')
-export class OrderResolver {
+@Resolver()
+export class ShopOrderResolver {
     constructor(
         private orderService: OrderService,
-        private shippingMethodService: ShippingMethodService,
         private customerService: CustomerService,
         private authService: AuthService,
-        private idCodecService: IdCodecService,
+        private countryService: CountryService,
     ) {}
 
     @Query()
-    @Allow(Permission.ReadOrder)
-    orders(@Ctx() ctx: RequestContext, @Args() args: OrdersQueryArgs): Promise<PaginatedList<Order>> {
-        return this.orderService.findAll(ctx, args.options || undefined);
+    availableCountries(
+        @Ctx() ctx: RequestContext,
+        @Args() args: CountriesQueryArgs,
+    ): Promise<Array<Translated<Country>>> {
+        return this.countryService
+            .findAll(ctx, {
+                filter: {
+                    enabled: {
+                        eq: true,
+                    },
+                },
+                skip: 0,
+                take: 99999,
+            })
+            .then(data => data.items);
     }
 
     @Query()
-    @Allow(Permission.ReadOrder, Permission.Owner)
+    @Allow(Permission.Owner)
     async order(@Ctx() ctx: RequestContext, @Args() args: OrderQueryArgs): Promise<Order | undefined> {
         const order = await this.orderService.findOne(ctx, args.id);
         if (order && ctx.authorizedAsOwnerOnly) {
@@ -59,25 +70,6 @@ export class OrderResolver {
                 return;
             }
         }
-        return order;
-    }
-
-    @ResolveProperty()
-    async payments(@Parent() order: Order) {
-        const orderId = this.idCodecService.decode(order.id);
-        return this.orderService.getOrderPayments(orderId);
-    }
-
-    @ResolveProperty()
-    async shippingMethod(@Parent() order: Order) {
-        if (order.shippingMethodId) {
-            // Does not need to be decoded because it is an internal property
-            // which is never exposed to the outside world.
-            const shippingMethodId = order.shippingMethodId;
-            return this.shippingMethodService.findOne(shippingMethodId);
-        } else {
-            return null;
-        }
     }
 
     @Query()

+ 70 - 0
server/src/api/resolvers/shop/shop-products.resolver.ts

@@ -0,0 +1,70 @@
+import { Args, Query, Resolver } from '@nestjs/graphql';
+
+import {
+    ProductCategoriesQueryArgs,
+    ProductCategoryQueryArgs,
+    ProductQueryArgs,
+    ProductsQueryArgs,
+    SearchResponse,
+} from '../../../../../shared/generated-types';
+import { Omit } from '../../../../../shared/omit';
+import { PaginatedList } from '../../../../../shared/shared-types';
+import { InternalServerError } from '../../../common/error/errors';
+import { Translated } from '../../../common/types/locale-types';
+import { ProductCategory } from '../../../entity';
+import { Product } from '../../../entity/product/product.entity';
+import { ProductCategoryService } from '../../../service';
+import { FacetValueService } from '../../../service/services/facet-value.service';
+import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { ProductService } from '../../../service/services/product.service';
+import { IdCodecService } from '../../common/id-codec.service';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver()
+export class ShopProductsResolver {
+    constructor(
+        private productService: ProductService,
+        private productVariantService: ProductVariantService,
+        private facetValueService: FacetValueService,
+        private idCodecService: IdCodecService,
+        private productCategoryService: ProductCategoryService,
+    ) {}
+
+    @Query()
+    async products(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ProductsQueryArgs,
+    ): Promise<PaginatedList<Translated<Product>>> {
+        return this.productService.findAll(ctx, args.options || undefined);
+    }
+
+    @Query()
+    async product(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ProductQueryArgs,
+    ): Promise<Translated<Product> | undefined> {
+        return this.productService.findOne(ctx, args.id);
+    }
+
+    @Query()
+    async productCategories(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ProductCategoriesQueryArgs,
+    ): Promise<PaginatedList<Translated<ProductCategory>>> {
+        return this.productCategoryService.findAll(ctx, args.options || undefined);
+    }
+
+    @Query()
+    async productCategory(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ProductCategoryQueryArgs,
+    ): Promise<Translated<ProductCategory> | undefined> {
+        return this.productCategoryService.findOne(ctx, args.id);
+    }
+
+    @Query()
+    async search(...args: any): Promise<Omit<SearchResponse, 'facetValues'>> {
+        throw new InternalServerError(`error.no-search-plugin-configured`);
+    }
+}

+ 33 - 0
server/src/api/schema/admin-api/administrator.api.graphql

@@ -0,0 +1,33 @@
+type Query {
+    administrators(options: AdministratorListOptions): AdministratorList!
+    administrator(id: ID!): Administrator
+}
+
+type Mutation {
+    "Create a new Administrator"
+    createAdministrator(input: CreateAdministratorInput!): Administrator!
+    "Update an existing Administrator"
+    updateAdministrator(input: UpdateAdministratorInput!): Administrator!
+    "Assign a Role to an Administrator"
+    assignRoleToAdministrator(administratorId: ID!, roleId: ID!): Administrator!
+}
+
+# generated by generateListOptions function
+input AdministratorListOptions
+
+input CreateAdministratorInput {
+    firstName: String!
+    lastName: String!
+    emailAddress: String!
+    password: String!
+    roleIds: [ID!]!
+}
+
+input UpdateAdministratorInput {
+    id: ID!
+    firstName: String
+    lastName: String
+    emailAddress: String
+    password: String
+    roleIds: [ID!]
+}

+ 17 - 0
server/src/api/schema/admin-api/asset.api.graphql

@@ -0,0 +1,17 @@
+type Query {
+    assets(options: AssetListOptions): AssetList!
+    asset(id: ID!): Asset
+}
+
+type Mutation {
+    "Create a new Asset"
+    createAssets(input: [CreateAssetInput!]!): [Asset!]!
+}
+
+# generated by generateListOptions function
+input AssetListOptions
+
+input CreateAssetInput {
+    file: Upload!
+}
+

+ 8 - 0
server/src/api/schema/admin-api/auth.api.graphql

@@ -0,0 +1,8 @@
+type Query {
+    me: CurrentUser
+}
+
+type Mutation {
+    login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
+    logout: Boolean!
+}

+ 12 - 11
server/src/entity/channel/channel.graphql → server/src/api/schema/admin-api/channel.api.graphql

@@ -1,14 +1,15 @@
-type Channel implements Node {
-    id: ID!
-    createdAt: DateTime!
-    updatedAt: DateTime!
-    code: String!
-    token: String!
-    defaultTaxZone: Zone
-    defaultShippingZone: Zone
-    defaultLanguageCode: LanguageCode!
-    currencyCode: CurrencyCode!
-    pricesIncludeTax: Boolean!
+type Query {
+    channels: [Channel!]!
+    channel(id: ID!): Channel
+    activeChannel: Channel!
+}
+
+type Mutation {
+    "Create a new Channel"
+    createChannel(input: CreateChannelInput!): Channel!
+
+    "Update an existing Channel"
+    updateChannel(input: UpdateChannelInput!): Channel!
 }
 
 input CreateChannelInput {

+ 1 - 1
server/src/api/types/config.api.graphql → server/src/api/schema/admin-api/config.api.graphql

@@ -1,5 +1,5 @@
 type Query {
-  config: Config!
+    config: Config!
 }
 
 type Config {

+ 38 - 0
server/src/api/schema/admin-api/country.api.graphql

@@ -0,0 +1,38 @@
+type Query {
+    countries(options: CountryListOptions): CountryList!
+    country(id: ID!): Country
+}
+
+type Mutation {
+    "Create a new Country"
+    createCountry(input: CreateCountryInput!): Country!
+
+    "Update an existing Country"
+    updateCountry(input: UpdateCountryInput!): Country!
+
+    "Delete a Country"
+    deleteCountry(id: ID!): DeletionResponse!
+}
+
+input CountryTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateCountryInput {
+    code: String!
+    translations: [CountryTranslationInput!]!
+    enabled: Boolean!
+}
+
+
+input UpdateCountryInput {
+    id: ID!
+    code: String
+    translations: [CountryTranslationInput!]
+    enabled: Boolean
+}
+
+# generated by generateListOptions function
+input CountryListOptions

+ 10 - 0
server/src/api/types/customer-group.api.graphql → server/src/api/schema/admin-api/customer-group.api.graphql

@@ -13,3 +13,13 @@ type Mutation {
     "Remove Customers from a CustomerGroup"
     removeCustomersFromGroup(customerGroupId: ID!, customerIds: [ID!]!): CustomerGroup!
 }
+
+input CreateCustomerGroupInput {
+    name: String!
+    customerIds: [ID!]
+}
+
+input UpdateCustomerGroupInput {
+    id: ID!
+    name: String
+}

+ 2 - 31
server/src/api/types/customer.api.graphql → server/src/api/schema/admin-api/customer.api.graphql

@@ -1,7 +1,6 @@
 type Query {
     customers(options: CustomerListOptions): CustomerList!
     customer(id: ID!): Customer
-    activeCustomer: Customer
 }
 
 type Mutation {
@@ -21,33 +20,5 @@ type Mutation {
     updateCustomerAddress(input: UpdateAddressInput!): Address!
 }
 
-type CustomerList implements PaginatedList {
-    items: [Customer!]!
-    totalItems: Int!
-}
-
-input CustomerListOptions {
-    take: Int
-    skip: Int
-    sort: CustomerSortParameter
-    filter: CustomerFilterParameter
-}
-
-input CustomerSortParameter {
-    id: SortOrder
-    createdAt: SortOrder
-    updatedAt: SortOrder
-    firstName: SortOrder
-    lastName: SortOrder
-    phoneNumber: SortOrder
-    emailAddress: SortOrder
-}
-
-input CustomerFilterParameter {
-    firstName: StringOperators
-    lastName: StringOperators
-    phoneNumber: StringOperators
-    emailAddress: StringOperators
-    createdAt: DateOperators
-    updatedAt: DateOperators
-}
+# generated by generateListOptions function
+input CustomerListOptions

+ 67 - 0
server/src/api/schema/admin-api/facet.api.graphql

@@ -0,0 +1,67 @@
+type Query {
+    facets(languageCode: LanguageCode, options: FacetListOptions): FacetList!
+    facet(id: ID!, languageCode: LanguageCode): Facet
+}
+
+type Mutation {
+    "Create a new Facet"
+    createFacet(input: CreateFacetInput!): Facet!
+
+    "Update an existing Facet"
+    updateFacet(input: UpdateFacetInput!): Facet!
+
+    "Delete an existing Facet"
+    deleteFacet(id: ID!, force: Boolean): DeletionResponse!
+
+    "Create one or more FacetValues"
+    createFacetValues(input: [CreateFacetValueInput!]!): [FacetValue!]!
+
+    "Update one or more FacetValues"
+    updateFacetValues(input: [UpdateFacetValueInput!]!): [FacetValue!]!
+
+    "Delete one or more FacetValues"
+    deleteFacetValues(ids: [ID!]!, force: Boolean): [DeletionResponse!]!
+}
+
+# generated by generateListOptions function
+input FacetListOptions
+
+input FacetTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateFacetInput {
+    code: String!
+    translations: [FacetTranslationInput!]!
+    values: [CreateFacetValueWithFacetInput!]
+}
+
+input UpdateFacetInput {
+    id: ID!
+    code: String
+    translations: [FacetTranslationInput!]
+}
+input FacetValueTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateFacetValueWithFacetInput {
+    code: String!
+    translations: [FacetValueTranslationInput!]!
+}
+
+input CreateFacetValueInput {
+    facetId: ID!
+    code: String!
+    translations: [FacetValueTranslationInput!]!
+}
+
+input UpdateFacetValueInput {
+    id: ID!
+    code: String
+    translations: [FacetValueTranslationInput!]
+}

+ 4 - 0
server/src/api/types/global-settings.api.graphql → server/src/api/schema/admin-api/global-settings.api.graphql

@@ -5,3 +5,7 @@ type Query {
 type Mutation {
     updateGlobalSettings(input: UpdateGlobalSettingsInput!): GlobalSettings!
 }
+
+input UpdateGlobalSettingsInput {
+    availableLanguages: [LanguageCode!]
+}

+ 3 - 0
server/src/api/schema/admin-api/import.api.graphql

@@ -0,0 +1,3 @@
+type Mutation {
+    importProducts(csvFile: Upload!): ImportInfo
+}

+ 7 - 0
server/src/api/schema/admin-api/order.api.graphql

@@ -0,0 +1,7 @@
+type Query {
+    order(id: ID!): Order
+    orders(options: OrderListOptions): OrderList!
+}
+
+# generated by generateListOptions function
+input OrderListOptions

+ 24 - 0
server/src/api/schema/admin-api/payment-method.api.graphql

@@ -0,0 +1,24 @@
+type Query {
+    paymentMethods(options: PaymentMethodListOptions): PaymentMethodList!
+    paymentMethod(id: ID!): PaymentMethod
+}
+
+type Mutation {
+    "Update an existing PaymentMethod"
+    updatePaymentMethod(input: UpdatePaymentMethodInput!): PaymentMethod!
+}
+
+type PaymentMethodList implements PaginatedList {
+    items: [PaymentMethod!]!
+    totalItems: Int!
+}
+
+# generated by generateListOptions function
+input PaymentMethodListOptions
+
+input UpdatePaymentMethodInput {
+    id: ID!
+    code: String
+    enabled: Boolean
+    configArgs: [ConfigArgInput!]
+}

+ 24 - 24
server/src/api/types/product-category.api.graphql → server/src/api/schema/admin-api/product-category.api.graphql

@@ -14,35 +14,35 @@ type Mutation {
     moveProductCategory(input: MoveProductCategoryInput!): ProductCategory!
 }
 
-type ProductCategoryList implements PaginatedList {
-    items: [ProductCategory!]!
-    totalItems: Int!
-}
+# generated by generateListOptions function
+input ProductCategoryListOptions
 
-input ProductCategoryListOptions {
-    take: Int
-    skip: Int
-    sort: ProductCategorySortParameter
-    filter: ProductCategoryFilterParameter
+input MoveProductCategoryInput {
+    categoryId: ID!
+    parentId: ID!
+    index: Int!
 }
 
-input ProductCategorySortParameter {
-    id: SortOrder
-    createdAt: SortOrder
-    updatedAt: SortOrder
-    name: SortOrder
-    description: SortOrder
+input ProductCategoryTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+    description: String
 }
 
-input ProductCategoryFilterParameter {
-    name: StringOperators
-    description: StringOperators
-    createdAt: DateOperators
-    updatedAt: DateOperators
+input CreateProductCategoryInput {
+    featuredAssetId: ID
+    assetIds: [ID!]
+    parentId: ID
+    facetValueIds: [ID!]
+    translations: [ProductCategoryTranslationInput!]!
 }
 
-input MoveProductCategoryInput {
-    categoryId: ID!
-    parentId: ID!
-    index: Int!
+input UpdateProductCategoryInput {
+    id: ID!
+    featuredAssetId: ID
+    parentId: ID
+    assetIds: [ID!]
+    facetValueIds: [ID!]
+    translations: [ProductCategoryTranslationInput!]!
 }

+ 40 - 0
server/src/api/schema/admin-api/product-option-group.api.graphql

@@ -0,0 +1,40 @@
+type Query {
+    productOptionGroups(languageCode: LanguageCode, filterTerm: String): [ProductOptionGroup!]!
+    productOptionGroup(id: ID!, languageCode: LanguageCode): ProductOptionGroup
+}
+
+type Mutation {
+    "Create a new ProductOptionGroup"
+    createProductOptionGroup(input: CreateProductOptionGroupInput!): ProductOptionGroup!
+    "Update an existing ProductOptionGroup"
+    updateProductOptionGroup(input: UpdateProductOptionGroupInput!): ProductOptionGroup!
+}
+
+input ProductOptionGroupTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateProductOptionGroupInput {
+    code: String!
+    translations: [ProductOptionGroupTranslationInput!]!
+    options: [CreateProductOptionInput!]!
+}
+
+input UpdateProductOptionGroupInput {
+    id: ID!
+    code: String
+    translations: [ProductOptionGroupTranslationInput!]
+}
+
+input ProductOptionTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateProductOptionInput {
+    code: String!
+    translations: [ProductOptionGroupTranslationInput!]!
+}

+ 7 - 0
server/src/api/schema/admin-api/product-search.api.graphql

@@ -0,0 +1,7 @@
+type Query {
+    search(input: SearchInput!): SearchResponse!
+}
+
+type Mutation {
+    reindex: SearchReindexResponse!
+}

+ 48 - 21
server/src/api/types/product.api.graphql → server/src/api/schema/admin-api/product.api.graphql

@@ -26,31 +26,58 @@ type Mutation {
     updateProductVariants(input: [UpdateProductVariantInput!]!): [ProductVariant]!
 }
 
-type ProductList implements PaginatedList {
-    items: [Product!]!
-    totalItems: Int!
-}
-
+# generated by generateListOptions function
 input ProductListOptions {
-    take: Int
-    skip: Int
-    sort: ProductSortParameter
-    filter: ProductFilterParameter
     categoryId: ID
 }
 
-input ProductSortParameter {
-    id: SortOrder
-    createdAt: SortOrder
-    updatedAt: SortOrder
-    name: SortOrder
-    slug: SortOrder
+input ProductTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+    slug: String
+    description: String
+}
+
+input CreateProductInput {
+    featuredAssetId: ID
+    assetIds: [ID!]
+    facetValueIds: [ID!]
+    translations: [ProductTranslationInput!]!
+}
+
+input UpdateProductInput {
+    id: ID!
+    featuredAssetId: ID
+    assetIds: [ID!]
+    facetValueIds: [ID!]
+    translations: [ProductTranslationInput!]
+}
+
+input ProductVariantTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
+input CreateProductVariantInput {
+    translations: [ProductVariantTranslationInput!]!
+    facetValueIds: [ID!]
+    sku: String!
+    price: Int
+    taxCategoryId: ID!
+    optionIds: [ID!]
+    featuredAssetId: ID
+    assetIds: [ID!]
 }
 
-input ProductFilterParameter {
-    name: StringOperators
-    slug: StringOperators
-    description: StringOperators
-    createdAt: DateOperators
-    updatedAt: DateOperators
+input UpdateProductVariantInput {
+    id: ID!
+    translations: [ProductVariantTranslationInput!]
+    facetValueIds: [ID!]
+    sku: String
+    taxCategoryId: ID
+    price: Int
+    featuredAssetId: ID
+    assetIds: [ID!]
 }

+ 29 - 0
server/src/api/schema/admin-api/promotion.api.graphql

@@ -0,0 +1,29 @@
+type Query {
+    promotion(id: ID!): Promotion
+    promotions(options: PromotionListOptions): PromotionList!
+    adjustmentOperations: AdjustmentOperations!
+}
+
+type Mutation {
+    createPromotion(input: CreatePromotionInput!): Promotion!
+    updatePromotion(input: UpdatePromotionInput!): Promotion!
+    deletePromotion(id: ID!): DeletionResponse!
+}
+
+# generated by generateListOptions function
+input PromotionListOptions
+
+input CreatePromotionInput {
+    name: String!
+    enabled: Boolean!
+    conditions: [AdjustmentOperationInput!]!
+    actions: [AdjustmentOperationInput!]!
+}
+
+input UpdatePromotionInput {
+    id: ID!
+    name: String
+    enabled: Boolean
+    conditions: [AdjustmentOperationInput!]
+    actions: [AdjustmentOperationInput!]
+}

+ 28 - 0
server/src/api/schema/admin-api/role.api.graphql

@@ -0,0 +1,28 @@
+type Query {
+  roles(options: RoleListOptions): RoleList!
+  role(id: ID!): Role
+}
+
+type Mutation {
+  "Create a new Role"
+  createRole(input: CreateRoleInput!): Role!
+  "Update an existing Role"
+  updateRole(input: UpdateRoleInput!): Role!
+}
+
+# generated by generateListOptions function
+input RoleListOptions
+
+input CreateRoleInput {
+    code: String!
+    description: String!
+    permissions: [Permission!]!
+}
+
+
+input UpdateRoleInput {
+    id: ID!
+    code: String
+    description: String
+    permissions: [Permission!]
+}

+ 31 - 0
server/src/api/schema/admin-api/shipping-method.api.graphql

@@ -0,0 +1,31 @@
+type Query {
+    shippingMethods(options: ShippingMethodListOptions): ShippingMethodList!
+    shippingMethod(id: ID!): ShippingMethod
+    shippingEligibilityCheckers: [AdjustmentOperation!]!
+    shippingCalculators: [AdjustmentOperation!]!
+}
+
+type Mutation {
+    "Create a new ShippingMethod"
+    createShippingMethod(input: CreateShippingMethodInput!): ShippingMethod!
+    "Update an existing ShippingMethod"
+    updateShippingMethod(input: UpdateShippingMethodInput!): ShippingMethod!
+}
+
+# generated by generateListOptions function
+input ShippingMethodListOptions
+
+input CreateShippingMethodInput {
+    code: String!
+    description: String!
+    checker: AdjustmentOperationInput!
+    calculator: AdjustmentOperationInput!
+}
+
+input UpdateShippingMethodInput {
+    id: ID!
+    code: String
+    description: String
+    checker: AdjustmentOperationInput
+    calculator: AdjustmentOperationInput
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff