diff --git a/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts b/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts index fc6a3a6ece..c81aaefc11 100644 --- a/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts +++ b/packages/core/src/plugin/default-search-plugin/default-search-plugin.ts @@ -3,7 +3,7 @@ import { ModuleRef } from '@nestjs/core'; import { SearchReindexResponse } from '@vendure/common/lib/generated-types'; import { ID, Type } from '@vendure/common/lib/shared-types'; import { buffer, debounceTime, delay, filter, map } from 'rxjs/operators'; -import { Column } from 'typeorm'; +import { Column, PrimaryColumn } from 'typeorm'; import { Injector } from '../../common'; import { idsAreEqual } from '../../common/utils'; @@ -109,6 +109,9 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap, OnApplicatio if (options.indexStockStatus === true) { this.addStockColumnsToEntity(); } + if (options.indexCurrencyCode) { + this.addCurrencyCodeToEntity(); + } return DefaultSearchPlugin; } @@ -240,4 +243,15 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap, OnApplicatio Column({ type: 'boolean', default: true })(instance, 'inStock'); Column({ type: 'boolean', default: true })(instance, 'productInStock'); } + + /** + * If the `indexCurrencyCode` option is set to `true`, we dynamically add + * a column to the SearchIndexItem entity. This is done in this way to allow us to add + * support for indexing on the currency code, while preventing a backwards-incompatible + * schema change. + */ + private static addCurrencyCodeToEntity() { + const instance = new SearchIndexItem(); + PrimaryColumn({ type: 'varchar' })(instance, 'currencyCode'); + } } diff --git a/packages/core/src/plugin/default-search-plugin/entities/search-index-item.entity.ts b/packages/core/src/plugin/default-search-plugin/entities/search-index-item.entity.ts index ab84a2b993..d8b314e507 100644 --- a/packages/core/src/plugin/default-search-plugin/entities/search-index-item.entity.ts +++ b/packages/core/src/plugin/default-search-plugin/entities/search-index-item.entity.ts @@ -91,4 +91,6 @@ export class SearchIndexItem { inStock?: boolean; // Added dynamically based on the `indexStockStatus` init option. productInStock?: boolean; + // Added dynamically based TODO + currencyCode?: CurrencyCode; } diff --git a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts index 751bc01a46..6173038b12 100644 --- a/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts +++ b/packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts @@ -304,7 +304,7 @@ export class IndexerController { }); if (variants) { - Logger.verbose(`Updating ${variants.length} variants`, workerLoggerCtx); + Logger.info(`Updating ${variants.length} variants`, workerLoggerCtx); await this.saveVariants(ctx, variants); } return true; @@ -400,8 +400,6 @@ export class IndexerController { } private async saveVariants(ctx: MutableRequestContext, variants: ProductVariant[]) { - const items: SearchIndexItem[] = []; - await this.removeSyntheticVariants(ctx, variants); const productMap = new Map(); @@ -417,93 +415,116 @@ export class IndexerController { productMap.set(variant.productId, product); } const availableLanguageCodes = unique(ctx.channel.availableLanguageCodes); - for (const languageCode of availableLanguageCodes) { - const productTranslation = this.getTranslation(product, languageCode); - const variantTranslation = this.getTranslation(variant, languageCode); - const collectionTranslations = variant.collections.map(c => - this.getTranslation(c, languageCode), - ); - let channelIds = variant.channels.map(x => x.id); - const clone = new ProductVariant({ id: variant.id }); - await this.entityHydrator.hydrate(ctx, clone, { - relations: ['channels', 'channels.defaultTaxZone'], - }); - channelIds.push( - ...clone.channels - .filter(x => x.availableLanguageCodes.includes(languageCode)) - .map(x => x.id), - ); - channelIds = unique(channelIds); - - for (const channel of variant.channels) { - ctx.setChannel(channel); - await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx); - const item = new SearchIndexItem({ - channelId: ctx.channelId, - languageCode, - productVariantId: variant.id, - price: variant.price, - priceWithTax: variant.priceWithTax, - sku: variant.sku, - enabled: product.enabled === false ? false : variant.enabled, - slug: productTranslation?.slug ?? '', - productId: product.id, - productName: productTranslation?.name ?? '', - description: this.constrainDescription(productTranslation?.description ?? ''), - productVariantName: variantTranslation?.name ?? '', - productAssetId: product.featuredAsset ? product.featuredAsset.id : null, - productPreviewFocalPoint: product.featuredAsset - ? product.featuredAsset.focalPoint - : null, - productVariantPreviewFocalPoint: variant.featuredAsset - ? variant.featuredAsset.focalPoint - : null, - productVariantAssetId: variant.featuredAsset ? variant.featuredAsset.id : null, - productPreview: product.featuredAsset ? product.featuredAsset.preview : '', - productVariantPreview: variant.featuredAsset ? variant.featuredAsset.preview : '', - channelIds: channelIds.map(x => x.toString()), - facetIds: this.getFacetIds(variant, product), - facetValueIds: this.getFacetValueIds(variant, product), - collectionIds: variant.collections.map(c => c.id.toString()), - collectionSlugs: - collectionTranslations.map(c => c?.slug).filter(notNullOrUndefined) ?? [], + const availableCurrencyCodes = this.options.indexCurrencyCode + ? unique(ctx.channel.availableCurrencyCodes) + : [ctx.channel.defaultCurrencyCode]; + + const items: SearchIndexItem[] = []; + + for (const currencyCode of availableCurrencyCodes) { + for (const languageCode of availableLanguageCodes) { + const productTranslation = this.getTranslation(product, languageCode); + const variantTranslation = this.getTranslation(variant, languageCode); + const collectionTranslations = variant.collections.map(c => + this.getTranslation(c, languageCode), + ); + let channelIds = variant.channels.map(x => x.id); + const clone = new ProductVariant({ id: variant.id }); + await this.entityHydrator.hydrate(ctx, clone, { + relations: ['channels', 'channels.defaultTaxZone'], }); - if (this.options.indexStockStatus) { - item.inStock = - 0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant)); - const productInStock = await this.requestContextCache.get( + channelIds.push( + ...clone.channels + .filter(x => x.availableLanguageCodes.includes(languageCode)) + .map(x => x.id), + ); + channelIds = unique(channelIds); + + for (const channel of variant.channels) { + const ch = new Channel({ ...channel, defaultCurrencyCode: currencyCode }); + ctx.setChannel(ch); + + const mutatedVariant = await this.productPriceApplicator.applyChannelPriceAndTax( + variant, ctx, - `productVariantsStock-${variant.productId}`, - () => - this.connection - .getRepository(ctx, ProductVariant) - .find({ - loadEagerRelations: false, - where: { - productId: variant.productId, - deletedAt: IsNull(), - }, - }) - .then(_variants => - Promise.all( - _variants.map(v => - this.productVariantService.getSaleableStockLevel(ctx, v), - ), - ), - ) - .then(stockLevels => stockLevels.some(stockLevel => 0 < stockLevel)), ); - item.productInStock = productInStock; + + const item = new SearchIndexItem({ + channelId: ctx.channelId, + languageCode, + currencyCode, + productVariantId: mutatedVariant.id, + price: mutatedVariant.price, + priceWithTax: mutatedVariant.priceWithTax, + sku: mutatedVariant.sku, + enabled: product.enabled === false ? false : mutatedVariant.enabled, + slug: productTranslation?.slug ?? '', + productId: product.id, + productName: productTranslation?.name ?? '', + description: this.constrainDescription(productTranslation?.description ?? ''), + productVariantName: variantTranslation?.name ?? '', + productAssetId: product.featuredAsset ? product.featuredAsset.id : null, + productPreviewFocalPoint: product.featuredAsset + ? product.featuredAsset.focalPoint + : null, + productVariantPreviewFocalPoint: mutatedVariant.featuredAsset + ? mutatedVariant.featuredAsset.focalPoint + : null, + productVariantAssetId: mutatedVariant.featuredAsset + ? mutatedVariant.featuredAsset.id + : null, + productPreview: product.featuredAsset ? product.featuredAsset.preview : '', + productVariantPreview: mutatedVariant.featuredAsset + ? mutatedVariant.featuredAsset.preview + : '', + channelIds: channelIds.map(x => x.toString()), + facetIds: this.getFacetIds(variant, product), + facetValueIds: this.getFacetValueIds(variant, product), + collectionIds: mutatedVariant.collections.map(c => c.id.toString()), + collectionSlugs: + collectionTranslations.map(c => c?.slug).filter(notNullOrUndefined) ?? [], + }); + if (this.options.indexStockStatus) { + item.inStock = + 0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant)); + const productInStock = await this.requestContextCache.get( + ctx, + `productVariantsStock-${mutatedVariant.productId}`, + () => + this.connection + .getRepository(ctx, ProductVariant) + .find({ + loadEagerRelations: false, + where: { + productId: mutatedVariant.productId, + deletedAt: IsNull(), + }, + }) + .then(_variants => + Promise.all( + _variants.map(v => + this.productVariantService.getSaleableStockLevel(ctx, v), + ), + ), + ) + .then(stockLevels => stockLevels.some(stockLevel => 0 < stockLevel)), + ); + item.productInStock = productInStock; + } + + items.push(item); } - items.push(item); } } - } - ctx.setChannel(originalChannel); - await this.queue.push(() => - this.connection.getRepository(ctx, SearchIndexItem).save(items, { chunk: 2500 }), - ); + // Save in batches per currency code to avoid JS closure issues + ctx.setChannel(originalChannel); + + await this.queue.push(() => { + this.connection.rawConnection.getRepository(SearchIndexItem).save(items, { chunk: 2500 }); + console.log(JSON.stringify(items, null, 4)); + }); + } } /** @@ -514,6 +535,7 @@ export class IndexerController { const productTranslation = this.getTranslation(product, ctx.languageCode); const item = new SearchIndexItem({ channelId: ctx.channelId, + currencyCode: ctx.currencyCode, languageCode: ctx.languageCode, productVariantId: 0, price: 0, diff --git a/packages/core/src/plugin/default-search-plugin/indexer/mutable-request-context.ts b/packages/core/src/plugin/default-search-plugin/indexer/mutable-request-context.ts index 0f85444dde..d60daa3233 100644 --- a/packages/core/src/plugin/default-search-plugin/indexer/mutable-request-context.ts +++ b/packages/core/src/plugin/default-search-plugin/indexer/mutable-request-context.ts @@ -17,8 +17,11 @@ export class MutableRequestContext extends RequestContext { } private mutatedChannel: Channel | undefined; - setChannel(channel: Channel) { + setChannel(channel: Channel, currencyCode?: CurrencyCode) { this.mutatedChannel = channel; + if (currencyCode) { + this.mutatedChannel.defaultCurrencyCode = currencyCode; + } } get channel(): Channel { diff --git a/packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts b/packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts index ab6f84da5f..4c0e950eb3 100644 --- a/packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts +++ b/packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts @@ -123,7 +123,14 @@ export class PostgresSearchStrategy implements SearchStrategy { .limit(take) .offset(skip) .getRawMany() - .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode))); + .then(res => + res.map(r => + mapToSearchResult( + r, + this.options.indexCurrencyCode ? r.si_currencyCode : ctx.channel.defaultCurrencyCode, + ), + ), + ); } async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise { @@ -254,6 +261,10 @@ export class PostgresSearchStrategy implements SearchStrategy { qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId }); applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode); + if (this.options.indexCurrencyCode) { + qb.andWhere('si.currencyCode = :currencyCode', { currencyCode: ctx.currencyCode }); + } + if (input.groupByProduct === true) { qb.groupBy('si.productId'); } @@ -267,7 +278,7 @@ export class PostgresSearchStrategy implements SearchStrategy { * "MIN" function in this case to all other columns than the productId. */ private createPostgresSelect(groupByProduct: boolean): string { - return getFieldsToSelect(this.options.indexStockStatus) + return getFieldsToSelect(this.options.indexStockStatus, this.options.indexCurrencyCode) .map(col => { const qualifiedName = `si.${col}`; const alias = `si_${col}`; diff --git a/packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts b/packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts index 0f8eac82b8..bcf74f3a6a 100644 --- a/packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts +++ b/packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts @@ -22,6 +22,13 @@ export const fieldsToSelect = [ 'productVariantPreviewFocalPoint', ]; -export function getFieldsToSelect(includeStockStatus: boolean = false) { - return includeStockStatus ? [...fieldsToSelect, 'inStock', 'productInStock'] : fieldsToSelect; +export function getFieldsToSelect(includeStockStatus: boolean = false, includeCurrencyCode: boolean = false) { + const _fieldsToSelect = [...fieldsToSelect]; + if (includeStockStatus) { + _fieldsToSelect.push('inStock'); + } + if (includeCurrencyCode) { + _fieldsToSelect.push('currencyCode'); + } + return _fieldsToSelect; } diff --git a/packages/core/src/plugin/default-search-plugin/types.ts b/packages/core/src/plugin/default-search-plugin/types.ts index 2c4ecce80d..c94f5357dc 100644 --- a/packages/core/src/plugin/default-search-plugin/types.ts +++ b/packages/core/src/plugin/default-search-plugin/types.ts @@ -22,6 +22,15 @@ export interface DefaultSearchPluginInitOptions { * @default false. */ indexStockStatus?: boolean; + /** + * @description + * If set to `true`, the currencyCode of the ProductVariant will be exposed in the + * `search` query results. Enabling this option on an existing Vendure installation + * will require a DB migration/synchronization. + * + * @default false. + */ + indexCurrencyCode?: boolean; /** * @description * If set to `true`, updates to Products, ProductVariants and Collections will not immediately