Procházet zdrojové kódy

feat(core): Allow channel to be specified in `populate()` function

Closes #877
Michael Bromley před 3 roky
rodič
revize
03b9fe15d4

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

@@ -204,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();
 })

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

@@ -48,6 +48,7 @@ import {
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
     GET_ASSET_LIST,
+    GET_COLLECTIONS,
     UPDATE_COLLECTION,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
@@ -1809,22 +1810,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}",                           ,

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

@@ -933,3 +933,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']);
+        });
+    });
+});

+ 48 - 23
packages/core/src/cli/populate.ts

@@ -2,7 +2,7 @@ import { INestApplicationContext } from '@nestjs/common';
 import fs from 'fs-extra';
 import path from 'path';
 
-import { logColored } from './cli-utils';
+const loggerCtx = 'Populate';
 
 // tslint:disable:no-console
 /**
@@ -11,6 +11,10 @@ import { logColored } from './cli-utils';
  * 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}.
@@ -47,20 +51,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(
@@ -69,48 +92,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) {
-        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) {
-        console.log(err.message);
+        Logger.info(err.message, loggerCtx);
     }
 }
 
@@ -118,10 +137,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 importer.parseAndImport(productData, languageCode, true).toPromise();
+    const ctx = await requestContextService.create({
+        apiType: 'admin',
+        languageCode,
+        channelOrToken: channel,
+    });
+    return importer.parseAndImport(productData, ctx, true).toPromise();
 }

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

@@ -2,6 +2,7 @@ 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';
@@ -26,7 +27,10 @@ export class AssetImporter {
      * 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;
@@ -43,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) {

+ 49 - 14
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,6 +23,7 @@ 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';
 
@@ -38,6 +40,7 @@ import { StockMovementService } from '../../../service/services/stock-movement.s
 @Injectable()
 export class FastImporterService {
     private defaultChannel: Channel;
+    private importCtx: RequestContext;
 
     /** @internal */
     constructor(
@@ -45,20 +48,36 @@ export class FastImporterService {
         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));
                 }
@@ -82,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,
@@ -92,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,
@@ -103,6 +124,7 @@ export class FastImporterService {
     }
 
     async addOptionGroupToProduct(productId: ID, optionGroupId: ID) {
+        this.ensureInitialized();
         await this.connection
             .getRepository(Product)
             .createQueryBuilder()
@@ -112,6 +134,7 @@ export class FastImporterService {
     }
 
     async createProductVariant(input: CreateProductVariantInput): Promise<ID> {
+        this.ensureInitialized();
         if (!input.optionIds) {
             input.optionIds = [];
         }
@@ -125,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));
@@ -158,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`,
+            );
+        }
+    }
 }

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

@@ -158,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);
@@ -176,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,
@@ -240,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,
@@ -289,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;

+ 24 - 17
packages/core/src/data-import/providers/populator/populator.ts

@@ -57,10 +57,11 @@ 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);
@@ -88,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) {
             Logger.error(`Could not set channel defaults`);
             Logger.error(e, 'populator', e.stack);
@@ -106,8 +107,8 @@ export class Populator {
      * 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>();
@@ -189,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;
@@ -217,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,
@@ -225,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(

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