Răsfoiți Sursa

Merge branch 'minor' into major

Michael Bromley 3 ani în urmă
părinte
comite
0f2515d1a7
38 a modificat fișierele cu 1877 adăugiri și 1294 ștergeri
  1. 15 3
      docs/content/developer-guide/importing-product-data.md
  2. 294 294
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  3. 8 0
      packages/admin-ui/src/lib/core/src/data/definitions/customer-definitions.ts
  4. 8 0
      packages/admin-ui/src/lib/core/src/data/providers/customer-data.service.ts
  5. 10 0
      packages/admin-ui/src/lib/customer/src/components/address-card/address-card.component.html
  6. 6 0
      packages/admin-ui/src/lib/customer/src/components/address-card/address-card.component.ts
  7. 3 1
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.html
  8. 4 0
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.scss
  9. 41 16
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts
  10. 288 289
      packages/common/src/generated-shop-types.ts
  11. 1 16
      packages/core/e2e/collection.e2e-spec.ts
  12. 3 0
      packages/core/e2e/fixtures/product-import-channel.csv
  13. 288 289
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  14. 16 0
      packages/core/e2e/graphql/shared-definitions.ts
  15. 195 0
      packages/core/e2e/populate.e2e-spec.ts
  16. 0 2
      packages/core/src/api/api.module.ts
  17. 3 1
      packages/core/src/api/common/request-context.ts
  18. 1 1
      packages/core/src/api/middleware/auth-guard.ts
  19. 80 24
      packages/core/src/cli/populate.ts
  20. 1 1
      packages/core/src/data-import/data-import.module.ts
  21. 3 0
      packages/core/src/data-import/index.ts
  22. 19 2
      packages/core/src/data-import/providers/asset-importer/asset-importer.ts
  23. 59 0
      packages/core/src/data-import/providers/import-parser/import-parser.ts
  24. 56 15
      packages/core/src/data-import/providers/importer/fast-importer.service.ts
  25. 27 13
      packages/core/src/data-import/providers/importer/importer.ts
  26. 32 18
      packages/core/src/data-import/providers/populator/populator.ts
  27. 22 0
      packages/core/src/event-bus/events/order-line-event.ts
  28. 2 0
      packages/core/src/event-bus/index.ts
  29. 3 0
      packages/core/src/plugin/plugin-common.module.ts
  30. 5 0
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  31. 73 10
      packages/core/src/service/helpers/request-context/request-context.service.ts
  32. 2 0
      packages/core/src/service/index.ts
  33. 2 0
      packages/core/src/service/service.module.ts
  34. 8 4
      packages/core/src/service/services/asset.service.ts
  35. 3 0
      packages/core/src/service/services/customer.service.ts
  36. 7 4
      packages/core/src/service/services/order.service.ts
  37. 1 2
      packages/core/src/service/services/session.service.ts
  38. 288 289
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts

+ 15 - 3
docs/content/developer-guide/importing-product-data.md

@@ -178,6 +178,7 @@ export const initialData: InitialData = {
 
 ## Populating The Server
 
+### The `populate()` function
 The `@vendure/core` package exposes a [`populate()` function]({{< relref "populate" >}}) which can be used along with the data formats described above to populate your Vendure server:
 
 ```TypeScript
@@ -203,7 +204,9 @@ populate(
   () => bootstrap(config),
   initialData,
   productsCsvFile,
-)
+  'my-channel-token' // optional - used to assign imported 
+)                    // entities to the specified Channel
+
 .then(app => {
   return app.close();
 })
@@ -216,7 +219,16 @@ populate(
 );
 ```
 
-{{< alert >}}
+### Custom populate scripts
+
 If you require more control over how your data is being imported - for example if you also need to import data into custom entities - you can create your own CLI script to do this: see [Stand-Alone CLI Scripts]({{< relref "stand-alone-scripts" >}}).
-{{< /alert >}} 
 
+In your script you can make use of the internal parse and import services:
+
+* [Importer]({{< relref "importer" >}})
+* [ImportParser]({{< relref "import-parser" >}})
+* [FastImporterService]({{< relref "fast-importer-service" >}})
+* [AssetImporter]({{< relref "asset-importer" >}})
+* [Populator]({{< relref "populator" >}})
+
+Using these specialized import services is preferable to using the normal service-layer services (ProductService, ProductVariantService etc.) for bulk imports. This is because these import services are optimized for bulk imports (they omit unnecessary checks, use optimized SQL queries) and also do not publish events when creating new entities.

Fișier diff suprimat deoarece este prea mare
+ 294 - 294
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 8 - 0
packages/admin-ui/src/lib/core/src/data/definitions/customer-definitions.ts

@@ -151,6 +151,14 @@ export const UPDATE_CUSTOMER_ADDRESS = gql`
     ${ADDRESS_FRAGMENT}
 `;
 
+export const DELETE_CUSTOMER_ADDRESS = gql`
+    mutation DeleteCustomerAddress($id: ID!) {
+        deleteCustomerAddress(id: $id) {
+            success
+        }
+    }
+`;
+
 export const CREATE_CUSTOMER_GROUP = gql`
     mutation CreateCustomerGroup($input: CreateCustomerGroupInput!) {
         createCustomerGroup(input: $input) {

+ 8 - 0
packages/admin-ui/src/lib/core/src/data/providers/customer-data.service.ts

@@ -6,6 +6,7 @@ import {
     CREATE_CUSTOMER_ADDRESS,
     CREATE_CUSTOMER_GROUP,
     DELETE_CUSTOMER,
+    DELETE_CUSTOMER_ADDRESS,
     DELETE_CUSTOMER_GROUP,
     DELETE_CUSTOMER_NOTE,
     GET_CUSTOMER,
@@ -102,6 +103,13 @@ export class CustomerDataService {
         });
     }
 
+    deleteCustomerAddress(id: string) {
+        return this.baseDataService.mutate<
+            Codegen.DeleteCustomerAddressMutation,
+            Codegen.DeleteCustomerAddressMutationVariables
+        >(DELETE_CUSTOMER_ADDRESS, { id });
+    }
+
     createCustomerGroup(input: Codegen.CreateCustomerGroupInput) {
         return this.baseDataService.mutate<
             Codegen.CreateCustomerGroupMutation,

+ 10 - 0
packages/admin-ui/src/lib/customer/src/components/address-card/address-card.component.html

@@ -48,6 +48,16 @@
                     >
                         {{ 'customer.set-as-default-billing-address' | translate }}
                     </button>
+                    <div class="dropdown-divider"></div>
+                    <button
+                        type="button"
+                        class="delete-button"
+                        (click)="delete()"
+                        vdrDropdownItem
+                    >
+                        <clr-icon shape="trash" class="is-danger"></clr-icon>
+                        {{ 'common.delete' | translate }}
+                    </button>
                 </vdr-dropdown-menu>
             </vdr-dropdown>
         </ng-container>

+ 6 - 0
packages/admin-ui/src/lib/customer/src/components/address-card/address-card.component.ts

@@ -31,6 +31,7 @@ export class AddressCardComponent implements OnInit, OnChanges {
     @Input() editable = true;
     @Output() setAsDefaultShipping = new EventEmitter<string>();
     @Output() setAsDefaultBilling = new EventEmitter<string>();
+    @Output() deleteAddress = new EventEmitter<string>();
     private dataDependenciesPopulated = new BehaviorSubject<boolean>(false);
 
     constructor(private modalService: ModalService, private changeDetector: ChangeDetectorRef) {}
@@ -75,6 +76,11 @@ export class AddressCardComponent implements OnInit, OnChanges {
         this.addressForm.markAsDirty();
     }
 
+    delete() {
+        this.deleteAddress.emit(this.addressForm.value.id);
+        this.addressForm.markAsDirty();
+    }
+
     editAddress() {
         this.modalService
             .fromComponent(AddressDetailDialogComponent, {

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

@@ -118,14 +118,16 @@
         <h3>{{ 'customer.addresses' | translate }}</h3>
         <vdr-address-card
             *ngFor="let addressForm of getAddressFormControls()"
+            [class.to-delete]="addressesToDeleteIds.has(addressForm.value.id)"
             [availableCountries]="availableCountries$ | async"
             [isDefaultBilling]="defaultBillingAddressId === addressForm.value.id"
             [isDefaultShipping]="defaultShippingAddressId === addressForm.value.id"
             [addressForm]="addressForm"
             [customFields]="addressCustomFields"
-            [editable]="['UpdateCustomer'] | hasPermission"
+            [editable]="(['UpdateCustomer'] | hasPermission) && !addressesToDeleteIds.has(addressForm.value.id)"
             (setAsDefaultBilling)="setDefaultBillingAddressId($event)"
             (setAsDefaultShipping)="setDefaultShippingAddressId($event)"
+            (deleteAddress)="toggleDeleteAddress($event)"
         ></vdr-address-card>
         <button class="btn btn-secondary" (click)="addAddress()" *vdrIfPermissions="'UpdateCustomer'">
             <clr-icon shape="plus"></clr-icon>

+ 4 - 0
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.scss

@@ -3,3 +3,7 @@
     margin-left: 6px;
     color: var(--color-grey-500);
 }
+
+.to-delete {
+    opacity: 0.5;
+}

+ 41 - 16
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts

@@ -10,6 +10,7 @@ import {
     Customer,
     CustomFieldConfig,
     DataService,
+    DeleteCustomerAddressMutation,
     EditNoteDialogComponent,
     GetAvailableCountriesQuery,
     GetCustomerHistoryQuery,
@@ -23,7 +24,7 @@ import {
     UpdateCustomerInput,
     UpdateCustomerMutation,
 } from '@vendure/admin-ui/core';
-import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { assertNever, notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { EMPTY, forkJoin, from, Observable, Subject } from 'rxjs';
 import {
     concatMap,
@@ -61,6 +62,7 @@ export class CustomerDetailComponent
     fetchHistory = new Subject<void>();
     defaultShippingAddressId: string;
     defaultBillingAddressId: string;
+    addressesToDeleteIds = new Set<string>();
     addressDefaultsUpdated = false;
     ordersPerPage = 10;
     currentOrdersPage = 1;
@@ -140,6 +142,14 @@ export class CustomerDetailComponent
         this.addressDefaultsUpdated = true;
     }
 
+    toggleDeleteAddress(id: string) {
+        if (this.addressesToDeleteIds.has(id)) {
+            this.addressesToDeleteIds.delete(id);
+        } else {
+            this.addressesToDeleteIds.add(id);
+        }
+    }
+
     addAddress() {
         const addressFormArray = this.detailForm.get('addresses') as FormArray;
         const newAddress = this.formBuilder.group({
@@ -227,6 +237,7 @@ export class CustomerDetailComponent
                             | UpdateCustomerMutation['updateCustomer']
                             | CreateCustomerAddressMutation['createCustomerAddress']
                             | UpdateCustomerAddressMutation['updateCustomerAddress']
+                            | DeleteCustomerAddressMutation['deleteCustomerAddress']
                         >
                     > = [];
                     const customerForm = this.detailForm.get('customer');
@@ -274,14 +285,22 @@ export class CustomerDetailComponent
                                             .pipe(map(res => res.createCustomerAddress)),
                                     );
                                 } else {
-                                    saveOperations.push(
-                                        this.dataService.customer
-                                            .updateCustomerAddress({
-                                                ...input,
-                                                id: address.id,
-                                            })
-                                            .pipe(map(res => res.updateCustomerAddress)),
-                                    );
+                                    if (this.addressesToDeleteIds.has(address.id)) {
+                                        saveOperations.push(
+                                            this.dataService.customer
+                                                .deleteCustomerAddress(address.id)
+                                                .pipe(map(res => res.deleteCustomerAddress)),
+                                        );
+                                    } else {
+                                        saveOperations.push(
+                                            this.dataService.customer
+                                                .updateCustomerAddress({
+                                                    ...input,
+                                                    id: address.id,
+                                                })
+                                                .pipe(map(res => res.updateCustomerAddress)),
+                                        );
+                                    }
                                 }
                             }
                         }
@@ -291,17 +310,23 @@ export class CustomerDetailComponent
             )
             .subscribe(
                 data => {
+                    let notified = false;
                     for (const result of data) {
                         switch (result.__typename) {
                             case 'Customer':
                             case 'Address':
-                                this.notificationService.success(_('common.notify-update-success'), {
-                                    entity: 'Customer',
-                                });
-                                this.detailForm.markAsPristine();
-                                this.addressDefaultsUpdated = false;
-                                this.changeDetector.markForCheck();
-                                this.fetchHistory.next();
+                            case 'Success':
+                                if (!notified) {
+                                    this.notificationService.success(_('common.notify-update-success'), {
+                                        entity: 'Customer',
+                                    });
+                                    notified = true;
+                                    this.detailForm.markAsPristine();
+                                    this.addressDefaultsUpdated = false;
+                                    this.changeDetector.markForCheck();
+                                    this.fetchHistory.next();
+                                    this.dataService.customer.getCustomer(this.id).single$.subscribe();
+                                }
                                 break;
                             case 'EmailAddressConflictError':
                                 this.notificationService.error(result.message);

Fișier diff suprimat deoarece este prea mare
+ 288 - 289
packages/common/src/generated-shop-types.ts


+ 1 - 16
packages/core/e2e/collection.e2e-spec.ts

@@ -31,6 +31,7 @@ import {
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
     GET_ASSET_LIST,
+    GET_COLLECTIONS,
     UPDATE_COLLECTION,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
@@ -1834,22 +1835,6 @@ const GET_FACET_VALUES = gql`
     ${FACET_VALUE_FRAGMENT}
 `;
 
-const GET_COLLECTIONS = gql`
-    query GetCollections {
-        collections {
-            items {
-                id
-                name
-                position
-                parent {
-                    id
-                    name
-                }
-            }
-        }
-    }
-`;
-
 const GET_COLLECTION_PRODUCT_VARIANTS = gql`
     query GetCollectionProducts($id: ID!) {
         collection(id: $id) {

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

@@ -0,0 +1,3 @@
+name                   ,slug                   ,description                         ,assets                              ,facets                           ,optionGroups ,optionValues    ,sku   ,price,taxCategory,stockOnHand,trackInventory,variantAssets  ,variantFacets          ,product:pageType,variant:weight,product:owner,product:keywords           ,product:localName
+Model Hand             ,model-hand             ,For when you want to draw a hand    ,vincent-botta-736919-unsplash.jpg   ,Material:wood                    ,size         ,Small           ,MHS   ,15.45 ,standard   ,0          ,false         ,               ,                      ,default         ,100           ,"{""id"": 1}",paper|stretching|watercolor,localModelHand
+                       ,                       ,                                    ,                                    ,                                 ,             ,Large           ,MHL   ,19.95 ,standard   ,0          ,false         ,               ,                      ,                ,100           ,"{""id"": 1}",                           ,

Fișier diff suprimat deoarece este prea mare
+ 288 - 289
packages/core/e2e/graphql/generated-e2e-shop-types.ts


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

@@ -938,3 +938,19 @@ export const GET_SHIPPING_METHOD_LIST = gql`
     }
     ${SHIPPING_METHOD_FRAGMENT}
 `;
+
+export const GET_COLLECTIONS = gql`
+    query GetCollections {
+        collections {
+            items {
+                id
+                name
+                position
+                parent {
+                    id
+                    name
+                }
+            }
+        }
+    }
+`;

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

@@ -0,0 +1,195 @@
+import { INestApplication } from '@nestjs/common';
+import { DefaultLogger, User } from '@vendure/core';
+import { populate } from '@vendure/core/cli';
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { InitialData } from '../src/index';
+
+import {
+    ChannelFragment,
+    CreateChannelMutation,
+    CreateChannelMutationVariables,
+    CurrencyCode,
+    GetAssetListQuery,
+    GetCollectionsQuery,
+    GetProductListQuery,
+    LanguageCode,
+} from './graphql/generated-e2e-admin-types';
+import {
+    CREATE_CHANNEL,
+    GET_ASSET_LIST,
+    GET_COLLECTIONS,
+    GET_PRODUCT_LIST,
+} from './graphql/shared-definitions';
+
+describe('populate() function', () => {
+    let channel2: ChannelFragment;
+    const { server, adminClient } = createTestEnvironment({
+        ...testConfig(),
+        // logger: new DefaultLogger(),
+        customFields: {
+            Product: [
+                { type: 'string', name: 'pageType' },
+                {
+                    name: 'owner',
+                    public: true,
+                    nullable: true,
+                    type: 'relation',
+                    entity: User,
+                    eager: true,
+                },
+                {
+                    name: 'keywords',
+                    public: true,
+                    nullable: true,
+                    type: 'string',
+                    list: true,
+                },
+                {
+                    name: 'localName',
+                    type: 'localeString',
+                },
+            ],
+            ProductVariant: [{ type: 'int', name: 'weight' }],
+        },
+    });
+
+    beforeAll(async () => {
+        await server.init({
+            initialData: { ...initialData, collections: [] },
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-empty.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+        const { createChannel } = await adminClient.query<
+            CreateChannelMutation,
+            CreateChannelMutationVariables
+        >(CREATE_CHANNEL, {
+            input: {
+                code: 'Channel 2',
+                token: 'channel-2',
+                currencyCode: CurrencyCode.EUR,
+                defaultLanguageCode: LanguageCode.en,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_2',
+                pricesIncludeTax: true,
+            },
+        });
+        channel2 = createChannel as ChannelFragment;
+        await server.destroy();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    describe('populating default channel', () => {
+        let app: INestApplication;
+
+        beforeAll(async () => {
+            const initialDataForPopulate: InitialData = {
+                defaultLanguage: initialData.defaultLanguage,
+                defaultZone: initialData.defaultZone,
+                taxRates: [],
+                shippingMethods: [],
+                paymentMethods: [],
+                countries: [],
+                collections: [{ name: 'Collection 1', filters: [] }],
+            };
+            const csvFile = path.join(__dirname, 'fixtures', 'product-import.csv');
+            app = await populate(
+                async () => {
+                    await server.bootstrap();
+                    return server.app;
+                },
+                initialDataForPopulate,
+                csvFile,
+            );
+        }, TEST_SETUP_TIMEOUT_MS);
+
+        afterAll(async () => {
+            await app.close();
+        });
+
+        it('populates products', async () => {
+            await adminClient.asSuperAdmin();
+            const { products } = await adminClient.query<GetProductListQuery>(GET_PRODUCT_LIST);
+            expect(products.totalItems).toBe(4);
+            expect(products.items.map(i => i.name).sort()).toEqual([
+                'Artists Smock',
+                'Giotto Mega Pencils',
+                'Mabef M/02 Studio Easel',
+                'Perfect Paper Stretcher',
+            ]);
+        });
+
+        it('populates assets', async () => {
+            const { assets } = await adminClient.query<GetAssetListQuery>(GET_ASSET_LIST);
+            expect(assets.items.map(i => i.name).sort()).toEqual([
+                'box-of-12.jpg',
+                'box-of-8.jpg',
+                'pps1.jpg',
+                'pps2.jpg',
+            ]);
+        });
+
+        it('populates collections', async () => {
+            const { collections } = await adminClient.query<GetCollectionsQuery>(GET_COLLECTIONS);
+            expect(collections.items.map(i => i.name).sort()).toEqual(['Collection 1']);
+        });
+    });
+
+    describe('populating a non-default channel', () => {
+        let app: INestApplication;
+        beforeAll(async () => {
+            const initialDataForPopulate: InitialData = {
+                defaultLanguage: initialData.defaultLanguage,
+                defaultZone: initialData.defaultZone,
+                taxRates: [],
+                shippingMethods: [],
+                paymentMethods: [],
+                countries: [],
+                collections: [{ name: 'Collection 2', filters: [] }],
+            };
+            const csvFile = path.join(__dirname, 'fixtures', 'product-import-channel.csv');
+
+            app = await populate(
+                async () => {
+                    await server.bootstrap();
+                    return server.app;
+                },
+                initialDataForPopulate,
+                csvFile,
+                channel2.token,
+            );
+        });
+
+        afterAll(async () => {
+            await app.close();
+        });
+
+        it('populates products', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(channel2.token);
+            const { products } = await adminClient.query<GetProductListQuery>(GET_PRODUCT_LIST);
+            expect(products.totalItems).toBe(1);
+            expect(products.items.map(i => i.name).sort()).toEqual(['Model Hand']);
+        });
+
+        it('populates assets', async () => {
+            const { assets } = await adminClient.query<GetAssetListQuery>(GET_ASSET_LIST);
+            expect(assets.items.map(i => i.name).sort()).toEqual(['vincent-botta-736919-unsplash.jpg']);
+        });
+
+        it('populates collections', async () => {
+            const { collections } = await adminClient.query<GetCollectionsQuery>(GET_COLLECTIONS);
+            expect(collections.items.map(i => i.name).sort()).toEqual(['Collection 2']);
+        });
+
+        it('product also assigned to default channel', async () => {
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { products } = await adminClient.query<GetProductListQuery>(GET_PRODUCT_LIST);
+            expect(products.totalItems).toBe(1);
+            expect(products.items.map(i => i.name).sort()).toEqual(['Model Hand']);
+        });
+    });
+});

+ 0 - 2
packages/core/src/api/api.module.ts

@@ -10,7 +10,6 @@ import { I18nModule } from '../i18n/i18n.module';
 import { ServiceModule } from '../service/service.module';
 
 import { AdminApiModule, ApiSharedModule, ShopApiModule } from './api-internal-modules';
-import { RequestContextService } from './common/request-context.service';
 import { configureGraphQLModule } from './config/configure-graphql-module';
 import { AuthGuard } from './middleware/auth-guard';
 import { ExceptionLoggerFilter } from './middleware/exception-logger.filter';
@@ -52,7 +51,6 @@ import { ValidateCustomFieldsInterceptor } from './middleware/validate-custom-fi
         })),
     ],
     providers: [
-        RequestContextService,
         {
             provide: APP_GUARD,
             useClass: AuthGuard,

+ 3 - 1
packages/core/src/api/common/request-context.ts

@@ -79,7 +79,9 @@ export class RequestContext {
      * @description
      * Creates an "empty" RequestContext object. This is only intended to be used
      * when a service method must be called outside the normal request-response
-     * cycle, e.g. when programmatically populating data.
+     * cycle, e.g. when programmatically populating data. Usually a better alternative
+     * is to use the {@link RequestContextService} `create()` method, which allows more control
+     * over the resulting RequestContext object.
      */
     static empty(): RequestContext {
         return new RequestContext({

+ 1 - 1
packages/core/src/api/middleware/auth-guard.ts

@@ -10,13 +10,13 @@ import { ConfigService } from '../../config/config.service';
 import { LogLevel } from '../../config/logger/vendure-logger';
 import { CachedSession } from '../../config/session-cache/session-cache-strategy';
 import { Customer } from '../../entity/customer/customer.entity';
+import { RequestContextService } from '../../service/helpers/request-context/request-context.service';
 import { ChannelService } from '../../service/services/channel.service';
 import { CustomerService } from '../../service/services/customer.service';
 import { SessionService } from '../../service/services/session.service';
 import { extractSessionToken } from '../common/extract-session-token';
 import { parseContext } from '../common/parse-context';
 import { RequestContext } from '../common/request-context';
-import { RequestContextService } from '../common/request-context.service';
 import { setSessionToken } from '../common/set-session-token';
 import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';
 

+ 80 - 24
packages/core/src/cli/populate.ts

@@ -3,13 +3,48 @@ import fs from 'fs-extra';
 import path from 'path';
 import { lastValueFrom } from 'rxjs';
 
-import { logColored } from './cli-utils';
+const loggerCtx = 'Populate';
 
 // tslint:disable:no-console
 /**
  * @description
  * Populates the Vendure server with some initial data and (optionally) product data from
- * a supplied CSV file.
+ * a supplied CSV file. The format of the CSV file is described in the section
+ * [Importing Product Data](/docs/developer-guide/importing-product-data).
+ *
+ * If the `channelOrToken` argument is provided, all ChannelAware entities (Products, ProductVariants,
+ * Assets, ShippingMethods, PaymentMethods etc.) will be assigned to the specified Channel.
+ * The argument can be either a Channel object or a valid channel `token`.
+ *
+ * Internally the `populate()` function does the following:
+ *
+ * 1. Uses the {@link Populator} to populate the {@link InitialData}.
+ * 2. If `productsCsvPath` is provided, uses {@link Importer} to populate Product data.
+ * 3. Uses {@Populator} to populate collections specified in the {@link InitialData}.
+ *
+ * @example
+ * ```TypeScript
+ * import { bootstrap } from '\@vendure/core';
+ * import { populate } from '\@vendure/core/cli';
+ * import { config } from './vendure-config.ts'
+ * import { initialData } from './my-initial-data.ts';
+ *
+ * const productsCsvFile = path.join(__dirname, 'path/to/products.csv')
+ *
+ * populate(
+ *   () => bootstrap(config),
+ *   initialData,
+ *   productsCsvFile,
+ * )
+ * .then(app => app.close())
+ * .then(
+ *   () => process.exit(0),
+ *   err => {
+ *     console.log(err);
+ *     process.exit(1);
+ *   },
+ * );
+ * ```
  *
  * @docsCategory import-export
  */
@@ -17,20 +52,39 @@ export async function populate<T extends INestApplicationContext>(
     bootstrapFn: () => Promise<T | undefined>,
     initialDataPathOrObject: string | object,
     productsCsvPath?: string,
+    channelOrToken?: string | import('@vendure/core').Channel,
 ): Promise<T> {
     const app = await bootstrapFn();
     if (!app) {
         throw new Error('Could not bootstrap the Vendure app');
     }
+    let channel: import('@vendure/core').Channel | undefined;
+    const { ChannelService, Channel, Logger } = await import('@vendure/core');
+    if (typeof channelOrToken === 'string') {
+        channel = await app.get(ChannelService).getChannelFromToken(channelOrToken);
+        if (!channel) {
+            Logger.warn(
+                `Warning: channel with token "${channelOrToken}" was not found. Using default Channel instead.`,
+                loggerCtx,
+            );
+        }
+    } else if (channelOrToken instanceof Channel) {
+        channel = channelOrToken;
+    }
     const initialData: import('@vendure/core').InitialData =
         typeof initialDataPathOrObject === 'string'
             ? require(initialDataPathOrObject)
             : initialDataPathOrObject;
 
-    await populateInitialData(app, initialData, logColored);
+    await populateInitialData(app, initialData, channel);
 
     if (productsCsvPath) {
-        const importResult = await importProductsFromCsv(app, productsCsvPath, initialData.defaultLanguage);
+        const importResult = await importProductsFromCsv(
+            app,
+            productsCsvPath,
+            initialData.defaultLanguage,
+            channel,
+        );
         if (importResult.errors && importResult.errors.length) {
             const errorFile = path.join(process.cwd(), 'vendure-import-error.log');
             console.log(
@@ -39,48 +93,44 @@ export async function populate<T extends INestApplicationContext>(
             await fs.writeFile(errorFile, importResult.errors.join('\n'));
         }
 
-        logColored(`\nImported ${importResult.imported} products`);
+        Logger.info(`Imported ${importResult.imported} products`, loggerCtx);
 
-        await populateCollections(app, initialData, logColored);
+        await populateCollections(app, initialData);
     }
 
-    logColored('\nDone!');
+    Logger.info('Done!', loggerCtx);
     return app;
 }
 
 export async function populateInitialData(
     app: INestApplicationContext,
     initialData: import('@vendure/core').InitialData,
-    loggingFn?: (message: string) => void,
+    channel?: import('@vendure/core').Channel,
 ) {
-    const { Populator } = await import('@vendure/core');
+    const { Populator, Logger } = await import('@vendure/core');
     const populator = app.get(Populator);
     try {
-        await populator.populateInitialData(initialData);
-        if (typeof loggingFn === 'function') {
-            loggingFn(`Populated initial data`);
-        }
+        await populator.populateInitialData(initialData, channel);
+        Logger.info(`Populated initial data`, loggerCtx);
     } catch (err: any) {
-        console.log(err.message);
+        Logger.error(err.message, loggerCtx);
     }
 }
 
 export async function populateCollections(
     app: INestApplicationContext,
     initialData: import('@vendure/core').InitialData,
-    loggingFn?: (message: string) => void,
+    channel?: import('@vendure/core').Channel,
 ) {
-    const { Populator } = await import('@vendure/core');
+    const { Populator, Logger } = await import('@vendure/core');
     const populator = app.get(Populator);
     try {
         if (initialData.collections.length) {
-            await populator.populateCollections(initialData);
-            if (typeof loggingFn === 'function') {
-                loggingFn(`Created ${initialData.collections.length} Collections`);
-            }
+            await populator.populateCollections(initialData, channel);
+            Logger.info(`Created ${initialData.collections.length} Collections`, loggerCtx);
         }
     } catch (err: any) {
-        console.log(err.message);
+        Logger.info(err.message, loggerCtx);
     }
 }
 
@@ -88,10 +138,16 @@ export async function importProductsFromCsv(
     app: INestApplicationContext,
     productsCsvPath: string,
     languageCode: import('@vendure/core').LanguageCode,
+    channel?: import('@vendure/core').Channel,
 ): Promise<import('@vendure/core').ImportProgress> {
-    const { Importer } = await import('@vendure/core');
+    const { Importer, RequestContextService } = await import('@vendure/core');
     const importer = app.get(Importer);
+    const requestContextService = app.get(RequestContextService);
     const productData = await fs.readFile(productsCsvPath, 'utf-8');
-
-    return lastValueFrom(importer.parseAndImport(productData, languageCode, true));
+    const ctx = await requestContextService.create({
+        apiType: 'admin',
+        languageCode,
+        channelOrToken: channel,
+    });
+    return lastValueFrom(importer.parseAndImport(productData, ctx, true));
 }

+ 1 - 1
packages/core/src/data-import/data-import.module.ts

@@ -15,7 +15,7 @@ import { Populator } from './providers/populator/populator';
     // Important! PluginModule must be defined before ServiceModule
     // in order that overrides of Services (e.g. SearchService) are correctly
     // registered with the injector.
-    imports: [PluginModule.forRoot(), ServiceModule, ConnectionModule.forRoot(), ConfigModule],
+    imports: [PluginModule.forRoot(), ServiceModule, ConnectionModule.forPlugin(), ConfigModule],
     exports: [ImportParser, Importer, Populator, FastImporterService, AssetImporter],
     providers: [ImportParser, Importer, Populator, FastImporterService, AssetImporter],
 })

+ 3 - 0
packages/core/src/data-import/index.ts

@@ -1,3 +1,6 @@
 export * from './providers/populator/populator';
 export * from './providers/importer/importer';
+export * from './providers/importer/fast-importer.service';
+export * from './providers/asset-importer/asset-importer';
+export * from './providers/import-parser/import-parser';
 export * from './types';

+ 19 - 2
packages/core/src/data-import/providers/asset-importer/asset-importer.ts

@@ -2,21 +2,35 @@ import { Injectable } from '@nestjs/common';
 import fs from 'fs-extra';
 import path from 'path';
 
+import { RequestContext } from '../../../api/index';
 import { ConfigService } from '../../../config/config.service';
 import { Asset } from '../../../entity/asset/asset.entity';
 import { AssetService } from '../../../service/services/asset.service';
 
+/**
+ * @description
+ * This service creates new {@link Asset} entities based on string paths provided in the CSV
+ * import format. The source files are resolved by joining the value of `importExportOptions.importAssetsDir`
+ * with the asset path. This service is used internally by the {@link Importer} service.
+ *
+ * @docsCategory import-export
+ */
 @Injectable()
 export class AssetImporter {
     private assetMap = new Map<string, Asset>();
 
+    /** @internal */
     constructor(private configService: ConfigService, private assetService: AssetService) {}
 
     /**
+     * @description
      * Creates Asset entities for the given paths, using the assetMap cache to prevent the
      * creation of duplicates.
      */
-    async getAssets(assetPaths: string[]): Promise<{ assets: Asset[]; errors: string[] }> {
+    async getAssets(
+        assetPaths: string[],
+        ctx?: RequestContext,
+    ): Promise<{ assets: Asset[]; errors: string[] }> {
         const assets: Asset[] = [];
         const errors: string[] = [];
         const { importAssetsDir } = this.configService.importExportOptions;
@@ -33,7 +47,10 @@ export class AssetImporter {
                     if (fileStat.isFile()) {
                         try {
                             const stream = fs.createReadStream(filename);
-                            const asset = (await this.assetService.createFromFileStream(stream)) as Asset;
+                            const asset = (await this.assetService.createFromFileStream(
+                                stream,
+                                ctx,
+                            )) as Asset;
                             this.assetMap.set(assetPath, asset);
                             assets.push(asset);
                         } catch (err: any) {

+ 59 - 0
packages/core/src/data-import/providers/import-parser/import-parser.ts

@@ -34,6 +34,14 @@ const requiredColumns: string[] = [
     'variantFacets',
 ];
 
+/**
+ * @description
+ * The intermediate representation of an OptionGroup after it has been parsed
+ * by the {@link ImportParser}.
+ *
+ * @docsCategory import-export
+ * @docsPage ImportParser
+ */
 export interface ParsedOptionGroup {
     translations: Array<{
         languageCode: LanguageCode;
@@ -42,6 +50,14 @@ export interface ParsedOptionGroup {
     }>;
 }
 
+/**
+ * @description
+ * The intermediate representation of a Facet after it has been parsed
+ * by the {@link ImportParser}.
+ *
+ * @docsCategory import-export
+ * @docsPage ImportParser
+ */
 export interface ParsedFacet {
     translations: Array<{
         languageCode: LanguageCode;
@@ -50,6 +66,14 @@ export interface ParsedFacet {
     }>;
 }
 
+/**
+ * @description
+ * The intermediate representation of a ProductVariant after it has been parsed
+ * by the {@link ImportParser}.
+ *
+ * @docsCategory import-export
+ * @docsPage ImportParser
+ */
 export interface ParsedProductVariant {
     sku: string;
     price: number;
@@ -67,6 +91,14 @@ export interface ParsedProductVariant {
     }>;
 }
 
+/**
+ * @description
+ * The intermediate representation of a Product after it has been parsed
+ * by the {@link ImportParser}.
+ *
+ * @docsCategory import-export
+ * @docsPage ImportParser
+ */
 export interface ParsedProduct {
     assetPaths: string[];
     optionGroups: ParsedOptionGroup[];
@@ -82,11 +114,26 @@ export interface ParsedProduct {
     }>;
 }
 
+/**
+ * @description
+ * The data structure into which an import CSV file is parsed by the
+ * {@link ImportParser} `parseProducts()` method.
+ *
+ * @docsCategory import-export
+ * @docsPage ImportParser
+ */
 export interface ParsedProductWithVariants {
     product: ParsedProduct;
     variants: ParsedProductVariant[];
 }
 
+/**
+ * @description
+ * The result returned by the {@link ImportParser} `parseProducts()` method.
+ *
+ * @docsCategory import-export
+ * @docsPage ImportParser
+ */
 export interface ParseResult<T> {
     results: T[];
     errors: string[];
@@ -94,12 +141,24 @@ export interface ParseResult<T> {
 }
 
 /**
+ * @description
  * Validates and parses CSV files into a data structure which can then be used to created new entities.
+ * This is used internally by the {@link Importer}.
+ *
+ * @docsCategory import-export
+ * @docsPage ImportParser
+ * @docsWeight 0
  */
 @Injectable()
 export class ImportParser {
+    /** @internal */
     constructor(private configService: ConfigService) {}
 
+    /**
+     * @description
+     * Parses the contents of the [product import CSV file](/docs/developer-guide/importing-product-data/#product-import-format) and
+     * returns a data structure which can then be used to populate Vendure using the {@link FastImporterService}.
+     */
     async parseProducts(
         input: string | Stream,
         mainLanguage: LanguageCode = this.configService.defaultLanguageCode,

+ 56 - 15
packages/core/src/data-import/providers/importer/fast-importer.service.ts

@@ -6,6 +6,7 @@ import {
     CreateProductVariantInput,
 } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
@@ -22,37 +23,61 @@ import { ProductAsset } from '../../../entity/product/product-asset.entity';
 import { ProductTranslation } from '../../../entity/product/product-translation.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { TranslatableSaver } from '../../../service/helpers/translatable-saver/translatable-saver';
+import { RequestContextService } from '../../../service/index';
 import { ChannelService } from '../../../service/services/channel.service';
 import { StockMovementService } from '../../../service/services/stock-movement.service';
 
 /**
+ * @description
  * A service to import entities into the database. This replaces the regular `create` methods of the service layer with faster
- * versions which skip much of the defensive checks and other DB calls which are not needed when running an import.
+ * versions which skip much of the defensive checks and other DB calls which are not needed when running an import. It also
+ * does not publish any events, so e.g. will not trigger search index jobs.
  *
  * In testing, the use of the FastImporterService approximately doubled the speed of bulk imports.
+ *
+ * @docsCategory import-export
  */
 @Injectable()
 export class FastImporterService {
     private defaultChannel: Channel;
+    private importCtx: RequestContext;
+
+    /** @internal */
     constructor(
         private connection: TransactionalConnection,
         private channelService: ChannelService,
         private stockMovementService: StockMovementService,
         private translatableSaver: TranslatableSaver,
+        private requestContextService: RequestContextService,
     ) {}
 
-    async initialize() {
+    /**
+     * @description
+     * This should be called prior to any of the import methods, as it establishes the
+     * default Channel as well as the context in which the new entities will be created.
+     *
+     * Passing a `channel` argument means that Products and ProductVariants will be assigned
+     * to that Channel.
+     */
+    async initialize(channel?: Channel) {
+        this.importCtx = channel
+            ? await this.requestContextService.create({
+                  apiType: 'admin',
+                  channelOrToken: channel,
+              })
+            : RequestContext.empty();
         this.defaultChannel = await this.channelService.getDefaultChannel();
     }
 
     async createProduct(input: CreateProductInput): Promise<ID> {
+        this.ensureInitialized();
         const product = await this.translatableSaver.create({
-            ctx: RequestContext.empty(),
+            ctx: this.importCtx,
             input,
             entityType: Product,
             translationType: ProductTranslation,
             beforeSave: async p => {
-                p.channels = [this.defaultChannel];
+                p.channels = unique([this.defaultChannel, this.importCtx.channel], 'id');
                 if (input.facetValueIds) {
                     p.facetValues = input.facetValueIds.map(id => ({ id } as any));
                 }
@@ -76,8 +101,9 @@ export class FastImporterService {
     }
 
     async createProductOptionGroup(input: CreateProductOptionGroupInput): Promise<ID> {
+        this.ensureInitialized();
         const group = await this.translatableSaver.create({
-            ctx: RequestContext.empty(),
+            ctx: this.importCtx,
             input,
             entityType: ProductOptionGroup,
             translationType: ProductOptionGroupTranslation,
@@ -86,8 +112,9 @@ export class FastImporterService {
     }
 
     async createProductOption(input: CreateProductOptionInput): Promise<ID> {
+        this.ensureInitialized();
         const option = await this.translatableSaver.create({
-            ctx: RequestContext.empty(),
+            ctx: this.importCtx,
             input,
             entityType: ProductOption,
             translationType: ProductOptionTranslation,
@@ -97,6 +124,7 @@ export class FastImporterService {
     }
 
     async addOptionGroupToProduct(productId: ID, optionGroupId: ID) {
+        this.ensureInitialized();
         await this.connection
             .getRepository(Product)
             .createQueryBuilder()
@@ -106,6 +134,7 @@ export class FastImporterService {
     }
 
     async createProductVariant(input: CreateProductVariantInput): Promise<ID> {
+        this.ensureInitialized();
         if (!input.optionIds) {
             input.optionIds = [];
         }
@@ -119,12 +148,12 @@ export class FastImporterService {
         delete inputWithoutPrice.price;
 
         const createdVariant = await this.translatableSaver.create({
-            ctx: RequestContext.empty(),
+            ctx: this.importCtx,
             input: inputWithoutPrice,
             entityType: ProductVariant,
             translationType: ProductVariantTranslation,
             beforeSave: async variant => {
-                variant.channels = [this.defaultChannel];
+                variant.channels = unique([this.defaultChannel, this.importCtx.channel], 'id');
                 const { optionIds } = input;
                 if (optionIds && optionIds.length) {
                     variant.options = optionIds.map(id => ({ id } as any));
@@ -152,18 +181,30 @@ export class FastImporterService {
         }
         if (input.stockOnHand != null && input.stockOnHand !== 0) {
             await this.stockMovementService.adjustProductVariantStock(
-                RequestContext.empty(),
+                this.importCtx,
                 createdVariant.id,
                 0,
                 input.stockOnHand,
             );
         }
-        const variantPrice = new ProductVariantPrice({
-            price: input.price,
-            channelId: this.defaultChannel.id,
-        });
-        variantPrice.variant = createdVariant;
-        await this.connection.getRepository(ProductVariantPrice).save(variantPrice, { reload: false });
+        const assignedChannelIds = unique([this.defaultChannel, this.importCtx.channel], 'id').map(c => c.id);
+        for (const channelId of assignedChannelIds) {
+            const variantPrice = new ProductVariantPrice({
+                price: input.price,
+                channelId,
+            });
+            variantPrice.variant = createdVariant;
+            await this.connection.getRepository(ProductVariantPrice).save(variantPrice, { reload: false });
+        }
+
         return createdVariant.id;
     }
+
+    private ensureInitialized() {
+        if (!this.defaultChannel || !this.importCtx) {
+            throw new Error(
+                `The FastImporterService must be initialized with a call to 'initialize()' before importing data`,
+            );
+        }
+    }
 }

+ 27 - 13
packages/core/src/data-import/providers/importer/importer.ts

@@ -28,6 +28,16 @@ export interface ImportProgress extends ImportInfo {
 
 export type OnProgressFn = (progess: ImportProgress) => void;
 
+/**
+ * @description
+ * Parses and imports Products using the CSV import format.
+ *
+ * Internally it is using the {@link ImportParser} to parse the CSV file, and then the
+ * {@link FastImporterService} and the {@link AssetImporter} to actually create the resulting
+ * entities in the Vendure database.
+ *
+ * @docsCategory import-export
+ */
 @Injectable()
 export class Importer {
     private taxCategoryMatches: { [name: string]: ID } = {};
@@ -36,6 +46,7 @@ export class Importer {
     private facetMap = new Map<string, Facet>();
     private facetValueMap = new Map<string, FacetValue>();
 
+    /** @internal */
     constructor(
         private configService: ConfigService,
         private importParser: ImportParser,
@@ -47,6 +58,13 @@ export class Importer {
         private fastImporter: FastImporterService,
     ) {}
 
+    /**
+     * @description
+     * Parses the contents of the [product import CSV file](/docs/developer-guide/importing-product-data/#product-import-format) and imports
+     * the resulting Product & ProductVariants, as well as any associated Assets, Facets & FacetValues.
+     *
+     * The `ctxOrLanguageCode` argument is used to specify the languageCode to be used when creating the Products.
+     */
     parseAndImport(
         input: string | Stream,
         ctxOrLanguageCode: RequestContext | LanguageCode,
@@ -140,13 +158,13 @@ export class Importer {
         let imported = 0;
         const languageCode = ctx.languageCode;
         const taxCategories = await this.taxCategoryService.findAll(ctx);
-        await this.fastImporter.initialize();
+        await this.fastImporter.initialize(ctx.channel);
         for (const { product, variants } of rows) {
             const productMainTranslation = this.getTranslationByCodeOrFirst(
                 product.translations,
                 ctx.languageCode,
             );
-            const createProductAssets = await this.assetImporter.getAssets(product.assetPaths);
+            const createProductAssets = await this.assetImporter.getAssets(product.assetPaths, ctx);
             const productAssets = createProductAssets.assets;
             if (createProductAssets.errors.length) {
                 errors = errors.concat(createProductAssets.errors);
@@ -158,7 +176,7 @@ export class Importer {
             const createdProductId = await this.fastImporter.createProduct({
                 featuredAssetId: productAssets.length ? productAssets[0].id : undefined,
                 assetIds: productAssets.map(a => a.id),
-                facetValueIds: await this.getFacetValueIds(product.facets, languageCode),
+                facetValueIds: await this.getFacetValueIds(ctx, product.facets, ctx.languageCode),
                 translations: product.translations.map(translation => {
                     return {
                         languageCode: translation.languageCode,
@@ -222,7 +240,7 @@ export class Importer {
                 }
                 let facetValueIds: ID[] = [];
                 if (0 < variant.facets.length) {
-                    facetValueIds = await this.getFacetValueIds(variant.facets, languageCode);
+                    facetValueIds = await this.getFacetValueIds(ctx, variant.facets, languageCode);
                 }
                 const variantCustomFields = this.processCustomFieldValues(
                     variantMainTranslation.customFields,
@@ -271,16 +289,12 @@ export class Importer {
         return errors;
     }
 
-    private async getFacetValueIds(facets: ParsedFacet[], languageCode: LanguageCode): Promise<ID[]> {
+    private async getFacetValueIds(
+        ctx: RequestContext,
+        facets: ParsedFacet[],
+        languageCode: LanguageCode,
+    ): Promise<ID[]> {
         const facetValueIds: ID[] = [];
-        const ctx = new RequestContext({
-            channel: await this.channelService.getDefaultChannel(),
-            apiType: 'admin',
-            isAuthorized: true,
-            authorizedAsOwnerOnly: false,
-            session: {} as any,
-        });
-
         for (const item of facets) {
             const itemMainTranslation = this.getTranslationByCodeOrFirst(item.translations, languageCode);
             const facetName = itemMainTranslation.facet;

+ 32 - 18
packages/core/src/data-import/providers/populator/populator.ts

@@ -30,10 +30,15 @@ import {
 import { AssetImporter } from '../asset-importer/asset-importer';
 
 /**
- * Responsible for populating the database with initial data.
+ * @description
+ * Responsible for populating the database with {@link InitialData}, i.e. non-product data such as countries, tax rates,
+ * shipping methods, payment methods & roles.
+ *
+ * @docsCategory import-export
  */
 @Injectable()
 export class Populator {
+    /** @internal */
     constructor(
         private countryService: CountryService,
         private zoneService: ZoneService,
@@ -50,11 +55,13 @@ export class Populator {
     ) {}
 
     /**
+     * @description
      * Should be run *before* populating the products, so that there are TaxRates by which
-     * product prices can be set.
+     * product prices can be set. If the `channel` argument is set, then any {@link ChannelAware}
+     * entities will be assigned to that Channel.
      */
-    async populateInitialData(data: InitialData) {
-        const { channel, ctx } = await this.createRequestContext(data);
+    async populateInitialData(data: InitialData, channel?: Channel) {
+        const ctx = await this.createRequestContext(data, channel);
         let zoneMap: ZoneMap;
         try {
             zoneMap = await this.populateCountries(ctx, data.countries);
@@ -82,7 +89,7 @@ export class Populator {
             Logger.error(e, 'populator', e.stack);
         }
         try {
-            await this.setChannelDefaults(zoneMap, data, channel);
+            await this.setChannelDefaults(zoneMap, data, ctx.channel);
         } catch (e: any) {
             Logger.error(`Could not set channel defaults`);
             Logger.error(e, 'populator', e.stack);
@@ -96,11 +103,12 @@ export class Populator {
     }
 
     /**
+     * @description
      * Should be run *after* the products have been populated, otherwise the expected FacetValues will not
      * yet exist.
      */
-    async populateCollections(data: InitialData) {
-        const { ctx } = await this.createRequestContext(data);
+    async populateCollections(data: InitialData, channel?: Channel) {
+        const ctx = await this.createRequestContext(data, channel);
 
         const allFacetValues = await this.facetValueService.findAll(ctx.languageCode);
         const collectionMap = new Map<string, Collection>();
@@ -182,23 +190,22 @@ export class Populator {
         }
     }
 
-    private async createRequestContext(data: InitialData) {
-        const channel = await this.channelService.getDefaultChannel();
+    private async createRequestContext(data: InitialData, channel?: Channel) {
         const ctx = new RequestContext({
             apiType: 'admin',
             isAuthorized: true,
             authorizedAsOwnerOnly: false,
-            channel,
+            channel: channel ?? (await this.channelService.getDefaultChannel()),
             languageCode: data.defaultLanguage,
         });
-        return { channel, ctx };
+        return ctx;
     }
 
     private async setChannelDefaults(zoneMap: ZoneMap, data: InitialData, channel: Channel) {
         const defaultZone = zoneMap.get(data.defaultZone);
         if (!defaultZone) {
             throw new Error(
-                `The defaultZone (${data.defaultZone}) did not match any zones from the InitialData`,
+                `The defaultZone (${data.defaultZone}) did not match any existing or created zone names`,
             );
         }
         const defaultZoneId = defaultZone.entity.id;
@@ -210,7 +217,11 @@ export class Populator {
     }
 
     private async populateCountries(ctx: RequestContext, countries: CountryDefinition[]): Promise<ZoneMap> {
-        const zones: ZoneMap = new Map();
+        const zoneMap: ZoneMap = new Map();
+        const existingZones = await this.zoneService.findAll(ctx);
+        for (const zone of existingZones) {
+            zoneMap.set(zone.name, { entity: zone, members: zone.members.map(m => m.id) });
+        }
         for (const { name, code, zone } of countries) {
             const countryEntity = await this.countryService.create(ctx, {
                 code,
@@ -218,23 +229,26 @@ export class Populator {
                 translations: [{ languageCode: ctx.languageCode, name }],
             });
 
-            let zoneItem = zones.get(zone);
+            let zoneItem = zoneMap.get(zone);
             if (!zoneItem) {
                 const zoneEntity = await this.zoneService.create(ctx, { name: zone });
                 zoneItem = { entity: zoneEntity, members: [] };
-                zones.set(zone, zoneItem);
+                zoneMap.set(zone, zoneItem);
+            }
+            if (!zoneItem.members.includes(countryEntity.id)) {
+                zoneItem.members.push(countryEntity.id);
             }
-            zoneItem.members.push(countryEntity.id);
         }
 
         // add the countries to the respective zones
-        for (const zoneItem of zones.values()) {
+        for (const zoneItem of zoneMap.values()) {
             await this.zoneService.addMembersToZone(ctx, {
                 zoneId: zoneItem.entity.id,
                 memberIds: zoneItem.members,
             });
         }
-        return zones;
+
+        return zoneMap;
     }
 
     private async populateTaxRates(

+ 22 - 0
packages/core/src/event-bus/events/order-line-event.ts

@@ -0,0 +1,22 @@
+import { RequestContext } from '../../api/common/request-context';
+import { Order, OrderLine } from '../../entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired whenever an {@link OrderLine} is added, updated
+ * or deleted.
+ *
+ * @docsCategory events
+ * @docsPage Event Types
+ */
+export class OrderLineEvent extends VendureEvent {
+    constructor(
+        public ctx: RequestContext,
+        public order: Order,
+        public orderLine: OrderLine,
+        public type: 'created' | 'updated' | 'deleted',
+    ) {
+        super();
+    }
+}

+ 2 - 0
packages/core/src/event-bus/index.ts

@@ -28,6 +28,8 @@ export * from './events/identifier-change-event';
 export * from './events/identifier-change-request-event';
 export * from './events/login-event';
 export * from './events/logout-event';
+export * from './events/order-event';
+export * from './events/order-line-event';
 export * from './events/order-placed-event';
 export * from './events/order-state-transition-event';
 export * from './events/password-reset-event';

+ 3 - 0
packages/core/src/plugin/plugin-common.module.ts

@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
 import { CacheModule } from '../cache/cache.module';
 import { ConfigModule } from '../config/config.module';
 import { ConnectionModule } from '../connection/connection.module';
+import { DataImportModule } from '../data-import/data-import.module';
 import { EventBusModule } from '../event-bus/event-bus.module';
 import { HealthCheckModule } from '../health-check/health-check.module';
 import { I18nModule } from '../i18n/i18n.module';
@@ -37,6 +38,7 @@ import { ServiceModule } from '../service/service.module';
         CacheModule,
         I18nModule,
         ProcessContextModule,
+        DataImportModule,
     ],
     exports: [
         EventBusModule,
@@ -48,6 +50,7 @@ import { ServiceModule } from '../service/service.module';
         CacheModule,
         I18nModule,
         ProcessContextModule,
+        DataImportModule,
     ],
 })
 export class PluginCommonModule {}

+ 5 - 0
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -28,6 +28,8 @@ import { ProductVariant } from '../../../entity/product-variant/product-variant.
 import { Promotion } from '../../../entity/promotion/promotion.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
 import { Surcharge } from '../../../entity/surcharge/surcharge.entity';
+import { EventBus } from '../../../event-bus/event-bus';
+import { OrderLineEvent } from '../../../event-bus/index';
 import { CountryService } from '../../services/country.service';
 import { PaymentService } from '../../services/payment.service';
 import { ProductVariantService } from '../../services/product-variant.service';
@@ -59,6 +61,7 @@ export class OrderModifier {
         private stockMovementService: StockMovementService,
         private productVariantService: ProductVariantService,
         private customFieldRelationService: CustomFieldRelationService,
+        private eventBus: EventBus,
     ) {}
 
     /**
@@ -139,6 +142,7 @@ export class OrderModifier {
         );
         order.lines.push(lineWithRelations);
         await this.connection.getRepository(ctx, Order).save(order, { reload: false });
+        this.eventBus.publish(new OrderLineEvent(ctx, order, lineWithRelations, 'created'));
         return lineWithRelations;
     }
 
@@ -216,6 +220,7 @@ export class OrderModifier {
             }
         }
         await this.connection.getRepository(ctx, OrderLine).save(orderLine);
+        this.eventBus.publish(new OrderLineEvent(ctx, order, orderLine, 'updated'));
         return orderLine;
     }
 

+ 73 - 10
packages/core/src/api/common/request-context.service.ts → packages/core/src/service/helpers/request-context/request-context.service.ts

@@ -1,26 +1,89 @@
 import { Injectable } from '@nestjs/common';
 import { LanguageCode, Permission } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
 import { Request } from 'express';
 import { GraphQLResolveInfo } from 'graphql';
+import ms from 'ms';
 
-import { idsAreEqual } from '../../common/utils';
-import { ConfigService } from '../../config/config.service';
-import { CachedSession, CachedSessionUser } from '../../config/session-cache/session-cache-strategy';
-import { Channel } from '../../entity/channel/channel.entity';
-import { ChannelService } from '../../service/services/channel.service';
-
-import { getApiType } from './get-api-type';
-import { RequestContext } from './request-context';
+import { ApiType, getApiType } from '../../../api/common/get-api-type';
+import { RequestContext } from '../../../api/common/request-context';
+import { idsAreEqual } from '../../../common/utils';
+import { ConfigService } from '../../../config/config.service';
+import { CachedSession, CachedSessionUser } from '../../../config/session-cache/session-cache-strategy';
+import { Channel } from '../../../entity/channel/channel.entity';
+import { User } from '../../../entity/index';
+import { ChannelService } from '../../services/channel.service';
+import { getUserChannelsPermissions } from '../utils/get-user-channels-permissions';
 
 /**
- * Creates new RequestContext instances.
+ * @description
+ * Creates new {@link RequestContext} instances.
+ *
+ * @docsCategory request
  */
 @Injectable()
 export class RequestContextService {
+    /** @internal */
     constructor(private channelService: ChannelService, private configService: ConfigService) {}
 
     /**
-     * Creates a new RequestContext based on an Express request object.
+     * @description
+     * Creates a RequestContext based on the config provided. This can be useful when interacting
+     * with services outside the request-response cycle, for example in stand-alone scripts or in
+     * worker jobs.
+     *
+     * @since 1.5.0
+     */
+    async create(config: {
+        req?: Request;
+        apiType: ApiType;
+        channelOrToken?: Channel | string;
+        languageCode?: LanguageCode;
+        user?: User;
+        activeOrderId?: ID;
+    }): Promise<RequestContext> {
+        const { req, apiType, channelOrToken, languageCode, user, activeOrderId } = config;
+        let channel: Channel;
+        if (channelOrToken instanceof Channel) {
+            channel = channelOrToken;
+        } else if (typeof channelOrToken === 'string') {
+            channel = await this.channelService.getChannelFromToken(channelOrToken);
+        } else {
+            channel = await this.channelService.getDefaultChannel();
+        }
+        let session: CachedSession | undefined;
+        if (user) {
+            const channelPermissions = user.roles ? getUserChannelsPermissions(user) : [];
+            session = {
+                user: {
+                    id: user.id,
+                    identifier: user.identifier,
+                    verified: user.verified,
+                    channelPermissions,
+                },
+                id: '__dummy_session_id__',
+                token: '__dummy_session_token__',
+                expires: new Date(Date.now() + ms('1y')),
+                cacheExpiry: ms('1y'),
+                activeOrderId,
+            };
+        }
+        return new RequestContext({
+            req,
+            apiType,
+            channel,
+            languageCode,
+            session,
+            isAuthorized: true,
+            authorizedAsOwnerOnly: false,
+        });
+    }
+
+    /**
+     * @description
+     * Creates a new RequestContext based on an Express request object. This is used internally
+     * in the API layer by the AuthGuard, and creates the RequestContext which is then passed
+     * to all resolvers & controllers.
      */
     async fromRequest(
         req: Request,

+ 2 - 0
packages/core/src/service/index.ts

@@ -13,6 +13,8 @@ export * from './helpers/order-state-machine/order-state';
 export * from './helpers/password-cipher/password-cipher';
 export * from './helpers/payment-state-machine/payment-state';
 export * from './helpers/product-price-applicator/product-price-applicator';
+export * from './helpers/refund-state-machine/refund-state';
+export * from './helpers/request-context/request-context.service';
 export * from './helpers/translatable-saver/translatable-saver';
 export * from './helpers/utils/patch-entity';
 export * from './helpers/utils/translate-entity';

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -22,6 +22,7 @@ import { PasswordCipher } from './helpers/password-cipher/password-cipher';
 import { PaymentStateMachine } from './helpers/payment-state-machine/payment-state-machine';
 import { ProductPriceApplicator } from './helpers/product-price-applicator/product-price-applicator';
 import { RefundStateMachine } from './helpers/refund-state-machine/refund-state-machine';
+import { RequestContextService } from './helpers/request-context/request-context.service';
 import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator';
 import { SlugValidator } from './helpers/slug-validator/slug-validator';
 import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
@@ -116,6 +117,7 @@ const helpers = [
     ActiveOrderService,
     ProductPriceApplicator,
     EntityHydrator,
+    RequestContextService,
 ];
 
 /**

+ 8 - 4
packages/core/src/service/services/asset.service.ts

@@ -425,18 +425,22 @@ export class AssetService {
      * @description
      * Create an Asset from a file stream, for example to create an Asset during data import.
      */
-    async createFromFileStream(stream: ReadStream): Promise<CreateAssetResult>;
+    async createFromFileStream(stream: ReadStream, ctx?: RequestContext): Promise<CreateAssetResult>;
     async createFromFileStream(stream: Readable, filePath: string): Promise<CreateAssetResult>;
     async createFromFileStream(
         stream: ReadStream | Readable,
-        maybeFilePath?: string,
+        maybeFilePathOrCtx?: string | RequestContext,
     ): Promise<CreateAssetResult> {
+        const filePathFromArgs =
+            maybeFilePathOrCtx instanceof RequestContext ? undefined : maybeFilePathOrCtx;
         const filePath =
-            stream instanceof ReadStream || stream instanceof FSReadStream ? stream.path : maybeFilePath;
+            stream instanceof ReadStream || stream instanceof FSReadStream ? stream.path : filePathFromArgs;
         if (typeof filePath === 'string') {
             const filename = path.basename(filePath);
             const mimetype = mime.lookup(filename) || 'application/octet-stream';
-            return this.createAssetInternal(RequestContext.empty(), stream, filename, mimetype);
+            const ctx =
+                maybeFilePathOrCtx instanceof RequestContext ? maybeFilePathOrCtx : RequestContext.empty();
+            return this.createAssetInternal(ctx, stream, filename, mimetype);
         } else {
             throw new InternalServerError(`error.path-should-be-a-string-got-buffer`);
         }

+ 3 - 0
packages/core/src/service/services/customer.service.ts

@@ -697,6 +697,7 @@ export class CustomerService {
             type: HistoryEntryType.CUSTOMER_ADDRESS_CREATED,
             data: { address: addressToLine(createdAddress) },
         });
+        createdAddress.customer = customer;
         this.eventBus.publish(new CustomerAddressEvent(ctx, createdAddress, 'created', input));
         return createdAddress;
     }
@@ -733,6 +734,7 @@ export class CustomerService {
                 input,
             },
         });
+        updatedAddress.customer = customer;
         this.eventBus.publish(new CustomerAddressEvent(ctx, updatedAddress, 'updated', input));
         return updatedAddress;
     }
@@ -761,6 +763,7 @@ export class CustomerService {
             },
         });
         await this.connection.getRepository(ctx, Address).remove(address);
+        address.customer = customer;
         this.eventBus.publish(new CustomerAddressEvent(ctx, address, 'deleted', id));
         return true;
     }

+ 7 - 4
packages/core/src/service/services/order.service.ts

@@ -89,10 +89,11 @@ import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
 import { Surcharge } from '../../entity/surcharge/surcharge.entity';
 import { User } from '../../entity/user/user.entity';
 import { EventBus } from '../../event-bus/event-bus';
-import { CouponCodeEvent } from '../../event-bus/events/coupon-code-event';
-import { OrderEvent } from '../../event-bus/events/order-event';
-import { OrderStateTransitionEvent } from '../../event-bus/events/order-state-transition-event';
-import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
+import { CouponCodeEvent } from '../../event-bus/index';
+import { OrderEvent } from '../../event-bus/index';
+import { OrderStateTransitionEvent } from '../../event-bus/index';
+import { RefundStateTransitionEvent } from '../../event-bus/index';
+import { OrderLineEvent } from '../../event-bus/index';
 import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
@@ -513,6 +514,7 @@ export class OrderService {
         if (correctedQuantity === 0) {
             order.lines = order.lines.filter(l => !idsAreEqual(l.id, orderLine.id));
             await this.connection.getRepository(ctx, OrderLine).remove(orderLine);
+            this.eventBus.publish(new OrderLineEvent(ctx, order, orderLine, 'deleted'));
             updatedOrderLines = [];
         } else {
             await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, correctedQuantity, order);
@@ -544,6 +546,7 @@ export class OrderService {
         order.lines = order.lines.filter(line => !idsAreEqual(line.id, orderLineId));
         const updatedOrder = await this.applyPriceAdjustments(ctx, order);
         await this.connection.getRepository(ctx, OrderLine).remove(orderLine);
+        this.eventBus.publish(new OrderLineEvent(ctx, order, orderLine, 'deleted'));
         return updatedOrder;
     }
 

+ 1 - 2
packages/core/src/service/services/session.service.ts

@@ -107,10 +107,9 @@ export class SessionService implements EntitySubscriberInterface {
      */
     async createAnonymousSession(): Promise<CachedSession> {
         const token = await this.generateSessionToken();
-        const anonymousSessionDurationInMs = ms('1y');
         const session = new AnonymousSession({
             token,
-            expires: this.getExpiryDate(anonymousSessionDurationInMs),
+            expires: this.getExpiryDate(this.sessionDurationInMs),
             invalidated: false,
         });
         // save the new session

Fișier diff suprimat deoarece este prea mare
+ 288 - 289
packages/payments-plugin/e2e/graphql/generated-shop-types.ts


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