indexer.controller.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. import { Inject, Injectable } from '@nestjs/common';
  2. import { JobState, LanguageCode } from '@vendure/common/lib/generated-types';
  3. import { ID } from '@vendure/common/lib/shared-types';
  4. import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
  5. import { unique } from '@vendure/common/lib/unique';
  6. import { Observable } from 'rxjs';
  7. import { FindManyOptions, In } from 'typeorm';
  8. import { RequestContext } from '../../../api/common/request-context';
  9. import { RequestContextCacheService } from '../../../cache/request-context-cache.service';
  10. import { AsyncQueue } from '../../../common/async-queue';
  11. import { Translatable, Translation } from '../../../common/types/locale-types';
  12. import { asyncObservable, idsAreEqual } from '../../../common/utils';
  13. import { ConfigService } from '../../../config/config.service';
  14. import { Logger } from '../../../config/logger/vendure-logger';
  15. import { TransactionalConnection } from '../../../connection/transactional-connection';
  16. import { Channel } from '../../../entity/channel/channel.entity';
  17. import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
  18. import { Product } from '../../../entity/product/product.entity';
  19. import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
  20. import { Job } from '../../../job-queue/job';
  21. import { EntityHydrator } from '../../../service/helpers/entity-hydrator/entity-hydrator.service';
  22. import { ProductPriceApplicator } from '../../../service/helpers/product-price-applicator/product-price-applicator';
  23. import { ProductVariantService } from '../../../service/services/product-variant.service';
  24. import { PLUGIN_INIT_OPTIONS } from '../constants';
  25. import { SearchIndexItem } from '../entities/search-index-item.entity';
  26. import {
  27. DefaultSearchPluginInitOptions,
  28. ProductChannelMessageData,
  29. ReindexMessageResponse,
  30. UpdateAssetMessageData,
  31. UpdateIndexQueueJobData,
  32. UpdateProductMessageData,
  33. UpdateVariantMessageData,
  34. UpdateVariantsByIdJobData,
  35. VariantChannelMessageData,
  36. } from '../types';
  37. import { MutableRequestContext } from './mutable-request-context';
  38. export const BATCH_SIZE = 1000;
  39. export const productRelations = ['featuredAsset', 'facetValues', 'facetValues.facet', 'channels'];
  40. export const variantRelations = [
  41. 'featuredAsset',
  42. 'facetValues',
  43. 'facetValues.facet',
  44. 'collections',
  45. 'taxCategory',
  46. 'channels',
  47. 'channels.defaultTaxZone',
  48. ];
  49. export const workerLoggerCtx = 'DefaultSearchPlugin Worker';
  50. @Injectable()
  51. export class IndexerController {
  52. private queue = new AsyncQueue('search-index');
  53. constructor(
  54. private connection: TransactionalConnection,
  55. private entityHydrator: EntityHydrator,
  56. private productPriceApplicator: ProductPriceApplicator,
  57. private configService: ConfigService,
  58. private requestContextCache: RequestContextCacheService,
  59. private productVariantService: ProductVariantService,
  60. @Inject(PLUGIN_INIT_OPTIONS) private options: DefaultSearchPluginInitOptions,
  61. ) {}
  62. reindex(job: Job<UpdateIndexQueueJobData>): Observable<ReindexMessageResponse> {
  63. const { ctx: rawContext } = job.data;
  64. const ctx = MutableRequestContext.deserialize(rawContext);
  65. return asyncObservable(async observer => {
  66. const timeStart = Date.now();
  67. const channel = ctx.channel ?? (await this.loadChannel(ctx, ctx.channelId));
  68. const qb = this.getSearchIndexQueryBuilder(ctx, channel);
  69. const count = await qb.getCount();
  70. Logger.verbose(`Reindexing ${count} variants for channel ${ctx.channel.code}`, workerLoggerCtx);
  71. const batches = Math.ceil(count / BATCH_SIZE);
  72. await this.connection.getRepository(ctx, SearchIndexItem).delete({ channelId: ctx.channelId });
  73. Logger.verbose('Deleted existing index items', workerLoggerCtx);
  74. for (let i = 0; i < batches; i++) {
  75. if (job.state === JobState.CANCELLED) {
  76. throw new Error('reindex job was cancelled');
  77. }
  78. Logger.verbose(`Processing batch ${i + 1} of ${batches}`, workerLoggerCtx);
  79. const variants = await qb
  80. .take(BATCH_SIZE)
  81. .skip(i * BATCH_SIZE)
  82. .getMany();
  83. await this.saveVariants(ctx, variants);
  84. observer.next({
  85. total: count,
  86. completed: Math.min((i + 1) * BATCH_SIZE, count),
  87. duration: +new Date() - timeStart,
  88. });
  89. }
  90. Logger.verbose('Completed reindexing', workerLoggerCtx);
  91. return {
  92. total: count,
  93. completed: count,
  94. duration: +new Date() - timeStart,
  95. };
  96. });
  97. }
  98. updateVariantsById(job: Job<UpdateVariantsByIdJobData>): Observable<ReindexMessageResponse> {
  99. const { ctx: rawContext, ids } = job.data;
  100. const ctx = MutableRequestContext.deserialize(rawContext);
  101. return asyncObservable(async observer => {
  102. const timeStart = Date.now();
  103. if (ids.length) {
  104. const batches = Math.ceil(ids.length / BATCH_SIZE);
  105. Logger.verbose(`Updating ${ids.length} variants...`);
  106. for (let i = 0; i < batches; i++) {
  107. if (job.state === JobState.CANCELLED) {
  108. throw new Error('updateVariantsById job was cancelled');
  109. }
  110. const begin = i * BATCH_SIZE;
  111. const end = begin + BATCH_SIZE;
  112. Logger.verbose(`Updating ids from index ${begin} to ${end}`);
  113. const batchIds = ids.slice(begin, end);
  114. const batch = await this.getSearchIndexQueryBuilder(
  115. ctx,
  116. ...(await this.getAllChannels(ctx)),
  117. )
  118. .where('variants.deletedAt IS NULL AND variants.id IN (:...ids)', { ids: batchIds })
  119. .getMany();
  120. await this.saveVariants(ctx, batch);
  121. observer.next({
  122. total: ids.length,
  123. completed: Math.min((i + 1) * BATCH_SIZE, ids.length),
  124. duration: +new Date() - timeStart,
  125. });
  126. }
  127. }
  128. Logger.verbose('Completed reindexing!');
  129. return {
  130. total: ids.length,
  131. completed: ids.length,
  132. duration: +new Date() - timeStart,
  133. };
  134. });
  135. }
  136. async updateProduct(data: UpdateProductMessageData): Promise<boolean> {
  137. const ctx = MutableRequestContext.deserialize(data.ctx);
  138. return this.updateProductInChannel(ctx, data.productId, ctx.channelId);
  139. }
  140. async updateVariants(data: UpdateVariantMessageData): Promise<boolean> {
  141. const ctx = MutableRequestContext.deserialize(data.ctx);
  142. return this.updateVariantsInChannel(ctx, data.variantIds, ctx.channelId);
  143. }
  144. async deleteProduct(data: UpdateProductMessageData): Promise<boolean> {
  145. const ctx = MutableRequestContext.deserialize(data.ctx);
  146. return this.deleteProductInChannel(
  147. ctx,
  148. data.productId,
  149. (await this.getAllChannels(ctx)).map(x => x.id),
  150. );
  151. }
  152. async deleteVariant(data: UpdateVariantMessageData): Promise<boolean> {
  153. const ctx = MutableRequestContext.deserialize(data.ctx);
  154. const variants = await this.connection.getRepository(ctx, ProductVariant).find({
  155. where: { id: In(data.variantIds) },
  156. });
  157. if (variants.length) {
  158. await this.removeSearchIndexItems(
  159. ctx,
  160. variants.map(v => v.id),
  161. (await this.getAllChannels(ctx)).map(c => c.id),
  162. );
  163. }
  164. return true;
  165. }
  166. async assignProductToChannel(data: ProductChannelMessageData): Promise<boolean> {
  167. const ctx = MutableRequestContext.deserialize(data.ctx);
  168. return this.updateProductInChannel(ctx, data.productId, data.channelId);
  169. }
  170. async removeProductFromChannel(data: ProductChannelMessageData): Promise<boolean> {
  171. const ctx = MutableRequestContext.deserialize(data.ctx);
  172. return this.deleteProductInChannel(ctx, data.productId, [data.channelId]);
  173. }
  174. async assignVariantToChannel(data: VariantChannelMessageData): Promise<boolean> {
  175. const ctx = MutableRequestContext.deserialize(data.ctx);
  176. return this.updateVariantsInChannel(ctx, [data.productVariantId], data.channelId);
  177. }
  178. async removeVariantFromChannel(data: VariantChannelMessageData): Promise<boolean> {
  179. const ctx = MutableRequestContext.deserialize(data.ctx);
  180. const variant = await this.connection
  181. .getRepository(ctx, ProductVariant)
  182. .findOne({ where: { id: data.productVariantId } });
  183. const languageVariants = variant?.translations.map(t => t.languageCode) ?? [];
  184. await this.removeSearchIndexItems(ctx, [data.productVariantId], [data.channelId]);
  185. return true;
  186. }
  187. async updateAsset(data: UpdateAssetMessageData): Promise<boolean> {
  188. const id = data.asset.id;
  189. const ctx = MutableRequestContext.deserialize(data.ctx);
  190. function getFocalPoint(point?: { x: number; y: number }) {
  191. return point && point.x && point.y ? point : null;
  192. }
  193. const focalPoint = getFocalPoint(data.asset.focalPoint);
  194. await this.connection
  195. .getRepository(ctx, SearchIndexItem)
  196. .update({ productAssetId: id }, { productPreviewFocalPoint: focalPoint });
  197. await this.connection
  198. .getRepository(ctx, SearchIndexItem)
  199. .update({ productVariantAssetId: id }, { productVariantPreviewFocalPoint: focalPoint });
  200. return true;
  201. }
  202. async deleteAsset(data: UpdateAssetMessageData): Promise<boolean> {
  203. const id = data.asset.id;
  204. const ctx = MutableRequestContext.deserialize(data.ctx);
  205. await this.connection
  206. .getRepository(ctx, SearchIndexItem)
  207. .update({ productAssetId: id }, { productAssetId: null });
  208. await this.connection
  209. .getRepository(ctx, SearchIndexItem)
  210. .update({ productVariantAssetId: id }, { productVariantAssetId: null });
  211. return true;
  212. }
  213. private async updateProductInChannel(
  214. ctx: MutableRequestContext,
  215. productId: ID,
  216. channelId: ID,
  217. ): Promise<boolean> {
  218. const channel = await this.loadChannel(ctx, channelId);
  219. ctx.setChannel(channel);
  220. const product = await this.getProductInChannelQueryBuilder(ctx, productId, channel).getOneOrFail();
  221. if (product) {
  222. const affectedChannels = await this.getAllChannels(ctx, {
  223. where: {
  224. availableLanguageCodes: In(product.translations.map(t => t.languageCode)),
  225. },
  226. });
  227. const updatedVariants = await this.getSearchIndexQueryBuilder(
  228. ctx,
  229. ...unique(affectedChannels.concat(channel)),
  230. )
  231. .andWhere('variants.productId = :productId', { productId })
  232. .getMany();
  233. if (updatedVariants.length === 0) {
  234. const clone = new Product({ id: product.id });
  235. await this.entityHydrator.hydrate(ctx, clone, { relations: ['translations' as never] });
  236. product.translations = clone.translations;
  237. await this.saveSyntheticVariant(ctx, product);
  238. } else {
  239. if (product.enabled === false) {
  240. updatedVariants.forEach(v => (v.enabled = false));
  241. }
  242. const variantsInCurrentChannel = updatedVariants.filter(
  243. v => !!v.channels.find(c => idsAreEqual(c.id, ctx.channelId)),
  244. );
  245. Logger.verbose(`Updating ${variantsInCurrentChannel.length} variants`, workerLoggerCtx);
  246. if (variantsInCurrentChannel.length) {
  247. await this.saveVariants(ctx, variantsInCurrentChannel);
  248. }
  249. }
  250. }
  251. return true;
  252. }
  253. private async updateVariantsInChannel(
  254. ctx: MutableRequestContext,
  255. variantIds: ID[],
  256. channelId: ID,
  257. ): Promise<boolean> {
  258. const channel = await this.loadChannel(ctx, channelId);
  259. ctx.setChannel(channel);
  260. const variants = await this.getSearchIndexQueryBuilder(ctx, channel)
  261. .andWhere('variants.deletedAt IS NULL AND variants.id IN (:...variantIds)', { variantIds })
  262. .getMany();
  263. if (variants) {
  264. Logger.verbose(`Updating ${variants.length} variants`, workerLoggerCtx);
  265. await this.saveVariants(ctx, variants);
  266. }
  267. return true;
  268. }
  269. private async deleteProductInChannel(
  270. ctx: RequestContext,
  271. productId: ID,
  272. channelIds: ID[],
  273. ): Promise<boolean> {
  274. const channels = await Promise.all(channelIds.map(channelId => this.loadChannel(ctx, channelId)));
  275. const product = await this.getProductInChannelQueryBuilder(ctx, productId, ...channels)
  276. .leftJoinAndSelect('product.variants', 'variants')
  277. .leftJoinAndSelect('variants.translations', 'variants_translations')
  278. .getOne();
  279. if (product) {
  280. const removedVariantIds = product.variants.map(v => v.id);
  281. if (removedVariantIds.length) {
  282. await this.removeSearchIndexItems(ctx, removedVariantIds, channelIds);
  283. }
  284. }
  285. return true;
  286. }
  287. private async loadChannel(ctx: RequestContext, channelId: ID): Promise<Channel> {
  288. return await this.connection.getRepository(ctx, Channel).findOneOrFail({ where: { id: channelId } });
  289. }
  290. private async getAllChannels(
  291. ctx: RequestContext,
  292. options?: FindManyOptions<Channel> | undefined,
  293. ): Promise<Channel[]> {
  294. return await this.connection.getRepository(ctx, Channel).find(options);
  295. }
  296. private getSearchIndexQueryBuilder(ctx: RequestContext, ...channels: Channel[]) {
  297. const channelLanguages = unique(
  298. channels.flatMap(c => c.availableLanguageCodes).concat(this.configService.defaultLanguageCode),
  299. );
  300. const qb = this.connection
  301. .getRepository(ctx, ProductVariant)
  302. .createQueryBuilder('variants')
  303. .setFindOptions({
  304. loadEagerRelations: false,
  305. })
  306. .leftJoinAndSelect(
  307. 'variants.channels',
  308. 'variant_channels',
  309. 'variant_channels.id IN (:...channelId)',
  310. {
  311. channelId: channels.map(x => x.id),
  312. },
  313. )
  314. .leftJoinAndSelect('variant_channels.defaultTaxZone', 'variant_channel_tax_zone')
  315. .leftJoinAndSelect('variants.taxCategory', 'variant_tax_category')
  316. .leftJoinAndSelect(
  317. 'variants.productVariantPrices',
  318. 'product_variant_prices',
  319. 'product_variant_prices.channelId IN (:...channelId)',
  320. { channelId: channels.map(x => x.id) },
  321. )
  322. .leftJoinAndSelect(
  323. 'variants.translations',
  324. 'product_variant_translation',
  325. 'product_variant_translation.baseId = variants.id AND product_variant_translation.languageCode IN (:...channelLanguages)',
  326. {
  327. channelLanguages,
  328. },
  329. )
  330. .leftJoin('variants.product', 'product')
  331. .leftJoinAndSelect('variants.facetValues', 'variant_facet_values')
  332. .leftJoinAndSelect(
  333. 'variant_facet_values.translations',
  334. 'variant_facet_value_translations',
  335. 'variant_facet_value_translations.languageCode IN (:...channelLanguages)',
  336. {
  337. channelLanguages,
  338. },
  339. )
  340. .leftJoinAndSelect('variant_facet_values.facet', 'facet_values_facet')
  341. .leftJoinAndSelect(
  342. 'facet_values_facet.translations',
  343. 'facet_values_facet_translations',
  344. 'facet_values_facet_translations.languageCode IN (:...channelLanguages)',
  345. {
  346. channelLanguages,
  347. },
  348. )
  349. .leftJoinAndSelect('variants.collections', 'collections')
  350. .leftJoinAndSelect(
  351. 'collections.channels',
  352. 'collection_channels',
  353. 'collection_channels.id IN (:...channelId)',
  354. { channelId: channels.map(x => x.id) },
  355. )
  356. .leftJoinAndSelect(
  357. 'collections.translations',
  358. 'collection_translations',
  359. 'collection_translations.languageCode IN (:...channelLanguages)',
  360. {
  361. channelLanguages,
  362. },
  363. )
  364. .leftJoin('product.channels', 'channel')
  365. .where('channel.id IN (:...channelId)', { channelId: channels.map(x => x.id) })
  366. .andWhere('product.deletedAt IS NULL')
  367. .andWhere('variants.deletedAt IS NULL');
  368. return qb;
  369. }
  370. private getProductInChannelQueryBuilder(ctx: RequestContext, productId: ID, ...channels: Channel[]) {
  371. const channelLanguages = unique(
  372. channels.flatMap(c => c.availableLanguageCodes).concat(this.configService.defaultLanguageCode),
  373. );
  374. return this.connection
  375. .getRepository(ctx, Product)
  376. .createQueryBuilder('product')
  377. .setFindOptions({
  378. loadEagerRelations: false,
  379. })
  380. .leftJoinAndSelect(
  381. 'product.translations',
  382. 'translations',
  383. 'translations.languageCode IN (:...channelLanguages)',
  384. {
  385. channelLanguages,
  386. },
  387. )
  388. .leftJoinAndSelect('product.featuredAsset', 'product_featured_asset')
  389. .leftJoinAndSelect('product.facetValues', 'product_facet_values')
  390. .leftJoinAndSelect(
  391. 'product_facet_values.translations',
  392. 'product_facet_value_translations',
  393. 'product_facet_value_translations.languageCode IN (:...channelLanguages)',
  394. {
  395. channelLanguages,
  396. },
  397. )
  398. .leftJoinAndSelect('product_facet_values.facet', 'product_facet')
  399. .leftJoinAndSelect(
  400. 'product_facet.translations',
  401. 'product_facet_translations',
  402. 'product_facet_translations.languageCode IN (:...channelLanguages)',
  403. {
  404. channelLanguages,
  405. },
  406. )
  407. .leftJoinAndSelect('product.channels', 'channel', 'channel.id IN (:...channelId)', {
  408. channelId: channels.map(x => x.id),
  409. })
  410. .where('product.id = :productId', { productId });
  411. }
  412. private async saveVariants(ctx: MutableRequestContext, variants: ProductVariant[]) {
  413. const items: SearchIndexItem[] = [];
  414. await this.removeSyntheticVariants(ctx, variants);
  415. const productMap = new Map<ID, Product>();
  416. for (const variant of variants) {
  417. let product = productMap.get(variant.productId);
  418. if (!product) {
  419. product = await this.getProductInChannelQueryBuilder(
  420. ctx,
  421. variant.productId,
  422. ctx.channel,
  423. ).getOneOrFail();
  424. productMap.set(variant.productId, product);
  425. }
  426. const availableLanguageCodes = unique(ctx.channel.availableLanguageCodes);
  427. for (const languageCode of availableLanguageCodes) {
  428. const productTranslation = this.getTranslation(product, languageCode);
  429. const variantTranslation = this.getTranslation(variant, languageCode);
  430. const collectionTranslations = variant.collections.map(c =>
  431. this.getTranslation(c, languageCode),
  432. );
  433. let channelIds = variant.channels.map(x => x.id);
  434. const clone = new ProductVariant({ id: variant.id });
  435. await this.entityHydrator.hydrate(ctx, clone, {
  436. relations: ['channels', 'channels.defaultTaxZone'],
  437. });
  438. channelIds.push(
  439. ...clone.channels
  440. .filter(x => x.availableLanguageCodes.includes(languageCode))
  441. .map(x => x.id),
  442. );
  443. channelIds = unique(channelIds);
  444. for (const channel of variant.channels) {
  445. ctx.setChannel(channel);
  446. await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx);
  447. const item = new SearchIndexItem({
  448. channelId: ctx.channelId,
  449. languageCode,
  450. productVariantId: variant.id,
  451. price: variant.price,
  452. priceWithTax: variant.priceWithTax,
  453. sku: variant.sku,
  454. enabled: product.enabled === false ? false : variant.enabled,
  455. slug: productTranslation?.slug ?? '',
  456. productId: product.id,
  457. productName: productTranslation?.name ?? '',
  458. description: this.constrainDescription(productTranslation?.description ?? ''),
  459. productVariantName: variantTranslation?.name ?? '',
  460. productAssetId: product.featuredAsset ? product.featuredAsset.id : null,
  461. productPreviewFocalPoint: product.featuredAsset
  462. ? product.featuredAsset.focalPoint
  463. : null,
  464. productVariantPreviewFocalPoint: variant.featuredAsset
  465. ? variant.featuredAsset.focalPoint
  466. : null,
  467. productVariantAssetId: variant.featuredAsset ? variant.featuredAsset.id : null,
  468. productPreview: product.featuredAsset ? product.featuredAsset.preview : '',
  469. productVariantPreview: variant.featuredAsset ? variant.featuredAsset.preview : '',
  470. channelIds: channelIds.map(x => x.toString()),
  471. facetIds: this.getFacetIds(variant, product),
  472. facetValueIds: this.getFacetValueIds(variant, product),
  473. collectionIds: variant.collections.map(c => c.id.toString()),
  474. collectionSlugs:
  475. collectionTranslations.map(c => c?.slug).filter(notNullOrUndefined) ?? [],
  476. });
  477. if (this.options.indexStockStatus) {
  478. item.inStock =
  479. 0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant));
  480. const productInStock = await this.requestContextCache.get(
  481. ctx,
  482. `productVariantsStock-${variant.productId}`,
  483. () =>
  484. this.connection
  485. .getRepository(ctx, ProductVariant)
  486. .find({
  487. loadEagerRelations: false,
  488. where: {
  489. productId: variant.productId,
  490. },
  491. })
  492. .then(_variants =>
  493. Promise.all(
  494. _variants.map(v =>
  495. this.productVariantService.getSaleableStockLevel(ctx, v),
  496. ),
  497. ),
  498. )
  499. .then(stockLevels => stockLevels.some(stockLevel => 0 < stockLevel)),
  500. );
  501. item.productInStock = productInStock;
  502. }
  503. items.push(item);
  504. }
  505. }
  506. }
  507. await this.queue.push(() =>
  508. this.connection.getRepository(ctx, SearchIndexItem).save(items, { chunk: 2500 }),
  509. );
  510. }
  511. /**
  512. * If a Product has no variants, we create a synthetic variant for the purposes
  513. * of making that product visible via the search query.
  514. */
  515. private async saveSyntheticVariant(ctx: RequestContext, product: Product) {
  516. const productTranslation = this.getTranslation(product, ctx.languageCode);
  517. const item = new SearchIndexItem({
  518. channelId: ctx.channelId,
  519. languageCode: ctx.languageCode,
  520. productVariantId: 0,
  521. price: 0,
  522. priceWithTax: 0,
  523. sku: '',
  524. enabled: false,
  525. slug: productTranslation.slug,
  526. productId: product.id,
  527. productName: productTranslation.name,
  528. description: this.constrainDescription(productTranslation.description),
  529. productVariantName: productTranslation.name,
  530. productAssetId: product.featuredAsset?.id ?? null,
  531. productPreviewFocalPoint: product.featuredAsset?.focalPoint ?? null,
  532. productVariantPreviewFocalPoint: null,
  533. productVariantAssetId: null,
  534. productPreview: product.featuredAsset?.preview ?? '',
  535. productVariantPreview: '',
  536. channelIds: [ctx.channelId.toString()],
  537. facetIds: product.facetValues?.map(fv => fv.facet.id.toString()) ?? [],
  538. facetValueIds: product.facetValues?.map(fv => fv.id.toString()) ?? [],
  539. collectionIds: [],
  540. collectionSlugs: [],
  541. });
  542. await this.queue.push(() => this.connection.getRepository(ctx, SearchIndexItem).save(item));
  543. }
  544. /**
  545. * Removes any synthetic variants for the given product
  546. */
  547. private async removeSyntheticVariants(ctx: RequestContext, variants: ProductVariant[]) {
  548. const prodIds = unique(variants.map(v => v.productId));
  549. for (const productId of prodIds) {
  550. await this.queue.push(() =>
  551. this.connection.getRepository(ctx, SearchIndexItem).delete({
  552. productId,
  553. sku: '',
  554. price: 0,
  555. }),
  556. );
  557. }
  558. }
  559. private getTranslation<T extends Translatable>(
  560. translatable: T,
  561. languageCode: LanguageCode,
  562. ): Translation<T> {
  563. return (translatable.translations.find(t => t.languageCode === languageCode) ||
  564. translatable.translations.find(t => t.languageCode === this.configService.defaultLanguageCode) ||
  565. translatable.translations[0]) as unknown as Translation<T>;
  566. }
  567. private getFacetIds(variant: ProductVariant, product: Product): string[] {
  568. const facetIds = (fv: FacetValue) => fv.facet.id.toString();
  569. const variantFacetIds = variant.facetValues.map(facetIds);
  570. const productFacetIds = product.facetValues.map(facetIds);
  571. return unique([...variantFacetIds, ...productFacetIds]);
  572. }
  573. private getFacetValueIds(variant: ProductVariant, product: Product): string[] {
  574. const facetValueIds = (fv: FacetValue) => fv.id.toString();
  575. const variantFacetValueIds = variant.facetValues.map(facetValueIds);
  576. const productFacetValueIds = product.facetValues.map(facetValueIds);
  577. return unique([...variantFacetValueIds, ...productFacetValueIds]);
  578. }
  579. /**
  580. * Remove items from the search index
  581. */
  582. private async removeSearchIndexItems(
  583. ctx: RequestContext,
  584. variantIds: ID[],
  585. channelIds: ID[],
  586. ...languageCodes: LanguageCode[]
  587. ) {
  588. const keys: Array<Partial<SearchIndexItem>> = [];
  589. for (const productVariantId of variantIds) {
  590. for (const channelId of channelIds) {
  591. if (languageCodes.length > 0) {
  592. for (const languageCode of languageCodes) {
  593. keys.push({
  594. productVariantId,
  595. channelId,
  596. languageCode,
  597. });
  598. }
  599. } else {
  600. keys.push({
  601. productVariantId,
  602. channelId,
  603. });
  604. }
  605. }
  606. }
  607. await this.queue.push(() => this.connection.getRepository(ctx, SearchIndexItem).delete(keys as any));
  608. }
  609. /**
  610. * Prevent postgres errors from too-long indices
  611. * https://github.com/vendure-ecommerce/vendure/issues/745
  612. */
  613. private constrainDescription(description: string): string {
  614. const { type } = this.connection.rawConnection.options;
  615. const isPostgresLike = type === 'postgres' || type === 'aurora-postgres' || type === 'cockroachdb';
  616. if (isPostgresLike) {
  617. return description.substring(0, 2600);
  618. }
  619. return description;
  620. }
  621. }