Browse Source

Merge branch 'channels'

This merge includes the basic infrastructure for Channels (see #12).
There is further work to be done to implement Channels fully, but this
can be done in the course of regular development on master, hence this
branch is now being merged.
Michael Bromley 7 years ago
parent
commit
d3f1b20f7a
95 changed files with 1621 additions and 2203 deletions
  1. 1 1
      .prettierignore
  2. 1 0
      admin-ui/package.json
  3. 14 6
      admin-ui/src/app/core/providers/auth/auth.service.ts
  4. 1 1
      admin-ui/src/app/core/providers/local-storage/local-storage.service.ts
  5. 3 3
      admin-ui/src/app/data/client-state/client-resolvers.ts
  6. 20 2
      admin-ui/src/app/data/data.module.ts
  7. 10 0
      admin-ui/src/app/data/fragments/auth-fragments.ts
  8. 15 0
      admin-ui/src/app/data/mutations/auth-mutations.ts
  9. 6 6
      admin-ui/src/app/data/mutations/local-mutations.ts
  10. 23 0
      admin-ui/src/app/data/providers/auth-data.service.ts
  11. 0 5
      admin-ui/src/app/data/providers/base-data.service.ts
  12. 9 9
      admin-ui/src/app/data/providers/client-data.service.ts
  13. 1 1
      admin-ui/src/app/data/providers/data.service.mock.ts
  14. 3 3
      admin-ui/src/app/data/providers/data.service.ts
  15. 1 1
      admin-ui/src/app/data/providers/facet-data.service.ts
  16. 1 1
      admin-ui/src/app/data/providers/product-data.service.ts
  17. 0 20
      admin-ui/src/app/data/providers/user-data.service.ts
  18. 12 0
      admin-ui/src/app/data/queries/auth-queries.ts
  19. 2 2
      admin-ui/src/app/data/types/client-types.graphql
  20. 0 1651
      admin-ui/src/app/data/types/gql-generated-types.ts
  21. 0 10
      admin-ui/src/app/data/types/response.ts
  22. 6 0
      admin-ui/yarn.lock
  23. 25 0
      docs/diagrams/determining-active-channel.puml
  24. 44 19
      docs/diagrams/full-class-diagram.puml
  25. 37 0
      docs/diagrams/theme.puml
  26. 0 0
      schema.json
  27. 1 0
      server/dev-config.ts
  28. 77 146
      server/e2e/__snapshots__/product.e2e-spec.ts.snap
  29. 1 1
      server/e2e/config/jest-e2e.json
  30. 2 0
      server/e2e/config/test-config.ts
  31. 1 1
      server/e2e/config/tsconfig.e2e.json
  32. 25 61
      server/e2e/product.e2e-spec.ts
  33. 8 1
      server/e2e/test-client.ts
  34. 19 13
      server/e2e/test-server.ts
  35. 9 0
      server/e2e/test-utils.ts
  36. 20 4
      server/mock-data/clear-all-tables.ts
  37. 35 0
      server/mock-data/get-default-channel-token.ts
  38. 24 1
      server/mock-data/mock-data.service.ts
  39. 3 2
      server/mock-data/populate-cli.ts
  40. 17 7
      server/mock-data/populate.ts
  41. 2 2
      server/mock-data/simple-graphql-client.ts
  42. 2 2
      server/package.json
  43. 6 3
      server/src/api/api.module.ts
  44. 72 0
      server/src/api/auth-guard.ts
  45. 19 0
      server/src/api/auth/auth.api.graphql
  46. 0 48
      server/src/api/auth/auth.controller.ts
  47. 57 0
      server/src/api/auth/auth.resolver.ts
  48. 3 0
      server/src/api/channel/channel.api.graphql
  49. 14 0
      server/src/api/channel/channel.resolver.ts
  50. 19 0
      server/src/api/common/request-context.pipe.ts
  51. 26 0
      server/src/api/common/request-context.service.ts
  52. 40 0
      server/src/api/common/request-context.ts
  53. 4 1
      server/src/api/jwt.strategy.ts
  54. 54 22
      server/src/api/product/product.resolver.ts
  55. 36 19
      server/src/api/roles-guard.ts
  56. 0 1
      server/src/app.module.ts
  57. 3 0
      server/src/bootstrap.ts
  58. 5 0
      server/src/common/constants.ts
  59. 2 2
      server/src/common/types/auth-types.ts
  60. 8 0
      server/src/common/types/common-types.ts
  61. 0 9
      server/src/common/types/role.ts
  62. 1 0
      server/src/config/config.service.mock.ts
  63. 10 0
      server/src/config/config.service.ts
  64. 7 0
      server/src/config/vendure-config.ts
  65. 29 0
      server/src/entity/channel/channel.entity.ts
  66. 7 0
      server/src/entity/channel/channel.graphql
  67. 6 0
      server/src/entity/entities.ts
  68. 21 0
      server/src/entity/product-variant/product-variant-price.entity.ts
  69. 9 1
      server/src/entity/product-variant/product-variant.entity.ts
  70. 37 0
      server/src/entity/product-variant/product-variant.subscriber.ts
  71. 8 3
      server/src/entity/product/product.entity.ts
  72. 20 0
      server/src/entity/role/permission.ts
  73. 26 0
      server/src/entity/role/role.entity.ts
  74. 6 0
      server/src/entity/role/role.graphql
  75. 8 0
      server/src/entity/subscribers.ts
  76. 6 5
      server/src/entity/user/user.entity.ts
  77. 8 4
      server/src/service/administrator.service.ts
  78. 10 9
      server/src/service/auth.service.ts
  79. 87 0
      server/src/service/channel.service.ts
  80. 4 3
      server/src/service/customer.service.ts
  81. 2 1
      server/src/service/facet-value.service.ts
  82. 9 0
      server/src/service/helpers/connection.decorator.ts
  83. 6 2
      server/src/service/helpers/create-translatable.ts
  84. 26 25
      server/src/service/product-variant.service.spec.ts
  85. 8 3
      server/src/service/product-variant.service.ts
  86. 11 4
      server/src/service/product.service.spec.ts
  87. 56 34
      server/src/service/product.service.ts
  88. 86 0
      server/src/service/role.service.ts
  89. 13 2
      server/src/service/service.module.ts
  90. 128 11
      server/yarn.lock
  91. 73 9
      shared/generated-types.ts
  92. 31 0
      shared/omit.spec.ts
  93. 13 0
      shared/omit.ts
  94. 0 0
      shared/pick.spec.ts
  95. 0 0
      shared/pick.ts

+ 1 - 1
.prettierignore

@@ -1 +1 @@
-gql-generated-types.ts
+generated-types.ts

+ 1 - 0
admin-ui/package.json

@@ -33,6 +33,7 @@
     "apollo-cache-inmemory": "^1.2.7",
     "apollo-client": "^2.3.8",
     "apollo-link": "^1.2.2",
+    "apollo-link-context": "^1.0.8",
     "apollo-link-state": "^0.4.1",
     "core-js": "^2.5.4",
     "graphql": "^0.13.2",

+ 14 - 6
admin-ui/src/app/core/providers/auth/auth.service.ts

@@ -1,8 +1,9 @@
 import { Injectable } from '@angular/core';
 import { Observable, of } from 'rxjs';
 import { catchError, map, mergeMap, switchMap } from 'rxjs/operators';
-import { LogIn } from 'shared/generated-types';
+import { AttemptLogin, SetAsLoggedIn } from 'shared/generated-types';
 
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
 import { DataService } from '../../../data/providers/data.service';
 import { LocalStorageService } from '../local-storage/local-storage.service';
 
@@ -17,10 +18,14 @@ export class AuthService {
      * Attempts to log in via the REST login endpoint and updates the app
      * state on success.
      */
-    logIn(username: string, password: string): Observable<LogIn> {
-        return this.dataService.user.attemptLogin(username, password).pipe(
+    logIn(username: string, password: string): Observable<SetAsLoggedIn> {
+        return this.dataService.auth.attemptLogin(username, password).pipe(
             switchMap(response => {
-                this.localStorageService.set('authToken', response.token);
+                this.localStorageService.setForSession('authToken', response.login.authToken);
+                this.localStorageService.setForSession(
+                    'activeChannelToken',
+                    response.login.user.channelTokens[0],
+                );
                 return this.dataService.client.loginSuccess(username);
             }),
         );
@@ -59,9 +64,12 @@ export class AuthService {
             return of(false);
         }
 
-        return this.dataService.user.checkLoggedIn().pipe(
+        return this.dataService.auth.checkLoggedIn().single$.pipe(
             map(result => {
-                this.dataService.client.loginSuccess(result.identifier);
+                if (!result.me) {
+                    return false;
+                }
+                this.dataService.client.loginSuccess(result.me.identifier);
                 return true;
             }),
             catchError(err => of(false)),

+ 1 - 1
admin-ui/src/app/core/providers/local-storage/local-storage.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core';
 
-export type LocalStorageKey = 'authToken';
+export type LocalStorageKey = 'authToken' | 'activeChannelToken';
 const PREFIX = 'vnd_';
 
 /**

+ 3 - 3
admin-ui/src/app/data/client-state/client-resolvers.ts

@@ -6,7 +6,7 @@ import {
     GetUserStatus,
     GetUserStatus_userStatus,
     LanguageCode,
-    LogInVariables,
+    SetAsLoggedInVariables,
     SetUiLanguageVariables,
 } from 'shared/generated-types';
 
@@ -32,7 +32,7 @@ export const clientResolvers: ResolverDefinition = {
         requestCompleted: (_, args, { cache }): number => {
             return updateRequestsInFlight(cache, -1);
         },
-        logIn: (_, args: LogInVariables, { cache }): GetUserStatus_userStatus => {
+        setAsLoggedIn: (_, args: SetAsLoggedInVariables, { cache }): GetUserStatus_userStatus => {
             const { username, loginTime } = args;
             const data: GetUserStatus = {
                 userStatus: {
@@ -45,7 +45,7 @@ export const clientResolvers: ResolverDefinition = {
             cache.writeData({ data });
             return data.userStatus;
         },
-        logOut: (_, args, { cache }): GetUserStatus_userStatus => {
+        setAsLoggedOut: (_, args, { cache }): GetUserStatus_userStatus => {
             const data: GetUserStatus = {
                 userStatus: {
                     __typename: 'UserStatus',

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

@@ -5,11 +5,13 @@ import { HttpLink, HttpLinkModule } from 'apollo-angular-link-http';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 import { ApolloClientOptions } from 'apollo-client';
 import { ApolloLink } from 'apollo-link';
+import { setContext } from 'apollo-link-context';
 import { withClientState } from 'apollo-link-state';
 
 import { API_PATH } from '../../../../shared/shared-constants';
 import { environment } from '../../environments/environment';
 import { API_URL } from '../app.config';
+import { LocalStorageService } from '../core/providers/local-storage/local-storage.service';
 
 import { clientDefaults } from './client-state/client-defaults';
 import { clientResolvers } from './client-state/client-resolvers';
@@ -32,11 +34,27 @@ const stateLink = withClientState({
     defaults: clientDefaults,
 });
 
-export function createApollo(httpLink: HttpLink): ApolloClientOptions<any> {
+export function createApollo(
+    httpLink: HttpLink,
+    localStorageService: LocalStorageService,
+): ApolloClientOptions<any> {
     return {
         link: ApolloLink.from([
             stateLink,
             new OmitTypenameLink(),
+            setContext(() => {
+                // Add JWT auth token & channel token to all requests.
+                const channelToken = localStorageService.get('activeChannelToken');
+                const authToken = localStorageService.get('authToken') || '';
+                if (!authToken) {
+                    return {};
+                } else {
+                    return {
+                        headers: { Authorization: `Bearer ${authToken}` },
+                        uri: `${API_URL}/${API_PATH}?token=${channelToken}`,
+                    };
+                }
+            }),
             httpLink.create({ uri: `${API_URL}/${API_PATH}` }),
         ]),
         cache: apolloCache,
@@ -57,7 +75,7 @@ export function createApollo(httpLink: HttpLink): ApolloClientOptions<any> {
         {
             provide: APOLLO_OPTIONS,
             useFactory: createApollo,
-            deps: [HttpLink],
+            deps: [HttpLink, LocalStorageService],
         },
         { provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true },
         {

+ 10 - 0
admin-ui/src/app/data/fragments/auth-fragments.ts

@@ -0,0 +1,10 @@
+import gql from 'graphql-tag';
+
+export const CURRENT_USER_FRAGMENT = gql`
+    fragment CurrentUser on CurrentUser {
+        id
+        identifier
+        channelTokens
+        roles
+    }
+`;

+ 15 - 0
admin-ui/src/app/data/mutations/auth-mutations.ts

@@ -0,0 +1,15 @@
+import gql from 'graphql-tag';
+
+import { CURRENT_USER_FRAGMENT } from '../fragments/auth-fragments';
+
+export const ATTEMPT_LOGIN = gql`
+    mutation AttemptLogin($username: String!, $password: String!) {
+        login(username: $username, password: $password) {
+            user {
+                ...CurrentUser
+            }
+            authToken
+        }
+    }
+    ${CURRENT_USER_FRAGMENT}
+`;

+ 6 - 6
admin-ui/src/app/data/mutations/local-mutations.ts

@@ -12,9 +12,9 @@ export const REQUEST_COMPLETED = gql`
     }
 `;
 
-export const LOG_IN = gql`
-    mutation LogIn($username: String!, $loginTime: String!) {
-        logIn(username: $username, loginTime: $loginTime) @client {
+export const SET_AS_LOGGED_IN = gql`
+    mutation SetAsLoggedIn($username: String!, $loginTime: String!) {
+        setAsLoggedIn(username: $username, loginTime: $loginTime) @client {
             username
             isLoggedIn
             loginTime
@@ -22,9 +22,9 @@ export const LOG_IN = gql`
     }
 `;
 
-export const LOG_OUT = gql`
-    mutation LogOut {
-        logOut @client {
+export const SET_AS_LOGGED_OUT = gql`
+    mutation SetAsLoggedOut {
+        setAsLoggedOut @client {
             username
             isLoggedIn
             loginTime

+ 23 - 0
admin-ui/src/app/data/providers/auth-data.service.ts

@@ -0,0 +1,23 @@
+import { Observable } from 'rxjs';
+import { AttemptLogin, AttemptLoginVariables, GetCurrentUser } from 'shared/generated-types';
+
+import { ATTEMPT_LOGIN } from '../mutations/auth-mutations';
+import { GET_CURRENT_USER } from '../queries/auth-queries';
+import { QueryResult } from '../types/query-result';
+
+import { BaseDataService } from './base-data.service';
+
+export class AuthDataService {
+    constructor(private baseDataService: BaseDataService) {}
+
+    checkLoggedIn(): QueryResult<GetCurrentUser> {
+        return this.baseDataService.query<GetCurrentUser>(GET_CURRENT_USER);
+    }
+
+    attemptLogin(username: string, password: string): Observable<AttemptLogin> {
+        return this.baseDataService.mutate<AttemptLogin, AttemptLoginVariables>(ATTEMPT_LOGIN, {
+            username,
+            password,
+        });
+    }
+}

+ 0 - 5
admin-ui/src/app/data/providers/base-data.service.ts

@@ -40,11 +40,6 @@ export class BaseDataService {
         const queryRef = this.apollo.watchQuery<T, V>({
             query,
             variables,
-            context: {
-                headers: {
-                    Authorization: this.getAuthHeader(),
-                },
-            },
             fetchPolicy,
         });
         return new QueryResult<T, any>(queryRef);

+ 9 - 9
admin-ui/src/app/data/providers/client-data.service.ts

@@ -4,20 +4,20 @@ import {
     GetUiState,
     GetUserStatus,
     LanguageCode,
-    LogIn,
-    LogInVariables,
-    LogOut,
     RequestCompleted,
     RequestStarted,
+    SetAsLoggedIn,
+    SetAsLoggedInVariables,
+    SetAsLoggedOut,
     SetUiLanguage,
     SetUiLanguageVariables,
 } from 'shared/generated-types';
 
 import {
-    LOG_IN,
-    LOG_OUT,
     REQUEST_COMPLETED,
     REQUEST_STARTED,
+    SET_AS_LOGGED_IN,
+    SET_AS_LOGGED_OUT,
     SET_UI_LANGUAGE,
 } from '../mutations/local-mutations';
 import { GET_NEWTORK_STATUS, GET_UI_STATE, GET_USER_STATUS } from '../queries/local-queries';
@@ -40,15 +40,15 @@ export class ClientDataService {
         return this.baseDataService.query<GetNetworkStatus>(GET_NEWTORK_STATUS);
     }
 
-    loginSuccess(username: string): Observable<LogIn> {
-        return this.baseDataService.mutate<LogIn, LogInVariables>(LOG_IN, {
+    loginSuccess(username: string): Observable<SetAsLoggedIn> {
+        return this.baseDataService.mutate<SetAsLoggedIn, SetAsLoggedInVariables>(SET_AS_LOGGED_IN, {
             username,
             loginTime: Date.now().toString(),
         });
     }
 
-    logOut(): Observable<LogOut> {
-        return this.baseDataService.mutate(LOG_OUT);
+    logOut(): Observable<SetAsLoggedOut> {
+        return this.baseDataService.mutate(SET_AS_LOGGED_OUT);
     }
 
     userStatus(): QueryResult<GetUserStatus> {

+ 1 - 1
admin-ui/src/app/data/providers/data.service.mock.ts

@@ -53,7 +53,7 @@ export class MockDataService implements DataServiceMock {
         generateProductVariants: spyObservable('generateProductVariants'),
         applyFacetValuesToProductVariants: spyObservable('applyFacetValuesToProductVariants'),
     };
-    user = {
+    auth = {
         checkLoggedIn: spyObservable('checkLoggedIn'),
         attemptLogin: spyObservable('attemptLogin'),
     };

+ 3 - 3
admin-ui/src/app/data/providers/data.service.ts

@@ -1,20 +1,20 @@
 import { Injectable } from '@angular/core';
 
+import { AuthDataService } from './auth-data.service';
 import { BaseDataService } from './base-data.service';
 import { ClientDataService } from './client-data.service';
 import { FacetDataService } from './facet-data.service';
 import { ProductDataService } from './product-data.service';
-import { UserDataService } from './user-data.service';
 
 @Injectable()
 export class DataService {
-    user: UserDataService;
+    auth: AuthDataService;
     product: ProductDataService;
     client: ClientDataService;
     facet: FacetDataService;
 
     constructor(baseDataService: BaseDataService) {
-        this.user = new UserDataService(baseDataService);
+        this.auth = new AuthDataService(baseDataService);
         this.product = new ProductDataService(baseDataService);
         this.client = new ClientDataService(baseDataService);
         this.facet = new FacetDataService(baseDataService);

+ 1 - 1
admin-ui/src/app/data/providers/facet-data.service.ts

@@ -17,9 +17,9 @@ import {
     UpdateFacetValuesVariables,
     UpdateFacetVariables,
 } from 'shared/generated-types';
+import { pick } from 'shared/pick';
 
 import { getDefaultLanguage } from '../../common/utilities/get-default-language';
-import { pick } from '../../common/utilities/pick';
 import { addCustomFields } from '../add-custom-fields';
 import {
     CREATE_FACET,

+ 1 - 1
admin-ui/src/app/data/providers/product-data.service.ts

@@ -27,9 +27,9 @@ import {
     UpdateProductVariants,
     UpdateProductVariantsVariables,
 } from 'shared/generated-types';
+import { pick } from 'shared/pick';
 
 import { getDefaultLanguage } from '../../common/utilities/get-default-language';
-import { pick } from '../../common/utilities/pick';
 import { addCustomFields } from '../add-custom-fields';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,

+ 0 - 20
admin-ui/src/app/data/providers/user-data.service.ts

@@ -1,20 +0,0 @@
-import { Observable } from 'rxjs';
-
-import { LoginResponse, UserResponse } from '../types/response';
-
-import { BaseDataService } from './base-data.service';
-
-export class UserDataService {
-    constructor(private baseDataService: BaseDataService) {}
-
-    checkLoggedIn(): Observable<UserResponse> {
-        return this.baseDataService.get('auth/me');
-    }
-
-    attemptLogin(username: string, password: string): Observable<LoginResponse> {
-        return this.baseDataService.post('auth/login', {
-            username,
-            password,
-        });
-    }
-}

+ 12 - 0
admin-ui/src/app/data/queries/auth-queries.ts

@@ -0,0 +1,12 @@
+import gql from 'graphql-tag';
+
+import { CURRENT_USER_FRAGMENT } from '../fragments/auth-fragments';
+
+export const GET_CURRENT_USER = gql`
+    query GetCurrentUser {
+        me {
+            ...CurrentUser
+        }
+    }
+    ${CURRENT_USER_FRAGMENT}
+`;

+ 2 - 2
admin-ui/src/app/data/types/client-types.graphql

@@ -7,8 +7,8 @@ extend type Query {
 extend type Mutation {
     requestStarted: Int!
     requestCompleted: Int!
-    logIn(username: String!, loginTime: String!): UserStatus
-    logOut: UserStatus
+    setAsLoggedIn(username: String!, loginTime: String!): UserStatus
+    setAsLoggedOut: UserStatus
     setUiLanguage(languageCode: LanguageCode): LanguageCode
 }
 

+ 0 - 1651
admin-ui/src/app/data/types/gql-generated-types.ts

@@ -1,1651 +0,0 @@
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: CreateFacet
-// ====================================================
-
-export interface CreateFacet_createFacet_translations {
-  __typename: "FacetTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface CreateFacet_createFacet_values_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface CreateFacet_createFacet_values {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: CreateFacet_createFacet_values_translations[];
-}
-
-export interface CreateFacet_createFacet {
-  __typename: "Facet";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  translations: CreateFacet_createFacet_translations[];
-  values: CreateFacet_createFacet_values[];
-}
-
-export interface CreateFacet {
-  /**
-   * Create a new Facet
-   */
-  createFacet: CreateFacet_createFacet;
-}
-
-export interface CreateFacetVariables {
-  input: CreateFacetInput;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: UpdateFacet
-// ====================================================
-
-export interface UpdateFacet_updateFacet_translations {
-  __typename: "FacetTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface UpdateFacet_updateFacet_values_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface UpdateFacet_updateFacet_values {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: UpdateFacet_updateFacet_values_translations[];
-}
-
-export interface UpdateFacet_updateFacet {
-  __typename: "Facet";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  translations: UpdateFacet_updateFacet_translations[];
-  values: UpdateFacet_updateFacet_values[];
-}
-
-export interface UpdateFacet {
-  /**
-   * Update an existing Facet
-   */
-  updateFacet: UpdateFacet_updateFacet;
-}
-
-export interface UpdateFacetVariables {
-  input: UpdateFacetInput;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: CreateFacetValues
-// ====================================================
-
-export interface CreateFacetValues_createFacetValues_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface CreateFacetValues_createFacetValues {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: CreateFacetValues_createFacetValues_translations[];
-}
-
-export interface CreateFacetValues {
-  /**
-   * Create one or more FacetValues
-   */
-  createFacetValues: CreateFacetValues_createFacetValues[];
-}
-
-export interface CreateFacetValuesVariables {
-  input: CreateFacetValueInput[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: UpdateFacetValues
-// ====================================================
-
-export interface UpdateFacetValues_updateFacetValues_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface UpdateFacetValues_updateFacetValues {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: UpdateFacetValues_updateFacetValues_translations[];
-}
-
-export interface UpdateFacetValues {
-  /**
-   * Update one or more FacetValues
-   */
-  updateFacetValues: UpdateFacetValues_updateFacetValues[];
-}
-
-export interface UpdateFacetValuesVariables {
-  input: UpdateFacetValueInput[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: RequestStarted
-// ====================================================
-
-export interface RequestStarted {
-  requestStarted: number;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: RequestCompleted
-// ====================================================
-
-export interface RequestCompleted {
-  requestCompleted: number;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: LogIn
-// ====================================================
-
-export interface LogIn_logIn {
-  __typename: "UserStatus";
-  username: string;
-  isLoggedIn: boolean;
-  loginTime: string;
-}
-
-export interface LogIn {
-  logIn: LogIn_logIn | null;
-}
-
-export interface LogInVariables {
-  username: string;
-  loginTime: string;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: LogOut
-// ====================================================
-
-export interface LogOut_logOut {
-  __typename: "UserStatus";
-  username: string;
-  isLoggedIn: boolean;
-  loginTime: string;
-}
-
-export interface LogOut {
-  logOut: LogOut_logOut | null;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: SetUiLanguage
-// ====================================================
-
-export interface SetUiLanguage {
-  setUiLanguage: LanguageCode | null;
-}
-
-export interface SetUiLanguageVariables {
-  languageCode: LanguageCode;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: UpdateProduct
-// ====================================================
-
-export interface UpdateProduct_updateProduct_translations {
-  __typename: "ProductTranslation";
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  description: string | null;
-}
-
-export interface UpdateProduct_updateProduct_optionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-}
-
-export interface UpdateProduct_updateProduct_variants_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface UpdateProduct_updateProduct_variants_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface UpdateProduct_updateProduct_variants_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface UpdateProduct_updateProduct_variants {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: UpdateProduct_updateProduct_variants_options[];
-  facetValues: UpdateProduct_updateProduct_variants_facetValues[];
-  translations: UpdateProduct_updateProduct_variants_translations[];
-}
-
-export interface UpdateProduct_updateProduct {
-  __typename: "Product";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  image: string;
-  description: string;
-  translations: UpdateProduct_updateProduct_translations[];
-  optionGroups: UpdateProduct_updateProduct_optionGroups[];
-  variants: UpdateProduct_updateProduct_variants[];
-}
-
-export interface UpdateProduct {
-  /**
-   * Update an existing Product
-   */
-  updateProduct: UpdateProduct_updateProduct;
-}
-
-export interface UpdateProductVariables {
-  input: UpdateProductInput;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: CreateProduct
-// ====================================================
-
-export interface CreateProduct_createProduct_translations {
-  __typename: "ProductTranslation";
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  description: string | null;
-}
-
-export interface CreateProduct_createProduct_optionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-}
-
-export interface CreateProduct_createProduct_variants_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface CreateProduct_createProduct_variants_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface CreateProduct_createProduct_variants_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface CreateProduct_createProduct_variants {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: CreateProduct_createProduct_variants_options[];
-  facetValues: CreateProduct_createProduct_variants_facetValues[];
-  translations: CreateProduct_createProduct_variants_translations[];
-}
-
-export interface CreateProduct_createProduct {
-  __typename: "Product";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  image: string;
-  description: string;
-  translations: CreateProduct_createProduct_translations[];
-  optionGroups: CreateProduct_createProduct_optionGroups[];
-  variants: CreateProduct_createProduct_variants[];
-}
-
-export interface CreateProduct {
-  /**
-   * Create a new Product
-   */
-  createProduct: CreateProduct_createProduct;
-}
-
-export interface CreateProductVariables {
-  input: CreateProductInput;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: GenerateProductVariants
-// ====================================================
-
-export interface GenerateProductVariants_generateVariantsForProduct_translations {
-  __typename: "ProductTranslation";
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  description: string | null;
-}
-
-export interface GenerateProductVariants_generateVariantsForProduct_optionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-}
-
-export interface GenerateProductVariants_generateVariantsForProduct_variants_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface GenerateProductVariants_generateVariantsForProduct_variants_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface GenerateProductVariants_generateVariantsForProduct_variants_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface GenerateProductVariants_generateVariantsForProduct_variants {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: GenerateProductVariants_generateVariantsForProduct_variants_options[];
-  facetValues: GenerateProductVariants_generateVariantsForProduct_variants_facetValues[];
-  translations: GenerateProductVariants_generateVariantsForProduct_variants_translations[];
-}
-
-export interface GenerateProductVariants_generateVariantsForProduct {
-  __typename: "Product";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  image: string;
-  description: string;
-  translations: GenerateProductVariants_generateVariantsForProduct_translations[];
-  optionGroups: GenerateProductVariants_generateVariantsForProduct_optionGroups[];
-  variants: GenerateProductVariants_generateVariantsForProduct_variants[];
-}
-
-export interface GenerateProductVariants {
-  /**
-   * Create a set of ProductVariants based on the OptionGroups assigned to the given Product
-   */
-  generateVariantsForProduct: GenerateProductVariants_generateVariantsForProduct;
-}
-
-export interface GenerateProductVariantsVariables {
-  productId: string;
-  defaultPrice?: number | null;
-  defaultSku?: string | null;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: UpdateProductVariants
-// ====================================================
-
-export interface UpdateProductVariants_updateProductVariants_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface UpdateProductVariants_updateProductVariants_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface UpdateProductVariants_updateProductVariants_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface UpdateProductVariants_updateProductVariants {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: UpdateProductVariants_updateProductVariants_options[];
-  facetValues: UpdateProductVariants_updateProductVariants_facetValues[];
-  translations: UpdateProductVariants_updateProductVariants_translations[];
-}
-
-export interface UpdateProductVariants {
-  /**
-   * Update existing ProductVariants
-   */
-  updateProductVariants: (UpdateProductVariants_updateProductVariants | null)[];
-}
-
-export interface UpdateProductVariantsVariables {
-  input: UpdateProductVariantInput[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: CreateProductOptionGroup
-// ====================================================
-
-export interface CreateProductOptionGroup_createProductOptionGroup_translations {
-  __typename: "ProductOptionGroupTranslation";
-  name: string;
-}
-
-export interface CreateProductOptionGroup_createProductOptionGroup_options_translations {
-  __typename: "ProductOptionTranslation";
-  name: string;
-}
-
-export interface CreateProductOptionGroup_createProductOptionGroup_options {
-  __typename: "ProductOption";
-  id: string;
-  languageCode: LanguageCode | null;
-  name: string | null;
-  code: string | null;
-  translations: CreateProductOptionGroup_createProductOptionGroup_options_translations[];
-}
-
-export interface CreateProductOptionGroup_createProductOptionGroup {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  translations: CreateProductOptionGroup_createProductOptionGroup_translations[];
-  options: CreateProductOptionGroup_createProductOptionGroup_options[];
-}
-
-export interface CreateProductOptionGroup {
-  /**
-   * Create a new ProductOptionGroup
-   */
-  createProductOptionGroup: CreateProductOptionGroup_createProductOptionGroup;
-}
-
-export interface CreateProductOptionGroupVariables {
-  input: CreateProductOptionGroupInput;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: AddOptionGroupToProduct
-// ====================================================
-
-export interface AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-}
-
-export interface AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  code: string;
-  options: AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups_options[];
-}
-
-export interface AddOptionGroupToProduct_addOptionGroupToProduct {
-  __typename: "Product";
-  id: string;
-  optionGroups: AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups[];
-}
-
-export interface AddOptionGroupToProduct {
-  /**
-   * Add an OptionGroup to a Product
-   */
-  addOptionGroupToProduct: AddOptionGroupToProduct_addOptionGroupToProduct;
-}
-
-export interface AddOptionGroupToProductVariables {
-  productId: string;
-  optionGroupId: string;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: RemoveOptionGroupFromProduct
-// ====================================================
-
-export interface RemoveOptionGroupFromProduct_removeOptionGroupFromProduct_optionGroups_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-}
-
-export interface RemoveOptionGroupFromProduct_removeOptionGroupFromProduct_optionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  code: string;
-  options: RemoveOptionGroupFromProduct_removeOptionGroupFromProduct_optionGroups_options[];
-}
-
-export interface RemoveOptionGroupFromProduct_removeOptionGroupFromProduct {
-  __typename: "Product";
-  id: string;
-  optionGroups: RemoveOptionGroupFromProduct_removeOptionGroupFromProduct_optionGroups[];
-}
-
-export interface RemoveOptionGroupFromProduct {
-  /**
-   * Remove an OptionGroup from a Product
-   */
-  removeOptionGroupFromProduct: RemoveOptionGroupFromProduct_removeOptionGroupFromProduct;
-}
-
-export interface RemoveOptionGroupFromProductVariables {
-  productId: string;
-  optionGroupId: string;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL mutation operation: ApplyFacetValuesToProductVariants
-// ====================================================
-
-export interface ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_options[];
-  facetValues: ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_facetValues[];
-  translations: ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_translations[];
-}
-
-export interface ApplyFacetValuesToProductVariants {
-  /**
-   * Applies a FacetValue to the given ProductVariants
-   */
-  applyFacetValuesToProductVariants: ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants[];
-}
-
-export interface ApplyFacetValuesToProductVariantsVariables {
-  facetValueIds: string[];
-  productVariantIds: string[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetFacetList
-// ====================================================
-
-export interface GetFacetList_facets_items_translations {
-  __typename: "FacetTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface GetFacetList_facets_items_values_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface GetFacetList_facets_items_values {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: GetFacetList_facets_items_values_translations[];
-}
-
-export interface GetFacetList_facets_items {
-  __typename: "Facet";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  translations: GetFacetList_facets_items_translations[];
-  values: GetFacetList_facets_items_values[];
-}
-
-export interface GetFacetList_facets {
-  __typename: "FacetList";
-  items: GetFacetList_facets_items[];
-  totalItems: number;
-}
-
-export interface GetFacetList {
-  facets: GetFacetList_facets;
-}
-
-export interface GetFacetListVariables {
-  options?: FacetListOptions | null;
-  languageCode?: LanguageCode | null;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetFacetWithValues
-// ====================================================
-
-export interface GetFacetWithValues_facet_translations {
-  __typename: "FacetTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface GetFacetWithValues_facet_values_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface GetFacetWithValues_facet_values {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: GetFacetWithValues_facet_values_translations[];
-}
-
-export interface GetFacetWithValues_facet {
-  __typename: "Facet";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  translations: GetFacetWithValues_facet_translations[];
-  values: GetFacetWithValues_facet_values[];
-}
-
-export interface GetFacetWithValues {
-  facet: GetFacetWithValues_facet | null;
-}
-
-export interface GetFacetWithValuesVariables {
-  id: string;
-  languageCode?: LanguageCode | null;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetNetworkStatus
-// ====================================================
-
-export interface GetNetworkStatus_networkStatus {
-  __typename: "NetworkStatus";
-  inFlightRequests: number;
-}
-
-export interface GetNetworkStatus {
-  networkStatus: GetNetworkStatus_networkStatus;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetUserStatus
-// ====================================================
-
-export interface GetUserStatus_userStatus {
-  __typename: "UserStatus";
-  username: string;
-  isLoggedIn: boolean;
-  loginTime: string;
-}
-
-export interface GetUserStatus {
-  userStatus: GetUserStatus_userStatus;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetUiState
-// ====================================================
-
-export interface GetUiState_uiState {
-  __typename: "UiState";
-  language: LanguageCode;
-}
-
-export interface GetUiState {
-  uiState: GetUiState_uiState;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetProductWithVariants
-// ====================================================
-
-export interface GetProductWithVariants_product_translations {
-  __typename: "ProductTranslation";
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  description: string | null;
-}
-
-export interface GetProductWithVariants_product_optionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-}
-
-export interface GetProductWithVariants_product_variants_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface GetProductWithVariants_product_variants_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface GetProductWithVariants_product_variants_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface GetProductWithVariants_product_variants {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: GetProductWithVariants_product_variants_options[];
-  facetValues: GetProductWithVariants_product_variants_facetValues[];
-  translations: GetProductWithVariants_product_variants_translations[];
-}
-
-export interface GetProductWithVariants_product {
-  __typename: "Product";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  image: string;
-  description: string;
-  translations: GetProductWithVariants_product_translations[];
-  optionGroups: GetProductWithVariants_product_optionGroups[];
-  variants: GetProductWithVariants_product_variants[];
-}
-
-export interface GetProductWithVariants {
-  product: GetProductWithVariants_product | null;
-}
-
-export interface GetProductWithVariantsVariables {
-  id: string;
-  languageCode?: LanguageCode | null;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetProductList
-// ====================================================
-
-export interface GetProductList_products_items {
-  __typename: "Product";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  description: string;
-}
-
-export interface GetProductList_products {
-  __typename: "ProductList";
-  items: GetProductList_products_items[];
-  totalItems: number;
-}
-
-export interface GetProductList {
-  products: GetProductList_products;
-}
-
-export interface GetProductListVariables {
-  options?: ProductListOptions | null;
-  languageCode?: LanguageCode | null;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetProductOptionGroups
-// ====================================================
-
-export interface GetProductOptionGroups_productOptionGroups_options {
-  __typename: "ProductOption";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string | null;
-  name: string | null;
-}
-
-export interface GetProductOptionGroups_productOptionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  options: GetProductOptionGroups_productOptionGroups_options[];
-}
-
-export interface GetProductOptionGroups {
-  productOptionGroups: GetProductOptionGroups_productOptionGroups[];
-}
-
-export interface GetProductOptionGroupsVariables {
-  filterTerm?: string | null;
-  languageCode?: LanguageCode | null;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL fragment: FacetValue
-// ====================================================
-
-export interface FacetValue_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface FacetValue {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: FacetValue_translations[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL fragment: FacetWithValues
-// ====================================================
-
-export interface FacetWithValues_translations {
-  __typename: "FacetTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface FacetWithValues_values_translations {
-  __typename: "FacetValueTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface FacetWithValues_values {
-  __typename: "FacetValue";
-  id: string;
-  languageCode: LanguageCode | null;
-  code: string;
-  name: string;
-  translations: FacetWithValues_values_translations[];
-}
-
-export interface FacetWithValues {
-  __typename: "Facet";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  translations: FacetWithValues_translations[];
-  values: FacetWithValues_values[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL fragment: ProductVariant
-// ====================================================
-
-export interface ProductVariant_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface ProductVariant_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface ProductVariant_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface ProductVariant {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: ProductVariant_options[];
-  facetValues: ProductVariant_facetValues[];
-  translations: ProductVariant_translations[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL fragment: ProductWithVariants
-// ====================================================
-
-export interface ProductWithVariants_translations {
-  __typename: "ProductTranslation";
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  description: string | null;
-}
-
-export interface ProductWithVariants_optionGroups {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-}
-
-export interface ProductWithVariants_variants_options {
-  __typename: "ProductOption";
-  id: string;
-  code: string | null;
-  languageCode: LanguageCode | null;
-  name: string | null;
-}
-
-export interface ProductWithVariants_variants_facetValues {
-  __typename: "FacetValue";
-  id: string;
-  code: string;
-  name: string;
-}
-
-export interface ProductWithVariants_variants_translations {
-  __typename: "ProductVariantTranslation";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-}
-
-export interface ProductWithVariants_variants {
-  __typename: "ProductVariant";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  price: number;
-  sku: string;
-  image: string | null;
-  options: ProductWithVariants_variants_options[];
-  facetValues: ProductWithVariants_variants_facetValues[];
-  translations: ProductWithVariants_variants_translations[];
-}
-
-export interface ProductWithVariants {
-  __typename: "Product";
-  id: string;
-  languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  image: string;
-  description: string;
-  translations: ProductWithVariants_translations[];
-  optionGroups: ProductWithVariants_optionGroups[];
-  variants: ProductWithVariants_variants[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL fragment: ProductOptionGroup
-// ====================================================
-
-export interface ProductOptionGroup_translations {
-  __typename: "ProductOptionGroupTranslation";
-  name: string;
-}
-
-export interface ProductOptionGroup_options_translations {
-  __typename: "ProductOptionTranslation";
-  name: string;
-}
-
-export interface ProductOptionGroup_options {
-  __typename: "ProductOption";
-  id: string;
-  languageCode: LanguageCode | null;
-  name: string | null;
-  code: string | null;
-  translations: ProductOptionGroup_options_translations[];
-}
-
-export interface ProductOptionGroup {
-  __typename: "ProductOptionGroup";
-  id: string;
-  languageCode: LanguageCode;
-  code: string;
-  name: string;
-  translations: ProductOptionGroup_translations[];
-  options: ProductOptionGroup_options[];
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-//==============================================================
-// START Enums and Input Objects
-//==============================================================
-
-/**
- * ISO 639-1 language code
- */
-export enum LanguageCode {
-  aa = "aa",
-  ab = "ab",
-  ae = "ae",
-  af = "af",
-  ak = "ak",
-  am = "am",
-  an = "an",
-  ar = "ar",
-  as = "as",
-  av = "av",
-  ay = "ay",
-  az = "az",
-  ba = "ba",
-  be = "be",
-  bg = "bg",
-  bh = "bh",
-  bi = "bi",
-  bm = "bm",
-  bn = "bn",
-  bo = "bo",
-  br = "br",
-  bs = "bs",
-  ca = "ca",
-  ce = "ce",
-  ch = "ch",
-  co = "co",
-  cr = "cr",
-  cs = "cs",
-  cu = "cu",
-  cv = "cv",
-  cy = "cy",
-  da = "da",
-  de = "de",
-  dv = "dv",
-  dz = "dz",
-  ee = "ee",
-  el = "el",
-  en = "en",
-  eo = "eo",
-  es = "es",
-  et = "et",
-  eu = "eu",
-  fa = "fa",
-  ff = "ff",
-  fi = "fi",
-  fj = "fj",
-  fo = "fo",
-  fr = "fr",
-  fy = "fy",
-  ga = "ga",
-  gd = "gd",
-  gl = "gl",
-  gn = "gn",
-  gu = "gu",
-  gv = "gv",
-  ha = "ha",
-  he = "he",
-  hi = "hi",
-  ho = "ho",
-  hr = "hr",
-  ht = "ht",
-  hu = "hu",
-  hy = "hy",
-  hz = "hz",
-  ia = "ia",
-  id = "id",
-  ie = "ie",
-  ig = "ig",
-  ii = "ii",
-  ik = "ik",
-  io = "io",
-  is = "is",
-  it = "it",
-  iu = "iu",
-  ja = "ja",
-  jv = "jv",
-  ka = "ka",
-  kg = "kg",
-  ki = "ki",
-  kj = "kj",
-  kk = "kk",
-  kl = "kl",
-  km = "km",
-  kn = "kn",
-  ko = "ko",
-  kr = "kr",
-  ks = "ks",
-  ku = "ku",
-  kv = "kv",
-  kw = "kw",
-  ky = "ky",
-  la = "la",
-  lb = "lb",
-  lg = "lg",
-  li = "li",
-  ln = "ln",
-  lo = "lo",
-  lt = "lt",
-  lu = "lu",
-  lv = "lv",
-  mg = "mg",
-  mh = "mh",
-  mi = "mi",
-  mk = "mk",
-  ml = "ml",
-  mn = "mn",
-  mr = "mr",
-  ms = "ms",
-  mt = "mt",
-  my = "my",
-  na = "na",
-  nb = "nb",
-  nd = "nd",
-  ne = "ne",
-  ng = "ng",
-  nl = "nl",
-  nn = "nn",
-  no = "no",
-  nr = "nr",
-  nv = "nv",
-  ny = "ny",
-  oc = "oc",
-  oj = "oj",
-  om = "om",
-  or = "or",
-  os = "os",
-  pa = "pa",
-  pi = "pi",
-  pl = "pl",
-  ps = "ps",
-  pt = "pt",
-  qu = "qu",
-  rm = "rm",
-  rn = "rn",
-  ro = "ro",
-  ru = "ru",
-  rw = "rw",
-  sa = "sa",
-  sc = "sc",
-  sd = "sd",
-  se = "se",
-  sg = "sg",
-  si = "si",
-  sk = "sk",
-  sl = "sl",
-  sm = "sm",
-  sn = "sn",
-  so = "so",
-  sq = "sq",
-  sr = "sr",
-  ss = "ss",
-  st = "st",
-  su = "su",
-  sv = "sv",
-  sw = "sw",
-  ta = "ta",
-  te = "te",
-  tg = "tg",
-  th = "th",
-  ti = "ti",
-  tk = "tk",
-  tl = "tl",
-  tn = "tn",
-  to = "to",
-  tr = "tr",
-  ts = "ts",
-  tt = "tt",
-  tw = "tw",
-  ty = "ty",
-  ug = "ug",
-  uk = "uk",
-  ur = "ur",
-  uz = "uz",
-  ve = "ve",
-  vi = "vi",
-  vo = "vo",
-  wa = "wa",
-  wo = "wo",
-  xh = "xh",
-  yi = "yi",
-  yo = "yo",
-  za = "za",
-  zh = "zh",
-  zu = "zu",
-}
-
-export enum SortOrder {
-  ASC = "ASC",
-  DESC = "DESC",
-}
-
-export interface BooleanOperators {
-  eq?: boolean | null;
-}
-
-export interface CreateFacetCustomFieldsInput {
-  searchable?: boolean | null;
-}
-
-export interface CreateFacetInput {
-  code: string;
-  translations: FacetTranslationInput[];
-  values?: CreateFacetValueInput[] | null;
-  customFields?: CreateFacetCustomFieldsInput | null;
-}
-
-export interface CreateFacetValueCustomFieldsInput {
-  link?: string | null;
-  available?: boolean | null;
-}
-
-export interface CreateFacetValueInput {
-  facetId: string;
-  code: string;
-  translations: FacetValueTranslationInput[];
-  customFields?: CreateFacetValueCustomFieldsInput | null;
-}
-
-export interface CreateProductCustomFieldsInput {
-  infoUrl?: string | null;
-  downloadable?: boolean | null;
-}
-
-export interface CreateProductInput {
-  image?: string | null;
-  translations: (ProductTranslationInput | null)[];
-  optionGroupCodes?: (string | null)[] | null;
-  customFields?: CreateProductCustomFieldsInput | null;
-}
-
-export interface CreateProductOptionGroupInput {
-  code: string;
-  translations: ProductOptionGroupTranslationInput[];
-  options: CreateProductOptionInput[];
-  customFields?: any | null;
-}
-
-export interface CreateProductOptionInput {
-  code: string;
-  translations: ProductOptionGroupTranslationInput[];
-  customFields?: any | null;
-}
-
-export interface DateOperators {
-  eq?: any | null;
-  before?: any | null;
-  after?: any | null;
-  between?: DateRange | null;
-}
-
-export interface DateRange {
-  start: any;
-  end: any;
-}
-
-export interface FacetFilterParameter {
-  name?: StringOperators | null;
-  code?: StringOperators | null;
-  createdAt?: DateOperators | null;
-  updatedAt?: DateOperators | null;
-  searchable?: BooleanOperators | null;
-}
-
-export interface FacetListOptions {
-  take?: number | null;
-  skip?: number | null;
-  sort?: FacetSortParameter | null;
-  filter?: FacetFilterParameter | null;
-}
-
-export interface FacetSortParameter {
-  id?: SortOrder | null;
-  createdAt?: SortOrder | null;
-  updatedAt?: SortOrder | null;
-  name?: SortOrder | null;
-  code?: SortOrder | null;
-  searchable?: SortOrder | null;
-}
-
-export interface FacetTranslationInput {
-  id?: string | null;
-  languageCode: LanguageCode;
-  name: string;
-  customFields?: any | null;
-}
-
-export interface FacetValueTranslationInput {
-  id?: string | null;
-  languageCode: LanguageCode;
-  name: string;
-  customFields?: any | null;
-}
-
-export interface ProductFilterParameter {
-  name?: StringOperators | null;
-  slug?: StringOperators | null;
-  description?: StringOperators | null;
-  createdAt?: DateOperators | null;
-  updatedAt?: DateOperators | null;
-  infoUrl?: StringOperators | null;
-  downloadable?: BooleanOperators | null;
-  nickname?: StringOperators | null;
-}
-
-export interface ProductListOptions {
-  take?: number | null;
-  skip?: number | null;
-  sort?: ProductSortParameter | null;
-  filter?: ProductFilterParameter | null;
-}
-
-export interface ProductOptionGroupTranslationInput {
-  id?: string | null;
-  languageCode: LanguageCode;
-  name: string;
-  customFields?: any | null;
-}
-
-export interface ProductSortParameter {
-  id?: SortOrder | null;
-  createdAt?: SortOrder | null;
-  updatedAt?: SortOrder | null;
-  name?: SortOrder | null;
-  slug?: SortOrder | null;
-  description?: SortOrder | null;
-  image?: SortOrder | null;
-  infoUrl?: SortOrder | null;
-  downloadable?: SortOrder | null;
-  nickname?: SortOrder | null;
-}
-
-export interface ProductTranslationCustomFieldsInput {
-  nickname?: string | null;
-}
-
-export interface ProductTranslationInput {
-  id?: string | null;
-  languageCode: LanguageCode;
-  name: string;
-  slug?: string | null;
-  description?: string | null;
-  customFields?: ProductTranslationCustomFieldsInput | null;
-}
-
-export interface ProductVariantTranslationInput {
-  id?: string | null;
-  languageCode: LanguageCode;
-  name: string;
-  customFields?: any | null;
-}
-
-export interface StringOperators {
-  eq?: string | null;
-  contains?: string | null;
-}
-
-export interface UpdateFacetCustomFieldsInput {
-  searchable?: boolean | null;
-}
-
-export interface UpdateFacetInput {
-  id: string;
-  code: string;
-  translations: FacetTranslationInput[];
-  customFields?: UpdateFacetCustomFieldsInput | null;
-}
-
-export interface UpdateFacetValueCustomFieldsInput {
-  link?: string | null;
-  available?: boolean | null;
-}
-
-export interface UpdateFacetValueInput {
-  id: string;
-  code: string;
-  translations: FacetValueTranslationInput[];
-  customFields?: UpdateFacetValueCustomFieldsInput | null;
-}
-
-export interface UpdateProductCustomFieldsInput {
-  infoUrl?: string | null;
-  downloadable?: boolean | null;
-}
-
-export interface UpdateProductInput {
-  id: string;
-  image?: string | null;
-  translations: (ProductTranslationInput | null)[];
-  optionGroupCodes?: (string | null)[] | null;
-  customFields?: UpdateProductCustomFieldsInput | null;
-}
-
-export interface UpdateProductVariantInput {
-  id: string;
-  translations: ProductVariantTranslationInput[];
-  sku: string;
-  image?: string | null;
-  price: number;
-  customFields?: any | null;
-}
-
-//==============================================================
-// END Enums and Input Objects
-//==============================================================

+ 0 - 10
admin-ui/src/app/data/types/response.ts

@@ -1,10 +0,0 @@
-export interface UserResponse {
-    id: string | number;
-    identifier: string;
-    roles: string[];
-}
-
-export interface LoginResponse {
-    token: string;
-    user: UserResponse;
-}

+ 6 - 0
admin-ui/yarn.lock

@@ -882,6 +882,12 @@ apollo-codegen-typescript@^0.27.1:
     change-case "^3.0.1"
     inflected "^2.0.3"
 
+apollo-link-context@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/apollo-link-context/-/apollo-link-context-1.0.8.tgz#c967a56ac6ed32add748937735bcb57c5cc64c95"
+  dependencies:
+    apollo-link "^1.2.2"
+
 apollo-link-dedup@^1.0.0:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/apollo-link-dedup/-/apollo-link-dedup-1.0.9.tgz#3c4e4af88ef027cbddfdb857c043fd0574051dad"

+ 25 - 0
docs/diagrams/determining-active-channel.puml

@@ -0,0 +1,25 @@
+' This diagram illustrates the logic used to determine the
+' active Channel when a request is received by the API
+@startuml
+!include theme.puml
+title Determining the active channelId for a request
+start
+:request received;
+if (token exists?) then (yes)
+    if (is token valid?) then (yes)
+        :get channelId by token lookup;
+    else (no)
+        #cc6666:return error;
+        stop
+    endif
+else (no)
+    if (is SuperAdmin?) then (yes)
+        :get active default channelId;
+    else (no)
+        #cc6666:return error;
+        stop
+    endif
+endif
+#66aa66:add channelId to request context;
+stop
+@enduml

+ 44 - 19
docs/diagrams/full-class-diagram.puml

@@ -1,10 +1,16 @@
 @startuml
+!include theme.puml
 title Vendure Class Diagram
 
+class Channel {
+    name: string
+    defaultLanguageCode: LanguageCode
+    defaultCurrencyCode: CurrencyCode
+}
 class User {
     identifier: string
     passwordHash: string
-    roles: Role[]
+    roles: UserRole[]
 }
 class Customer {
     user: User
@@ -14,10 +20,18 @@ class Customer {
 class Administrator {
     user: User
 }
-enum Role {
-    Authenticated
-    Customer
-    Administrator
+class Role {
+    name: string
+    permissions: Permission[]
+    user: User
+    channel: Channel
+}
+enum Permission {
+    CreateCatalog
+    ReadCatalog
+    UpdateCatalog
+    DeleteCatalog
+    CreateUser
     ...etc
 }
 class Address {
@@ -31,7 +45,11 @@ class ProductOption {
 }
 class ProductVariant {
     sku: string
-    price: number
+    price: ProductVariantPrice[]
+}
+class ProductVariantPrice {
+    value: number
+    currencyCode: CurrencyCode
 }
 class Order
 class OrderItem {
@@ -62,25 +80,32 @@ class FacetValue {
 class Category {
 }
 
-Customer o-- User
-Administrator o-- User
+Customer --  User
+Administrator -- User
 User o-- Role
-Customer o-- Address
-Product o-- ProductVariant
-ProductOptionGroup o-- ProductOption
-Product o-- ProductOptionGroup
-ProductVariant o-- ProductOption
-ProductVariant --- FacetValue
-Facet o-- FacetValue
-Category o-- FacetValue
-Customer o-- Order
+Role o-- "1..*" Permission
+Role -- Channel
+Customer *-- "0..*" Address
+Product *-- "1..*" ProductVariant
+ProductOptionGroup *-- "1..*" ProductOption
+Product o-- "0..*" ProductOptionGroup
+ProductVariant o-- "0..*" ProductOption
+ProductVariant o-- "0..*" FacetValue
+Facet *-- "1..*" FacetValue
+Category o-- "1..*" FacetValue
+Customer *-- "0..*" Order
 OrderItem - ProductVariant
-Order o-- OrderItem
-OrderItem o-- OrderItemUnit
+Order *-- OrderItem
+Order -- Channel
+OrderItem *-- OrderItemUnit
 OrderItemUnit o-- Adjustment
 OrderItem o-- Adjustment
 Order o-- Adjustment
 Adjustment - AdjustmentType
 AdjustmentSource - AdjustmentType
+AdjustmentSource o-- Channel
+Product o-- Channel
+ProductVariant *-- "1..*" ProductVariantPrice
+ProductVariantPrice o-- Channel
 
 @enduml

+ 37 - 0
docs/diagrams/theme.puml

@@ -0,0 +1,37 @@
+@startuml
+!define BLACK   #363D5D
+!define LINE    #1a3164
+!define BACKGROUND #b9cefc
+!define BORDER  #e58e26
+
+' Base Setting
+skinparam Shadowing false
+skinparam backgroundColor #f4f3f1-#edf3ff
+skinparam ComponentStyle uml2
+skinparam Default {
+  FontName  'Impact'
+  FontColor BLACK
+  FontSize  14
+  FontStyle plain
+}
+
+skinparam Sequence {
+  ArrowThickness 2
+  ArrowColor LINE
+  ActorBorderThickness 1
+  LifeLineBorderColor GREEN
+  ParticipantBorderThickness 0
+  BorderColor BORDER
+  BackgroundColor BACKGROUND
+}
+skinparam Participant {
+  BackgroundColor BLACK
+  BorderColor BORDER
+  FontColor #FFFFFF
+}
+
+skinparam Actor {
+  BackgroundColor BLACK
+  BorderColor BLACK
+}
+@enduml

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 1 - 0
server/dev-config.ts

@@ -6,6 +6,7 @@ import { VendureConfig } from './src/config/vendure-config';
  * Config settings used during development
  */
 export const devConfig: VendureConfig = {
+    disableAuth: true,
     port: API_PORT,
     apiPath: API_PATH,
     cors: true,

+ 77 - 146
server/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -57,166 +57,97 @@ Object {
 exports[`Product resolver product mutation variants applyFacetValuesToProductVariants adds facets to variants 1`] = `
 Array [
   Object {
-    "facetValues": Array [
-      Object {
-        "code": "Wisoky_-_Spinka",
-        "id": "1",
-        "name": "Wisoky - Spinka",
-      },
-      Object {
-        "code": "Bosco_LLC",
-        "id": "3",
-        "name": "Bosco LLC",
-      },
-      Object {
-        "code": "Hilll_-_Auer",
-        "id": "5",
-        "name": "Hilll - Auer",
-      },
-    ],
-    "id": "41",
-    "image": "new-image",
-    "languageCode": "en",
-    "name": "en Mashed Potato Large",
-    "options": Array [
-      Object {
-        "code": "large",
-        "id": "2",
-        "languageCode": "en",
-        "name": "Large",
-      },
-    ],
-    "price": 432,
-    "sku": "ABC",
-    "translations": Array [
-      Object {
-        "id": "81",
-        "languageCode": "en",
-        "name": "en Mashed Potato Large",
-      },
-    ],
+    "code": "Schneider_Inc",
+    "id": "1",
+    "name": "Schneider Inc",
   },
   Object {
-    "facetValues": Array [
-      Object {
-        "code": "Wisoky_-_Spinka",
-        "id": "1",
-        "name": "Wisoky - Spinka",
-      },
-      Object {
-        "code": "Bosco_LLC",
-        "id": "3",
-        "name": "Bosco LLC",
-      },
-      Object {
-        "code": "Hilll_-_Auer",
-        "id": "5",
-        "name": "Hilll - Auer",
-      },
-    ],
-    "id": "42",
-    "image": "",
-    "languageCode": "en",
-    "name": "en Mashed Potato Small",
-    "options": Array [
-      Object {
-        "code": "small",
-        "id": "1",
-        "languageCode": "en",
-        "name": "Small",
-      },
-    ],
-    "price": 123,
-    "sku": "ABC",
-    "translations": Array [
-      Object {
-        "id": "82",
-        "languageCode": "en",
-        "name": "en Mashed Potato Small",
-      },
-    ],
+    "code": "Ledner_-_Smitham",
+    "id": "3",
+    "name": "Ledner - Smitham",
+  },
+  Object {
+    "code": "Rempel_LLC",
+    "id": "5",
+    "name": "Rempel LLC",
   },
 ]
 `;
 
-exports[`Product resolver product mutation variants generateVariantsForProduct generates variants 1`] = `
+exports[`Product resolver product mutation variants applyFacetValuesToProductVariants adds facets to variants 2`] = `
 Array [
   Object {
-    "facetValues": Array [],
-    "id": "41",
-    "image": "",
-    "languageCode": "en",
-    "name": "en Mashed Potato Large",
-    "options": Array [
-      Object {
-        "code": "large",
-        "id": "2",
-        "languageCode": "en",
-        "name": "Large",
-      },
-    ],
-    "price": 123,
-    "sku": "ABC",
-    "translations": Array [
-      Object {
-        "id": "81",
-        "languageCode": "en",
-        "name": "en Mashed Potato Large",
-      },
-    ],
+    "code": "Schneider_Inc",
+    "id": "1",
+    "name": "Schneider Inc",
+  },
+  Object {
+    "code": "Ledner_-_Smitham",
+    "id": "3",
+    "name": "Ledner - Smitham",
   },
   Object {
-    "facetValues": Array [],
-    "id": "42",
-    "image": "",
-    "languageCode": "en",
-    "name": "en Mashed Potato Small",
-    "options": Array [
-      Object {
-        "code": "small",
-        "id": "1",
-        "languageCode": "en",
-        "name": "Small",
-      },
-    ],
-    "price": 123,
-    "sku": "ABC",
-    "translations": Array [
-      Object {
-        "id": "82",
-        "languageCode": "en",
-        "name": "en Mashed Potato Small",
-      },
-    ],
+    "code": "Rempel_LLC",
+    "id": "5",
+    "name": "Rempel LLC",
   },
 ]
 `;
 
-exports[`Product resolver product mutation variants updateProductVariants updates variants 1`] = `
+exports[`Product resolver product query returns expected properties 1`] = `
+Object {
+  "description": "en Sed dignissimos debitis incidunt accusantium sed libero.",
+  "id": "2",
+  "image": "http://lorempixel.com/640/480",
+  "languageCode": "en",
+  "name": "en Practical Plastic Chicken",
+  "optionGroups": Array [
+    Object {
+      "code": "size",
+      "id": "1",
+      "languageCode": "en",
+      "name": "Size",
+    },
+  ],
+  "slug": "en practical-plastic-chicken",
+  "translations": Array [
+    Object {
+      "description": "en Sed dignissimos debitis incidunt accusantium sed libero.",
+      "languageCode": "en",
+      "name": "en Practical Plastic Chicken",
+      "slug": "en practical-plastic-chicken",
+    },
+    Object {
+      "description": "de Sed dignissimos debitis incidunt accusantium sed libero.",
+      "languageCode": "de",
+      "name": "de Practical Plastic Chicken",
+      "slug": "de practical-plastic-chicken",
+    },
+  ],
+}
+`;
+
+exports[`Product resolver products list query sorts by name 1`] = `
 Array [
-  Object {
-    "facetValues": Array [],
-    "id": "41",
-    "image": "new-image",
-    "languageCode": "en",
-    "name": "en Mashed Potato Large",
-    "options": Array [
-      Object {
-        "code": "large",
-        "id": "2",
-        "languageCode": "en",
-        "name": "Large",
-      },
-    ],
-    "price": 432,
-    "sku": "ABC",
-    "translations": Array [
-      Object {
-        "id": "81",
-        "languageCode": "en",
-        "name": "en Mashed Potato Large",
-      },
-    ],
-  },
+  "en Awesome Granite Chair",
+  "en Fantastic Fresh Cheese",
+  "en Fantastic Rubber Sausages",
+  "en Fantastic Steel Computer",
+  "en Gorgeous Plastic Shoes",
+  "en Handcrafted Concrete Computer",
+  "en Intelligent Steel Ball",
+  "en Intelligent Wooden Car",
+  "en Licensed Frozen Chair",
+  "en Licensed Plastic Bike",
+  "en Practical Frozen Fish",
+  "en Practical Plastic Chicken",
+  "en Refined Cotton Chair",
+  "en Refined Plastic Computer",
+  "en Rustic Frozen Car",
+  "en Rustic Steel Salad",
+  "en Sleek Wooden Fish",
+  "en Unbranded Concrete Cheese",
+  "en Unbranded Concrete Salad",
+  "en Unbranded Plastic Pants",
 ]
 `;

+ 1 - 1
server/e2e/config/jest-e2e.json

@@ -10,7 +10,7 @@
   },
   "globals": {
     "ts-jest": {
-      "skipBabel": true,
+      "skipBabel": false,
       "tsConfigFile": "<rootDir>/config/tsconfig.e2e.json",
       "enableTsDiagnostics": false
     }

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

@@ -2,6 +2,8 @@ import { API_PATH } from 'shared/shared-constants';
 
 import { VendureConfig } from '../../src/config/vendure-config';
 
+export const TEST_CONNECTION_NAME = undefined;
+
 /**
  * Config settings used for e2e tests
  */

+ 1 - 1
server/e2e/config/tsconfig.e2e.json

@@ -4,7 +4,7 @@
     "types": ["jest", "node"],
     "lib": ["es2015"],
     "skipLibCheck": false,
-    "sourceMap": true
+    "inlineSourceMap": true
   },
   "include": ["../**/*.e2e-spec.ts", "../**/*.d.ts"]
 }

+ 25 - 61
server/e2e/product.e2e-spec.ts

@@ -22,6 +22,7 @@ import {
     UpdateProductVariants,
     UpdateProductVariantsVariables,
 } from 'shared/generated-types';
+import { omit } from 'shared/omit';
 
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
@@ -45,10 +46,13 @@ describe('Product resolver', () => {
     const server = new TestServer();
 
     beforeAll(async () => {
-        await server.init({
+        const token = await server.init({
+            logging: true,
+            disableAuth: true,
             productCount: 20,
             customerCount: 1,
         });
+        await client.init();
     }, 30000);
 
     afterAll(async () => {
@@ -90,8 +94,9 @@ describe('Product resolver', () => {
                 },
             });
 
-            expect(result.products.items.length).toBe(1);
+            expect(result.products.items.length).toBe(2);
             expect(result.products.items[0].name).toBe('en Practical Frozen Fish');
+            expect(result.products.items[1].name).toBe('en Sleek Wooden Fish');
         });
 
         it('sorts by name', async () => {
@@ -104,28 +109,7 @@ describe('Product resolver', () => {
                 },
             });
 
-            expect(result.products.items.map(p => p.name)).toEqual([
-                'en Fantastic Granite Salad',
-                'en Fantastic Rubber Sausages',
-                'en Generic Metal Keyboard',
-                'en Generic Wooden Sausages',
-                'en Handcrafted Granite Shirt',
-                'en Handcrafted Plastic Gloves',
-                'en Handmade Cotton Salad',
-                'en Incredible Metal Shirt',
-                'en Incredible Steel Cheese',
-                'en Intelligent Frozen Ball',
-                'en Intelligent Wooden Car',
-                'en Licensed Cotton Shirt',
-                'en Licensed Frozen Chair',
-                'en Practical Frozen Fish',
-                'en Refined Fresh Bacon',
-                'en Rustic Steel Salad',
-                'en Rustic Wooden Hat',
-                'en Small Granite Chicken',
-                'en Small Steel Cheese',
-                'en Tasty Soft Gloves',
-            ]);
+            expect(result.products.items.map(p => p.name)).toMatchSnapshot();
         });
     });
 
@@ -143,40 +127,8 @@ describe('Product resolver', () => {
                 fail('Product not found');
                 return;
             }
-            expect(result.product).toEqual(
-                expect.objectContaining({
-                    description: 'en Ut nulla quam ipsam nobis cupiditate sed dignissimos debitis incidunt.',
-                    id: '2',
-                    image: 'http://lorempixel.com/640/480',
-                    languageCode: 'en',
-                    name: 'en Incredible Metal Shirt',
-                    optionGroups: [
-                        {
-                            code: 'size',
-                            id: '1',
-                            languageCode: 'en',
-                            name: 'Size',
-                        },
-                    ],
-                    slug: 'en incredible-metal-shirt',
-                    translations: [
-                        {
-                            description:
-                                'en Ut nulla quam ipsam nobis cupiditate sed dignissimos debitis incidunt.',
-                            languageCode: 'en',
-                            name: 'en Incredible Metal Shirt',
-                            slug: 'en incredible-metal-shirt',
-                        },
-                        {
-                            description:
-                                'de Ut nulla quam ipsam nobis cupiditate sed dignissimos debitis incidunt.',
-                            languageCode: 'de',
-                            name: 'de Incredible Metal Shirt',
-                            slug: 'de incredible-metal-shirt',
-                        },
-                    ],
-                }),
-            );
+            expect(omit(result.product, ['variants'])).toMatchSnapshot();
+            expect(result.product.variants.length).toBe(2);
         });
 
         it('returns null when id not found', async () => {
@@ -360,7 +312,9 @@ describe('Product resolver', () => {
                     },
                 );
                 variants = result.generateVariantsForProduct.variants;
-                expect(variants).toMatchSnapshot();
+                expect(variants.length).toBe(2);
+                expect(variants[0].options.length).toBe(1);
+                expect(variants[1].options.length).toBe(1);
             });
 
             it('generateVariantsForProduct throws with an invalid productId', async () => {
@@ -395,7 +349,14 @@ describe('Product resolver', () => {
                         ],
                     },
                 );
-                expect(result.updateProductVariants).toMatchSnapshot();
+                const updatedVariant = result.updateProductVariants[0];
+                if (!updatedVariant) {
+                    fail('no updated variant returned.');
+                    return;
+                }
+                expect(updatedVariant.sku).toBe('ABC');
+                expect(updatedVariant.image).toBe('new-image');
+                expect(updatedVariant.price).toBe(432);
             });
 
             it('updateProductVariants throws with an invalid variant id', async () => {
@@ -430,7 +391,10 @@ describe('Product resolver', () => {
                     facetValueIds: ['1', '3', '5'],
                     productVariantIds: variants.map(v => v.id),
                 });
-                expect(result.applyFacetValuesToProductVariants).toMatchSnapshot();
+
+                expect(result.applyFacetValuesToProductVariants.length).toBe(2);
+                expect(result.applyFacetValuesToProductVariants[0].facetValues).toMatchSnapshot();
+                expect(result.applyFacetValuesToProductVariants[1].facetValues).toMatchSnapshot();
             });
 
             it('applyFacetValuesToProductVariants errors with invalid facet value id', async () => {

+ 8 - 1
server/e2e/test-client.ts

@@ -1,3 +1,4 @@
+import { getDefaultChannelToken } from '../mock-data/get-default-channel-token';
 import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
 
 import { testConfig } from './config/test-config';
@@ -7,6 +8,12 @@ import { testConfig } from './config/test-config';
  */
 export class TestClient extends SimpleGraphQLClient {
     constructor() {
-        super(`http://localhost:${testConfig.port}/${testConfig.apiPath}`);
+        super();
+    }
+
+    async init() {
+        const testingConfig = testConfig;
+        const token = await getDefaultChannelToken(testingConfig.dbConnectionOptions);
+        super.apiUrl = `http://localhost:${testConfig.port}/${testConfig.apiPath}?token=${token}`;
     }
 }

+ 19 - 13
server/e2e/test-server.ts

@@ -10,6 +10,7 @@ import { Mutable } from '../src/common/types/common-types';
 import { VendureConfig } from '../src/config/vendure-config';
 
 import { testConfig } from './config/test-config';
+import { setTestEnvironment } from './test-utils';
 
 // tslint:disable:no-console
 /**
@@ -25,20 +26,17 @@ export class TestServer {
      * The populated data is saved into an .sqlite file for each test file. On subsequent runs, this file
      * is loaded so that the populate step can be skipped, which speeds up the tests significantly.
      */
-    async init(options: PopulateOptions) {
+    async init(options: PopulateOptions & { disableAuth?: boolean }): Promise<void> {
+        setTestEnvironment();
         const testingConfig = testConfig;
-        const dbDataDir = '__data__';
-        // tslint:disable-next-line:no-non-null-assertion
-        const testFilePath = module!.parent!.filename;
-        const dbFileName = path.basename(testFilePath) + '.sqlite';
-        const dbFilePath = path.join(path.dirname(testFilePath), dbDataDir, dbFileName);
-
+        const dbFilePath = this.getDbFilePath();
+        testingConfig.disableAuth = options.disableAuth;
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
         if (!fs.existsSync(dbFilePath)) {
             console.log(`Test data not found. Populating database and saving to "${dbFilePath}"`);
             await this.populateInitialData(testingConfig, options);
         }
-        console.log(`Loading test from "${dbFilePath}"`);
+        console.log(`Loading test data from "${dbFilePath}"`);
         this.app = await this.bootstrapForTesting(testingConfig);
     }
 
@@ -49,16 +47,25 @@ export class TestServer {
         await this.app.close();
     }
 
+    private getDbFilePath() {
+        const dbDataDir = '__data__';
+        // tslint:disable-next-line:no-non-null-assertion
+        const testFilePath = module!.parent!.filename;
+        const dbFileName = path.basename(testFilePath) + '.sqlite';
+        const dbFilePath = path.join(path.dirname(testFilePath), dbDataDir, dbFileName);
+        return dbFilePath;
+    }
+
     /**
      * Populates an .sqlite database file based on the PopulateOptions.
      */
-    private async populateInitialData(testingConfig: VendureConfig, options: PopulateOptions) {
+    private async populateInitialData(testingConfig: VendureConfig, options: PopulateOptions): Promise<void> {
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = true;
-        const populateApp = await populate(testingConfig, this.bootstrapForTesting, {
-            ...options,
+        const app = await populate(testingConfig, this.bootstrapForTesting, {
             logging: false,
+            ...options,
         });
-        await populateApp.close();
+        await app.close();
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = false;
     }
 
@@ -67,7 +74,6 @@ export class TestServer {
      */
     private async bootstrapForTesting(userConfig: Partial<VendureConfig>): Promise<INestApplication> {
         const config = await preBootstrapConfig(userConfig);
-
         const appModule = await import('../src/app.module');
         const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
         await app.listen(config.port);

+ 9 - 0
server/e2e/test-utils.ts

@@ -0,0 +1,9 @@
+export const E2E_TESTING_ENV_VARIABLE = 'isE2ETest';
+
+export function setTestEnvironment() {
+    process.env[E2E_TESTING_ENV_VARIABLE] = '1';
+}
+
+export function isTestEnvironment() {
+    return !!process.env[E2E_TESTING_ENV_VARIABLE];
+}

+ 20 - 4
server/mock-data/clear-all-tables.ts

@@ -1,4 +1,7 @@
-import { ConnectionOptions, createConnection } from 'typeorm';
+import { ConnectionOptions, createConnection, getSqljsManager } from 'typeorm';
+
+import { TEST_CONNECTION_NAME } from '../e2e/config/test-config';
+import { isTestEnvironment } from '../e2e/test-utils';
 
 // tslint:disable:no-console
 // tslint:disable:no-floating-promises
@@ -7,12 +10,25 @@ import { ConnectionOptions, createConnection } from 'typeorm';
  */
 export async function clearAllTables(connectionOptions: ConnectionOptions, logging = true) {
     (connectionOptions as any).entities = [__dirname + '/../src/**/*.entity.ts'];
-    const connection = await createConnection({ ...connectionOptions, name: 'clearAllTables' });
+    const name = isTestEnvironment() ? undefined : 'clearAllTables';
+    const connection = await createConnection({ ...connectionOptions, name });
     if (logging) {
         console.log('Clearing all tables...');
     }
-    await connection.synchronize(true);
-    await connection.close();
+    try {
+        await connection.synchronize(true);
+        if (connectionOptions.type === 'sqljs') {
+            console.log(
+                `tables in "${connection.options.name}": `,
+                await connection.query('SELECT * FROM sqlite_master'),
+            );
+        }
+    } catch (err) {
+        console.error('Error occurred when attempting to clear tables!');
+        console.error(err);
+    } finally {
+        await connection.close();
+    }
     if (logging) {
         console.log('Done!');
     }

+ 35 - 0
server/mock-data/get-default-channel-token.ts

@@ -0,0 +1,35 @@
+import { ConnectionOptions, getConnection } from 'typeorm';
+
+import { DEFAULT_CHANNEL_CODE } from '../src/common/constants';
+import { Channel } from '../src/entity/channel/channel.entity';
+
+// tslint:disable:no-console
+// tslint:disable:no-floating-promises
+/**
+ * Queries the database for the default Channel and returns its token.
+ */
+export async function getDefaultChannelToken(
+    connectionOptions: ConnectionOptions,
+    logging = true,
+): Promise<string> {
+    (connectionOptions as any).entities = [__dirname + '/../src/**/*.entity.ts'];
+    const connection = await getConnection();
+    let defaultChannel: Channel | undefined;
+    try {
+        defaultChannel = await connection.manager.getRepository(Channel).findOne({
+            where: {
+                code: DEFAULT_CHANNEL_CODE,
+            },
+        });
+    } catch (err) {
+        console.log(`Error occurred when attempting to get default Channel`);
+        console.error(err);
+    }
+    if (!defaultChannel) {
+        throw new Error(`No default channel could be found!`);
+    }
+    if (logging) {
+        console.log(`Got default channel token: ${defaultChannel.token}`);
+    }
+    return defaultChannel.token;
+}

+ 24 - 1
server/mock-data/mock-data.service.ts

@@ -2,7 +2,6 @@ import * as faker from 'faker/locale/en_GB';
 import gql from 'graphql-tag';
 import {
     CreateFacet,
-    CreateFacetValueInput,
     CreateFacetValueWithFacetInput,
     CreateFacetVariables,
     CreateProduct,
@@ -26,6 +25,7 @@ import {
 } from '../../admin-ui/src/app/data/mutations/product-mutations';
 import { CreateAddressDto } from '../src/entity/address/address.dto';
 import { CreateAdministratorDto } from '../src/entity/administrator/administrator.dto';
+import { Channel } from '../src/entity/channel/channel.entity';
 import { CreateCustomerDto } from '../src/entity/customer/customer.dto';
 import { Customer } from '../src/entity/customer/customer.entity';
 
@@ -43,6 +43,24 @@ export class MockDataService {
         faker.seed(1);
     }
 
+    async populateChannels(channelCodes: string[]): Promise<Channel[]> {
+        const channels: Channel[] = [];
+        for (const code of channelCodes) {
+            const channel = await this.client.query<any>(gql`
+                mutation {
+                    createChannel(code: "${code}") {
+                        id
+                        code
+                        token
+                    }
+                }
+            `);
+            channels.push(channel.createChannel);
+            this.log(`Created Channel: ${channel.createChannel.code}`);
+        }
+        return channels;
+    }
+
     async populateOptions(): Promise<any> {
         await this.client
             .query<CreateProductOptionGroup, CreateProductOptionGroupVariables>(CREATE_PRODUCT_OPTION_GROUP, {
@@ -272,6 +290,11 @@ export class MockDataService {
         const query = GENERATE_PRODUCT_VARIANTS;
         return this.client.query<GenerateProductVariants, GenerateProductVariantsVariables>(query, {
             productId,
+            defaultSku: faker.random.alphaNumeric(5),
+            defaultPrice: faker.random.number({
+                min: 100,
+                max: 1000,
+            }),
         });
     }
 

+ 3 - 2
server/mock-data/populate-cli.ts

@@ -16,8 +16,9 @@ if (require.main === module) {
     // tslint:disable
     populate(populateConfig, bootstrap, {
         logging: true,
-        customerCount: 100,
-        productCount: 200,
+        customerCount: 10,
+        productCount: 20,
+        channels: ['mobile-app'],
     })
         .then(app => app.close())
         .then(() => process.exit(0));

+ 17 - 7
server/mock-data/populate.ts

@@ -2,8 +2,10 @@ import { INestApplication } from '@nestjs/common';
 
 import { VendureBootstrapFunction } from '../src/bootstrap';
 import { setConfig, VendureConfig } from '../src/config/vendure-config';
+import { Channel } from '../src/entity/channel/channel.entity';
 
 import { clearAllTables } from './clear-all-tables';
+import { getDefaultChannelToken } from './get-default-channel-token';
 import { MockDataService } from './mock-data.service';
 import { SimpleGraphQLClient } from './simple-graphql-client';
 
@@ -11,6 +13,7 @@ export interface PopulateOptions {
     logging?: boolean;
     productCount: number;
     customerCount: number;
+    channels?: string[];
 }
 
 // tslint:disable:no-floating-promises
@@ -27,12 +30,19 @@ export async function populate(
     setConfig(config);
     await clearAllTables(config.dbConnectionOptions, logging);
     const app = await bootstrapFn(config);
-    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.apiPath}`);
-    const mockDataClientService = new MockDataService(client, logging);
-    await mockDataClientService.populateOptions();
-    await mockDataClientService.populateProducts(options.productCount);
-    await mockDataClientService.populateCustomers(options.customerCount);
-    await mockDataClientService.populateFacets();
-    await mockDataClientService.populateAdmins();
+    const defaultChannelToken = await getDefaultChannelToken(config.dbConnectionOptions, logging);
+    const client = new SimpleGraphQLClient(
+        `http://localhost:${config.port}/${config.apiPath}?token=${defaultChannelToken}`,
+    );
+    const mockDataService = new MockDataService(client, logging);
+    let channels: Channel[] = [];
+    if (options.channels) {
+        channels = await mockDataService.populateChannels(options.channels);
+    }
+    await mockDataService.populateOptions();
+    await mockDataService.populateProducts(options.productCount);
+    await mockDataService.populateCustomers(options.customerCount);
+    await mockDataService.populateFacets();
+    await mockDataService.populateAdmins();
     return app;
 }

+ 2 - 2
server/mock-data/simple-graphql-client.ts

@@ -3,14 +3,14 @@ import { request } from 'graphql-request';
 import { print } from 'graphql/language/printer';
 
 export interface GraphQlClient {
-    query<T, V = Record<string, any>>(query: DocumentNode, variables: V): Promise<T>;
+    query<T, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<T>;
 }
 
 /**
  * A minimalistic GraphQL client for populating test data.
  */
 export class SimpleGraphQLClient implements GraphQlClient {
-    constructor(private apiUrl: string) {}
+    constructor(public apiUrl: string = '') {}
 
     query<T, V = Record<string, any>>(query: DocumentNode, variables: V): Promise<T> {
         const queryString = print(query);

+ 2 - 2
server/package.json

@@ -22,14 +22,14 @@
   "dependencies": {
     "@nestjs/common": "^5.3.2",
     "@nestjs/core": "^5.3.2",
-    "@nestjs/graphql": "5.1.1",
+    "@nestjs/graphql": "5.1.2",
     "@nestjs/passport": "^5.0.1",
     "@nestjs/testing": "^5.3.1",
     "@nestjs/typeorm": "^5.2.0",
     "apollo-server-express": "^2.0.4",
     "bcrypt": "^3.0.0",
     "body-parser": "^1.18.3",
-    "graphql": "^14.0.0-rc.2",
+    "graphql": "^14.0.0",
     "graphql-iso-date": "^3.5.0",
     "graphql-tag": "^2.9.2",
     "graphql-tools": "^3.1.1",

+ 6 - 3
server/src/api/api.module.ts

@@ -6,7 +6,9 @@ import { I18nModule } from '../i18n/i18n.module';
 import { ServiceModule } from '../service/service.module';
 
 import { AdministratorResolver } from './administrator/administrator.resolver';
-import { AuthController } from './auth/auth.controller';
+import { AuthResolver } from './auth/auth.resolver';
+import { ChannelResolver } from './channel/channel.resolver';
+import { RequestContextService } from './common/request-context.service';
 import { ConfigResolver } from './config/config.resolver';
 import { CustomerResolver } from './customer/customer.resolver';
 import { FacetResolver } from './facet/facet.resolver';
@@ -17,6 +19,8 @@ import { ProductResolver } from './product/product.resolver';
 
 const exportedProviders = [
     AdministratorResolver,
+    AuthResolver,
+    ChannelResolver,
     ConfigResolver,
     FacetResolver,
     CustomerResolver,
@@ -37,8 +41,7 @@ const exportedProviders = [
             imports: [ConfigModule, I18nModule],
         }),
     ],
-    controllers: [AuthController],
-    providers: [...exportedProviders, JwtStrategy],
+    providers: [...exportedProviders, JwtStrategy, RequestContextService],
     exports: exportedProviders,
 })
 export class ApiModule {}

+ 72 - 0
server/src/api/auth-guard.ts

@@ -0,0 +1,72 @@
+import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
+import { GqlExecutionContext } from '@nestjs/graphql';
+import * as passport from 'passport';
+import { ExtractJwt, Strategy } from 'passport-jwt';
+import { Observable } from 'rxjs';
+
+import { JwtPayload } from '../common/types/auth-types';
+import { ConfigService } from '../config/config.service';
+import { User } from '../entity/user/user.entity';
+import { AuthService } from '../service/auth.service';
+
+/**
+ * A guard which uses passport.js & the passport-jwt strategy to authenticate incoming GraphQL requests.
+ * At this time, the Nest AuthGuard is not working with Apollo Server 2, see https://github.com/nestjs/graphql/issues/48
+ *
+ * If the above issue is fixed, it may make sense to switch to the Nest AuthGuard.
+ */
+@Injectable()
+export class AuthGuard implements CanActivate {
+    strategy: any;
+
+    constructor(private configService: ConfigService, private authService: AuthService) {
+        this.strategy = new Strategy(
+            {
+                secretOrKey: configService.jwtSecret,
+                jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+                // TODO: make this configurable. See https://github.com/vendure-ecommerce/vendure/issues/16
+                jsonWebTokenOptions: {
+                    expiresIn: '2 days',
+                },
+            },
+            (payload: any, done: () => void) => this.validate(payload, done),
+        );
+    }
+
+    async canActivate(context: ExecutionContext): Promise<boolean> {
+        if (this.configService.disableAuth) {
+            return true;
+        }
+        const ctx = GqlExecutionContext.create(context).getContext();
+        return this.authenticate(ctx.req);
+    }
+
+    async validate(payload: JwtPayload, done: (err: Error | null, user: any) => void) {
+        const user = await this.authService.validateUser(payload.identifier);
+        if (!user) {
+            return done(new UnauthorizedException(), false);
+        }
+        done(null, user);
+    }
+
+    /**
+     * Wraps the JwtStrategy.authenticate() call in a Promise, and also patches
+     * the methods which it expects to exist because it is designed to run as
+     * an Express middleware function.
+     */
+    private authenticate(request: any): Promise<boolean> {
+        return new Promise((resolve, reject) => {
+            this.strategy.fail = info => {
+                resolve(false);
+            };
+            this.strategy.success = (user, info) => {
+                request.user = user;
+                resolve(true);
+            };
+            this.strategy.error = err => {
+                reject(err);
+            };
+            this.strategy.authenticate(request);
+        });
+    }
+}

+ 19 - 0
server/src/api/auth/auth.api.graphql

@@ -0,0 +1,19 @@
+type Query {
+    me: CurrentUser
+}
+
+type Mutation {
+    login(username: String!, password: String!): LoginResult!
+}
+
+type LoginResult {
+    authToken: String!
+    user: CurrentUser!
+}
+
+type CurrentUser {
+    id: ID!
+    identifier: String!
+    roles: [String!]!
+    channelTokens: [String!]!
+}

+ 0 - 48
server/src/api/auth/auth.controller.ts

@@ -1,48 +0,0 @@
-import { Body, Controller, Get, Post, Req } from '@nestjs/common';
-
-import { Role } from '../../common/types/role';
-import { User } from '../../entity/user/user.entity';
-import { AuthService } from '../../service/auth.service';
-import { RolesGuard } from '../roles-guard';
-
-@Controller('auth')
-export class AuthController {
-    constructor(private authService: AuthService) {}
-
-    /**
-     * Attempts a login given the username and password of a user. If successful, returns
-     * the user data and a token to be used by Bearer auth.
-     */
-    @Post('login')
-    async login(@Body() loginDto: { username: string; password: string }) {
-        const { user, token } = await this.authService.createToken(loginDto.username, loginDto.password);
-
-        if (token) {
-            return {
-                token,
-                user: this.publiclyAccessibleUser(user),
-            };
-        }
-    }
-
-    /**
-     * Returns information about the current authenticated user.
-     */
-    @RolesGuard([Role.Authenticated])
-    @Get('me')
-    async me(@Req() request) {
-        const user = request.user as User;
-        return this.publiclyAccessibleUser(user);
-    }
-
-    /**
-     * Exposes a subset of the User properties which we want to expose to the public API.
-     */
-    private publiclyAccessibleUser(user: User): Pick<User, 'id' | 'identifier' | 'roles'> {
-        return {
-            id: user.id,
-            identifier: user.identifier,
-            roles: user.roles,
-        };
-    }
-}

+ 57 - 0
server/src/api/auth/auth.resolver.ts

@@ -0,0 +1,57 @@
+import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
+
+import { Permission } from '../../entity/role/permission';
+import { User } from '../../entity/user/user.entity';
+import { AuthService } from '../../service/auth.service';
+import { ChannelService } from '../../service/channel.service';
+import { RolesGuard } from '../roles-guard';
+
+@Resolver('Auth')
+export class AuthResolver {
+    constructor(private authService: AuthService, private channelService: ChannelService) {}
+
+    /**
+     * Attempts a login given the username and password of a user. If successful, returns
+     * the user data and a token to be used by Bearer auth.
+     */
+    @Mutation()
+    async login(@Args() args: { username: string; password: string }) {
+        const { user, token } = await this.authService.createToken(args.username, args.password);
+
+        if (token) {
+            return {
+                authToken: token,
+                user: this.publiclyAccessibleUser(user),
+            };
+        }
+    }
+
+    /**
+     * Returns information about the current authenticated user.
+     */
+    @RolesGuard([Permission.Authenticated])
+    @Query()
+    async me(@Context('req') request: any) {
+        const user = await this.authService.validateUser(request.user.identifier);
+        return user ? this.publiclyAccessibleUser(user) : null;
+    }
+
+    /**
+     * 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,
+            roles: user.roles.reduce(
+                (roleTypes, role) => [...roleTypes, ...role.permissions],
+                [] as Permission[],
+            ),
+            channelTokens: this.getAvailableChannelTokens(user),
+        };
+    }
+
+    private getAvailableChannelTokens(user: User): string[] {
+        return user.roles.reduce((tokens, role) => role.channels.map(c => c.token), [] as string[]);
+    }
+}

+ 3 - 0
server/src/api/channel/channel.api.graphql

@@ -0,0 +1,3 @@
+type Mutation {
+  createChannel(code: String!): Channel!
+}

+ 14 - 0
server/src/api/channel/channel.resolver.ts

@@ -0,0 +1,14 @@
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+
+import { Channel } from '../../entity/channel/channel.entity';
+import { ChannelService } from '../../service/channel.service';
+
+@Resolver('Channel')
+export class ChannelResolver {
+    constructor(private channelService: ChannelService) {}
+
+    @Mutation()
+    createChannel(@Args() args): Promise<Channel> {
+        return this.channelService.create(args.code);
+    }
+}

+ 19 - 0
server/src/api/common/request-context.pipe.ts

@@ -0,0 +1,19 @@
+import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
+
+import { I18nError } from '../../i18n/i18n-error';
+import { ChannelService } from '../../service/channel.service';
+
+import { RequestContext } from './request-context';
+import { RequestContextService } from './request-context.service';
+
+/**
+ * Creates a new RequestContext based on the token passed in the query string of the request.
+ */
+@Injectable()
+export class RequestContextPipe implements PipeTransform<any, RequestContext> {
+    constructor(private requestContextService: RequestContextService) {}
+
+    transform(value: any, metadata: ArgumentMetadata) {
+        return this.requestContextService.fromRequest(value.req);
+    }
+}

+ 26 - 0
server/src/api/common/request-context.service.ts

@@ -0,0 +1,26 @@
+import { Injectable } from '@nestjs/common';
+
+import { I18nError } from '../../i18n/i18n-error';
+import { ChannelService } from '../../service/channel.service';
+
+import { RequestContext } from './request-context';
+
+/**
+ * Creates new RequestContext instances.
+ */
+@Injectable()
+export class RequestContextService {
+    constructor(private channelService: ChannelService) {}
+
+    /**
+     * Creates a new RequestContext based on an Express request object.
+     */
+    fromRequest(req: any): RequestContext {
+        if (req && req.query) {
+            const token = req.query.token;
+            const channel = this.channelService.getChannelFromToken(token);
+            return new RequestContext(channel);
+        }
+        throw new I18nError(`error.unexpected-request-context`);
+    }
+}

+ 40 - 0
server/src/api/common/request-context.ts

@@ -0,0 +1,40 @@
+import { LanguageCode } from 'shared/generated-types';
+import { ID } from 'shared/shared-types';
+
+import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
+import { Channel } from '../../entity/channel/channel.entity';
+
+/**
+ * The RequestContext is intended to hold information relevant to the current request, which may be
+ * required at various points of the stack. Primarily, the current token and active Channel id is
+ * exposed, as well as the active language.
+ */
+export class RequestContext {
+    get channel(): Channel {
+        return this._channel || ({} as any);
+    }
+
+    get channelId(): ID | undefined {
+        return this._channel && this._channel.id;
+    }
+
+    get languageCode(): LanguageCode {
+        if (this._languageCode) {
+            return this._languageCode;
+        } else if (this._channel) {
+            return this._channel.defaultLanguageCode;
+        } else {
+            return DEFAULT_LANGUAGE_CODE;
+        }
+    }
+
+    private _languageCode: LanguageCode;
+
+    constructor(private _channel?: Channel) {}
+
+    setLanguageCode(value: LanguageCode | null | undefined) {
+        if (value) {
+            this._languageCode = value;
+        }
+    }
+}

+ 4 - 1
server/src/api/jwt.strategy.ts

@@ -7,6 +7,9 @@ import { AuthService } from '../service/auth.service';
 
 import { JwtPayload } from '../common/types/auth-types';
 
+/**
+ * Currently unused - see note at {@link AuthGuard}
+ */
 @Injectable()
 export class JwtStrategy extends PassportStrategy(Strategy) {
     constructor(private readonly authService: AuthService, private configService: ConfigService) {
@@ -17,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
     }
 
     async validate(payload: JwtPayload, done: (err: Error | null, user: any) => void) {
-        const user = await this.authService.validateUser(payload);
+        const user = await this.authService.validateUser(payload.identifier);
         if (!user) {
             return done(new UnauthorizedException(), false);
         }

+ 54 - 22
server/src/api/product/product.resolver.ts

@@ -1,9 +1,13 @@
-import { Mutation, Query, Resolver } from '@nestjs/graphql';
+import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    AddOptionGroupToProductVariables,
+    ApplyFacetValuesToProductVariantsVariables,
     CreateProductVariables,
     GenerateProductVariantsVariables,
     GetProductListVariables,
     GetProductWithVariantsVariables,
+    RemoveOptionGroupFromProductVariables,
+    UpdateProductVariables,
     UpdateProductVariantsVariables,
 } from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
@@ -13,11 +17,15 @@ 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 { Permission } from '../../entity/role/permission';
 import { I18nError } from '../../i18n/i18n-error';
 import { FacetValueService } from '../../service/facet-value.service';
 import { ProductVariantService } from '../../service/product-variant.service';
 import { ProductService } from '../../service/product.service';
 import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
+import { RequestContext } from '../common/request-context';
+import { RequestContextPipe } from '../common/request-context.pipe';
+import { RolesGuard } from '../roles-guard';
 
 @Resolver('Product')
 export class ProductResolver {
@@ -27,62 +35,83 @@ export class ProductResolver {
         private facetValueService: FacetValueService,
     ) {}
 
-    @Query('products')
+    @Query()
+    @RolesGuard([Permission.ReadCatalog])
     @ApplyIdCodec()
-    async products(obj, args: GetProductListVariables): Promise<PaginatedList<Translated<Product>>> {
-        return this.productService.findAll(args.languageCode, args.options || undefined);
+    async products(
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: GetProductListVariables,
+    ): Promise<PaginatedList<Translated<Product>>> {
+        ctx.setLanguageCode(args.languageCode);
+        return this.productService.findAll(ctx, args.options || undefined);
     }
 
-    @Query('product')
+    @Query()
     @ApplyIdCodec()
-    async product(obj, args: GetProductWithVariantsVariables): Promise<Translated<Product> | undefined> {
-        return this.productService.findOne(args.id, args.languageCode || undefined);
+    async product(
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: GetProductWithVariantsVariables,
+    ): Promise<Translated<Product> | undefined> {
+        ctx.setLanguageCode(args.languageCode);
+        return this.productService.findOne(ctx, args.id);
     }
 
     @Mutation()
     @ApplyIdCodec()
-    async createProduct(_, args: CreateProductVariables): Promise<Translated<Product>> {
+    async createProduct(
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: CreateProductVariables,
+    ): Promise<Translated<Product>> {
         const { input } = args;
-        return this.productService.create(input);
+        return this.productService.create(ctx, input);
     }
 
     @Mutation()
     @ApplyIdCodec()
-    async updateProduct(_, args): Promise<Translated<Product>> {
+    async updateProduct(
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: UpdateProductVariables,
+    ): Promise<Translated<Product>> {
         const { input } = args;
-        return this.productService.update(input);
+        return this.productService.update(ctx, input);
     }
 
     @Mutation()
     @ApplyIdCodec(['productId', 'optionGroupId'])
-    async addOptionGroupToProduct(_, args): Promise<Translated<Product>> {
+    async addOptionGroupToProduct(
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: AddOptionGroupToProductVariables,
+    ): Promise<Translated<Product>> {
         const { productId, optionGroupId } = args;
-        return this.productService.addOptionGroupToProduct(productId, optionGroupId);
+        return this.productService.addOptionGroupToProduct(ctx, productId, optionGroupId);
     }
 
     @Mutation()
     @ApplyIdCodec(['productId', 'optionGroupId'])
-    async removeOptionGroupFromProduct(_, args): Promise<Translated<Product>> {
+    async removeOptionGroupFromProduct(
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: RemoveOptionGroupFromProductVariables,
+    ): Promise<Translated<Product>> {
         const { productId, optionGroupId } = args;
-        return this.productService.removeOptionGroupFromProduct(productId, optionGroupId);
+        return this.productService.removeOptionGroupFromProduct(ctx, productId, optionGroupId);
     }
 
     @Mutation()
     @ApplyIdCodec()
     async generateVariantsForProduct(
-        _,
-        args: GenerateProductVariantsVariables,
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: GenerateProductVariantsVariables,
     ): Promise<Translated<Product>> {
         const { productId, defaultPrice, defaultSku } = args;
-        await this.productVariantService.generateVariantsForProduct(productId, defaultPrice, defaultSku);
-        return assertFound(this.productService.findOne(productId, DEFAULT_LANGUAGE_CODE));
+        await this.productVariantService.generateVariantsForProduct(ctx, productId, defaultPrice, defaultSku);
+        return assertFound(this.productService.findOne(ctx, productId));
     }
 
     @Mutation()
     @ApplyIdCodec()
     async updateProductVariants(
-        _,
-        args: UpdateProductVariantsVariables,
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: UpdateProductVariantsVariables,
     ): Promise<Array<Translated<ProductVariant>>> {
         const { input } = args;
         return Promise.all(input.map(variant => this.productVariantService.update(variant)));
@@ -90,7 +119,10 @@ export class ProductResolver {
 
     @Mutation()
     @ApplyIdCodec()
-    async applyFacetValuesToProductVariants(_, args): Promise<Array<Translated<ProductVariant>>> {
+    async applyFacetValuesToProductVariants(
+        @Context(RequestContextPipe) ctx: RequestContext,
+        @Args() args: ApplyFacetValuesToProductVariantsVariables,
+    ): Promise<Array<Translated<ProductVariant>>> {
         const { facetValueIds, productVariantIds } = args;
         const facetValues = await Promise.all(
             (facetValueIds as ID[]).map(async facetValueId => {

+ 36 - 19
server/src/api/roles-guard.ts

@@ -1,10 +1,12 @@
-import { CanActivate, ExecutionContext, UseGuards } from '@nestjs/common';
-import { AuthGuard } from '@nestjs/passport';
+import { CanActivate, ExecutionContext, Injectable, UseGuards } from '@nestjs/common';
 import { ExtractJwt, Strategy } from 'passport-jwt';
 
+import { ConfigService } from '../config/config.service';
+import { Permission } from '../entity/role/permission';
 import { User } from '../entity/user/user.entity';
 
-import { Role } from '../common/types/role';
+import { AuthGuard } from './auth-guard';
+import { RequestContextService } from './common/request-context.service';
 
 /**
  * A guard which combines the JWT passport auth method with restrictions based on
@@ -12,48 +14,63 @@ import { Role } from '../common/types/role';
  *
  * @example
  * ```
- *  @RolesGuard([Role.Superadmin])
+ *  @RolesGuard([Permission.SuperAdmin])
  *  @Query('administrators')
  *  getAdministrators() {
  *      // ...
  *  }
  * ```
  */
-export function RolesGuard(roles: Role[]) {
+export function RolesGuard(permissions: Permission[]) {
     // tslint:disable-next-line:ban-types
-    const guards: Array<CanActivate | Function> = [AuthGuard('jwt')];
+    const guards: Array<CanActivate | Function> = [AuthGuard];
 
-    if (roles.length && !authenticatedOnly(roles)) {
-        guards.push(forRoles(roles));
+    if (permissions.length && !authenticatedOnly(permissions)) {
+        guards.push(forPermissions(permissions));
     }
 
     return UseGuards(...guards);
 }
 
-function authenticatedOnly(roles: Role[]): boolean {
-    return roles.length === 1 && roles[0] === Role.Authenticated;
+function authenticatedOnly(permissions: Permission[]): boolean {
+    return permissions.length === 1 && permissions[0] === Permission.Authenticated;
 }
 
 /**
- * A guard which specifies which roles are authorized to access a given
- * route or property in a Controller / Resolver.
+ * A guard which specifies which permissions are authorized to access a given
+ * route or property in a Resolver.
  */
-function forRoles(roles: Role[]) {
-    return {
+function forPermissions(permissions: Permission[]) {
+    @Injectable()
+    class RoleGuard implements CanActivate {
+        constructor(
+            private requestContextService: RequestContextService,
+            private configService: ConfigService,
+        ) {}
+
         canActivate(context: ExecutionContext) {
-            const user: User = context.switchToHttp().getRequest().user;
+            if (this.configService.disableAuth) {
+                return true;
+            }
+            const req = context.getArgByIndex(2).req;
+            const ctx = this.requestContextService.fromRequest(req);
+            const user: User = req.user;
             if (!user) {
                 return false;
             }
-            return arraysIntersect(roles, user.roles);
-        },
-    } as CanActivate;
+            const permissionsOnChannel = user.roles
+                .filter(role => role.channels.find(c => c.id === ctx.channel.id))
+                .reduce((output, role) => [...output, ...role.permissions], [] as Permission[]);
+            return arraysIntersect(permissions, permissionsOnChannel);
+        }
+    }
+    return RoleGuard;
 }
 
 /**
  * Returns true if any element of arr1 appears in arr2.
  */
-function arraysIntersect(arr1, arr2): boolean {
+function arraysIntersect<T>(arr1: T[], arr2: T[]): boolean {
     return arr1.reduce((intersects, role) => {
         return intersects || arr2.includes(role);
     }, false);

+ 0 - 1
server/src/app.module.ts

@@ -16,7 +16,6 @@ export class AppModule implements NestModule {
 
     configure(consumer: MiddlewareConsumer) {
         validateCustomFieldsConfig(this.configService.customFields);
-
         consumer.apply(this.i18nService.handle()).forRoutes(this.configService.apiPath);
     }
 }

+ 3 - 0
server/src/bootstrap.ts

@@ -1,6 +1,7 @@
 import { INestApplication } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { Type } from 'shared/shared-types';
+import { EntitySubscriberInterface } from 'typeorm';
 
 import { getConfig, setConfig, VendureConfig } from './config/vendure-config';
 import { VendureEntity } from './entity/base/base.entity';
@@ -35,9 +36,11 @@ export async function preBootstrapConfig(userConfig: Partial<VendureConfig>): Pr
     // specified in the EntityIdStrategy.
     // tslint:disable-next-line:whitespace
     const { coreEntitiesMap } = await import('./entity/entities');
+    const { coreSubscribersMap } = await import('./entity/subscribers');
     setConfig({
         dbConnectionOptions: {
             entities: Object.values(coreEntitiesMap) as Array<Type<VendureEntity>>,
+            subscribers: Object.values(coreSubscribersMap) as Array<Type<EntitySubscriberInterface>>,
         },
     });
 

+ 5 - 0
server/src/common/constants.ts

@@ -1,3 +1,8 @@
 import { LanguageCode } from 'shared/generated-types';
 
 export const DEFAULT_LANGUAGE_CODE = LanguageCode.en;
+export const DEFAULT_CHANNEL_CODE = '__default_channel__';
+export const SUPER_ADMIN_ROLE_CODE = '__super_admin_role__';
+export const SUPER_ADMIN_ROLE_DESCRIPTION = 'SuperAdmin';
+export const CUSTOMER_ROLE_CODE = '__customer_role__';
+export const CUSTOMER_ROLE_DESCRIPTION = 'Customer';

+ 2 - 2
server/src/common/types/auth-types.ts

@@ -1,6 +1,6 @@
-import { Role } from './role';
+import { Permission } from '../../entity/role/permission';
 
 export interface JwtPayload {
     identifier: string;
-    roles: Role[];
+    roles: Permission[];
 }

+ 8 - 0
server/src/common/types/common-types.ts

@@ -1,7 +1,15 @@
 import { VendureEntity } from '../../entity/base/base.entity';
+import { Channel } from '../../entity/channel/channel.entity';
 
 import { LocaleString } from './locale-types';
 
+/**
+ * Entities which can be assigned to Channels should implement this interface.
+ */
+export interface ChannelAware {
+    channels: Channel[];
+}
+
 /**
  * Creates a type based on T, but with all properties non-optional
  * and readonly.

+ 0 - 9
server/src/common/types/role.ts

@@ -1,9 +0,0 @@
-/**
- * All possible authorization roles for registered users.
- */
-export enum Role {
-    // The Authenticated role means simply that the user is logged in
-    Authenticated = 'Authenticated',
-    Customer = 'Customer',
-    Superadmin = 'Superadmin',
-}

+ 1 - 0
server/src/config/config.service.mock.ts

@@ -5,6 +5,7 @@ import { ConfigService } from './config.service';
 import { EntityIdStrategy, PrimaryKeyType } from './entity-id-strategy/entity-id-strategy';
 
 export class MockConfigService implements MockClass<ConfigService> {
+    disableAuth = false;
     apiPath = 'api';
     port = 3000;
     cors = false;

+ 10 - 0
server/src/config/config.service.ts

@@ -10,6 +10,10 @@ import { getConfig, VendureConfig } from '../config/vendure-config';
 
 @Injectable()
 export class ConfigService implements VendureConfig {
+    get disableAuth(): boolean {
+        return this.activeConfig.disableAuth;
+    }
+
     get defaultLanguageCode(): LanguageCode {
         return this.activeConfig.defaultLanguageCode;
     }
@@ -46,5 +50,11 @@ export class ConfigService implements VendureConfig {
 
     constructor() {
         this.activeConfig = getConfig();
+        if (this.activeConfig.disableAuth) {
+            // tslint:disable-next-line
+            console.warn(
+                'WARNING: auth has been disabled. This should never be the case for a production system!',
+            );
+        }
     }
 }

+ 7 - 0
server/src/config/vendure-config.ts

@@ -11,6 +11,12 @@ import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
 
 export interface VendureConfig {
+    /**
+     * Disable authentication & permissions checks.
+     * NEVER set the to true in production. It exists
+     * only to aid the ease of development.
+     */
+    disableAuth?: boolean;
     /**
      * The default languageCode of the app.
      */
@@ -53,6 +59,7 @@ export interface VendureConfig {
 }
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
+    disableAuth: false,
     defaultLanguageCode: LanguageCode.en,
     port: API_PORT,
     cors: false,

+ 29 - 0
server/src/entity/channel/channel.entity.ts

@@ -0,0 +1,29 @@
+import { LanguageCode } from 'shared/generated-types';
+import { DeepPartial } from 'shared/shared-types';
+import { Column, Entity } from 'typeorm';
+
+import { VendureEntity } from '../base/base.entity';
+
+@Entity()
+export class Channel extends VendureEntity {
+    constructor(input?: DeepPartial<Channel>) {
+        super(input);
+        this.token = this.generateToken();
+    }
+
+    @Column({ unique: true })
+    code: string;
+
+    @Column({ unique: true })
+    token: string;
+
+    @Column('varchar') defaultLanguageCode: LanguageCode;
+
+    private generateToken(): string {
+        const randomString = () =>
+            Math.random()
+                .toString(36)
+                .substr(3, 10);
+        return `${randomString()}${randomString()}`;
+    }
+}

+ 7 - 0
server/src/entity/channel/channel.graphql

@@ -0,0 +1,7 @@
+type Channel implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    code: String!
+    token: String!
+}

+ 6 - 0
server/src/entity/entities.ts

@@ -1,5 +1,6 @@
 import { Address } from './address/address.entity';
 import { Administrator } from './administrator/administrator.entity';
+import { Channel } from './channel/channel.entity';
 import { Customer } from './customer/customer.entity';
 import { FacetValueTranslation } from './facet-value/facet-value-translation.entity';
 import { FacetValue } from './facet-value/facet-value.entity';
@@ -9,10 +10,12 @@ import { ProductOptionGroupTranslation } from './product-option-group/product-op
 import { ProductOptionGroup } from './product-option-group/product-option-group.entity';
 import { ProductOptionTranslation } from './product-option/product-option-translation.entity';
 import { ProductOption } from './product-option/product-option.entity';
+import { ProductVariantPrice } from './product-variant/product-variant-price.entity';
 import { ProductVariantTranslation } from './product-variant/product-variant-translation.entity';
 import { ProductVariant } from './product-variant/product-variant.entity';
 import { ProductTranslation } from './product/product-translation.entity';
 import { Product } from './product/product.entity';
+import { Role } from './role/role.entity';
 import { User } from './user/user.entity';
 
 /**
@@ -21,6 +24,7 @@ import { User } from './user/user.entity';
 export const coreEntitiesMap = {
     Address,
     Administrator,
+    Channel,
     Customer,
     Facet,
     FacetTranslation,
@@ -33,6 +37,8 @@ export const coreEntitiesMap = {
     ProductOptionGroup,
     ProductOptionGroupTranslation,
     ProductVariant,
+    ProductVariantPrice,
     ProductVariantTranslation,
+    Role,
     User,
 };

+ 21 - 0
server/src/entity/product-variant/product-variant-price.entity.ts

@@ -0,0 +1,21 @@
+import { DeepPartial } from 'shared/shared-types';
+import { Column, Entity, ManyToOne } from 'typeorm';
+
+import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
+
+import { ProductVariant } from './product-variant.entity';
+
+@Entity()
+export class ProductVariantPrice extends VendureEntity {
+    constructor(input?: DeepPartial<ProductVariantPrice>) {
+        super(input);
+    }
+
+    @Column() price: number;
+
+    @Column() channelId: number;
+
+    @ManyToOne(type => ProductVariant, variant => variant.productVariantPrices)
+    variant: ProductVariant;
+}

+ 9 - 1
server/src/entity/product-variant/product-variant.entity.ts

@@ -8,6 +8,7 @@ import { FacetValue } from '../facet-value/facet-value.entity';
 import { ProductOption } from '../product-option/product-option.entity';
 import { Product } from '../product/product.entity';
 
+import { ProductVariantPrice } from './product-variant-price.entity';
 import { ProductVariantTranslation } from './product-variant-translation.entity';
 
 @Entity()
@@ -22,7 +23,14 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
 
     @Column() image: string;
 
-    @Column() price: number;
+    @Column({
+        name: 'lastPriceValue',
+        comment: 'Not used - actual price is stored in product_variant_price table',
+    })
+    price: number;
+
+    @OneToMany(type => ProductVariantPrice, price => price.variant, { eager: true })
+    productVariantPrices: ProductVariantPrice[];
 
     @OneToMany(type => ProductVariantTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<ProductVariant>>;

+ 37 - 0
server/src/entity/product-variant/product-variant.subscriber.ts

@@ -0,0 +1,37 @@
+import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
+
+import { I18nError } from '../../i18n/i18n-error';
+
+import { ProductVariantPrice } from './product-variant-price.entity';
+import { ProductVariant } from './product-variant.entity';
+
+/**
+ * This subscriber listens for CRUD events on ProductVariants and transparently handles
+ */
+@EventSubscriber()
+export class ProductVariantSubscriber implements EntitySubscriberInterface<ProductVariant> {
+    listenTo() {
+        return ProductVariant;
+    }
+
+    async afterInsert(event: InsertEvent<ProductVariant>) {
+        const channelId = event.queryRunner.data.channelId;
+        const price = event.entity.price || 0;
+        if (channelId === undefined) {
+            throw new I18nError(`error.channel-id-not-set`);
+        }
+        const variantPrice = new ProductVariantPrice({ price, channelId });
+        variantPrice.variant = event.entity;
+        await event.manager.save(variantPrice);
+    }
+
+    async afterUpdate(event: InsertEvent<ProductVariant>) {
+        const prices = await event.connection.getRepository(ProductVariantPrice).find({
+            where: {
+                variant: event.entity.id,
+            },
+        });
+        prices[0].price = event.entity.price || 0;
+        await event.manager.save(prices[0]);
+    }
+}

+ 8 - 3
server/src/entity/product/product.entity.ts

@@ -1,9 +1,10 @@
-import { DeepPartial } from 'shared/shared-types';
-import { HasCustomFields } from 'shared/shared-types';
+import { DeepPartial, HasCustomFields } from 'shared/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 
+import { ChannelAware } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { CustomProductFields } from '../custom-entity-fields';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
@@ -11,7 +12,7 @@ import { ProductVariant } from '../product-variant/product-variant.entity';
 import { ProductTranslation } from './product-translation.entity';
 
 @Entity()
-export class Product extends VendureEntity implements Translatable, HasCustomFields {
+export class Product extends VendureEntity implements Translatable, HasCustomFields, ChannelAware {
     constructor(input?: DeepPartial<Product>) {
         super(input);
     }
@@ -35,4 +36,8 @@ export class Product extends VendureEntity implements Translatable, HasCustomFie
 
     @Column(type => CustomProductFields)
     customFields: CustomProductFields;
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 20 - 0
server/src/entity/role/permission.ts

@@ -0,0 +1,20 @@
+/**
+ * Permissions for administrators.
+ */
+export enum Permission {
+    // The Authenticated role means simply that the user is logged in
+    Authenticated = 'Authenticated',
+    // CRUD permissions on the various classes of entity
+    CreateCatalog = 'CreateCatalog',
+    ReadCatalog = 'ReadCatalog',
+    UpdateCatalog = 'UpdateCatalog',
+    DeleteCatalog = 'DeleteCatalog',
+    CreateCustomer = 'CreateCustomer',
+    ReadCustomer = 'ReadCustomer',
+    UpdateCustomer = 'UpdateCustomer',
+    DeleteCustomer = 'DeleteCustomer',
+    CreateOrder = 'CreateOrder',
+    ReadOrder = 'ReadOrder',
+    UpdateOrder = 'UpdateOrder',
+    DeleteOrder = 'DeleteOrder',
+}

+ 26 - 0
server/src/entity/role/role.entity.ts

@@ -0,0 +1,26 @@
+import { DeepPartial } from 'shared/shared-types';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from 'typeorm';
+
+import { ChannelAware } from '../../common/types/common-types';
+import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
+import { User } from '../user/user.entity';
+
+import { Permission } from './permission';
+
+@Entity()
+export class Role extends VendureEntity implements ChannelAware {
+    constructor(input?: DeepPartial<Role>) {
+        super(input);
+    }
+
+    @Column() code: string;
+
+    @Column() description: string;
+
+    @Column('simple-array') permissions: Permission[];
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
+}

+ 6 - 0
server/src/entity/role/role.graphql

@@ -0,0 +1,6 @@
+type Role {
+    code: String!
+    description: String!
+    permissions: [String!]!
+    channels: [Channel!]!
+}

+ 8 - 0
server/src/entity/subscribers.ts

@@ -0,0 +1,8 @@
+import { ProductVariantSubscriber } from './product-variant/product-variant.subscriber';
+
+/**
+ * A map of the core TypeORM Subscribers.
+ */
+export const coreSubscribersMap = {
+    ProductVariantSubscriber,
+};

+ 6 - 5
server/src/entity/user/user.entity.ts

@@ -1,10 +1,9 @@
-import { DeepPartial } from 'shared/shared-types';
-import { HasCustomFields } from 'shared/shared-types';
-import { Column, Entity } from 'typeorm';
+import { DeepPartial, HasCustomFields } from 'shared/shared-types';
+import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
-import { Role } from '../../common/types/role';
 import { VendureEntity } from '../base/base.entity';
 import { CustomUserFields } from '../custom-entity-fields';
+import { Role } from '../role/role.entity';
 
 @Entity()
 export class User extends VendureEntity implements HasCustomFields {
@@ -17,7 +16,9 @@ export class User extends VendureEntity implements HasCustomFields {
 
     @Column() passwordHash: string;
 
-    @Column('simple-array') roles: Role[];
+    @ManyToMany(type => Role)
+    @JoinTable()
+    roles: Role[];
 
     @Column({ nullable: true })
     lastLogin: string;

+ 8 - 4
server/src/service/administrator.service.ts

@@ -2,18 +2,19 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
-import { Role } from '../common/types/role';
 import { CreateAdministratorDto } from '../entity/administrator/administrator.dto';
 import { Administrator } from '../entity/administrator/administrator.entity';
 import { User } from '../entity/user/user.entity';
 
 import { PasswordService } from './password.service';
+import { RoleService } from './role.service';
 
 @Injectable()
 export class AdministratorService {
     constructor(
         @InjectConnection() private connection: Connection,
         private passwordService: PasswordService,
+        private roleService: RoleService,
     ) {}
 
     findAll(): Promise<Administrator[]> {
@@ -30,10 +31,13 @@ export class AdministratorService {
         const user = new User();
         user.passwordHash = await this.passwordService.hash(createAdministratorDto.password);
         user.identifier = createAdministratorDto.emailAddress;
-        user.roles = [Role.Superadmin];
-        const createdUser = await this.connection.getRepository(User).save(user);
+        // TODO: for now all Admins are added to the SuperAdmin role.
+        // It should be possible to add them to other roles.
+        user.roles = [await this.roleService.getSuperAdminRole()];
+
+        const createdUser = await this.connection.manager.save(user);
         administrator.user = createdUser;
 
-        return this.connection.getRepository(Administrator).save(administrator);
+        return this.connection.manager.save(administrator);
     }
 }

+ 10 - 9
server/src/service/auth.service.ts

@@ -4,8 +4,8 @@ import * as jwt from 'jsonwebtoken';
 import { Connection } from 'typeorm';
 
 import { JwtPayload } from '../common/types/auth-types';
-import { Role } from '../common/types/role';
 import { ConfigService } from '../config/config.service';
+import { Permission } from '../entity/role/permission';
 import { User } from '../entity/user/user.entity';
 
 import { PasswordService } from './password.service';
@@ -20,9 +20,8 @@ export class AuthService {
 
     async createToken(identifier: string, password: string): Promise<{ user: User; token: string }> {
         const user = await this.connection.getRepository(User).findOne({
-            where: {
-                identifier,
-            },
+            where: { identifier },
+            relations: ['roles', 'roles.channels'],
         });
 
         if (!user) {
@@ -34,17 +33,19 @@ export class AuthService {
         if (!passwordMatches) {
             throw new UnauthorizedException();
         }
-        const payload: JwtPayload = { identifier, roles: user.roles };
+        const payload: JwtPayload = {
+            identifier,
+            roles: user.roles.reduce((roles, r) => [...roles, ...r.permissions], [] as Permission[]),
+        };
         const token = jwt.sign(payload, this.configService.jwtSecret, { expiresIn: 3600 });
 
         return { user, token };
     }
 
-    async validateUser(payload: JwtPayload): Promise<any> {
+    async validateUser(identifier: string): Promise<User | undefined> {
         return await this.connection.getRepository(User).findOne({
-            where: {
-                identifier: payload.identifier,
-            },
+            where: { identifier },
+            relations: ['roles', 'roles.channels'],
         });
     }
 }

+ 87 - 0
server/src/service/channel.service.ts

@@ -0,0 +1,87 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+
+import { RequestContext } from '../api/common/request-context';
+import { DEFAULT_CHANNEL_CODE, DEFAULT_LANGUAGE_CODE } from '../common/constants';
+import { ChannelAware } from '../common/types/common-types';
+import { Channel } from '../entity/channel/channel.entity';
+import { I18nError } from '../i18n/i18n-error';
+
+@Injectable()
+export class ChannelService {
+    private allChannels: Channel[] = [];
+
+    constructor(@InjectConnection() private connection: Connection) {}
+
+    /**
+     * When the app is bootstrapped, ensure a default Channel exists and populate the
+     * channel lookup array.
+     */
+    async initChannels() {
+        await this.ensureDefaultChannelExists();
+        this.allChannels = await this.findAll();
+    }
+
+    /**
+     * Assigns a ChannelAware entity to the default Channel as well as any channel
+     * specified in the RequestContext.
+     */
+    assignToChannels<T extends ChannelAware>(entity: T, ctx: RequestContext): T {
+        const channelIds = [...new Set([ctx.channelId, this.getDefaultChannel().id])];
+        entity.channels = channelIds.map(id => ({ id })) as any;
+        return entity;
+    }
+
+    /**
+     * Given a channel token, returns the corresponding Channel if it exists.
+     */
+    getChannelFromToken(token: string): Channel | undefined {
+        return this.allChannels.find(channel => channel.token === token);
+    }
+
+    /**
+     * Returns the default Channel.
+     */
+    getDefaultChannel(): Channel {
+        const defaultChannel = this.allChannels.find(channel => channel.code === DEFAULT_CHANNEL_CODE);
+
+        if (!defaultChannel) {
+            throw new I18nError(`error.default-channel-not-found`);
+        }
+        return defaultChannel;
+    }
+
+    findAll(): Promise<Channel[]> {
+        return this.connection.getRepository(Channel).find();
+    }
+
+    async create(code: string): Promise<Channel> {
+        const channel = new Channel({
+            code,
+            defaultLanguageCode: DEFAULT_LANGUAGE_CODE,
+        });
+        const newChannel = await this.connection.getRepository(Channel).save(channel);
+        this.allChannels.push(channel);
+        return channel;
+    }
+
+    /**
+     * There must always be a default Channel. If none yet exists, this method creates one.
+     */
+    private async ensureDefaultChannelExists() {
+        const defaultChannel = await this.connection.getRepository(Channel).findOne({
+            where: {
+                code: DEFAULT_CHANNEL_CODE,
+            },
+        });
+
+        if (!defaultChannel) {
+            const newDefaultChannel = new Channel({
+                code: DEFAULT_CHANNEL_CODE,
+                defaultLanguageCode: DEFAULT_LANGUAGE_CODE,
+            });
+            await this.connection.manager.save(newDefaultChannel);
+        }
+    }
+}

+ 4 - 3
server/src/service/customer.service.ts

@@ -4,7 +4,6 @@ import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { ListQueryOptions } from '../common/types/common-types';
-import { Role } from '../common/types/role';
 import { CreateAddressDto } from '../entity/address/address.dto';
 import { Address } from '../entity/address/address.entity';
 import { CreateCustomerDto } from '../entity/customer/customer.dto';
@@ -14,12 +13,14 @@ import { I18nError } from '../i18n/i18n-error';
 
 import { buildListQuery } from './helpers/build-list-query';
 import { PasswordService } from './password.service';
+import { RoleService } from './role.service';
 
 @Injectable()
 export class CustomerService {
     constructor(
         @InjectConnection() private connection: Connection,
         private passwordService: PasswordService,
+        private roleService: RoleService,
     ) {}
 
     findAll(options: ListQueryOptions<Customer>): Promise<PaginatedList<Customer>> {
@@ -47,8 +48,8 @@ export class CustomerService {
             const user = new User();
             user.passwordHash = await this.passwordService.hash(password);
             user.identifier = createCustomerDto.emailAddress;
-            user.roles = [Role.Customer];
-            const createdUser = await this.connection.getRepository(User).save(user);
+            user.roles = [await this.roleService.getCustomerRole()];
+            const createdUser = await this.connection.manager.save(user);
             customer.user = createdUser;
         }
 

+ 2 - 1
server/src/service/facet-value.service.ts

@@ -16,6 +16,7 @@ import { FacetValueTranslation } from '../entity/facet-value/facet-value-transla
 import { FacetValue } from '../entity/facet-value/facet-value.entity';
 import { Facet } from '../entity/facet/facet.entity';
 
+import { ActiveConnection } from './helpers/connection.decorator';
 import { createTranslatable } from './helpers/create-translatable';
 import { translateDeep } from './helpers/translate-entity';
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
@@ -24,7 +25,7 @@ import { updateTranslatable } from './helpers/update-translatable';
 @Injectable()
 export class FacetValueService {
     constructor(
-        @InjectConnection() private connection: Connection,
+        @ActiveConnection() private connection: Connection,
         private translationUpdaterService: TranslationUpdaterService,
     ) {}
 

+ 9 - 0
server/src/service/helpers/connection.decorator.ts

@@ -0,0 +1,9 @@
+import { InjectConnection } from '@nestjs/typeorm';
+import { getConnectionManager } from 'typeorm';
+
+import { getConfig } from '../../config/vendure-config';
+
+export function ActiveConnection() {
+    const cm = getConnectionManager();
+    return InjectConnection(getConfig().dbConnectionOptions.name);
+}

+ 6 - 2
server/src/service/helpers/create-translatable.ts

@@ -12,7 +12,11 @@ export function createTranslatable<T extends Translatable>(
     translationType: Type<Translation<T>>,
     beforeSave?: (newEntity: T) => void,
 ) {
-    return async function saveTranslatable(connection: Connection, dto: TranslatedInput<T>): Promise<T> {
+    return async function saveTranslatable(
+        connection: Connection,
+        dto: TranslatedInput<T>,
+        data?: any,
+    ): Promise<T> {
         const entity = new entityType(dto);
         const translations: Array<Translation<T>> = [];
 
@@ -26,6 +30,6 @@ export function createTranslatable<T extends Translatable>(
         if (typeof beforeSave === 'function') {
             await beforeSave(entity);
         }
-        return await connection.manager.save(entity);
+        return await connection.manager.save(entity, { data });
     };
 }

+ 26 - 25
server/src/service/product-variant.service.spec.ts

@@ -3,6 +3,7 @@ import { LanguageCode } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
+import { RequestContext } from '../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ProductOption } from '../entity/product-option/product-option.entity';
 import { ProductVariantTranslation } from '../entity/product-variant/product-variant-translation.entity';
@@ -34,7 +35,7 @@ describe('ProductVariantService', () => {
     describe('create()', () => {
         it('saves a new ProductVariant with the correct properties', async () => {
             const productEntity = new Product();
-            await productVariantService.create(productEntity, {
+            await productVariantService.create(new RequestContext(), productEntity, {
                 sku: '123456',
                 price: 123,
                 translations: [
@@ -56,7 +57,7 @@ describe('ProductVariantService', () => {
 
         it('saves each ProductVariantTranslation', async () => {
             const productEntity = new Product();
-            await productVariantService.create(productEntity, {
+            await productVariantService.create(new RequestContext(), productEntity, {
                 sku: '123456',
                 price: 123,
                 translations: [
@@ -86,7 +87,7 @@ describe('ProductVariantService', () => {
                 .registerMockRepository(ProductOption)
                 .find.mockReturnValue(mockOptions);
 
-            await productVariantService.create(productEntity, {
+            await productVariantService.create(new RequestContext(), productEntity, {
                 sku: '123456',
                 price: 123,
                 translations: [
@@ -131,13 +132,13 @@ describe('ProductVariantService', () => {
                 .registerMockRepository(Product)
                 .findOne.mockReturnValue(mockProduct);
             const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
-            await productVariantService.generateVariantsForProduct(123);
+            await productVariantService.generateVariantsForProduct(new RequestContext(), 123);
 
             const saveCalls = mockCreate.mock.calls;
             expect(saveCalls.length).toBe(1);
-            expect(saveCalls[0][0]).toBe(mockProduct);
-            expect(saveCalls[0][1].translations[0].name).toBe('Mock Product');
-            expect(saveCalls[0][1].optionCodes).toEqual([]);
+            expect(saveCalls[0][1]).toBe(mockProduct);
+            expect(saveCalls[0][2].translations[0].name).toBe('Mock Product');
+            expect(saveCalls[0][2].optionCodes).toEqual([]);
         });
 
         it('generates variants for a product with a single optionGroup', async () => {
@@ -161,15 +162,15 @@ describe('ProductVariantService', () => {
                 .registerMockRepository(Product)
                 .findOne.mockReturnValue(mockProduct);
             const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
-            await productVariantService.generateVariantsForProduct(123);
+            await productVariantService.generateVariantsForProduct(new RequestContext(), 123);
 
             const saveCalls = mockCreate.mock.calls;
             expect(saveCalls.length).toBe(3);
-            expect(saveCalls[0][0]).toBe(mockProduct);
-            expect(saveCalls[0][1].translations[0].name).toBe('Mock Product Small');
-            expect(saveCalls[0][1].optionCodes).toEqual(['small']);
-            expect(saveCalls[1][1].optionCodes).toEqual(['medium']);
-            expect(saveCalls[2][1].optionCodes).toEqual(['large']);
+            expect(saveCalls[0][1]).toBe(mockProduct);
+            expect(saveCalls[0][2].translations[0].name).toBe('Mock Product Small');
+            expect(saveCalls[0][2].optionCodes).toEqual(['small']);
+            expect(saveCalls[1][2].optionCodes).toEqual(['medium']);
+            expect(saveCalls[2][2].optionCodes).toEqual(['large']);
         });
 
         it('generates variants for a product multiples optionGroups', async () => {
@@ -199,21 +200,21 @@ describe('ProductVariantService', () => {
                 .findOne.mockReturnValue(mockProduct);
             const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
 
-            await productVariantService.generateVariantsForProduct(123);
+            await productVariantService.generateVariantsForProduct(new RequestContext(), 123);
 
             const saveCalls = mockCreate.mock.calls;
             expect(saveCalls.length).toBe(9);
-            expect(saveCalls[0][0]).toBe(mockProduct);
-            expect(saveCalls[0][1].translations[0].name).toBe('Mock Product Small Red');
-            expect(saveCalls[0][1].optionCodes).toEqual(['small', 'red']);
-            expect(saveCalls[1][1].optionCodes).toEqual(['small', 'green']);
-            expect(saveCalls[2][1].optionCodes).toEqual(['small', 'blue']);
-            expect(saveCalls[3][1].optionCodes).toEqual(['medium', 'red']);
-            expect(saveCalls[4][1].optionCodes).toEqual(['medium', 'green']);
-            expect(saveCalls[5][1].optionCodes).toEqual(['medium', 'blue']);
-            expect(saveCalls[6][1].optionCodes).toEqual(['large', 'red']);
-            expect(saveCalls[7][1].optionCodes).toEqual(['large', 'green']);
-            expect(saveCalls[8][1].optionCodes).toEqual(['large', 'blue']);
+            expect(saveCalls[0][1]).toBe(mockProduct);
+            expect(saveCalls[0][2].translations[0].name).toBe('Mock Product Small Red');
+            expect(saveCalls[0][2].optionCodes).toEqual(['small', 'red']);
+            expect(saveCalls[1][2].optionCodes).toEqual(['small', 'green']);
+            expect(saveCalls[2][2].optionCodes).toEqual(['small', 'blue']);
+            expect(saveCalls[3][2].optionCodes).toEqual(['medium', 'red']);
+            expect(saveCalls[4][2].optionCodes).toEqual(['medium', 'green']);
+            expect(saveCalls[5][2].optionCodes).toEqual(['medium', 'blue']);
+            expect(saveCalls[6][2].optionCodes).toEqual(['large', 'red']);
+            expect(saveCalls[7][2].optionCodes).toEqual(['large', 'green']);
+            expect(saveCalls[8][2].optionCodes).toEqual(['large', 'blue']);
         });
     });
 });

+ 8 - 3
server/src/service/product-variant.service.ts

@@ -5,12 +5,14 @@ import { ID } from 'shared/shared-types';
 import { generateAllCombinations } from 'shared/shared-utils';
 import { Connection } from 'typeorm';
 
+import { RequestContext } from '../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { Translated } from '../common/types/locale-types';
 import { assertFound } from '../common/utils';
 import { FacetValue } from '../entity/facet-value/facet-value.entity';
 import { ProductOption } from '../entity/product-option/product-option.entity';
 import { CreateProductVariantDto } from '../entity/product-variant/create-product-variant.dto';
+import { ProductVariantPrice } from '../entity/product-variant/product-variant-price.entity';
 import { ProductVariantTranslation } from '../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../entity/product-variant/product-variant.entity';
 import { Product } from '../entity/product/product.entity';
@@ -29,6 +31,7 @@ export class ProductVariantService {
     ) {}
 
     async create(
+        ctx: RequestContext,
         product: Product,
         createProductVariantDto: CreateProductVariantDto,
     ): Promise<ProductVariant> {
@@ -40,8 +43,9 @@ export class ProductVariantService {
                 variant.options = selectedOptions;
             }
             variant.product = product;
+            const variantPrice = new ProductVariantPrice();
         });
-        return await save(this.connection, createProductVariantDto);
+        return await save(this.connection, createProductVariantDto, { channelId: ctx.channelId });
     }
 
     async update(updateProductVariantsDto: UpdateProductVariantInput): Promise<Translated<ProductVariant>> {
@@ -60,6 +64,7 @@ export class ProductVariantService {
     }
 
     async generateVariantsForProduct(
+        ctx: RequestContext,
         productId: ID,
         defaultPrice?: number | null,
         defaultSku?: string | null,
@@ -81,14 +86,14 @@ export class ProductVariantService {
         const variants: ProductVariant[] = [];
         for (const options of optionCombinations) {
             const name = this.createVariantName(productName, options);
-            const variant = await this.create(product, {
+            const variant = await this.create(ctx, product, {
                 sku: defaultSku || 'sku-not-set',
                 price: defaultPrice || 0,
                 image: '',
                 optionCodes: options.map(o => o.code),
                 translations: [
                     {
-                        languageCode: DEFAULT_LANGUAGE_CODE,
+                        languageCode: ctx.languageCode,
                         name,
                     },
                 ],

+ 11 - 4
server/src/service/product.service.spec.ts

@@ -2,11 +2,13 @@ import { Test } from '@nestjs/testing';
 import { LanguageCode, UpdateProductInput } from 'shared/generated-types';
 import { Connection } from 'typeorm';
 
+import { RequestContext } from '../api/common/request-context';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../entity/product/product-translation.entity';
 import { Product } from '../entity/product/product.entity';
 import { MockConnection } from '../testing/connection.mock';
 
+import { ChannelService } from './channel.service';
 import { MockTranslationUpdaterService } from './helpers/translation-updater.mock';
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
 import { ProductService } from './product.service';
@@ -22,6 +24,7 @@ describe('ProductService', () => {
                 ProductService,
                 { provide: TranslationUpdaterService, useClass: MockTranslationUpdaterService },
                 { provide: Connection, useClass: MockConnection },
+                { provide: ChannelService, useClass: MockChannelService },
             ],
         }).compile();
 
@@ -37,7 +40,7 @@ describe('ProductService', () => {
         });
 
         it('saves a new Product with the correct properties', async () => {
-            await productService.create({
+            await productService.create(new RequestContext(), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -59,7 +62,7 @@ describe('ProductService', () => {
         });
 
         it('saves each ProductTranslation', async () => {
-            await productService.create({
+            await productService.create(new RequestContext(), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -92,7 +95,7 @@ describe('ProductService', () => {
             ];
             connection.registerMockRepository(ProductOptionGroup).find.mockReturnValue(mockOptionGroups);
 
-            await productService.create({
+            await productService.create(new RequestContext(), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -123,7 +126,7 @@ describe('ProductService', () => {
                 image: 'some-image',
                 translations: [],
             };
-            await productService.update(dto);
+            await productService.update(new RequestContext(), dto);
             const savedProduct = connection.manager.save.mock.calls[0][0];
 
             expect(translationUpdater.diff).toHaveBeenCalledTimes(1);
@@ -132,3 +135,7 @@ describe('ProductService', () => {
         });
     });
 });
+
+class MockChannelService {
+    assignToChannels = jest.fn();
+}

+ 56 - 34
server/src/service/product.service.ts

@@ -1,11 +1,11 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { CreateProductInput, LanguageCode, UpdateProductInput } from 'shared/generated-types';
+import { CreateProductInput, UpdateProductInput } from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
-import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
-import { ListQueryOptions, NullOptionals } from '../common/types/common-types';
+import { RequestContext } from '../api/common/request-context';
+import { ListQueryOptions } from '../common/types/common-types';
 import { Translated } from '../common/types/locale-types';
 import { assertFound } from '../common/utils';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
@@ -13,6 +13,7 @@ import { ProductTranslation } from '../entity/product/product-translation.entity
 import { Product } from '../entity/product/product.entity';
 import { I18nError } from '../i18n/i18n-error';
 
+import { ChannelService } from './channel.service';
 import { buildListQuery } from './helpers/build-list-query';
 import { createTranslatable } from './helpers/create-translatable';
 import { translateDeep } from './helpers/translate-entity';
@@ -24,10 +25,11 @@ export class ProductService {
     constructor(
         @InjectConnection() private connection: Connection,
         private translationUpdaterService: TranslationUpdaterService,
+        private channelService: ChannelService,
     ) {}
 
     findAll(
-        lang?: LanguageCode | null,
+        ctx: RequestContext,
         options?: ListQueryOptions<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
         const relations = ['variants', 'optionGroups', 'variants.options', 'variants.facetValues'];
@@ -35,14 +37,16 @@ export class ProductService {
         return buildListQuery(this.connection, Product, options, relations)
             .getManyAndCount()
             .then(([products, totalItems]) => {
-                const items = products.map(product =>
-                    translateDeep(product, lang || DEFAULT_LANGUAGE_CODE, [
-                        'optionGroups',
-                        'variants',
-                        ['variants', 'options'],
-                        ['variants', 'facetValues'],
-                    ]),
-                );
+                const items = products
+                    .map(product =>
+                        translateDeep(product, ctx.languageCode, [
+                            'optionGroups',
+                            'variants',
+                            ['variants', 'options'],
+                            ['variants', 'facetValues'],
+                        ]),
+                    )
+                    .map(product => this.applyChannelPriceToVariants(product, ctx));
                 return {
                     items,
                     totalItems,
@@ -50,25 +54,24 @@ export class ProductService {
             });
     }
 
-    findOne(productId: ID, lang?: LanguageCode): Promise<Translated<Product> | undefined> {
+    async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | undefined> {
         const relations = ['variants', 'optionGroups', 'variants.options', 'variants.facetValues'];
-
-        return this.connection.manager
-            .findOne(Product, productId, { relations })
-            .then(
-                product =>
-                    product &&
-                    translateDeep(product, lang || DEFAULT_LANGUAGE_CODE, [
-                        'optionGroups',
-                        'variants',
-                        ['variants', 'options'],
-                        ['variants', 'facetValues'],
-                    ]),
-            );
+        const product = await this.connection.manager.findOne(Product, productId, { relations });
+        if (!product) {
+            return;
+        }
+        const translated = translateDeep(product, ctx.languageCode, [
+            'optionGroups',
+            'variants',
+            ['variants', 'options'],
+            ['variants', 'facetValues'],
+        ]);
+        return this.applyChannelPriceToVariants(translated, ctx);
     }
 
-    async create(createProductDto: CreateProductInput): Promise<Translated<Product>> {
+    async create(ctx: RequestContext, createProductDto: CreateProductInput): Promise<Translated<Product>> {
         const save = createTranslatable(Product, ProductTranslation, async p => {
+            this.channelService.assignToChannels(p, ctx);
             const { optionGroupCodes } = createProductDto;
             if (optionGroupCodes && optionGroupCodes.length) {
                 const optionGroups = await this.connection.getRepository(ProductOptionGroup).find();
@@ -77,16 +80,20 @@ export class ProductService {
             }
         });
         const product = await save(this.connection, createProductDto);
-        return assertFound(this.findOne(product.id, DEFAULT_LANGUAGE_CODE));
+        return assertFound(this.findOne(ctx, product.id));
     }
 
-    async update(updateProductDto: UpdateProductInput): Promise<Translated<Product>> {
+    async update(ctx: RequestContext, updateProductDto: UpdateProductInput): Promise<Translated<Product>> {
         const save = updateTranslatable(Product, ProductTranslation, this.translationUpdaterService);
         const product = await save(this.connection, updateProductDto);
-        return assertFound(this.findOne(product.id, DEFAULT_LANGUAGE_CODE));
+        return assertFound(this.findOne(ctx, product.id));
     }
 
-    async addOptionGroupToProduct(productId: ID, optionGroupId: ID): Promise<Translated<Product>> {
+    async addOptionGroupToProduct(
+        ctx: RequestContext,
+        productId: ID,
+        optionGroupId: ID,
+    ): Promise<Translated<Product>> {
         const product = await this.getProductWithOptionGroups(productId);
         const optionGroup = await this.connection.getRepository(ProductOptionGroup).findOne(optionGroupId);
         if (!optionGroup) {
@@ -103,15 +110,30 @@ export class ProductService {
         }
 
         await this.connection.manager.save(product);
-        return assertFound(this.findOne(productId, DEFAULT_LANGUAGE_CODE));
+        return assertFound(this.findOne(ctx, productId));
     }
 
-    async removeOptionGroupFromProduct(productId: ID, optionGroupId: ID): Promise<Translated<Product>> {
+    async removeOptionGroupFromProduct(
+        ctx: RequestContext,
+        productId: ID,
+        optionGroupId: ID,
+    ): Promise<Translated<Product>> {
         const product = await this.getProductWithOptionGroups(productId);
         product.optionGroups = product.optionGroups.filter(g => g.id !== optionGroupId);
 
         await this.connection.manager.save(product);
-        return assertFound(this.findOne(productId, DEFAULT_LANGUAGE_CODE));
+        return assertFound(this.findOne(ctx, productId));
+    }
+
+    private applyChannelPriceToVariants<T extends Product>(product: T, ctx: RequestContext): T {
+        product.variants.forEach(v => {
+            const channelPrice = v.productVariantPrices.find(p => p.channelId === ctx.channelId);
+            if (!channelPrice) {
+                throw new I18nError(`error.no-price-found-for-channel`);
+            }
+            v.price = channelPrice.price;
+        });
+        return product;
     }
 
     private async getProductWithOptionGroups(productId: ID): Promise<Product> {

+ 86 - 0
server/src/service/role.service.ts

@@ -0,0 +1,86 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+
+import {
+    CUSTOMER_ROLE_CODE,
+    CUSTOMER_ROLE_DESCRIPTION,
+    SUPER_ADMIN_ROLE_CODE,
+    SUPER_ADMIN_ROLE_DESCRIPTION,
+} from '../common/constants';
+import { Permission } from '../entity/role/permission';
+import { Role } from '../entity/role/role.entity';
+import { I18nError } from '../i18n/i18n-error';
+
+import { ChannelService } from './channel.service';
+import { ActiveConnection } from './helpers/connection.decorator';
+
+export interface CreateRoleDto {
+    code: string;
+    description: string;
+    permissions: Permission[];
+}
+
+@Injectable()
+export class RoleService {
+    constructor(@ActiveConnection() private connection: Connection, private channelService: ChannelService) {}
+
+    async initRoles() {
+        await this.ensureSuperAdminRoleExists();
+        await this.ensureCustomerRoleExists();
+    }
+
+    getSuperAdminRole(): Promise<Role> {
+        return this.getRoleByCode(SUPER_ADMIN_ROLE_CODE).then(role => {
+            if (!role) {
+                throw new I18nError(`error.super-admin-role-not-found`);
+            }
+            return role;
+        });
+    }
+
+    getCustomerRole(): Promise<Role> {
+        return this.getRoleByCode(CUSTOMER_ROLE_CODE).then(role => {
+            if (!role) {
+                throw new I18nError(`error.customer-role-not-found`);
+            }
+            return role;
+        });
+    }
+
+    async create(input: CreateRoleDto): Promise<Role> {
+        const role = new Role(input);
+        role.channels = [this.channelService.getDefaultChannel()];
+        return this.connection.manager.save(role);
+    }
+
+    private getRoleByCode(code: string): Promise<Role | undefined> {
+        return this.connection.getRepository(Role).findOne({
+            where: { code },
+        });
+    }
+
+    private async ensureSuperAdminRoleExists() {
+        try {
+            await this.getSuperAdminRole();
+        } catch (err) {
+            await this.create({
+                code: SUPER_ADMIN_ROLE_CODE,
+                description: SUPER_ADMIN_ROLE_DESCRIPTION,
+                permissions: Object.values(Permission),
+            });
+        }
+    }
+
+    private async ensureCustomerRoleExists() {
+        try {
+            await this.getCustomerRole();
+        } catch (err) {
+            await this.create({
+                code: CUSTOMER_ROLE_CODE,
+                description: CUSTOMER_ROLE_DESCRIPTION,
+                permissions: [Permission.Authenticated],
+            });
+        }
+    }
+}

+ 13 - 2
server/src/service/service.module.ts

@@ -1,4 +1,4 @@
-import { Module } from '@nestjs/common';
+import { Module, OnModuleInit } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 
 import { ConfigModule } from '../config/config.module';
@@ -6,6 +6,7 @@ import { getConfig } from '../config/vendure-config';
 
 import { AdministratorService } from './administrator.service';
 import { AuthService } from './auth.service';
+import { ChannelService } from './channel.service';
 import { CustomerService } from './customer.service';
 import { FacetValueService } from './facet-value.service';
 import { FacetService } from './facet.service';
@@ -15,10 +16,12 @@ import { ProductOptionGroupService } from './product-option-group.service';
 import { ProductOptionService } from './product-option.service';
 import { ProductVariantService } from './product-variant.service';
 import { ProductService } from './product.service';
+import { RoleService } from './role.service';
 
 const exportedProviders = [
     AdministratorService,
     AuthService,
+    ChannelService,
     CustomerService,
     FacetService,
     FacetValueService,
@@ -26,6 +29,7 @@ const exportedProviders = [
     ProductOptionGroupService,
     ProductService,
     ProductVariantService,
+    RoleService,
 ];
 
 /**
@@ -40,4 +44,11 @@ const exportedProviders = [
     providers: [...exportedProviders, PasswordService, TranslationUpdaterService],
     exports: exportedProviders,
 })
-export class ServiceModule {}
+export class ServiceModule implements OnModuleInit {
+    constructor(private channelService: ChannelService, private roleService: RoleService) {}
+
+    async onModuleInit() {
+        await this.channelService.initChannels();
+        await this.roleService.initRoles();
+    }
+}

+ 128 - 11
server/yarn.lock

@@ -35,6 +35,20 @@
     core-js "^2.5.7"
     regenerator-runtime "^0.12.0"
 
+"@dsherret/to-absolute-glob@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1f6475dc8bd974cea07a2daf3864b317b1dd332c"
+  dependencies:
+    is-absolute "^1.0.0"
+    is-negated-glob "^1.0.0"
+
+"@mrmlnc/readdir-enhanced@^2.2.1":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
+  dependencies:
+    call-me-maybe "^1.0.1"
+    glob-to-regexp "^0.3.0"
+
 "@nestjs/common@^5.3.2":
   version "5.3.2"
   resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-5.3.2.tgz#53b872d1e9e0e7058194c6da04c2102a66c22b39"
@@ -59,14 +73,15 @@
     path-to-regexp "2.2.1"
     uuid "3.3.2"
 
-"@nestjs/graphql@5.1.1":
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/@nestjs/graphql/-/graphql-5.1.1.tgz#5d0ebd7a469cf632aba10e0fe07926227bfad5ab"
+"@nestjs/graphql@5.1.2":
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/@nestjs/graphql/-/graphql-5.1.2.tgz#66472be935daf31a6828ef86aa8dba1b7bfbbc73"
   dependencies:
     glob "^7.1.2"
     graphql-tools "^3.1.1"
     lodash "^4.17.4"
     merge-graphql-schemas "^1.3.0"
+    ts-simple-ast "^14.4.2"
 
 "@nestjs/passport@^5.0.1":
   version "5.0.1"
@@ -83,6 +98,10 @@
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/@nestjs/typeorm/-/typeorm-5.2.0.tgz#315be2a0eafca611f53e82d926295d0be880abf8"
 
+"@nodelib/fs.stat@^1.0.1":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26"
+
 "@nuxtjs/opencollective@0.1.0":
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/@nuxtjs/opencollective/-/opencollective-0.1.0.tgz#5dfb10b2148ce77e9590bca9b9ed6e71d2a500eb"
@@ -535,6 +554,10 @@ arr-union@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
 
+array-differ@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
+
 array-each@^1.0.0, array-each@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f"
@@ -572,6 +595,16 @@ array-sort@^1.0.0:
     get-value "^2.0.6"
     kind-of "^5.0.2"
 
+array-union@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
+  dependencies:
+    array-uniq "^1.0.1"
+
+array-uniq@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
+
 array-unique@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
@@ -580,7 +613,7 @@ array-unique@^0.3.2:
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
 
-arrify@^1.0.1:
+arrify@^1.0.0, arrify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
 
@@ -1002,6 +1035,10 @@ cache-base@^1.0.1:
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+call-me-maybe@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
+
 callsites@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"
@@ -1178,6 +1215,10 @@ co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
 
+code-block-writer@^7.2.1:
+  version "7.2.1"
+  resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-7.2.1.tgz#c03073518fdb98d4d1a5bb46dcd60137958b5a3a"
+
 code-point-at@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
@@ -1501,6 +1542,13 @@ diff@^3.2.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
 
+dir-glob@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
+  dependencies:
+    arrify "^1.0.1"
+    path-type "^3.0.0"
+
 domexception@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
@@ -1847,6 +1895,17 @@ fast-deep-equal@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614"
 
+fast-glob@^2.0.2:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.2.tgz#71723338ac9b4e0e2fff1d6748a2a13d5ed352bf"
+  dependencies:
+    "@mrmlnc/readdir-enhanced" "^2.2.1"
+    "@nodelib/fs.stat" "^1.0.1"
+    glob-parent "^3.1.0"
+    is-glob "^4.0.0"
+    merge2 "^1.2.1"
+    micromatch "^3.1.10"
+
 fast-json-stable-stringify@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
@@ -2012,7 +2071,7 @@ from@~0:
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"
 
-fs-extra@6.0.1:
+fs-extra@6.0.1, fs-extra@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-6.0.1.tgz#8abc128f7946e310135ddc93b98bddb410e7a34b"
   dependencies:
@@ -2114,6 +2173,10 @@ glob-stream@^6.1.0:
     to-absolute-glob "^2.0.0"
     unique-stream "^2.0.2"
 
+glob-to-regexp@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
+
 glob-watcher@^5.0.0:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/glob-watcher/-/glob-watcher-5.0.1.tgz#239aaa621b6bd843b288fdf6b155f50963c7d7ea"
@@ -2162,6 +2225,18 @@ globals@^9.18.0:
   version "9.18.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
 
+globby@^8.0.1:
+  version "8.0.1"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.1.tgz#b5ad48b8aa80b35b814fc1281ecc851f1d2b5b50"
+  dependencies:
+    array-union "^1.0.1"
+    dir-glob "^2.0.0"
+    fast-glob "^2.0.2"
+    glob "^7.1.2"
+    ignore "^3.3.5"
+    pify "^3.0.0"
+    slash "^1.0.0"
+
 glogg@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.1.tgz#dcf758e44789cc3f3d32c1f3562a3676e6a34810"
@@ -2228,9 +2303,9 @@ graphql-type-json@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.2.1.tgz#d2c177e2f1b17d87f81072cd05311c0754baa420"
 
-graphql@^14.0.0-rc.2:
-  version "14.0.0-rc.2"
-  resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.0-rc.2.tgz#a5d6795e6c08b6f04abf471d7c0565b7fa79ea10"
+graphql@^14.0.0:
+  version "14.0.0"
+  resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.0.tgz#4ee771c5266d08cb75df2d3ac41e8dd51ce3d599"
   dependencies:
     iterall "^1.2.2"
 
@@ -2459,6 +2534,10 @@ ignore-walk@^3.0.1:
   dependencies:
     minimatch "^3.0.4"
 
+ignore@^3.3.5:
+  version "3.3.10"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
+
 import-lazy@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
@@ -3568,6 +3647,10 @@ merge-stream@^1.0.1:
   dependencies:
     readable-stream "^2.0.1"
 
+merge2@^1.2.1:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.2.tgz#03212e3da8d86c4d8523cebd6318193414f94e34"
+
 merge@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da"
@@ -3594,7 +3677,7 @@ micromatch@^2.3.11:
     parse-glob "^3.0.4"
     regex-cache "^0.4.2"
 
-micromatch@^3.0.4, micromatch@^3.1.4, micromatch@^3.1.8:
+micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8:
   version "3.1.10"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
   dependencies:
@@ -3634,7 +3717,7 @@ minimalistic-assert@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
 
-minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
+minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   dependencies:
@@ -3699,6 +3782,15 @@ multer@1.3.0:
     type-is "^1.6.4"
     xtend "^4.0.0"
 
+multimatch@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-2.1.0.tgz#9c7906a22fb4c02919e2f5f75161b4cdbd4b2a2b"
+  dependencies:
+    array-differ "^1.0.0"
+    array-union "^1.0.1"
+    arrify "^1.0.0"
+    minimatch "^3.0.0"
+
 mute-stdout@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/mute-stdout/-/mute-stdout-1.0.0.tgz#5b32ea07eb43c9ded6130434cf926f46b2a7fd4d"
@@ -3902,7 +3994,7 @@ object-assign@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2"
 
-object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0:
+object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
 
@@ -4205,6 +4297,12 @@ path-type@^1.0.0:
     pify "^2.0.0"
     pinkie-promise "^2.0.0"
 
+path-type@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
+  dependencies:
+    pify "^3.0.0"
+
 pause-stream@0.0.11:
   version "0.0.11"
   resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
@@ -5247,6 +5345,21 @@ ts-jest@^23.1.4:
     json5 "^0.5.0"
     lodash "^4.17.10"
 
+ts-simple-ast@^14.4.2:
+  version "14.4.2"
+  resolved "https://registry.yarnpkg.com/ts-simple-ast/-/ts-simple-ast-14.4.2.tgz#243664c8632968e97c8a2c59bf2d642e0656a306"
+  dependencies:
+    "@dsherret/to-absolute-glob" "^2.0.2"
+    code-block-writer "^7.2.1"
+    fs-extra "^6.0.1"
+    glob-parent "^3.1.0"
+    globby "^8.0.1"
+    is-negated-glob "^1.0.0"
+    multimatch "^2.1.0"
+    object-assign "^4.1.1"
+    tslib "^1.9.0"
+    typescript "3.0.1"
+
 tsconfig-paths@^3.5.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.5.0.tgz#a447c7721e49281af97343d9a850864e949a0b51"
@@ -5306,6 +5419,10 @@ typeorm@^0.2.6:
     yargonaut "^1.1.2"
     yargs "^11.1.0"
 
+typescript@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.1.tgz#43738f29585d3a87575520a4b93ab6026ef11fdb"
+
 typescript@^2.9.0:
   version "2.9.2"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.2.tgz#1cbf61d05d6b96269244eb6a3bce4bd914e0f00c"

+ 73 - 9
shared/generated-types.ts

@@ -1,6 +1,36 @@
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL mutation operation: AttemptLogin
+// ====================================================
+
+export interface AttemptLogin_login_user {
+  __typename: "CurrentUser";
+  id: string;
+  identifier: string;
+  channelTokens: string[];
+  roles: string[];
+}
+
+export interface AttemptLogin_login {
+  __typename: "LoginResult";
+  user: AttemptLogin_login_user;
+  authToken: string;
+}
+
+export interface AttemptLogin {
+  login: AttemptLogin_login;
+}
+
+export interface AttemptLoginVariables {
+  username: string;
+  password: string;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL mutation operation: CreateFacet
 // ====================================================
@@ -194,21 +224,21 @@ export interface RequestCompleted {
 // This file was automatically generated and should not be edited.
 
 // ====================================================
-// GraphQL mutation operation: LogIn
+// GraphQL mutation operation: SetAsLoggedIn
 // ====================================================
 
-export interface LogIn_logIn {
+export interface SetAsLoggedIn_setAsLoggedIn {
   __typename: "UserStatus";
   username: string;
   isLoggedIn: boolean;
   loginTime: string;
 }
 
-export interface LogIn {
-  logIn: LogIn_logIn | null;
+export interface SetAsLoggedIn {
+  setAsLoggedIn: SetAsLoggedIn_setAsLoggedIn | null;
 }
 
-export interface LogInVariables {
+export interface SetAsLoggedInVariables {
   username: string;
   loginTime: string;
 }
@@ -217,18 +247,18 @@ export interface LogInVariables {
 // This file was automatically generated and should not be edited.
 
 // ====================================================
-// GraphQL mutation operation: LogOut
+// GraphQL mutation operation: SetAsLoggedOut
 // ====================================================
 
-export interface LogOut_logOut {
+export interface SetAsLoggedOut_setAsLoggedOut {
   __typename: "UserStatus";
   username: string;
   isLoggedIn: boolean;
   loginTime: string;
 }
 
-export interface LogOut {
-  logOut: LogOut_logOut | null;
+export interface SetAsLoggedOut {
+  setAsLoggedOut: SetAsLoggedOut_setAsLoggedOut | null;
 }
 
 /* tslint:disable */
@@ -727,6 +757,25 @@ export interface ApplyFacetValuesToProductVariantsVariables {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL query operation: GetCurrentUser
+// ====================================================
+
+export interface GetCurrentUser_me {
+  __typename: "CurrentUser";
+  id: string;
+  identifier: string;
+  channelTokens: string[];
+  roles: string[];
+}
+
+export interface GetCurrentUser {
+  me: GetCurrentUser_me | null;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL query operation: GetFacetList
 // ====================================================
@@ -1025,6 +1074,21 @@ export interface GetProductOptionGroupsVariables {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL fragment: CurrentUser
+// ====================================================
+
+export interface CurrentUser {
+  __typename: "CurrentUser";
+  id: string;
+  identifier: string;
+  channelTokens: string[];
+  roles: string[];
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL fragment: FacetValue
 // ====================================================

+ 31 - 0
shared/omit.spec.ts

@@ -0,0 +1,31 @@
+import { omit } from './omit';
+
+describe('omit()', () => {
+
+    it('returns a new object', () => {
+        const obj = { foo: 1, bar: 2 };
+        expect(omit(obj, ['bar'])).not.toBe(obj);
+    });
+
+    it('works with 1-level-deep objects', () => {
+        expect(omit({ foo: 1, bar: 2 }, ['bar'])).toEqual({ foo: 1 });
+        expect(omit({ foo: 1, bar: 2 }, ['bar', 'foo'])).toEqual({});
+    });
+
+    it('works with deeply-nested objects', () => {
+        expect(omit({
+            name: {
+                first: 'joe',
+                last: 'smith',
+            },
+            address: {
+                number: 12,
+            },
+        }, ['address'])).toEqual({
+            name: {
+                first: 'joe',
+                last: 'smith',
+            },
+        });
+    });
+});

+ 13 - 0
shared/omit.ts

@@ -0,0 +1,13 @@
+export type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
+
+/**
+ * Type-safe omit function - returns a new object which omits the specified keys.
+ */
+export function omit<T extends object, K extends keyof T>(obj: T, keysToOmit: K[]): Omit<T, K> {
+    return Object.keys(obj).reduce((output: any, key) => {
+        if (keysToOmit.includes(key as K)) {
+            return output;
+        }
+        return { ...output, [key]: (obj as any)[key] };
+    }, {} as Omit<T, K>);
+}

+ 0 - 0
admin-ui/src/app/common/utilities/pick.spec.ts → shared/pick.spec.ts


+ 0 - 0
admin-ui/src/app/common/utilities/pick.ts → shared/pick.ts


Some files were not shown because too many files changed in this diff