Browse Source

feat(elasticsearch-plugin): Store asset focal point data

Relates to #93
Michael Bromley 6 years ago
parent
commit
9027beb5fa

+ 67 - 5
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -22,6 +22,7 @@ import {
     SearchFacetValues,
     SearchGetPrices,
     SearchInput,
+    UpdateAsset,
     UpdateCollection,
     UpdateProduct,
     UpdateProductVariants,
@@ -36,6 +37,7 @@ import {
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
     REMOVE_PRODUCT_FROM_CHANNEL,
+    UPDATE_ASSET,
     UPDATE_COLLECTION,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
@@ -45,11 +47,17 @@ import { ElasticsearchPlugin } from '../src/plugin';
 
 import { SEARCH_PRODUCTS_SHOP } from './../../core/e2e/graphql/shop-definitions';
 import { awaitRunningJobs } from './../../core/e2e/utils/await-running-jobs';
-import { GetJobInfo, JobState, Reindex } from './graphql/generated-e2e-elasticsearch-plugin-types';
+import {
+    GetJobInfo,
+    JobState,
+    Reindex,
+    SearchProductsAdmin,
+} from './graphql/generated-e2e-elasticsearch-plugin-types';
 
 describe('Elasticsearch plugin', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig, {
+            logger: new DefaultLogger({ level: LogLevel.Info }),
             plugins: [
                 ElasticsearchPlugin.init({
                     indexPrefix: 'e2e-tests',
@@ -76,7 +84,7 @@ describe('Elasticsearch plugin', () => {
     });
 
     function doAdminSearchQuery(input: SearchInput) {
-        return adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
+        return adminClient.query<SearchProductsAdmin.Query, SearchProductsAdmin.Variables>(SEARCH_PRODUCTS, {
             input,
         });
     }
@@ -444,12 +452,12 @@ describe('Elasticsearch plugin', () => {
                 });
                 await awaitRunningJobs(adminClient);
                 const result = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true });
-                expect(result.search.items.map(i => i.productName)).toEqual([
-                    'Gaming PC',
+                expect(result.search.items.map(i => i.productName).sort()).toEqual([
                     'Clacky Keyboard',
-                    'USB Cable',
                     'Curvy Monitor',
+                    'Gaming PC',
                     'Hard Drive',
+                    'USB Cable',
                 ]);
             });
 
@@ -579,6 +587,44 @@ describe('Elasticsearch plugin', () => {
                 ]);
             });
 
+            it('updates index when asset focalPoint is changed', async () => {
+                const { search: search1 } = await doAdminSearchQuery({
+                    term: 'laptop',
+                    groupByProduct: true,
+                    take: 1,
+                    sort: {
+                        name: SortOrder.ASC,
+                    },
+                });
+
+                expect(search1.items[0].productAsset!.id).toBe('T_1');
+                expect(search1.items[0].productAsset!.focalPoint).toBeNull();
+
+                await adminClient.query<UpdateAsset.Mutation, UpdateAsset.Variables>(UPDATE_ASSET, {
+                    input: {
+                        id: 'T_1',
+                        focalPoint: {
+                            x: 0.42,
+                            y: 0.42,
+                        },
+                    },
+                });
+
+                await awaitRunningJobs(adminClient);
+
+                const { search: search2 } = await doAdminSearchQuery({
+                    term: 'laptop',
+                    groupByProduct: true,
+                    take: 1,
+                    sort: {
+                        name: SortOrder.ASC,
+                    },
+                });
+
+                expect(search2.items[0].productAsset!.id).toBe('T_1');
+                expect(search2.items[0].productAsset!.focalPoint).toEqual({ x: 0.42, y: 0.42 });
+            });
+
             it('returns disabled field when not grouped', async () => {
                 const result = await doAdminSearchQuery({ groupByProduct: false, term: 'laptop' });
                 expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([
@@ -714,9 +760,25 @@ export const SEARCH_PRODUCTS = gql`
                 enabled
                 productId
                 productName
+                productAsset {
+                    id
+                    preview
+                    focalPoint {
+                        x
+                        y
+                    }
+                }
                 productPreview
                 productVariantId
                 productVariantName
+                productVariantAsset {
+                    id
+                    preview
+                    focalPoint {
+                        x
+                        y
+                    }
+                }
                 productVariantPreview
                 sku
             }

+ 38 - 1
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -3480,7 +3480,28 @@ export type SearchProductsAdminQuery = { __typename?: 'Query' } & {
                     | 'productVariantName'
                     | 'productVariantPreview'
                     | 'sku'
-                >
+                > & {
+                        productAsset: Maybe<
+                            { __typename?: 'SearchResultAsset' } & Pick<
+                                SearchResultAsset,
+                                'id' | 'preview'
+                            > & {
+                                    focalPoint: Maybe<
+                                        { __typename?: 'Coordinate' } & Pick<Coordinate, 'x' | 'y'>
+                                    >;
+                                }
+                        >;
+                        productVariantAsset: Maybe<
+                            { __typename?: 'SearchResultAsset' } & Pick<
+                                SearchResultAsset,
+                                'id' | 'preview'
+                            > & {
+                                    focalPoint: Maybe<
+                                        { __typename?: 'Coordinate' } & Pick<Coordinate, 'x' | 'y'>
+                                    >;
+                                }
+                        >;
+                    }
             >;
         };
 };
@@ -3548,6 +3569,22 @@ export namespace SearchProductsAdmin {
     export type Query = SearchProductsAdminQuery;
     export type Search = SearchProductsAdminQuery['search'];
     export type Items = NonNullable<SearchProductsAdminQuery['search']['items'][0]>;
+    export type ProductAsset = NonNullable<
+        (NonNullable<SearchProductsAdminQuery['search']['items'][0]>)['productAsset']
+    >;
+    export type FocalPoint = NonNullable<
+        (NonNullable<
+            (NonNullable<SearchProductsAdminQuery['search']['items'][0]>)['productAsset']
+        >)['focalPoint']
+    >;
+    export type ProductVariantAsset = NonNullable<
+        (NonNullable<SearchProductsAdminQuery['search']['items'][0]>)['productVariantAsset']
+    >;
+    export type _FocalPoint = NonNullable<
+        (NonNullable<
+            (NonNullable<SearchProductsAdminQuery['search']['items'][0]>)['productVariantAsset']
+        >)['focalPoint']
+    >;
 }
 
 export namespace SearchFacetValues {

+ 10 - 0
packages/elasticsearch-plugin/src/elasticsearch-index.service.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import {
+    Asset,
     ID,
     Job,
     JobReporter,
@@ -19,6 +20,7 @@ import {
     DeleteVariantMessage,
     ReindexMessage,
     RemoveProductFromChannelMessage,
+    UpdateAssetMessage,
     UpdateProductMessage,
     UpdateVariantMessage,
     UpdateVariantsByIdMessage,
@@ -106,6 +108,14 @@ export class ElasticsearchIndexService {
         });
     }
 
+    updateAsset(ctx: RequestContext, asset: Asset) {
+        const data = { ctx, asset };
+        return this.createShortWorkerJob(new UpdateAssetMessage(data), {
+            entity: 'Asset',
+            id: asset.id,
+        });
+    }
+
     /**
      * Creates a short-running job that does not expect progress updates.
      */

+ 34 - 7
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -1,6 +1,6 @@
 import { Client } from '@elastic/elasticsearch';
 import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
-import { JobInfo, SearchResult } from '@vendure/common/lib/generated-types';
+import { JobInfo, SearchResult, SearchResultAsset } from '@vendure/common/lib/generated-types';
 import {
     DeepRequired,
     FacetValue,
@@ -255,8 +255,11 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
 
     private mapVariantToSearchResult(hit: SearchHit<VariantIndexItem>): SearchResult {
         const source = hit._source;
+        const { productAsset, productVariantAsset } = this.getSearchResultAssets(source);
         const result = {
             ...source,
+            productAsset,
+            productVariantAsset,
             price: {
                 value: source.price,
             },
@@ -272,18 +275,22 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
 
     private mapProductToSearchResult(hit: SearchHit<ProductIndexItem>): SearchResult {
         const source = hit._source;
+        const { productAsset, productVariantAsset } = this.getSearchResultAssets(source);
         const result = {
             ...source,
+            productAsset,
+            productVariantAsset,
             productId: source.productId.toString(),
-            productName: source.productName[0],
-            productVariantId: source.productVariantId[0].toString(),
-            productVariantName: source.productVariantName[0],
-            productVariantPreview: source.productVariantPreview[0] || '',
+            productName: source.productName,
+            productPreview: source.productPreview || '', // TODO: deprecated and to be removed
+            productVariantId: source.productVariantId.toString(),
+            productVariantName: source.productVariantName,
+            productVariantPreview: source.productVariantPreview || '', // TODO: deprecated and to be removed
             facetIds: source.facetIds as string[],
             facetValueIds: source.facetValueIds as string[],
             collectionIds: source.collectionIds as string[],
-            sku: source.sku[0],
-            slug: source.slug[0],
+            sku: source.sku,
+            slug: source.slug,
             price: {
                 min: source.priceMin,
                 max: source.priceMax,
@@ -299,6 +306,26 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         return result;
     }
 
+    private getSearchResultAssets(
+        source: ProductIndexItem | VariantIndexItem,
+    ): { productAsset: SearchResultAsset | null; productVariantAsset: SearchResultAsset | null } {
+        const productAsset: SearchResultAsset | null = source.productAssetId
+            ? {
+                  id: source.productAssetId.toString(),
+                  preview: source.productPreview,
+                  focalPoint: source.productPreviewFocalPoint,
+              }
+            : null;
+        const productVariantAsset: SearchResultAsset | null = source.productVariantAssetId
+            ? {
+                  id: source.productVariantAssetId.toString(),
+                  preview: source.productVariantPreview,
+                  focalPoint: source.productVariantPreviewFocalPoint,
+              }
+            : null;
+        return { productAsset, productVariantAsset };
+    }
+
     private addCustomMappings(
         result: any,
         source: any,

+ 80 - 9
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -4,6 +4,7 @@ import { MessagePattern } from '@nestjs/microservices';
 import { InjectConnection } from '@nestjs/typeorm';
 import { unique } from '@vendure/common/lib/unique';
 import {
+    Asset,
     asyncObservable,
     AsyncQueue,
     FacetValue,
@@ -40,6 +41,7 @@ import {
     ProductIndexItem,
     ReindexMessage,
     RemoveProductFromChannelMessage,
+    UpdateAssetMessage,
     UpdateProductMessage,
     UpdateVariantMessage,
     UpdateVariantsByIdMessage,
@@ -305,6 +307,61 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         });
     }
 
+    @MessagePattern(UpdateAssetMessage.pattern)
+    updateAsset(data: UpdateAssetMessage['data']): Observable<UpdateAssetMessage['response']> {
+        return asyncObservable(async () => {
+            const result1 = await this.updateAssetForIndex(PRODUCT_INDEX_NAME, data.asset);
+            const result2 = await this.updateAssetForIndex(VARIANT_INDEX_NAME, data.asset);
+            await this.client.indices.refresh({
+                index: [
+                    this.options.indexPrefix + PRODUCT_INDEX_NAME,
+                    this.options.indexPrefix + VARIANT_INDEX_NAME,
+                ],
+            });
+            return result1 && result2;
+        });
+    }
+
+    private async updateAssetForIndex(indexName: string, asset: Asset): Promise<boolean> {
+        const focalPoint = asset.focalPoint || null;
+        const params = { focalPoint };
+        const result1 = await this.client.update_by_query({
+            index: this.options.indexPrefix + indexName,
+            body: {
+                script: {
+                    source: 'ctx._source.productPreviewFocalPoint = params.focalPoint',
+                    params,
+                },
+                query: {
+                    term: {
+                        productAssetId: asset.id,
+                    },
+                },
+            },
+        });
+        for (const failure of result1.body.failures) {
+            Logger.error(`${failure.cause.type}: ${failure.cause.reason}`, loggerCtx);
+        }
+        const result2 = await this.client.update_by_query({
+            index: this.options.indexPrefix + indexName,
+            body: {
+                script: {
+                    source: 'ctx._source.productVariantPreviewFocalPoint = params.focalPoint',
+                    params,
+                },
+                query: {
+                    term: {
+                        productVariantAssetId: asset.id,
+                    },
+                },
+            },
+        });
+        for (const failure of result1.body.failures) {
+            Logger.error(`${failure.cause.type}: ${failure.cause.reason}`, loggerCtx);
+        }
+        return result1.body.failures.length === 0 && result2.body.failures === 0;
+    }
+
     private async processVariantBatch(
         variants: ProductVariant[],
         variantsInProduct: ProductVariant[],
@@ -509,6 +566,8 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
     }
 
     private createVariantIndexItem(v: ProductVariant, channelId: ID): VariantIndexItem {
+        const productAsset = v.product.featuredAsset;
+        const variantAsset = v.featuredAsset;
         const item: VariantIndexItem = {
             channelId,
             productVariantId: v.id as string,
@@ -516,9 +575,13 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             slug: v.product.slug,
             productId: v.product.id as string,
             productName: v.product.name,
-            productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
+            productAssetId: productAsset ? productAsset.id : null,
+            productPreview: productAsset ? productAsset.preview : '',
+            productPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
             productVariantName: v.name,
-            productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
+            productVariantAssetId: variantAsset ? variantAsset.id : null,
+            productVariantPreview: variantAsset ? variantAsset.preview : '',
+            productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
             price: v.price,
             priceWithTax: v.priceWithTax,
             currencyCode: v.currencyCode,
@@ -540,16 +603,24 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         const first = variants[0];
         const prices = variants.map(v => v.price);
         const pricesWithTax = variants.map(v => v.priceWithTax);
+        const productAsset = first.product.featuredAsset;
+        const variantAsset = variants.filter(v => v.featuredAsset).length
+            ? variants.filter(v => v.featuredAsset)[0].featuredAsset
+            : null;
         const item: ProductIndexItem = {
             channelId,
-            sku: variants.map(v => v.sku),
-            slug: variants.map(v => v.product.slug),
+            sku: first.sku,
+            slug: first.product.slug,
             productId: first.product.id,
-            productName: variants.map(v => v.product.name),
-            productPreview: first.product.featuredAsset ? first.product.featuredAsset.preview : '',
-            productVariantId: variants.map(v => v.id),
-            productVariantName: variants.map(v => v.name),
-            productVariantPreview: variants.filter(v => v.featuredAsset).map(v => v.featuredAsset.preview),
+            productName: first.product.name,
+            productAssetId: productAsset ? productAsset.id : null,
+            productPreview: productAsset ? productAsset.preview : '',
+            productPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
+            productVariantId: first.id,
+            productVariantName: first.name,
+            productVariantAssetId: variantAsset ? variantAsset.id : null,
+            productVariantPreview: variantAsset ? variantAsset.preview : '',
+            productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
             priceMin: Math.min(...prices),
             priceMax: Math.max(...prices),
             priceWithTaxMin: Math.min(...pricesWithTax),

+ 6 - 0
packages/elasticsearch-plugin/src/plugin.ts

@@ -1,4 +1,5 @@
 import {
+    AssetEvent,
     CollectionModificationEvent,
     DeepRequired,
     EventBus,
@@ -252,6 +253,11 @@ export class ElasticsearchPlugin implements OnVendureBootstrap {
                 return this.elasticsearchIndexService.updateVariants(event.ctx, event.variants).start();
             }
         });
+        this.eventBus.ofType(AssetEvent).subscribe(event => {
+            if (event.type === 'updated') {
+                return this.elasticsearchIndexService.updateAsset(event.ctx, event.asset).start();
+            }
+        });
 
         this.eventBus.ofType(ProductChannelEvent).subscribe(event => {
             if (event.type === 'assigned') {

+ 35 - 15
packages/elasticsearch-plugin/src/types.ts

@@ -1,4 +1,5 @@
 import {
+    Coordinate,
     CurrencyCode,
     PriceRange,
     SearchInput,
@@ -6,7 +7,7 @@ import {
     SearchResult,
 } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
-import { RequestContext, WorkerMessage } from '@vendure/core';
+import { Asset, RequestContext, WorkerMessage } from '@vendure/core';
 
 export type ElasticSearchInput = SearchInput & {
     priceRange?: PriceRange;
@@ -29,22 +30,34 @@ export type PriceRangeBucket = {
     count: number;
 };
 
-export type VariantIndexItem = Omit<SearchResult, 'score' | 'price' | 'priceWithTax'> & {
-    channelId: ID;
-    price: number;
-    priceWithTax: number;
-    [customMapping: string]: any;
+export type IndexItemAssets = {
+    productAssetId: ID | null;
+    productPreview: string;
+    productPreviewFocalPoint: Coordinate | null;
+    productVariantAssetId: ID | null;
+    productVariantPreview: string;
+    productVariantPreviewFocalPoint: Coordinate | null;
 };
-export type ProductIndexItem = {
-    sku: string[];
-    slug: string[];
+
+export type VariantIndexItem = Omit<
+    SearchResult,
+    'score' | 'price' | 'priceWithTax' | 'productAsset' | 'productVariantAsset'
+> &
+    IndexItemAssets & {
+        channelId: ID;
+        price: number;
+        priceWithTax: number;
+        [customMapping: string]: any;
+    };
+
+export type ProductIndexItem = IndexItemAssets & {
+    sku: string;
+    slug: string;
     productId: ID;
     channelId: ID;
-    productName: string[];
-    productPreview: string;
-    productVariantId: ID[];
-    productVariantName: string[];
-    productVariantPreview: string[];
+    productName: string;
+    productVariantId: ID;
+    productVariantName: string;
     currencyCode: CurrencyCode;
     description: string;
     facetIds: ID[];
@@ -121,7 +134,7 @@ export type BulkResponseResult = {
         _seq_no?: number;
         _primary_term?: number;
         error?: any;
-    }
+    };
 };
 export type BulkResponseBody = {
     took: number;
@@ -160,6 +173,10 @@ export interface ProductChannelMessageData {
     productId: ID;
     channelId: ID;
 }
+export interface UpdateAssetMessageData {
+    ctx: RequestContext;
+    asset: Asset;
+}
 
 export class ReindexMessage extends WorkerMessage<ReindexMessageData, ReindexMessageResponse> {
     static readonly pattern = 'Reindex';
@@ -188,6 +205,9 @@ export class AssignProductToChannelMessage extends WorkerMessage<ProductChannelM
 export class RemoveProductFromChannelMessage extends WorkerMessage<ProductChannelMessageData, boolean> {
     static readonly pattern = 'RemoveProductFromChannel';
 }
+export class UpdateAssetMessage extends WorkerMessage<UpdateAssetMessageData, boolean> {
+    static readonly pattern = 'UpdateAsset';
+}
 
 type Maybe<T> = T | null | undefined;
 type CustomMappingDefinition<Args extends any[], T extends string, R> = {