UNPKG

@vendure/core

Version:

A modern, headless ecommerce framework

514 lines 27.7 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; Object.defineProperty(exports, "__esModule", { value: true }); exports.IndexerController = exports.workerLoggerCtx = exports.variantRelations = exports.productRelations = exports.BATCH_SIZE = void 0; const common_1 = require("@nestjs/common"); const generated_types_1 = require("@vendure/common/lib/generated-types"); const shared_utils_1 = require("@vendure/common/lib/shared-utils"); const unique_1 = require("@vendure/common/lib/unique"); const typeorm_1 = require("typeorm"); const request_context_cache_service_1 = require("../../../cache/request-context-cache.service"); const async_queue_1 = require("../../../common/async-queue"); const utils_1 = require("../../../common/utils"); const config_service_1 = require("../../../config/config.service"); const vendure_logger_1 = require("../../../config/logger/vendure-logger"); const transactional_connection_1 = require("../../../connection/transactional-connection"); const channel_entity_1 = require("../../../entity/channel/channel.entity"); const product_variant_entity_1 = require("../../../entity/product-variant/product-variant.entity"); const product_entity_1 = require("../../../entity/product/product.entity"); const entity_hydrator_service_1 = require("../../../service/helpers/entity-hydrator/entity-hydrator.service"); const product_price_applicator_1 = require("../../../service/helpers/product-price-applicator/product-price-applicator"); const product_variant_service_1 = require("../../../service/services/product-variant.service"); const constants_1 = require("../constants"); const search_index_item_entity_1 = require("../entities/search-index-item.entity"); const mutable_request_context_1 = require("./mutable-request-context"); exports.BATCH_SIZE = 1000; exports.productRelations = [ 'translations', 'featuredAsset', 'facetValues', 'facetValues.facet', 'variants', 'channels', ]; exports.variantRelations = [ 'translations', 'taxCategory', 'featuredAsset', 'productVariantPrices', 'channels', 'channels.defaultTaxZone', 'facetValues', 'facetValues.facet', 'product', 'product.translations', 'product.channels', 'product.facetValues', 'product.facetValues.facet', 'collections', 'collections.translations', ]; exports.workerLoggerCtx = 'DefaultSearchPlugin Worker'; let IndexerController = class IndexerController { constructor(connection, entityHydrator, productPriceApplicator, configService, requestContextCache, productVariantService, options) { this.connection = connection; this.entityHydrator = entityHydrator; this.productPriceApplicator = productPriceApplicator; this.configService = configService; this.requestContextCache = requestContextCache; this.productVariantService = productVariantService; this.options = options; this.queue = new async_queue_1.AsyncQueue('search-index'); } reindex(job) { const { ctx: rawContext } = job.data; const ctx = mutable_request_context_1.MutableRequestContext.deserialize(rawContext); return (0, utils_1.asyncObservable)(async (observer) => { var _a; const timeStart = Date.now(); const channel = (_a = ctx.channel) !== null && _a !== void 0 ? _a : (await this.loadChannel(ctx, ctx.channelId)); const { count } = await this.getSearchIndexQueryBuilder(ctx, { channels: [channel] }); vendure_logger_1.Logger.verbose(`Reindexing ${count} variants for channel ${ctx.channel.code}`, exports.workerLoggerCtx); const batches = Math.ceil(count / exports.BATCH_SIZE); await this.connection.getRepository(ctx, search_index_item_entity_1.SearchIndexItem).delete({ channelId: ctx.channelId }); vendure_logger_1.Logger.verbose('Deleted existing index items', exports.workerLoggerCtx); for (let i = 0; i < batches; i++) { if (job.state === generated_types_1.JobState.CANCELLED) { throw new Error('reindex job was cancelled'); } vendure_logger_1.Logger.verbose(`Processing batch ${i + 1} of ${batches}`, exports.workerLoggerCtx); const { variants } = await this.getSearchIndexQueryBuilder(ctx, { channels: [channel], take: exports.BATCH_SIZE, skip: i * exports.BATCH_SIZE, }); await this.saveVariants(ctx, variants); observer.next({ total: count, completed: Math.min((i + 1) * exports.BATCH_SIZE, count), duration: +new Date() - timeStart, }); } vendure_logger_1.Logger.verbose('Completed reindexing', exports.workerLoggerCtx); return { total: count, completed: count, duration: +new Date() - timeStart, }; }); } updateVariantsById(job) { const { ctx: rawContext, ids } = job.data; const ctx = mutable_request_context_1.MutableRequestContext.deserialize(rawContext); return (0, utils_1.asyncObservable)(async (observer) => { const timeStart = Date.now(); if (ids.length) { const batches = Math.ceil(ids.length / exports.BATCH_SIZE); vendure_logger_1.Logger.verbose(`Updating ${ids.length} variants...`); for (let i = 0; i < batches; i++) { if (job.state === generated_types_1.JobState.CANCELLED) { throw new Error('updateVariantsById job was cancelled'); } const begin = i * exports.BATCH_SIZE; const end = begin + exports.BATCH_SIZE; vendure_logger_1.Logger.verbose(`Updating ids from index ${begin} to ${end}`); const batchIds = ids.slice(begin, end); const { variants: batch } = await this.getSearchIndexQueryBuilder(ctx, { channels: await this.getAllChannels(ctx), productVariantIds: batchIds, }); await this.saveVariants(ctx, batch); observer.next({ total: ids.length, completed: Math.min((i + 1) * exports.BATCH_SIZE, ids.length), duration: +new Date() - timeStart, }); } } vendure_logger_1.Logger.verbose('Completed reindexing!'); return { total: ids.length, completed: ids.length, duration: +new Date() - timeStart, }; }); } async updateProduct(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); return this.updateProductInChannel(ctx, data.productId, ctx.channelId); } async updateVariants(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); return this.updateVariantsInChannel(ctx, data.variantIds, ctx.channelId); } async deleteProduct(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); return this.deleteProductInChannel(ctx, data.productId, (await this.getAllChannels(ctx)).map(x => x.id)); } async deleteVariant(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); const variants = await this.connection.getRepository(ctx, product_variant_entity_1.ProductVariant).find({ where: { id: (0, typeorm_1.In)(data.variantIds) }, }); if (variants.length) { await this.removeSearchIndexItems(ctx, variants.map(v => v.id), (await this.getAllChannels(ctx)).map(c => c.id)); } return true; } async assignProductToChannel(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); return this.updateProductInChannel(ctx, data.productId, data.channelId); } async removeProductFromChannel(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); return this.deleteProductInChannel(ctx, data.productId, [data.channelId]); } async assignVariantToChannel(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); return this.updateVariantsInChannel(ctx, [data.productVariantId], data.channelId); } async removeVariantFromChannel(data) { const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); const variant = await this.connection .getRepository(ctx, product_variant_entity_1.ProductVariant) .findOne({ where: { id: data.productVariantId } }); await this.removeSearchIndexItems(ctx, [data.productVariantId], [data.channelId]); return true; } async updateAsset(data) { const id = data.asset.id; const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); function getFocalPoint(point) { return point && point.x && point.y ? point : null; } const focalPoint = getFocalPoint(data.asset.focalPoint); await this.connection .getRepository(ctx, search_index_item_entity_1.SearchIndexItem) .update({ productAssetId: id }, { productPreviewFocalPoint: focalPoint }); await this.connection .getRepository(ctx, search_index_item_entity_1.SearchIndexItem) .update({ productVariantAssetId: id }, { productVariantPreviewFocalPoint: focalPoint }); return true; } async deleteAsset(data) { const id = data.asset.id; const ctx = mutable_request_context_1.MutableRequestContext.deserialize(data.ctx); await this.connection .getRepository(ctx, search_index_item_entity_1.SearchIndexItem) .update({ productAssetId: id }, { productAssetId: null }); await this.connection .getRepository(ctx, search_index_item_entity_1.SearchIndexItem) .update({ productVariantAssetId: id }, { productVariantAssetId: null }); return true; } async updateProductInChannel(ctx, productId, channelId) { const channel = await this.loadChannel(ctx, channelId); ctx.setChannel(channel); const product = await this.getProductInChannelQueryBuilder(ctx, productId, channel); if (product) { const affectedChannels = await this.getAllChannels(ctx, { where: { availableLanguageCodes: (0, typeorm_1.In)(product.translations.map(t => t.languageCode)), }, }); const { variants: updatedVariants } = await this.getSearchIndexQueryBuilder(ctx, { channels: (0, unique_1.unique)(affectedChannels.concat(channel)), productId, }); if (updatedVariants.length === 0) { const clone = new product_entity_1.Product({ id: product.id }); await this.entityHydrator.hydrate(ctx, clone, { relations: ['translations'] }); product.translations = clone.translations; await this.saveSyntheticVariant(ctx, product); } else { if (product.enabled === false) { updatedVariants.forEach(v => (v.enabled = false)); } const variantsInCurrentChannel = updatedVariants.filter(v => !!v.channels.find(c => (0, utils_1.idsAreEqual)(c.id, ctx.channelId))); vendure_logger_1.Logger.verbose(`Updating ${variantsInCurrentChannel.length} variants`, exports.workerLoggerCtx); if (variantsInCurrentChannel.length) { await this.saveVariants(ctx, variantsInCurrentChannel); } } } return true; } async updateVariantsInChannel(ctx, variantIds, channelId) { const channel = await this.loadChannel(ctx, channelId); ctx.setChannel(channel); const { variants } = await this.getSearchIndexQueryBuilder(ctx, { channels: [channel], productVariantIds: variantIds, }); if (variants) { vendure_logger_1.Logger.verbose(`Updating ${variants.length} variants`, exports.workerLoggerCtx); await this.saveVariants(ctx, variants); } return true; } async deleteProductInChannel(ctx, productId, channelIds) { const channels = await Promise.all(channelIds.map(channelId => this.loadChannel(ctx, channelId))); const product = await this.getProductInChannelQueryBuilder(ctx, productId, ...channels); if (product) { const removedVariantIds = product.variants.map(v => v.id); if (removedVariantIds.length) { await this.removeSearchIndexItems(ctx, removedVariantIds, channelIds); } } return true; } async loadChannel(ctx, channelId) { return await this.connection.getRepository(ctx, channel_entity_1.Channel).findOneOrFail({ where: { id: channelId } }); } async getAllChannels(ctx, options) { return await this.connection .getRepository(ctx, channel_entity_1.Channel) .find(Object.assign(Object.assign({}, options), { relationLoadStrategy: 'query' })); } async getSearchIndexQueryBuilder(ctx, options) { const { productId = undefined, productVariantIds = undefined, channels = [], take = 50, skip = 0, } = options !== null && options !== void 0 ? options : {}; const where = { deletedAt: (0, typeorm_1.IsNull)(), product: { deletedAt: (0, typeorm_1.IsNull)(), }, }; if (productId) { where.productId = productId; } if (productVariantIds && productVariantIds.length > 0) { where.id = (0, typeorm_1.In)(productVariantIds); } where.channels = { id: (0, typeorm_1.In)(channels.map(c => c.id)) }; const [variants, count] = await this.connection.getRepository(ctx, product_variant_entity_1.ProductVariant).findAndCount({ loadEagerRelations: false, relations: exports.variantRelations, where, take, skip, relationLoadStrategy: 'query', }); return { variants, count }; } async getProductInChannelQueryBuilder(ctx, productId, ...channels) { const channelLanguages = (0, unique_1.unique)(channels.flatMap(c => c.availableLanguageCodes).concat(this.configService.defaultLanguageCode)); const product = await this.connection.getRepository(ctx, product_entity_1.Product).findOne({ loadEagerRelations: false, relations: exports.productRelations, relationLoadStrategy: 'query', where: { id: (0, typeorm_1.Equal)(productId), channels: { id: (0, typeorm_1.In)(channels.map(x => x.id)) } }, }); return product !== null && product !== void 0 ? product : undefined; } async saveVariants(ctx, variants) { var _a, _b, _c, _d, _e; const items = []; await this.removeSyntheticVariants(ctx, variants); const productMap = new Map(); const originalChannel = ctx.channel; for (const variant of variants) { ctx.setChannel(originalChannel); let product = productMap.get(variant.productId); if (!product) { product = await this.getProductInChannelQueryBuilder(ctx, variant.productId, ctx.channel); if (!product) { throw new Error('Product not found for variant!'); } productMap.set(variant.productId, product); } const availableLanguageCodes = (0, unique_1.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 product_variant_entity_1.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 = (0, unique_1.unique)(channelIds); for (const channel of variant.channels) { ctx.setChannel(channel); await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx); const item = new search_index_item_entity_1.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: (_a = productTranslation === null || productTranslation === void 0 ? void 0 : productTranslation.slug) !== null && _a !== void 0 ? _a : '', productId: product.id, productName: (_b = productTranslation === null || productTranslation === void 0 ? void 0 : productTranslation.name) !== null && _b !== void 0 ? _b : '', description: this.constrainDescription((_c = productTranslation === null || productTranslation === void 0 ? void 0 : productTranslation.description) !== null && _c !== void 0 ? _c : ''), productVariantName: (_d = variantTranslation === null || variantTranslation === void 0 ? void 0 : variantTranslation.name) !== null && _d !== void 0 ? _d : '', 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: (_e = collectionTranslations.map(c => c === null || c === void 0 ? void 0 : c.slug).filter(shared_utils_1.notNullOrUndefined)) !== null && _e !== void 0 ? _e : [], }); if (this.options.indexStockStatus) { item.inStock = 0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant)); const productInStock = await this.requestContextCache.get(ctx, `productVariantsStock-${variant.productId}`, () => this.connection .getRepository(ctx, product_variant_entity_1.ProductVariant) .find({ loadEagerRelations: false, where: { productId: variant.productId, deletedAt: (0, typeorm_1.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); } } } ctx.setChannel(originalChannel); await this.queue.push(() => this.connection.getRepository(ctx, search_index_item_entity_1.SearchIndexItem).save(items, { chunk: 2500 })); } /** * If a Product has no variants, we create a synthetic variant for the purposes * of making that product visible via the search query. */ async saveSyntheticVariant(ctx, product) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; const productTranslation = this.getTranslation(product, ctx.languageCode); const item = new search_index_item_entity_1.SearchIndexItem({ channelId: ctx.channelId, languageCode: ctx.languageCode, productVariantId: 0, price: 0, priceWithTax: 0, sku: '', enabled: false, slug: productTranslation.slug, productId: product.id, productName: productTranslation.name, description: this.constrainDescription(productTranslation.description), productVariantName: productTranslation.name, productAssetId: (_b = (_a = product.featuredAsset) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null, productPreviewFocalPoint: (_d = (_c = product.featuredAsset) === null || _c === void 0 ? void 0 : _c.focalPoint) !== null && _d !== void 0 ? _d : null, productVariantPreviewFocalPoint: null, productVariantAssetId: null, productPreview: (_f = (_e = product.featuredAsset) === null || _e === void 0 ? void 0 : _e.preview) !== null && _f !== void 0 ? _f : '', productVariantPreview: '', channelIds: [ctx.channelId.toString()], facetIds: (_h = (_g = product.facetValues) === null || _g === void 0 ? void 0 : _g.map(fv => fv.facet.id.toString())) !== null && _h !== void 0 ? _h : [], facetValueIds: (_k = (_j = product.facetValues) === null || _j === void 0 ? void 0 : _j.map(fv => fv.id.toString())) !== null && _k !== void 0 ? _k : [], collectionIds: [], collectionSlugs: [], }); await this.queue.push(() => this.connection.getRepository(ctx, search_index_item_entity_1.SearchIndexItem).save(item)); } /** * Removes any synthetic variants for the given product */ async removeSyntheticVariants(ctx, variants) { const prodIds = (0, unique_1.unique)(variants.map(v => v.productId)); for (const productId of prodIds) { await this.queue.push(() => this.connection.getRepository(ctx, search_index_item_entity_1.SearchIndexItem).delete({ productId, sku: '', price: 0, })); } } getTranslation(translatable, languageCode) { return (translatable.translations.find(t => t.languageCode === languageCode) || translatable.translations.find(t => t.languageCode === this.configService.defaultLanguageCode) || translatable.translations[0]); } getFacetIds(variant, product) { const facetIds = (fv) => fv.facet.id.toString(); const variantFacetIds = variant.facetValues.map(facetIds); const productFacetIds = product.facetValues.map(facetIds); return (0, unique_1.unique)([...variantFacetIds, ...productFacetIds]); } getFacetValueIds(variant, product) { const facetValueIds = (fv) => fv.id.toString(); const variantFacetValueIds = variant.facetValues.map(facetValueIds); const productFacetValueIds = product.facetValues.map(facetValueIds); return (0, unique_1.unique)([...variantFacetValueIds, ...productFacetValueIds]); } /** * Remove items from the search index */ async removeSearchIndexItems(ctx, variantIds, channelIds, ...languageCodes) { const keys = []; for (const productVariantId of variantIds) { for (const channelId of channelIds) { if (languageCodes.length > 0) { for (const languageCode of languageCodes) { keys.push({ productVariantId, channelId, languageCode, }); } } else { keys.push({ productVariantId, channelId, }); } } } await this.queue.push(() => this.connection.getRepository(ctx, search_index_item_entity_1.SearchIndexItem).delete(keys)); } /** * Prevent postgres errors from too-long indices * https://github.com/vendure-ecommerce/vendure/issues/745 */ constrainDescription(description) { const { type } = this.connection.rawConnection.options; const isPostgresLike = type === 'postgres' || type === 'aurora-postgres' || type === 'cockroachdb'; if (isPostgresLike) { return description.substring(0, 2600); } return description; } }; exports.IndexerController = IndexerController; exports.IndexerController = IndexerController = __decorate([ (0, common_1.Injectable)(), __param(6, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)), __metadata("design:paramtypes", [transactional_connection_1.TransactionalConnection, entity_hydrator_service_1.EntityHydrator, product_price_applicator_1.ProductPriceApplicator, config_service_1.ConfigService, request_context_cache_service_1.RequestContextCacheService, product_variant_service_1.ProductVariantService, Object]) ], IndexerController); //# sourceMappingURL=indexer.controller.js.map