indexer.controller.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. import { Client } from '@elastic/elasticsearch';
  2. import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
  3. import { unique } from '@vendure/common/lib/unique';
  4. import {
  5. Asset,
  6. asyncObservable,
  7. AsyncQueue,
  8. Channel,
  9. Collection,
  10. ConfigService,
  11. FacetValue,
  12. ID,
  13. LanguageCode,
  14. Logger,
  15. Product,
  16. ProductPriceApplicator,
  17. ProductVariant,
  18. RequestContext,
  19. TransactionalConnection,
  20. Translatable,
  21. Translation,
  22. } from '@vendure/core';
  23. import { Observable } from 'rxjs';
  24. import { ELASTIC_SEARCH_OPTIONS, loggerCtx, VARIANT_INDEX_NAME } from './constants';
  25. import { createIndices, getClient, getIndexNameByAlias } from './indexing-utils';
  26. import { ElasticsearchOptions } from './options';
  27. import {
  28. BulkOperation,
  29. BulkOperationDoc,
  30. BulkResponseBody,
  31. ProductChannelMessageData,
  32. ProductIndexItem,
  33. ReindexMessageData,
  34. UpdateAssetMessageData,
  35. UpdateProductMessageData,
  36. UpdateVariantMessageData,
  37. UpdateVariantsByIdMessageData,
  38. VariantChannelMessageData,
  39. VariantIndexItem,
  40. } from './types';
  41. export const productRelations = [
  42. 'variants',
  43. 'featuredAsset',
  44. 'facetValues',
  45. 'facetValues.facet',
  46. 'channels',
  47. 'channels.defaultTaxZone',
  48. ];
  49. export const variantRelations = [
  50. 'featuredAsset',
  51. 'facetValues',
  52. 'facetValues.facet',
  53. 'collections',
  54. 'taxCategory',
  55. 'channels',
  56. 'channels.defaultTaxZone',
  57. ];
  58. export interface ReindexMessageResponse {
  59. total: number;
  60. completed: number;
  61. duration: number;
  62. }
  63. type BulkVariantOperation = {
  64. index: typeof VARIANT_INDEX_NAME;
  65. operation: BulkOperation | BulkOperationDoc<VariantIndexItem>;
  66. };
  67. @Injectable()
  68. export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDestroy {
  69. private client: Client;
  70. private asyncQueue = new AsyncQueue('elasticsearch-indexer', 5);
  71. constructor(
  72. private connection: TransactionalConnection,
  73. @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
  74. private productPriceApplicator: ProductPriceApplicator,
  75. private configService: ConfigService,
  76. ) {}
  77. onModuleInit(): any {
  78. this.client = getClient(this.options);
  79. }
  80. onModuleDestroy(): any {
  81. return this.client.close();
  82. }
  83. /**
  84. * Updates the search index only for the affected product.
  85. */
  86. async updateProduct({ ctx: rawContext, productId }: UpdateProductMessageData): Promise<boolean> {
  87. await this.updateProductsInternal([productId]);
  88. return true;
  89. }
  90. /**
  91. * Updates the search index only for the affected product.
  92. */
  93. async deleteProduct({ ctx: rawContext, productId }: UpdateProductMessageData): Promise<boolean> {
  94. const operations = await this.deleteProductOperations(productId);
  95. await this.executeBulkOperations(operations);
  96. return true;
  97. }
  98. /**
  99. * Updates the search index only for the affected product.
  100. */
  101. async assignProductToChannel({
  102. ctx: rawContext,
  103. productId,
  104. channelId,
  105. }: ProductChannelMessageData): Promise<boolean> {
  106. await this.updateProductsInternal([productId]);
  107. return true;
  108. }
  109. /**
  110. * Updates the search index only for the affected product.
  111. */
  112. async removeProductFromChannel({
  113. ctx: rawContext,
  114. productId,
  115. channelId,
  116. }: ProductChannelMessageData): Promise<boolean> {
  117. await this.updateProductsInternal([productId]);
  118. return true;
  119. }
  120. async assignVariantToChannel({
  121. ctx: rawContext,
  122. productVariantId,
  123. channelId,
  124. }: VariantChannelMessageData): Promise<boolean> {
  125. const productIds = await this.getProductIdsByVariantIds([productVariantId]);
  126. await this.updateProductsInternal(productIds);
  127. return true;
  128. }
  129. async removeVariantFromChannel({
  130. ctx: rawContext,
  131. productVariantId,
  132. channelId,
  133. }: VariantChannelMessageData): Promise<boolean> {
  134. const productIds = await this.getProductIdsByVariantIds([productVariantId]);
  135. await this.updateProductsInternal(productIds);
  136. return true;
  137. }
  138. /**
  139. * Updates the search index only for the affected entities.
  140. */
  141. async updateVariants({ ctx: rawContext, variantIds }: UpdateVariantMessageData): Promise<boolean> {
  142. return this.asyncQueue.push(async () => {
  143. const productIds = await this.getProductIdsByVariantIds(variantIds);
  144. await this.updateProductsInternal(productIds);
  145. return true;
  146. });
  147. }
  148. async deleteVariants({ ctx: rawContext, variantIds }: UpdateVariantMessageData): Promise<boolean> {
  149. const productIds = await this.getProductIdsByVariantIds(variantIds);
  150. for (const productId of productIds) {
  151. await this.updateProductsInternal([productId]);
  152. }
  153. return true;
  154. }
  155. updateVariantsById({
  156. ctx: rawContext,
  157. ids,
  158. }: UpdateVariantsByIdMessageData): Observable<ReindexMessageResponse> {
  159. return asyncObservable(async observer => {
  160. return this.asyncQueue.push(async () => {
  161. const timeStart = Date.now();
  162. const productIds = await this.getProductIdsByVariantIds(ids);
  163. if (productIds.length) {
  164. let finishedProductsCount = 0;
  165. for (const productId of productIds) {
  166. await this.updateProductsInternal([productId]);
  167. finishedProductsCount++;
  168. observer.next({
  169. total: productIds.length,
  170. completed: Math.min(finishedProductsCount, productIds.length),
  171. duration: +new Date() - timeStart,
  172. });
  173. }
  174. }
  175. Logger.verbose(`Completed updating variants`, loggerCtx);
  176. return {
  177. total: productIds.length,
  178. completed: productIds.length,
  179. duration: +new Date() - timeStart,
  180. };
  181. });
  182. });
  183. }
  184. reindex({ ctx: rawContext }: ReindexMessageData): Observable<ReindexMessageResponse> {
  185. return asyncObservable(async observer => {
  186. return this.asyncQueue.push(async () => {
  187. const timeStart = Date.now();
  188. const operations: BulkVariantOperation[] = [];
  189. const reindexTempName = new Date().getTime();
  190. const variantIndexName = this.options.indexPrefix + VARIANT_INDEX_NAME;
  191. const reindexVariantAliasName = variantIndexName + `-reindex-${reindexTempName}`;
  192. try {
  193. await createIndices(
  194. this.client,
  195. this.options.indexPrefix,
  196. this.options.indexSettings,
  197. this.options.indexMappingProperties,
  198. true,
  199. `-reindex-${reindexTempName}`,
  200. );
  201. const reindexVariantIndexName = await getIndexNameByAlias(
  202. this.client,
  203. reindexVariantAliasName,
  204. );
  205. const originalVariantAliasExist = await this.client.indices.existsAlias({
  206. name: variantIndexName,
  207. });
  208. const originalVariantIndexExist = await this.client.indices.exists({
  209. index: variantIndexName,
  210. });
  211. const originalVariantIndexName = await getIndexNameByAlias(this.client, variantIndexName);
  212. if (originalVariantAliasExist.body || originalVariantIndexExist.body) {
  213. await this.client.reindex({
  214. refresh: true,
  215. body: {
  216. source: {
  217. index: variantIndexName,
  218. },
  219. dest: {
  220. index: reindexVariantAliasName,
  221. },
  222. },
  223. });
  224. }
  225. const actions = [
  226. {
  227. remove: {
  228. index: reindexVariantIndexName,
  229. alias: reindexVariantAliasName,
  230. },
  231. },
  232. {
  233. add: {
  234. index: reindexVariantIndexName,
  235. alias: variantIndexName,
  236. },
  237. },
  238. ];
  239. if (originalVariantAliasExist.body) {
  240. actions.push({
  241. remove: {
  242. index: originalVariantIndexName,
  243. alias: variantIndexName,
  244. },
  245. });
  246. } else if (originalVariantIndexExist.body) {
  247. await this.client.indices.delete({
  248. index: [variantIndexName],
  249. });
  250. }
  251. await this.client.indices.updateAliases({
  252. body: {
  253. actions,
  254. },
  255. });
  256. if (originalVariantAliasExist.body) {
  257. await this.client.indices.delete({
  258. index: [originalVariantIndexName],
  259. });
  260. }
  261. } catch (e) {
  262. Logger.warn(
  263. `Could not recreate indices. Reindexing continue with existing indices.`,
  264. loggerCtx,
  265. );
  266. Logger.warn(JSON.stringify(e), loggerCtx);
  267. } finally {
  268. const reindexVariantAliasExist = await this.client.indices.existsAlias({
  269. name: reindexVariantAliasName,
  270. });
  271. if (reindexVariantAliasExist.body) {
  272. const reindexVariantAliasResult = await this.client.indices.getAlias({
  273. name: reindexVariantAliasName,
  274. });
  275. const reindexVariantIndexName = Object.keys(reindexVariantAliasResult.body)[0];
  276. await this.client.indices.delete({
  277. index: [reindexVariantIndexName],
  278. });
  279. }
  280. }
  281. const deletedProductIds = await this.connection
  282. .getRepository(Product)
  283. .createQueryBuilder('product')
  284. .select('product.id')
  285. .where('product.deletedAt IS NOT NULL')
  286. .getMany();
  287. for (const { id: deletedProductId } of deletedProductIds) {
  288. operations.push(...(await this.deleteProductOperations(deletedProductId)));
  289. }
  290. const productIds = await this.connection
  291. .getRepository(Product)
  292. .createQueryBuilder('product')
  293. .select('product.id')
  294. .where('product.deletedAt IS NULL')
  295. .getMany();
  296. Logger.verbose(`Reindexing ${productIds.length} Products`, loggerCtx);
  297. let finishedProductsCount = 0;
  298. for (const { id: productId } of productIds) {
  299. operations.push(...(await this.updateProductsOperations([productId])));
  300. finishedProductsCount++;
  301. observer.next({
  302. total: productIds.length,
  303. completed: Math.min(finishedProductsCount, productIds.length),
  304. duration: +new Date() - timeStart,
  305. });
  306. }
  307. Logger.verbose(`Will execute ${operations.length} bulk update operations`, loggerCtx);
  308. await this.executeBulkOperations(operations);
  309. Logger.verbose(`Completed reindexing!`, loggerCtx);
  310. return {
  311. total: productIds.length,
  312. completed: productIds.length,
  313. duration: +new Date() - timeStart,
  314. };
  315. });
  316. });
  317. }
  318. async updateAsset(data: UpdateAssetMessageData): Promise<boolean> {
  319. const result = await this.updateAssetFocalPointForIndex(VARIANT_INDEX_NAME, data.asset);
  320. await this.client.indices.refresh({
  321. index: [this.options.indexPrefix + VARIANT_INDEX_NAME],
  322. });
  323. return result;
  324. }
  325. async deleteAsset(data: UpdateAssetMessageData): Promise<boolean> {
  326. const result = await this.deleteAssetForIndex(VARIANT_INDEX_NAME, data.asset);
  327. await this.client.indices.refresh({
  328. index: [this.options.indexPrefix + VARIANT_INDEX_NAME],
  329. });
  330. return result;
  331. }
  332. private async updateAssetFocalPointForIndex(indexName: string, asset: Asset): Promise<boolean> {
  333. const focalPoint = asset.focalPoint || null;
  334. const params = { focalPoint };
  335. return this.updateAssetForIndex(
  336. indexName,
  337. asset,
  338. {
  339. source: 'ctx._source.productPreviewFocalPoint = params.focalPoint',
  340. params,
  341. },
  342. {
  343. source: 'ctx._source.productVariantPreviewFocalPoint = params.focalPoint',
  344. params,
  345. },
  346. );
  347. }
  348. private async deleteAssetForIndex(indexName: string, asset: Asset): Promise<boolean> {
  349. return this.updateAssetForIndex(
  350. indexName,
  351. asset,
  352. { source: 'ctx._source.productAssetId = null' },
  353. { source: 'ctx._source.productVariantAssetId = null' },
  354. );
  355. }
  356. private async updateAssetForIndex(
  357. indexName: string,
  358. asset: Asset,
  359. updateProductScript: { source: string; params?: any },
  360. updateVariantScript: { source: string; params?: any },
  361. ): Promise<boolean> {
  362. const result1 = await this.client.update_by_query({
  363. index: this.options.indexPrefix + indexName,
  364. body: {
  365. script: updateProductScript,
  366. query: {
  367. term: {
  368. productAssetId: asset.id,
  369. },
  370. },
  371. },
  372. });
  373. for (const failure of result1.body.failures) {
  374. Logger.error(`${failure.cause.type}: ${failure.cause.reason}`, loggerCtx);
  375. }
  376. const result2 = await this.client.update_by_query({
  377. index: this.options.indexPrefix + indexName,
  378. body: {
  379. script: updateVariantScript,
  380. query: {
  381. term: {
  382. productVariantAssetId: asset.id,
  383. },
  384. },
  385. },
  386. });
  387. for (const failure of result1.body.failures) {
  388. Logger.error(`${failure.cause.type}: ${failure.cause.reason}`, loggerCtx);
  389. }
  390. return result1.body.failures.length === 0 && result2.body.failures === 0;
  391. }
  392. private async updateProductsInternal(productIds: ID[]) {
  393. const operations = await this.updateProductsOperations(productIds);
  394. await this.executeBulkOperations(operations);
  395. }
  396. private async updateProductsOperations(productIds: ID[]): Promise<BulkVariantOperation[]> {
  397. Logger.verbose(`Updating ${productIds.length} Products`, loggerCtx);
  398. const operations: BulkVariantOperation[] = [];
  399. for (const productId of productIds) {
  400. operations.push(...(await this.deleteProductOperations(productId)));
  401. const product = await this.connection.getRepository(Product).findOne(productId, {
  402. relations: productRelations,
  403. where: {
  404. deletedAt: null,
  405. },
  406. });
  407. if (product) {
  408. const updatedProductVariants = await this.connection.getRepository(ProductVariant).findByIds(
  409. product.variants.map(v => v.id),
  410. {
  411. relations: variantRelations,
  412. where: {
  413. deletedAt: null,
  414. },
  415. order: {
  416. id: 'ASC',
  417. },
  418. },
  419. );
  420. updatedProductVariants.forEach(variant => (variant.product = product));
  421. if (!product.enabled) {
  422. updatedProductVariants.forEach(v => (v.enabled = false));
  423. }
  424. Logger.verbose(`Updating Product (${productId})`, loggerCtx);
  425. const languageVariants: LanguageCode[] = [];
  426. languageVariants.push(...product.translations.map(t => t.languageCode));
  427. for (const variant of product.variants) {
  428. languageVariants.push(...variant.translations.map(t => t.languageCode));
  429. }
  430. const uniqueLanguageVariants = unique(languageVariants);
  431. for (const channel of product.channels) {
  432. const channelCtx = new RequestContext({
  433. channel,
  434. apiType: 'admin',
  435. authorizedAsOwnerOnly: false,
  436. isAuthorized: true,
  437. session: {} as any,
  438. });
  439. const variantsInChannel = updatedProductVariants.filter(v =>
  440. v.channels.map(c => c.id).includes(channelCtx.channelId),
  441. );
  442. for (const variant of variantsInChannel) {
  443. await this.productPriceApplicator.applyChannelPriceAndTax(variant, channelCtx);
  444. }
  445. for (const languageCode of uniqueLanguageVariants) {
  446. if (variantsInChannel.length) {
  447. for (const variant of variantsInChannel) {
  448. operations.push(
  449. {
  450. index: VARIANT_INDEX_NAME,
  451. operation: {
  452. update: {
  453. _id: ElasticsearchIndexerController.getId(
  454. variant.id,
  455. channelCtx.channelId,
  456. languageCode,
  457. ),
  458. },
  459. },
  460. },
  461. {
  462. index: VARIANT_INDEX_NAME,
  463. operation: {
  464. doc: this.createVariantIndexItem(
  465. variant,
  466. variantsInChannel,
  467. channelCtx,
  468. languageCode,
  469. ),
  470. doc_as_upsert: true,
  471. },
  472. },
  473. );
  474. }
  475. } else {
  476. operations.push(
  477. {
  478. index: VARIANT_INDEX_NAME,
  479. operation: {
  480. update: {
  481. _id: ElasticsearchIndexerController.getId(
  482. -product.id,
  483. channelCtx.channelId,
  484. languageCode,
  485. ),
  486. },
  487. },
  488. },
  489. {
  490. index: VARIANT_INDEX_NAME,
  491. operation: {
  492. doc: this.createSyntheticProductIndexItem(
  493. product,
  494. channelCtx,
  495. languageCode,
  496. ),
  497. doc_as_upsert: true,
  498. },
  499. },
  500. );
  501. }
  502. }
  503. }
  504. }
  505. }
  506. return operations;
  507. }
  508. private async deleteProductOperations(productId: ID): Promise<BulkVariantOperation[]> {
  509. const channels = await this.connection
  510. .getRepository(Channel)
  511. .createQueryBuilder('channel')
  512. .select('channel.id')
  513. .getMany();
  514. const product = await this.connection.getRepository(Product).findOne(productId, {
  515. relations: ['variants'],
  516. });
  517. if (!product) {
  518. return [];
  519. }
  520. Logger.verbose(`Deleting 1 Product (id: ${productId})`, loggerCtx);
  521. const operations: BulkVariantOperation[] = [];
  522. const languageVariants: LanguageCode[] = [];
  523. languageVariants.push(...product.translations.map(t => t.languageCode));
  524. for (const variant of product.variants) {
  525. languageVariants.push(...variant.translations.map(t => t.languageCode));
  526. }
  527. const uniqueLanguageVariants = unique(languageVariants);
  528. for (const { id: channelId } of channels) {
  529. for (const languageCode of uniqueLanguageVariants) {
  530. operations.push({
  531. index: VARIANT_INDEX_NAME,
  532. operation: {
  533. delete: {
  534. _id: ElasticsearchIndexerController.getId(-product.id, channelId, languageCode),
  535. },
  536. },
  537. });
  538. }
  539. }
  540. operations.push(
  541. ...(await this.deleteVariantsInternalOperations(
  542. product.variants,
  543. channels.map(c => c.id),
  544. uniqueLanguageVariants,
  545. )),
  546. );
  547. return operations;
  548. }
  549. private async deleteVariantsInternalOperations(
  550. variants: ProductVariant[],
  551. channelIds: ID[],
  552. languageVariants: LanguageCode[],
  553. ): Promise<BulkVariantOperation[]> {
  554. Logger.verbose(`Deleting ${variants.length} ProductVariants`, loggerCtx);
  555. const operations: BulkVariantOperation[] = [];
  556. for (const variant of variants) {
  557. for (const channelId of channelIds) {
  558. for (const languageCode of languageVariants) {
  559. operations.push({
  560. index: VARIANT_INDEX_NAME,
  561. operation: {
  562. delete: {
  563. _id: ElasticsearchIndexerController.getId(
  564. variant.id,
  565. channelId,
  566. languageCode,
  567. ),
  568. },
  569. },
  570. });
  571. }
  572. }
  573. }
  574. return operations;
  575. }
  576. private async getProductIdsByVariantIds(variantIds: ID[]): Promise<ID[]> {
  577. const variants = await this.connection.getRepository(ProductVariant).findByIds(variantIds, {
  578. relations: ['product'],
  579. loadEagerRelations: false,
  580. });
  581. return unique(variants.map(v => v.product.id));
  582. }
  583. private async executeBulkOperations(operations: BulkVariantOperation[]) {
  584. const variantOperations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem>> = [];
  585. for (const operation of operations) {
  586. variantOperations.push(operation.operation);
  587. }
  588. return Promise.all([this.runBulkOperationsOnIndex(VARIANT_INDEX_NAME, variantOperations)]);
  589. }
  590. private async runBulkOperationsOnIndex(
  591. indexName: string,
  592. operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem | ProductIndexItem>>,
  593. ) {
  594. if (operations.length === 0) {
  595. return;
  596. }
  597. try {
  598. const fullIndexName = this.options.indexPrefix + indexName;
  599. const { body }: { body: BulkResponseBody } = await this.client.bulk({
  600. refresh: true,
  601. index: fullIndexName,
  602. body: operations,
  603. });
  604. if (body.errors) {
  605. Logger.error(
  606. `Some errors occurred running bulk operations on ${fullIndexName}! Set logger to "debug" to print all errors.`,
  607. loggerCtx,
  608. );
  609. body.items.forEach(item => {
  610. if (item.index) {
  611. Logger.debug(JSON.stringify(item.index.error, null, 2), loggerCtx);
  612. }
  613. if (item.update) {
  614. Logger.debug(JSON.stringify(item.update.error, null, 2), loggerCtx);
  615. }
  616. if (item.delete) {
  617. Logger.debug(JSON.stringify(item.delete.error, null, 2), loggerCtx);
  618. }
  619. });
  620. } else {
  621. Logger.debug(
  622. `Executed ${body.items.length} bulk operations on index [${fullIndexName}]`,
  623. loggerCtx,
  624. );
  625. }
  626. return body;
  627. } catch (e) {
  628. Logger.error(`Error when attempting to run bulk operations [${e.toString()}]`, loggerCtx);
  629. Logger.error('Error details: ' + JSON.stringify(e.body?.error, null, 2), loggerCtx);
  630. }
  631. }
  632. private createVariantIndexItem(
  633. v: ProductVariant,
  634. variants: ProductVariant[],
  635. ctx: RequestContext,
  636. languageCode: LanguageCode,
  637. ): VariantIndexItem {
  638. const productAsset = v.product.featuredAsset;
  639. const variantAsset = v.featuredAsset;
  640. const productTranslation = this.getTranslation(v.product, languageCode);
  641. const variantTranslation = this.getTranslation(v, languageCode);
  642. const collectionTranslations = v.collections.map(c => this.getTranslation(c, languageCode));
  643. const productCollectionTranslations = variants.reduce(
  644. (translations, variant) => [
  645. ...translations,
  646. ...variant.collections.map(c => this.getTranslation(c, languageCode)),
  647. ],
  648. [] as Array<Translation<Collection>>,
  649. );
  650. const prices = variants.map(variant => variant.price);
  651. const pricesWithTax = variants.map(variant => variant.priceWithTax);
  652. const item: VariantIndexItem = {
  653. channelId: ctx.channelId,
  654. languageCode,
  655. productVariantId: v.id,
  656. sku: v.sku,
  657. slug: productTranslation.slug,
  658. productId: v.product.id,
  659. productName: productTranslation.name,
  660. productAssetId: productAsset ? productAsset.id : undefined,
  661. productPreview: productAsset ? productAsset.preview : '',
  662. productPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
  663. productVariantName: variantTranslation.name,
  664. productVariantAssetId: variantAsset ? variantAsset.id : undefined,
  665. productVariantPreview: variantAsset ? variantAsset.preview : '',
  666. productVariantPreviewFocalPoint: variantAsset ? variantAsset.focalPoint || undefined : undefined,
  667. price: v.price,
  668. priceWithTax: v.priceWithTax,
  669. currencyCode: v.currencyCode,
  670. description: productTranslation.description,
  671. facetIds: this.getFacetIds([v]),
  672. channelIds: v.channels.map(c => c.id),
  673. facetValueIds: this.getFacetValueIds([v]),
  674. collectionIds: v.collections.map(c => c.id.toString()),
  675. collectionSlugs: collectionTranslations.map(c => c.slug),
  676. enabled: v.enabled && v.product.enabled,
  677. productEnabled: variants.some(variant => variant.enabled) && v.product.enabled,
  678. productPriceMin: Math.min(...prices),
  679. productPriceMax: Math.max(...prices),
  680. productPriceWithTaxMin: Math.min(...pricesWithTax),
  681. productPriceWithTaxMax: Math.max(...pricesWithTax),
  682. productFacetIds: this.getFacetIds(variants),
  683. productFacetValueIds: this.getFacetValueIds(variants),
  684. productCollectionIds: unique(
  685. variants.reduce(
  686. (ids, variant) => [...ids, ...variant.collections.map(c => c.id)],
  687. [] as ID[],
  688. ),
  689. ),
  690. productCollectionSlugs: unique(productCollectionTranslations.map(c => c.slug)),
  691. productChannelIds: v.product.channels.map(c => c.id),
  692. };
  693. const variantCustomMappings = Object.entries(this.options.customProductVariantMappings);
  694. for (const [name, def] of variantCustomMappings) {
  695. item[`variant-${name}`] = def.valueFn(v, languageCode);
  696. }
  697. const productCustomMappings = Object.entries(this.options.customProductMappings);
  698. for (const [name, def] of productCustomMappings) {
  699. item[`product-${name}`] = def.valueFn(v.product, variants, languageCode);
  700. }
  701. return item;
  702. }
  703. /**
  704. * If a Product has no variants, we create a synthetic variant for the purposes
  705. * of making that product visible via the search query.
  706. */
  707. private createSyntheticProductIndexItem(
  708. product: Product,
  709. ctx: RequestContext,
  710. languageCode: LanguageCode,
  711. ): VariantIndexItem {
  712. const productTranslation = this.getTranslation(product, languageCode);
  713. const productAsset = product.featuredAsset;
  714. return {
  715. channelId: ctx.channelId,
  716. languageCode,
  717. productVariantId: 0,
  718. sku: '',
  719. slug: productTranslation.slug,
  720. productId: product.id,
  721. productName: productTranslation.name,
  722. productAssetId: productAsset ? productAsset.id : undefined,
  723. productPreview: productAsset ? productAsset.preview : '',
  724. productPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
  725. productVariantName: productTranslation.name,
  726. productVariantAssetId: undefined,
  727. productVariantPreview: '',
  728. productVariantPreviewFocalPoint: undefined,
  729. price: 0,
  730. priceWithTax: 0,
  731. currencyCode: ctx.channel.currencyCode,
  732. description: productTranslation.description,
  733. facetIds: product.facetValues?.map(fv => fv.facet.id.toString()) ?? [],
  734. channelIds: [ctx.channelId],
  735. facetValueIds: product.facetValues?.map(fv => fv.id.toString()) ?? [],
  736. collectionIds: [],
  737. collectionSlugs: [],
  738. enabled: false,
  739. productEnabled: false,
  740. productPriceMin: 0,
  741. productPriceMax: 0,
  742. productPriceWithTaxMin: 0,
  743. productPriceWithTaxMax: 0,
  744. productFacetIds: product.facetValues?.map(fv => fv.facet.id.toString()) ?? [],
  745. productFacetValueIds: product.facetValues?.map(fv => fv.id.toString()) ?? [],
  746. productCollectionIds: [],
  747. productCollectionSlugs: [],
  748. productChannelIds: product.channels.map(c => c.id),
  749. };
  750. }
  751. private getTranslation<T extends Translatable>(
  752. translatable: T,
  753. languageCode: LanguageCode,
  754. ): Translation<T> {
  755. return (translatable.translations.find(t => t.languageCode === languageCode) ||
  756. translatable.translations.find(t => t.languageCode === this.configService.defaultLanguageCode) ||
  757. translatable.translations[0]) as unknown as Translation<T>;
  758. }
  759. private getFacetIds(variants: ProductVariant[]): string[] {
  760. const facetIds = (fv: FacetValue) => fv.facet.id.toString();
  761. const variantFacetIds = variants.reduce(
  762. (ids, v) => [...ids, ...v.facetValues.map(facetIds)],
  763. [] as string[],
  764. );
  765. const productFacetIds = variants[0].product.facetValues.map(facetIds);
  766. return unique([...variantFacetIds, ...productFacetIds]);
  767. }
  768. private getFacetValueIds(variants: ProductVariant[]): string[] {
  769. const facetValueIds = (fv: FacetValue) => fv.id.toString();
  770. const variantFacetValueIds = variants.reduce(
  771. (ids, v) => [...ids, ...v.facetValues.map(facetValueIds)],
  772. [] as string[],
  773. );
  774. const productFacetValueIds = variants[0].product.facetValues.map(facetValueIds);
  775. return unique([...variantFacetValueIds, ...productFacetValueIds]);
  776. }
  777. private static getId(entityId: ID, channelId: ID, languageCode: LanguageCode): string {
  778. return `${channelId.toString()}_${entityId.toString()}_${languageCode}`;
  779. }
  780. }