Просмотр исходного кода

feat(server): Implement Channel-based pricing

Michael Bromley 7 лет назад
Родитель
Сommit
2ef248d404
38 измененных файлов с 684 добавлено и 321 удалено
  1. 1 1
      admin-ui/src/app/data/providers/facet-data.service.ts
  2. 1 1
      admin-ui/src/app/data/providers/product-data.service.ts
  3. 25 0
      docs/diagrams/determining-active-channel.puml
  4. 38 16
      docs/diagrams/full-class-diagram.puml
  5. 37 0
      docs/diagrams/theme.puml
  6. 77 146
      server/e2e/__snapshots__/product.e2e-spec.ts.snap
  7. 22 60
      server/e2e/product.e2e-spec.ts
  8. 8 1
      server/e2e/test-client.ts
  9. 31 0
      server/mock-data/get-default-channel-token.ts
  10. 5 0
      server/mock-data/mock-data.service.ts
  11. 5 1
      server/mock-data/populate.ts
  12. 1 1
      server/mock-data/simple-graphql-client.ts
  13. 29 0
      server/src/api/common/request-context.pipe.ts
  14. 41 0
      server/src/api/common/request-context.ts
  15. 51 22
      server/src/api/product/product.resolver.ts
  16. 1 1
      server/src/api/roles-guard.ts
  17. 0 1
      server/src/app.module.ts
  18. 3 0
      server/src/bootstrap.ts
  19. 1 0
      server/src/common/constants.ts
  20. 2 1
      server/src/common/types/role.ts
  21. 26 0
      server/src/entity/channel/channel.entity.ts
  22. 4 0
      server/src/entity/entities.ts
  23. 21 0
      server/src/entity/product-variant/product-variant-price.entity.ts
  24. 9 1
      server/src/entity/product-variant/product-variant.entity.ts
  25. 37 0
      server/src/entity/product-variant/product-variant.subscriber.ts
  26. 8 0
      server/src/entity/subscribers.ts
  27. 1 1
      server/src/service/administrator.service.ts
  28. 49 0
      server/src/service/channel.service.ts
  29. 6 2
      server/src/service/helpers/create-translatable.ts
  30. 26 25
      server/src/service/product-variant.service.spec.ts
  31. 8 3
      server/src/service/product-variant.service.ts
  32. 5 4
      server/src/service/product.service.spec.ts
  33. 51 31
      server/src/service/product.service.ts
  34. 10 2
      server/src/service/service.module.ts
  35. 31 0
      shared/omit.spec.ts
  36. 13 0
      shared/omit.ts
  37. 0 0
      shared/pick.spec.ts
  38. 0 0
      shared/pick.ts

+ 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,

+ 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

+ 38 - 16
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,6 +20,11 @@ class Customer {
 class Administrator {
     user: User
 }
+class UserRole {
+    role: Role
+    user: User
+    channel: Channel
+}
 enum Role {
     Authenticated
     Customer
@@ -31,7 +42,11 @@ class ProductOption {
 }
 class ProductVariant {
     sku: string
-    price: number
+    price: ProductVariantPrice[]
+}
+class ProductVariantPrice {
+    value: number
+    currencyCode: CurrencyCode
 }
 class Order
 class OrderItem {
@@ -62,25 +77,32 @@ class FacetValue {
 class Category {
 }
 
-Customer o-- User
-Administrator o-- 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
+Customer --  User
+Administrator -- User
+User o-- UserRole
+UserRole o-- "1..*" Role
+UserRole -- 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

+ 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",
 ]
 `;

+ 22 - 60
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,
@@ -49,6 +50,7 @@ describe('Product resolver', () => {
             productCount: 20,
             customerCount: 1,
         });
+        await client.init();
     }, 30000);
 
     afterAll(async () => {
@@ -90,8 +92,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 +107,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 +125,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 +310,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 +347,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 +389,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}`;
     }
 }

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

@@ -0,0 +1,31 @@
+import { ConnectionOptions, createConnection } 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 createConnection({ ...connectionOptions, name: 'getDefaultChannelToken' });
+
+    const defaultChannel = await connection.manager.getRepository(Channel).findOne({
+        where: {
+            code: DEFAULT_CHANNEL_CODE,
+        },
+    });
+    await connection.close();
+    if (!defaultChannel) {
+        throw new Error(`No default channel could be found!`);
+    }
+    if (logging) {
+        console.log(`Got default channel token: ${defaultChannel.token}'`);
+    }
+    return defaultChannel.token;
+}

+ 5 - 0
server/mock-data/mock-data.service.ts

@@ -272,6 +272,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,
+            }),
         });
     }
 

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

@@ -4,6 +4,7 @@ import { VendureBootstrapFunction } from '../src/bootstrap';
 import { setConfig, VendureConfig } from '../src/config/vendure-config';
 
 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';
 
@@ -27,7 +28,10 @@ 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 defaultChannelToken = await getDefaultChannelToken(config.dbConnectionOptions, logging);
+    const client = new SimpleGraphQLClient(
+        `http://localhost:${config.port}/${config.apiPath}?token=${defaultChannelToken}`,
+    );
     const mockDataClientService = new MockDataService(client, logging);
     await mockDataClientService.populateOptions();
     await mockDataClientService.populateProducts(options.productCount);

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

@@ -10,7 +10,7 @@ export interface GraphQlClient {
  * 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);

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

@@ -0,0 +1,29 @@
+import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
+
+import { I18nError } from '../../i18n/i18n-error';
+import { ChannelService } from '../../service/channel.service';
+
+import { RequestContext } from './request-context';
+
+/**
+ * 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 channelService: ChannelService) {}
+
+    transform(value: any, metadata: ArgumentMetadata) {
+        const v = value;
+        if (value.req && value.req.query) {
+            const token = value.req.query.token;
+
+            const ctx = new RequestContext(value.req.query.token);
+            const channel = this.channelService.getChannelFromToken(token);
+            if (channel) {
+                ctx.channelId = channel.id;
+            }
+            return ctx;
+        }
+        throw new I18nError(`error.unexpected-request-context`);
+    }
+}

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

@@ -0,0 +1,41 @@
+import { LanguageCode } from 'shared/generated-types';
+import { ID } from 'shared/shared-types';
+
+import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
+
+/**
+ * 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 channelId(): ID {
+        return this._channelId;
+    }
+
+    set channelId(value: ID) {
+        this._channelId = value;
+    }
+
+    get token(): string {
+        return this._token;
+    }
+    get languageCode(): LanguageCode {
+        return this._languageCode;
+    }
+
+    private _languageCode: LanguageCode;
+    private _channelId: ID;
+
+    constructor(private _token: string) {
+        this._languageCode = DEFAULT_LANGUAGE_CODE;
+    }
+
+    setLanguageCode(value: LanguageCode | null | undefined) {
+        if (value) {
+            this._languageCode = value;
+        } else {
+            this._languageCode = DEFAULT_LANGUAGE_CODE;
+        }
+    }
+}

+ 51 - 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';
@@ -18,6 +22,8 @@ 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';
 
 @Resolver('Product')
 export class ProductResolver {
@@ -27,62 +33,82 @@ export class ProductResolver {
         private facetValueService: FacetValueService,
     ) {}
 
-    @Query('products')
+    @Query()
     @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 +116,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 => {

+ 1 - 1
server/src/api/roles-guard.ts

@@ -12,7 +12,7 @@ import { Role } from '../common/types/role';
  *
  * @example
  * ```
- *  @RolesGuard([Role.Superadmin])
+ *  @RolesGuard([Role.SuperAdmin])
  *  @Query('administrators')
  *  getAdministrators() {
  *      // ...

+ 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>>,
         },
     });
 

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

@@ -1,3 +1,4 @@
 import { LanguageCode } from 'shared/generated-types';
 
 export const DEFAULT_LANGUAGE_CODE = LanguageCode.en;
+export const DEFAULT_CHANNEL_CODE = '__default_channel__';

+ 2 - 1
server/src/common/types/role.ts

@@ -5,5 +5,6 @@ export enum Role {
     // The Authenticated role means simply that the user is logged in
     Authenticated = 'Authenticated',
     Customer = 'Customer',
-    Superadmin = 'Superadmin',
+    ChannelAdmin = 'ChannelAdmin',
+    SuperAdmin = 'SuperAdmin',
 }

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

@@ -0,0 +1,26 @@
+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;
+
+    private generateToken(): string {
+        const randomString = () =>
+            Math.random()
+                .toString(36)
+                .substr(3, 10);
+        return `${randomString()}${randomString()}`;
+    }
+}

+ 4 - 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,6 +10,7 @@ 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';
@@ -21,6 +23,7 @@ import { User } from './user/user.entity';
 export const coreEntitiesMap = {
     Address,
     Administrator,
+    Channel,
     Customer,
     Facet,
     FacetTranslation,
@@ -33,6 +36,7 @@ export const coreEntitiesMap = {
     ProductOptionGroup,
     ProductOptionGroupTranslation,
     ProductVariant,
+    ProductVariantPrice,
     ProductVariantTranslation,
     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 - 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,
+};

+ 1 - 1
server/src/service/administrator.service.ts

@@ -30,7 +30,7 @@ export class AdministratorService {
         const user = new User();
         user.passwordHash = await this.passwordService.hash(createAdministratorDto.password);
         user.identifier = createAdministratorDto.emailAddress;
-        user.roles = [Role.Superadmin];
+        user.roles = [Role.SuperAdmin];
         const createdUser = await this.connection.getRepository(User).save(user);
         administrator.user = createdUser;
 

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

@@ -0,0 +1,49 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+
+import { DEFAULT_CHANNEL_CODE } from '../common/constants';
+import { Channel } from '../entity/channel/channel.entity';
+
+@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();
+    }
+
+    /**
+     * Given a channel token, returns the corresponding Channel if it exists.
+     */
+    getChannelFromToken(token: string): Channel | undefined {
+        return this.allChannels.find(channel => channel.token === token);
+    }
+
+    findAll(): Promise<Channel[]> {
+        return this.connection.getRepository(Channel).find();
+    }
+
+    /**
+     * 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 });
+            await this.connection.manager.save(newDefaultChannel);
+        }
+    }
+}

+ 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,
                     },
                 ],

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

@@ -2,6 +2,7 @@ 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';
@@ -37,7 +38,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 +60,7 @@ describe('ProductService', () => {
         });
 
         it('saves each ProductTranslation', async () => {
-            await productService.create({
+            await productService.create(new RequestContext(''), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -92,7 +93,7 @@ describe('ProductService', () => {
             ];
             connection.registerMockRepository(ProductOptionGroup).find.mockReturnValue(mockOptionGroups);
 
-            await productService.create({
+            await productService.create(new RequestContext(''), {
                 translations: [
                     {
                         languageCode: LanguageCode.en,
@@ -123,7 +124,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);

+ 51 - 31
server/src/service/product.service.ts

@@ -4,6 +4,7 @@ import { CreateProductInput, LanguageCode, UpdateProductInput } from 'shared/gen
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
+import { RequestContext } from '../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ListQueryOptions, NullOptionals } from '../common/types/common-types';
 import { Translated } from '../common/types/locale-types';
@@ -27,7 +28,7 @@ export class ProductService {
     ) {}
 
     findAll(
-        lang?: LanguageCode | null,
+        ctx: RequestContext,
         options?: ListQueryOptions<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
         const relations = ['variants', 'optionGroups', 'variants.options', 'variants.facetValues'];
@@ -35,14 +36,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,24 +53,22 @@ 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 => {
             const { optionGroupCodes } = createProductDto;
             if (optionGroupCodes && optionGroupCodes.length) {
@@ -77,16 +78,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 +108,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> {

+ 10 - 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';
@@ -19,6 +20,7 @@ import { ProductService } from './product.service';
 const exportedProviders = [
     AdministratorService,
     AuthService,
+    ChannelService,
     CustomerService,
     FacetService,
     FacetValueService,
@@ -40,4 +42,10 @@ const exportedProviders = [
     providers: [...exportedProviders, PasswordService, TranslationUpdaterService],
     exports: exportedProviders,
 })
-export class ServiceModule {}
+export class ServiceModule implements OnModuleInit {
+    constructor(private channelService: ChannelService) {}
+
+    async onModuleInit() {
+        await this.channelService.initChannels();
+    }
+}

+ 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