import { clamp, maxBy, minBy, union, uniq } from 'lodash'; import { RetailerProductVariant } from '@/entities'; import { TypeormDataLoader } from '@/entities/utils'; import logger from '@/logger'; import { AlgoliaIndexKind, AlgoliaSearchIndex } from '@/services'; import { filterRetailerByUrlUsingWhitelist, RETAILER_DOMAIN_WHITELIST_SET, sortRetailers, } from '@/utils/retailer'; export type AlgoliaProduct = { objectID: string; variantID: string; name: string; imgUrl: string; category?: string; price: number; lowestPrice: number; highestPrice: number; savingRatio: number; retailers: string[]; priceByRetailer: Record; lastModified: number; isAvailable: boolean; variantsCount: number; featured?: boolean; }; export const syncProductsToAlgolia = async ( aggregateProductIds: string[], dbDataLoader?: TypeormDataLoader ): Promise => { if (!process.env.ALGOLIA_SYNCING_ENABLED) return; const innerDbDataLoader = dbDataLoader || new TypeormDataLoader(); const algoliaIndex = AlgoliaSearchIndex.fromIndex(AlgoliaIndexKind.PRODUCTS); const algoliaProducts = ( await Promise.all( aggregateProductIds.map(async (id) => getAlgoliaProduct({ id, dbDataLoader: innerDbDataLoader }) ) ) ).filter((value): value is AlgoliaProduct => !!value); if (algoliaProducts.length === 0) { return; } logger.info( `Creating or updating ${algoliaProducts.length} records in algolia`, { ids: algoliaProducts.map(({ objectID }) => objectID), } ); await algoliaIndex.createOrUpdateRecords(algoliaProducts); }; /** * Maps AggregateProduct to AlgoliaProduct. * * Returns null if name is undefined or image is undefined * or the product has only 1 retailer. * * Otherwise it returns a properly constructed AlgoliaProduct. */ export const getAlgoliaProduct = async ({ id, dbDataLoader, }: { id: string; dbDataLoader: TypeormDataLoader; }): Promise => { const retailerProducts = await dbDataLoader.retailerProductsByAggregateProductId.load(id); const retailerProductVariants = ( await dbDataLoader.retailerProductVariantsByRetailerProductId.loadMany( retailerProducts.map(({ id }) => id) ) ) .filter((e): e is RetailerProductVariant[] => !(e instanceof Error)) .map((variants) => variants.filter((variant) => filterRetailerByUrlUsingWhitelist( variant.url, RETAILER_DOMAIN_WHITELIST_SET ) ) ) .flat(); const variantsCount = ( await dbDataLoader.aggregateProductVariantsByAggregateProductId.load(id) ).filter((variant) => variant.imageUrls.length).length; const isAvailable = retailerProductVariants.some( ({ isAvailable }) => isAvailable ); const retailers = union( retailerProductVariants.map(({ retailer }) => retailer) ); if (retailers.length < 2) { return null; } const preferredRetailer = sortRetailers(retailers)[0]; const preferredRetailerProductVariants = retailerProductVariants.filter( ({ retailer }) => retailer === preferredRetailer ); const preferredRetailerProductVariant = minBy( preferredRetailerProductVariants, 'price' ); if (!preferredRetailerProductVariant) { throw new Error( `Impossible situation: can't find ${preferredRetailer} in ${JSON.stringify( retailerProductVariants )}` ); } const name = preferredRetailerProductVariant.name; const imgUrl = preferredRetailerProductVariant.imageUrls[0]; if (!name || !imgUrl) { return null; } const category = preferredRetailerProductVariant.category || undefined; const variantID = preferredRetailerProductVariant.variantId; const featured = preferredRetailerProductVariant.featured; const retailersForVariant = retailerProductVariants.filter((product) => { return product.variantId === variantID; }); const priceByRetailer = retailersForVariant.reduce((acc, curr) => { if (curr.retailer in acc && acc[curr.retailer] < curr.price) { return acc; } return { ...acc, ...{ [curr.retailer]: curr.price }, }; }, {} as Record); const price = preferredRetailerProductVariant.price; const lowestPrice = minBy(retailersForVariant, 'price')?.price || 0; const highestPrice = maxBy(retailersForVariant, 'price')?.price || 0; const savingRatio = clamp( (highestPrice - lowestPrice) / Math.max(0.01, highestPrice), 0, 1 ); const lastModifiedDate = maxBy(retailersForVariant, 'updatedAt')?.updatedAt || new Date(); return { objectID: id, variantID, name, imgUrl, isAvailable, variantsCount, price, lowestPrice, highestPrice, savingRatio, retailers: uniq(retailersForVariant.map(({ retailer }) => retailer)), priceByRetailer, lastModified: lastModifiedDate.getTime(), category, featured, }; };