Browse Source

fix(elasticsearch-plugin): Correctly index language variants

Relates to #493
Michael Bromley 5 years ago
parent
commit
e37e5c9807

+ 89 - 3
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -15,9 +15,10 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import {
     AssignProductsToChannel,
+    ChannelFragment,
     CreateChannel,
     CreateCollection,
     CreateFacet,
@@ -846,7 +847,7 @@ describe('Elasticsearch plugin', () => {
 
         describe('channel handling', () => {
             const SECOND_CHANNEL_TOKEN = 'second-channel-token';
-            let secondChannel: CreateChannel.CreateChannel;
+            let secondChannel: ChannelFragment;
 
             beforeAll(async () => {
                 const { createChannel } = await adminClient.query<
@@ -863,7 +864,7 @@ describe('Elasticsearch plugin', () => {
                         defaultShippingZoneId: 'T_1',
                     },
                 });
-                secondChannel = createChannel;
+                secondChannel = createChannel as ChannelFragment;
             });
 
             it('adding product to channel', async () => {
@@ -915,6 +916,89 @@ describe('Elasticsearch plugin', () => {
                 expect(search.items.map(i => i.productId).sort()).toEqual(['T_1']);
             });
         });
+
+        describe('multiple language handling', () => {
+            function searchInLanguage(languageCode: LanguageCode, groupByProduct: boolean) {
+                return adminClient.query<SearchProductsAdmin.Query, SearchProductsAdmin.Variables>(
+                    SEARCH_PRODUCTS,
+                    {
+                        input: {
+                            take: 1,
+                            term: 'laptop',
+                            groupByProduct,
+                        },
+                    },
+                    {
+                        languageCode,
+                    },
+                );
+            }
+
+            beforeAll(async () => {
+                const { updateProduct } = await adminClient.query<
+                    UpdateProduct.Mutation,
+                    UpdateProduct.Variables
+                >(UPDATE_PRODUCT, {
+                    input: {
+                        id: 'T_1',
+                        translations: [
+                            {
+                                languageCode: LanguageCode.de,
+                                name: 'laptop name de',
+                                slug: 'laptop-slug-de',
+                                description: 'laptop description de',
+                            },
+                            {
+                                languageCode: LanguageCode.zh,
+                                name: 'laptop name zh',
+                                slug: 'laptop-slug-zh',
+                                description: 'laptop description zh',
+                            },
+                        ],
+                    },
+                });
+
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                id: updateProduct.variants[0].id,
+                                translations: [
+                                    {
+                                        languageCode: LanguageCode.fr,
+                                        name: 'laptop variant fr',
+                                    },
+                                ],
+                            },
+                        ],
+                    },
+                );
+
+                await awaitRunningJobs(adminClient);
+            });
+
+            it('indexes product-level languages', async () => {
+                const { search: search1 } = await searchInLanguage(LanguageCode.de, true);
+
+                expect(search1.items[0].productName).toBe('laptop name de');
+                expect(search1.items[0].slug).toBe('laptop-slug-de');
+                expect(search1.items[0].description).toBe('laptop description de');
+
+                const { search: search2 } = await searchInLanguage(LanguageCode.zh, true);
+
+                expect(search2.items[0].productName).toBe('laptop name zh');
+                expect(search2.items[0].slug).toBe('laptop-slug-zh');
+                expect(search2.items[0].description).toBe('laptop description zh');
+            });
+
+            it('indexes product variant-level languages', async () => {
+                const { search: search1 } = await searchInLanguage(LanguageCode.fr, false);
+
+                expect(search1.items[0].productName).toBe('Laptop');
+                expect(search1.items[0].productVariantName).toBe('laptop variant fr');
+            });
+        });
     });
 });
 
@@ -926,6 +1010,8 @@ export const SEARCH_PRODUCTS = gql`
                 enabled
                 productId
                 productName
+                slug
+                description
                 productAsset {
                     id
                     preview

+ 2 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -3861,6 +3861,8 @@ export type SearchProductsAdminQuery = {
                 | 'enabled'
                 | 'productId'
                 | 'productName'
+                | 'slug'
+                | 'description'
                 | 'productPreview'
                 | 'productVariantId'
                 | 'productVariantName'

+ 53 - 18
packages/elasticsearch-plugin/src/build-elastic-body.spec.ts

@@ -1,4 +1,4 @@
-import { LogicalOperator, SortOrder } from '@vendure/common/lib/generated-types';
+import { LanguageCode, LogicalOperator, SortOrder } from '@vendure/common/lib/generated-types';
 import { DeepRequired } from '@vendure/core';
 
 import { buildElasticBody } from './build-elastic-body';
@@ -8,12 +8,13 @@ describe('buildElasticBody()', () => {
     const searchConfig = defaultOptions.searchConfig;
     const CHANNEL_ID = 42;
     const CHANNEL_ID_TERM = { term: { channelId: CHANNEL_ID } };
+    const LANGUAGE_CODE_TERM = { term: { languageCode: LanguageCode.en } };
 
     it('search term', () => {
-        const result = buildElasticBody({ term: 'test' }, searchConfig, CHANNEL_ID);
+        const result = buildElasticBody({ term: 'test' }, searchConfig, CHANNEL_ID, LanguageCode.en);
         expect(result.query).toEqual({
             bool: {
-                filter: [CHANNEL_ID_TERM],
+                filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM],
                 must: [
                     {
                         multi_match: {
@@ -32,11 +33,13 @@ describe('buildElasticBody()', () => {
             { facetValueIds: ['1', '2'], facetValueOperator: LogicalOperator.AND },
             searchConfig,
             CHANNEL_ID,
+            LanguageCode.en,
         );
         expect(result.query).toEqual({
             bool: {
                 filter: [
                     CHANNEL_ID_TERM,
+                    LANGUAGE_CODE_TERM,
                     {
                         bool: {
                             must: [{ term: { facetValueIds: '1' } }, { term: { facetValueIds: '2' } }],
@@ -52,11 +55,13 @@ describe('buildElasticBody()', () => {
             { facetValueIds: ['1', '2'], facetValueOperator: LogicalOperator.OR },
             searchConfig,
             CHANNEL_ID,
+            LanguageCode.en,
         );
         expect(result.query).toEqual({
             bool: {
                 filter: [
                     CHANNEL_ID_TERM,
+                    LANGUAGE_CODE_TERM,
                     {
                         bool: {
                             should: [{ term: { facetValueIds: '1' } }, { term: { facetValueIds: '2' } }],
@@ -68,41 +73,56 @@ describe('buildElasticBody()', () => {
     });
 
     it('collectionId', () => {
-        const result = buildElasticBody({ collectionId: '1' }, searchConfig, CHANNEL_ID);
+        const result = buildElasticBody({ collectionId: '1' }, searchConfig, CHANNEL_ID, LanguageCode.en);
         expect(result.query).toEqual({
             bool: {
-                filter: [CHANNEL_ID_TERM, { term: { collectionIds: '1' } }],
+                filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM, { term: { collectionIds: '1' } }],
             },
         });
     });
 
     it('collectionSlug', () => {
-        const result = buildElasticBody({ collectionSlug: 'plants' }, searchConfig, CHANNEL_ID);
+        const result = buildElasticBody(
+            { collectionSlug: 'plants' },
+            searchConfig,
+            CHANNEL_ID,
+            LanguageCode.en,
+        );
         expect(result.query).toEqual({
             bool: {
-                filter: [CHANNEL_ID_TERM, { term: { collectionSlugs: 'plants' } }],
+                filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM, { term: { collectionSlugs: 'plants' } }],
             },
         });
     });
 
     it('paging', () => {
-        const result = buildElasticBody({ skip: 20, take: 10 }, searchConfig, CHANNEL_ID);
+        const result = buildElasticBody({ skip: 20, take: 10 }, searchConfig, CHANNEL_ID, LanguageCode.en);
         expect(result).toEqual({
             from: 20,
             size: 10,
-            query: { bool: { filter: [CHANNEL_ID_TERM] } },
+            query: { bool: { filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM] } },
             sort: [],
         });
     });
 
     describe('sorting', () => {
         it('name', () => {
-            const result = buildElasticBody({ sort: { name: SortOrder.DESC } }, searchConfig, CHANNEL_ID);
+            const result = buildElasticBody(
+                { sort: { name: SortOrder.DESC } },
+                searchConfig,
+                CHANNEL_ID,
+                LanguageCode.en,
+            );
             expect(result.sort).toEqual([{ 'productName.keyword': { order: 'desc' } }]);
         });
 
         it('price', () => {
-            const result = buildElasticBody({ sort: { price: SortOrder.ASC } }, searchConfig, CHANNEL_ID);
+            const result = buildElasticBody(
+                { sort: { price: SortOrder.ASC } },
+                searchConfig,
+                CHANNEL_ID,
+                LanguageCode.en,
+            );
             expect(result.sort).toEqual([{ price: { order: 'asc' } }]);
         });
 
@@ -111,24 +131,25 @@ describe('buildElasticBody()', () => {
                 { sort: { price: SortOrder.ASC }, groupByProduct: true },
                 searchConfig,
                 CHANNEL_ID,
+                LanguageCode.en,
             );
             expect(result.sort).toEqual([{ priceMin: { order: 'asc' } }]);
         });
     });
 
     it('enabledOnly true', () => {
-        const result = buildElasticBody({}, searchConfig, CHANNEL_ID, true);
+        const result = buildElasticBody({}, searchConfig, CHANNEL_ID, LanguageCode.en, true);
         expect(result.query).toEqual({
             bool: {
-                filter: [CHANNEL_ID_TERM, { term: { enabled: true } }],
+                filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM, { term: { enabled: true } }],
             },
         });
     });
 
     it('enabledOnly false', () => {
-        const result = buildElasticBody({}, searchConfig, CHANNEL_ID, false);
+        const result = buildElasticBody({}, searchConfig, CHANNEL_ID, LanguageCode.en, false);
         expect(result.query).toEqual({
-            bool: { filter: [CHANNEL_ID_TERM] },
+            bool: { filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM] },
         });
     });
 
@@ -147,6 +168,7 @@ describe('buildElasticBody()', () => {
             },
             searchConfig,
             CHANNEL_ID,
+            LanguageCode.en,
             true,
         );
 
@@ -166,6 +188,8 @@ describe('buildElasticBody()', () => {
                     ],
                     filter: [
                         CHANNEL_ID_TERM,
+
+                        LANGUAGE_CODE_TERM,
                         {
                             bool: {
                                 should: [{ term: { facetValueIds: '6' } }, { term: { facetValueIds: '7' } }],
@@ -185,10 +209,11 @@ describe('buildElasticBody()', () => {
             { term: 'test' },
             { ...searchConfig, multiMatchType: 'phrase' },
             CHANNEL_ID,
+            LanguageCode.en,
         );
         expect(result.query).toEqual({
             bool: {
-                filter: [CHANNEL_ID_TERM],
+                filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM],
                 must: [
                     {
                         multi_match: {
@@ -214,10 +239,10 @@ describe('buildElasticBody()', () => {
                 },
             },
         };
-        const result = buildElasticBody({ term: 'test' }, config, CHANNEL_ID);
+        const result = buildElasticBody({ term: 'test' }, config, CHANNEL_ID, LanguageCode.en);
         expect(result.query).toEqual({
             bool: {
-                filter: [CHANNEL_ID_TERM],
+                filter: [CHANNEL_ID_TERM, LANGUAGE_CODE_TERM],
                 must: [
                     {
                         multi_match: {
@@ -237,11 +262,13 @@ describe('buildElasticBody()', () => {
                 { priceRange: { min: 500, max: 1500 }, groupByProduct: false },
                 searchConfig,
                 CHANNEL_ID,
+                LanguageCode.en,
             );
             expect(result.query).toEqual({
                 bool: {
                     filter: [
                         CHANNEL_ID_TERM,
+                        LANGUAGE_CODE_TERM,
                         {
                             range: {
                                 price: {
@@ -260,11 +287,13 @@ describe('buildElasticBody()', () => {
                 { priceRangeWithTax: { min: 500, max: 1500 }, groupByProduct: false },
                 searchConfig,
                 CHANNEL_ID,
+                LanguageCode.en,
             );
             expect(result.query).toEqual({
                 bool: {
                     filter: [
                         CHANNEL_ID_TERM,
+                        LANGUAGE_CODE_TERM,
                         {
                             range: {
                                 priceWithTax: {
@@ -283,11 +312,13 @@ describe('buildElasticBody()', () => {
                 { priceRange: { min: 500, max: 1500 }, groupByProduct: true },
                 searchConfig,
                 CHANNEL_ID,
+                LanguageCode.en,
             );
             expect(result.query).toEqual({
                 bool: {
                     filter: [
                         CHANNEL_ID_TERM,
+                        LANGUAGE_CODE_TERM,
                         {
                             range: {
                                 priceMin: {
@@ -312,11 +343,13 @@ describe('buildElasticBody()', () => {
                 { priceRangeWithTax: { min: 500, max: 1500 }, groupByProduct: true },
                 searchConfig,
                 CHANNEL_ID,
+                LanguageCode.en,
             );
             expect(result.query).toEqual({
                 bool: {
                     filter: [
                         CHANNEL_ID_TERM,
+                        LANGUAGE_CODE_TERM,
                         {
                             range: {
                                 priceWithTaxMin: {
@@ -346,11 +379,13 @@ describe('buildElasticBody()', () => {
                 },
                 searchConfig,
                 CHANNEL_ID,
+                LanguageCode.en,
             );
             expect(result.query).toEqual({
                 bool: {
                     filter: [
                         CHANNEL_ID_TERM,
+                        LANGUAGE_CODE_TERM,
                         {
                             bool: {
                                 should: [{ term: { facetValueIds: '5' } }],

+ 3 - 1
packages/elasticsearch-plugin/src/build-elastic-body.ts

@@ -1,4 +1,4 @@
-import { LogicalOperator, PriceRange, SortOrder } from '@vendure/common/lib/generated-types';
+import { LanguageCode, LogicalOperator, PriceRange, SortOrder } from '@vendure/common/lib/generated-types';
 import { DeepRequired, ID } from '@vendure/core';
 
 import { SearchConfig } from './options';
@@ -11,6 +11,7 @@ export function buildElasticBody(
     input: ElasticSearchInput,
     searchConfig: DeepRequired<SearchConfig>,
     channelId: ID,
+    languageCode: LanguageCode,
     enabledOnly: boolean = false,
 ): SearchRequestBody {
     const {
@@ -31,6 +32,7 @@ export function buildElasticBody(
     };
     ensureBoolFilterExists(query);
     query.bool.filter.push({ term: { channelId } });
+    query.bool.filter.push({ term: { languageCode } });
 
     if (term) {
         query.bool.must = [

+ 9 - 1
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -100,6 +100,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             input,
             this.options.searchConfig,
             ctx.channelId,
+            ctx.languageCode,
             enabledOnly,
         );
         if (groupByProduct) {
@@ -138,6 +139,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             input,
             this.options.searchConfig,
             ctx.channelId,
+            ctx.languageCode,
             enabledOnly,
         );
         elasticSearchBody.from = 0;
@@ -174,7 +176,13 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
     async priceRange(ctx: RequestContext, input: ElasticSearchInput): Promise<SearchPriceData> {
         const { indexPrefix, searchConfig } = this.options;
         const { groupByProduct } = input;
-        const elasticSearchBody = buildElasticBody(input, searchConfig, ctx.channelId, true);
+        const elasticSearchBody = buildElasticBody(
+            input,
+            searchConfig,
+            ctx.channelId,
+            ctx.languageCode,
+            true,
+        );
         elasticSearchBody.from = 0;
         elasticSearchBody.size = 0;
         elasticSearchBody.aggs = {

+ 157 - 64
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -6,15 +6,19 @@ import {
     Asset,
     asyncObservable,
     AsyncQueue,
+    ConfigService,
     FacetValue,
     ID,
+    LanguageCode,
     Logger,
     Product,
     ProductVariant,
     ProductVariantService,
     RequestContext,
     TransactionalConnection,
+    Translatable,
     translateDeep,
+    Translation,
 } from '@vendure/core';
 import { Observable } from 'rxjs';
 import { SelectQueryBuilder } from 'typeorm';
@@ -76,6 +80,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         private connection: TransactionalConnection,
         @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
         private productVariantService: ProductVariantService,
+        private configService: ConfigService,
     ) {}
 
     onModuleInit(): any {
@@ -114,12 +119,13 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
     }: DeleteProductMessage['data']): Observable<DeleteProductMessage['response']> {
         const ctx = RequestContext.deserialize(rawContext);
         return asyncObservable(async () => {
-            await this.deleteProductInternal(productId, ctx.channelId);
+            const product = await this.connection.getRepository(Product).findOne(productId);
+            if (!product) {
+                return false;
+            }
+            await this.deleteProductInternal(product, ctx.channelId);
             const variants = await this.productVariantService.getVariantsByProductId(ctx, productId);
-            await this.deleteVariantsInternal(
-                variants.map(v => v.id),
-                ctx.channelId,
-            );
+            await this.deleteVariantsInternal(variants, ctx.channelId);
             return true;
         });
     }
@@ -157,12 +163,13 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
     }: RemoveProductFromChannelMessage['data']): Observable<RemoveProductFromChannelMessage['response']> {
         const ctx = RequestContext.deserialize(rawContext);
         return asyncObservable(async () => {
-            await this.deleteProductInternal(productId, channelId);
+            const product = await this.connection.getRepository(Product).findOne(productId);
+            if (!product) {
+                return false;
+            }
+            await this.deleteProductInternal(product, channelId);
             const variants = await this.productVariantService.getVariantsByProductId(ctx, productId);
-            await this.deleteVariantsInternal(
-                variants.map(v => v.id),
-                channelId,
-            );
+            await this.deleteVariantsInternal(variants, channelId);
             return true;
         });
     }
@@ -198,7 +205,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             for (const productId of productIds) {
                 await this.updateProductInternal(ctx, productId, ctx.channelId);
             }
-            await this.deleteVariantsInternal(variantIds, ctx.channelId);
+            await this.deleteVariantsInternal(variants, ctx.channelId);
             return true;
         });
     }
@@ -229,16 +236,42 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                             variants,
                             variantsInProduct,
                             (operations, variant) => {
-                                operations.push(
-                                    { update: { _id: this.getId(variant.id, ctx.channelId) } },
-                                    { doc: this.createVariantIndexItem(variant, ctx.channelId) },
-                                );
+                                const languageVariants = variant.translations.map(t => t.languageCode);
+                                for (const languageCode of languageVariants) {
+                                    operations.push(
+                                        {
+                                            update: {
+                                                _id: this.getId(variant.id, ctx.channelId, languageCode),
+                                            },
+                                        },
+                                        {
+                                            doc: this.createVariantIndexItem(
+                                                variant,
+                                                ctx.channelId,
+                                                languageCode,
+                                            ),
+                                        },
+                                    );
+                                }
                             },
                             (operations, product, _variants) => {
-                                operations.push(
-                                    { update: { _id: this.getId(product.id, ctx.channelId) } },
-                                    { doc: this.createProductIndexItem(_variants, ctx.channelId) },
-                                );
+                                const languageVariants = product.translations.map(t => t.languageCode);
+                                for (const languageCode of languageVariants) {
+                                    operations.push(
+                                        {
+                                            update: {
+                                                _id: this.getId(product.id, ctx.channelId, languageCode),
+                                            },
+                                        },
+                                        {
+                                            doc: this.createProductIndexItem(
+                                                _variants,
+                                                ctx.channelId,
+                                                languageCode,
+                                            ),
+                                        },
+                                    );
+                                }
                             },
                         );
                         observer.next({
@@ -295,16 +328,22 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                         variants,
                         variantsInProduct,
                         (operations, variant) => {
-                            operations.push(
-                                { index: { _id: this.getId(variant.id, ctx.channelId) } },
-                                this.createVariantIndexItem(variant, ctx.channelId),
-                            );
+                            const languageVariants = variant.translations.map(t => t.languageCode);
+                            for (const languageCode of languageVariants) {
+                                operations.push(
+                                    { index: { _id: this.getId(variant.id, ctx.channelId, languageCode) } },
+                                    this.createVariantIndexItem(variant, ctx.channelId, languageCode),
+                                );
+                            }
                         },
                         (operations, product, _variants) => {
-                            operations.push(
-                                { index: { _id: this.getId(product.id, ctx.channelId) } },
-                                this.createProductIndexItem(_variants, ctx.channelId),
-                            );
+                            const languageVariants = product.translations.map(t => t.languageCode);
+                            for (const languageCode of languageVariants) {
+                                operations.push(
+                                    { index: { _id: this.getId(product.id, ctx.channelId, languageCode) } },
+                                    this.createProductIndexItem(_variants, ctx.channelId, languageCode),
+                                );
+                            }
                         },
                     );
                     observer.next({
@@ -473,13 +512,19 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             for (const variantProductId of productIdsOfVariants) {
                 await this.updateProductInternal(ctx, variantProductId, channelId);
             }
-            const operations = updatedVariants.reduce((ops, variant) => {
-                return [
-                    ...ops,
-                    { update: { _id: this.getId(variant.id, channelId) } },
-                    { doc: this.createVariantIndexItem(variant, channelId), doc_as_upsert: true },
-                ];
-            }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
+            const operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem>> = [];
+            for (const variant of updatedVariants) {
+                const languageVariants = variant.translations.map(t => t.languageCode);
+                for (const languageCode of languageVariants) {
+                    operations.push(
+                        { update: { _id: this.getId(variant.id, channelId, languageCode) } },
+                        {
+                            doc: this.createVariantIndexItem(variant, channelId, languageCode),
+                            doc_as_upsert: true,
+                        },
+                    );
+                }
+            }
             Logger.verbose(`Updating ${updatedVariants.length} ProductVariants`, loggerCtx);
             await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
         }
@@ -503,30 +548,53 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             if (product.enabled === false) {
                 updatedProductVariants.forEach(v => (v.enabled = false));
             }
-        }
-        if (updatedProductVariants.length) {
-            Logger.verbose(`Updating 1 Product (${productId})`, loggerCtx);
-            updatedProductVariants = this.hydrateVariants(ctx, updatedProductVariants);
-            const updatedProductIndexItem = this.createProductIndexItem(updatedProductVariants, channelId);
-            const operations: [BulkOperation, BulkOperationDoc<ProductIndexItem>] = [
-                { update: { _id: this.getId(updatedProductIndexItem.productId, channelId) } },
-                { doc: updatedProductIndexItem, doc_as_upsert: true },
-            ];
-            await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, operations);
+
+            if (updatedProductVariants.length) {
+                Logger.verbose(`Updating 1 Product (${productId})`, loggerCtx);
+                updatedProductVariants = this.hydrateVariants(ctx, updatedProductVariants);
+                const operations: Array<BulkOperation | BulkOperationDoc<ProductIndexItem>> = [];
+                const languageVariants = product.translations.map(t => t.languageCode);
+                for (const languageCode of languageVariants) {
+                    const updatedProductIndexItem = this.createProductIndexItem(
+                        updatedProductVariants,
+                        channelId,
+                        languageCode,
+                    );
+                    operations.push(
+                        {
+                            update: {
+                                _id: this.getId(updatedProductIndexItem.productId, channelId, languageCode),
+                            },
+                        },
+                        { doc: updatedProductIndexItem, doc_as_upsert: true },
+                    );
+                }
+                await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, operations);
+            }
         }
     }
 
-    private async deleteProductInternal(productId: ID, channelId: ID) {
-        Logger.verbose(`Deleting 1 Product (${productId})`, loggerCtx);
-        const operations: BulkOperation[] = [{ delete: { _id: this.getId(productId, channelId) } }];
+    private async deleteProductInternal(product: Product, channelId: ID) {
+        Logger.verbose(`Deleting 1 Product (${product.id})`, loggerCtx);
+        const operations: BulkOperation[] = [];
+        const languageVariants = product.translations.map(t => t.languageCode);
+        for (const languageCode of languageVariants) {
+            operations.push({ delete: { _id: this.getId(product.id, channelId, languageCode) } });
+        }
         await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, operations);
     }
 
-    private async deleteVariantsInternal(variantIds: ID[], channelId: ID) {
-        Logger.verbose(`Deleting ${variantIds.length} ProductVariants`, loggerCtx);
-        const operations: BulkOperation[] = variantIds.map(id => ({
-            delete: { _id: this.getId(id, channelId) },
-        }));
+    private async deleteVariantsInternal(variants: ProductVariant[], channelId: ID) {
+        Logger.verbose(`Deleting ${variants.length} ProductVariants`, loggerCtx);
+        const operations: BulkOperation[] = [];
+        for (const variant of variants) {
+            const languageVariants = variant.translations.map(t => t.languageCode);
+            for (const languageCode of languageVariants) {
+                operations.push({
+                    delete: { _id: this.getId(variant.id, channelId, languageCode) },
+                });
+            }
+        }
         await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
     }
 
@@ -631,27 +699,35 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             .map(v => translateDeep(v, ctx.languageCode, ['product', 'collections']));
     }
 
-    private createVariantIndexItem(v: ProductVariant, channelId: ID): VariantIndexItem {
+    private createVariantIndexItem(
+        v: ProductVariant,
+        channelId: ID,
+        languageCode: LanguageCode,
+    ): VariantIndexItem {
         const productAsset = v.product.featuredAsset;
         const variantAsset = v.featuredAsset;
+        const productTranslation = this.getTranslation(v.product, languageCode);
+        const variantTranslation = this.getTranslation(v, languageCode);
+
         const item: VariantIndexItem = {
             channelId,
+            languageCode,
             productVariantId: v.id,
             sku: v.sku,
-            slug: v.product.slug,
+            slug: productTranslation.slug,
             productId: v.product.id,
-            productName: v.product.name,
+            productName: productTranslation.name,
             productAssetId: productAsset ? productAsset.id : undefined,
             productPreview: productAsset ? productAsset.preview : '',
             productPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
-            productVariantName: v.name,
+            productVariantName: variantTranslation.name,
             productVariantAssetId: variantAsset ? variantAsset.id : undefined,
             productVariantPreview: variantAsset ? variantAsset.preview : '',
             productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
             price: v.price,
             priceWithTax: v.priceWithTax,
             currencyCode: v.currencyCode,
-            description: v.product.description,
+            description: productTranslation.description,
             facetIds: this.getFacetIds([v]),
             channelIds: v.product.channels.map(c => c.id),
             facetValueIds: this.getFacetValueIds([v]),
@@ -666,7 +742,11 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         return item;
     }
 
-    private createProductIndexItem(variants: ProductVariant[], channelId: ID): ProductIndexItem {
+    private createProductIndexItem(
+        variants: ProductVariant[],
+        channelId: ID,
+        languageCode: LanguageCode,
+    ): ProductIndexItem {
         const first = variants[0];
         const prices = variants.map(v => v.price);
         const pricesWithTax = variants.map(v => v.priceWithTax);
@@ -674,17 +754,21 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         const variantAsset = variants.filter(v => v.featuredAsset).length
             ? variants.filter(v => v.featuredAsset)[0].featuredAsset
             : null;
+        const productTranslation = this.getTranslation(first.product, languageCode);
+        const variantTranslation = this.getTranslation(first, languageCode);
+
         const item: ProductIndexItem = {
             channelId,
+            languageCode,
             sku: first.sku,
-            slug: first.product.slug,
+            slug: productTranslation.slug,
             productId: first.product.id,
-            productName: first.product.name,
+            productName: productTranslation.name,
             productAssetId: productAsset ? productAsset.id : undefined,
             productPreview: productAsset ? productAsset.preview : '',
             productPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
             productVariantId: first.id,
-            productVariantName: first.name,
+            productVariantName: variantTranslation.name,
             productVariantAssetId: variantAsset ? variantAsset.id : undefined,
             productVariantPreview: variantAsset ? variantAsset.preview : '',
             productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
@@ -693,7 +777,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             priceWithTaxMin: Math.min(...pricesWithTax),
             priceWithTaxMax: Math.max(...pricesWithTax),
             currencyCode: first.currencyCode,
-            description: first.product.description,
+            description: productTranslation.description,
             facetIds: this.getFacetIds(variants),
             facetValueIds: this.getFacetValueIds(variants),
             collectionIds: variants.reduce((ids, v) => [...ids, ...v.collections.map(c => c.id)], [] as ID[]),
@@ -712,6 +796,15 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         return item;
     }
 
+    private getTranslation<T extends Translatable>(
+        translatable: T,
+        languageCode: LanguageCode,
+    ): Translation<T> {
+        return ((translatable.translations.find(t => t.languageCode === languageCode) ||
+            translatable.translations.find(t => t.languageCode === this.configService.defaultLanguageCode) ||
+            translatable.translations[0]) as unknown) as Translation<T>;
+    }
+
     private getFacetIds(variants: ProductVariant[]): string[] {
         const facetIds = (fv: FacetValue) => fv.facet.id.toString();
         const variantFacetIds = variants.reduce(
@@ -732,7 +825,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         return unique([...variantFacetValueIds, ...productFacetValueIds]);
     }
 
-    private getId(entityId: ID, channelId: ID): string {
-        return `${channelId.toString()}__${entityId.toString()}`;
+    private getId(entityId: ID, channelId: ID, languageCode: LanguageCode): string {
+        return `${channelId.toString()}_${entityId.toString()}_${languageCode}`;
     }
 }

+ 3 - 0
packages/elasticsearch-plugin/src/types.ts

@@ -1,6 +1,7 @@
 import {
     Coordinate,
     CurrencyCode,
+    LanguageCode,
     PriceRange,
     SearchInput,
     SearchResponse,
@@ -45,6 +46,7 @@ export type VariantIndexItem = Omit<
 > &
     IndexItemAssets & {
         channelId: ID;
+        languageCode: LanguageCode;
         price: number;
         priceWithTax: number;
         collectionSlugs: string[];
@@ -56,6 +58,7 @@ export type ProductIndexItem = IndexItemAssets & {
     slug: string;
     productId: ID;
     channelId: ID;
+    languageCode: LanguageCode;
     productName: string;
     productVariantId: ID;
     productVariantName: string;