import type { Category, InvalidInputError, InventoryEntry, Product, ProductPagedSearchResponse, ProductProjection, ProductSearchRequest, ProductSearchResult, } from "@commercetools/platform-sdk"; import type { Config } from "./config.ts"; import { CommercetoolsError } from "./exceptions.ts"; import { parseSearchQuery } from "./lib/productSearchFilter.ts"; import { validateSearchQuery } from "./lib/searchQueryTypeChecker.ts"; import { applyPriceSelector } from "./priceSelector.ts"; import type { AbstractStorage } from "./storage/index.ts"; interface ProductSearchVariantAvailability { isOnStock: boolean; availableQuantity: number; isOnStockForChannel: string | undefined; } export class ProductSearch { protected _storage: AbstractStorage; constructor(config: Config) { this._storage = config.storage; } async search( projectKey: string, params: ProductSearchRequest, ): Promise { const inventoryEntries = await this._storage.all( projectKey, "inventory-entry", ); const availabilityBySku = inventoryEntries.reduce( ( acc: Map, entry: InventoryEntry, ) => { const existingEntry = acc.get(entry.sku); acc.set(entry.sku, { isOnStock: existingEntry?.isOnStock || entry.quantityOnStock > 0, availableQuantity: existingEntry?.availableQuantity ?? 0 + entry.quantityOnStock, // NOTE: This doesn't handle inventory entries for multiple channels, // so it doesn't exactly replicate the behavior of the commercetools api. isOnStockForChannel: existingEntry?.isOnStockForChannel ?? entry.supplyChannel?.id, }); return acc; }, new Map(), ); const categories = await this._storage.all(projectKey, "category"); const categoriesById = categories.reduce( (acc: Map, category: Category) => { return acc.set(category.id, category); }, new Map(), ); const allProducts = await this._storage.all(projectKey, "product"); let productResources = allProducts .map((r: Product) => this.transformProduct( r, params.productProjectionParameters?.staged ?? false, availabilityBySku, categoriesById, ), ) .filter((p: ProductProjection) => { if (!(params.productProjectionParameters?.staged ?? false)) { return p.published; } return true; }); const markMatchingVariant = params.markMatchingVariants ?? false; // Apply filters pre facetting if (params.query) { try { validateSearchQuery(params.query); const matchFunc = parseSearchQuery(params.query); // Filters can modify the output. So clone the resources first. productResources = productResources.filter((resource) => matchFunc(resource, markMatchingVariant), ); } catch (err) { throw new CommercetoolsError( { code: "InvalidInput", message: (err as any).message, }, 400, ); } } // Apply the priceSelector if (params.productProjectionParameters) { applyPriceSelector(productResources, { country: params.productProjectionParameters.priceCountry, channel: params.productProjectionParameters.priceChannel, customerGroup: params.productProjectionParameters.priceCustomerGroup, currency: params.productProjectionParameters.priceCurrency, }); } // @TODO: Determine whether or not to spoof search, facet filtering, wildcard, boosting and/or sorting. // For now this is deliberately not supported. const offset = params.offset || 0; const limit = params.limit || 20; const productProjectionsResult = productResources.slice( offset, offset + limit, ); /** * Do not supply productProjection if productProjectionParameters are not given * https://docs.commercetools.com/api/projects/product-search#with-product-projection-parameters */ const productProjectionsParameterGiven = !!params?.productProjectionParameters; // Transform to ProductSearchResult const results: ProductSearchResult[] = productProjectionsResult.map( (product) => ({ productProjection: productProjectionsParameterGiven ? product : undefined, id: product.id, /** * @TODO: possibly add support for optional matchingVariants * https://docs.commercetools.com/api/projects/product-search#productsearchmatchingvariants */ }), ); return { total: productResources.length, offset: offset, limit: limit, results: results, facets: [], }; } transformProduct( product: Product, staged: boolean, availabilityBySku: Map, categoriesById: Map, ): ProductProjection { const obj = !staged ? product.masterData.current : product.masterData.staged; const getVariantAvailability = (sku?: string) => { if (!sku) { return { isOnStock: false, availableQuantity: 0, isOnStockForChannel: undefined, }; } return ( availabilityBySku.get(sku) || { isOnStock: false, availableQuantity: 0, isOnStockForChannel: undefined, } ); }; return { id: product.id, createdAt: product.createdAt, lastModifiedAt: product.lastModifiedAt, version: product.version, name: obj.name, key: product.key, description: obj.description, metaTitle: obj.metaTitle, metaDescription: obj.metaDescription, metaKeywords: obj.metaKeywords, searchKeywords: obj.searchKeywords, slug: obj.slug, categories: obj.categories.map((category) => ({ ...category, obj: categoriesById.get(category.id), })), attributes: obj.attributes, masterVariant: { ...obj.masterVariant, availability: getVariantAvailability(obj.masterVariant.sku), }, variants: obj.variants.map((variant) => ({ ...variant, availability: getVariantAvailability(variant.sku), })), productType: product.productType, taxCategory: product.taxCategory, state: product.state, priceMode: product.priceMode, hasStagedChanges: product.masterData.hasStagedChanges, published: product.masterData.published, }; } }