From 2cb7e5edee7c605eeaddfd2fc1d7e810c674c516 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Mon, 31 Jul 2023 12:08:05 +0200 Subject: [PATCH] Improve Search/Algolia mappings (#827) * parse metadata before sending to algolia * variants mapping * extract metadata mapping * Changeset * rename field --- .changeset/friendly-planets-care.md | 9 +++ .../graphql/fragments/ProductData.graphql | 3 + .../fragments/ProductVariantData.graphql | 4 ++ apps/search/src/lib/algolia/algoliaUtils.ts | 12 ++-- .../metadata-to-algolia-attribute.test.ts | 64 +++++++++++++++++++ .../algolia/metadata-to-algolia-attribute.ts | 6 ++ apps/search/src/lib/safe-parse-json.ts | 11 ++++ .../saleor/product_variant_updated.ts | 5 +- 8 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 .changeset/friendly-planets-care.md create mode 100644 apps/search/src/lib/algolia/metadata-to-algolia-attribute.test.ts create mode 100644 apps/search/src/lib/algolia/metadata-to-algolia-attribute.ts create mode 100644 apps/search/src/lib/safe-parse-json.ts diff --git a/.changeset/friendly-planets-care.md b/.changeset/friendly-planets-care.md new file mode 100644 index 0000000..33a4f48 --- /dev/null +++ b/.changeset/friendly-planets-care.md @@ -0,0 +1,9 @@ +--- +"saleor-app-search": minor +--- + +Improved attributes mapping. Now Algolia will receive better products data: +- Added `otherVariants` field which is *an array of variant id strings*. It will only contain other variants, so if the array is empty, it means the variant is the only one. It can be quickly used to count alternative variants or to reference them by ID. +- Added `variantMetadata` field. Now `metadata` contains data for product parent and `variantMetadata` for each variant. +- Improved JSON fields mapping. Now json-like fields will be sent to Algolia as structure jsons, not strings. This include: `description`, `metadata`, `variantMetadata`, `otherVariants`. + diff --git a/apps/search/graphql/fragments/ProductData.graphql b/apps/search/graphql/fragments/ProductData.graphql index 927b29c..10e4b6b 100644 --- a/apps/search/graphql/fragments/ProductData.graphql +++ b/apps/search/graphql/fragments/ProductData.graphql @@ -1,4 +1,7 @@ fragment ProductData on Product { + variants { + id + } id name description diff --git a/apps/search/graphql/fragments/ProductVariantData.graphql b/apps/search/graphql/fragments/ProductVariantData.graphql index f06c084..ad0d1ff 100644 --- a/apps/search/graphql/fragments/ProductVariantData.graphql +++ b/apps/search/graphql/fragments/ProductVariantData.graphql @@ -1,4 +1,8 @@ fragment ProductVariantData on ProductVariant { + metadata { + key + value + } id name sku diff --git a/apps/search/src/lib/algolia/algoliaUtils.ts b/apps/search/src/lib/algolia/algoliaUtils.ts index 0d6716a..d20b1c6 100644 --- a/apps/search/src/lib/algolia/algoliaUtils.ts +++ b/apps/search/src/lib/algolia/algoliaUtils.ts @@ -3,6 +3,8 @@ import { ProductVariantWebhookPayloadFragment, } from "../../../generated/graphql"; import { isNotNil } from "../isNotNil"; +import { safeParseJson } from "../safe-parse-json"; +import { metadataToAlgoliaAttribute } from "./metadata-to-algolia-attribute"; type PartialChannelListing = { channel: { @@ -58,10 +60,6 @@ export function categoryHierarchicalFacets({ product }: ProductVariantWebhookPay return categoryLvlMapping; } -export function formatMetadata({ product }: ProductVariantWebhookPayloadFragment) { - return Object.fromEntries(product.metadata?.map(({ key, value }) => [key, value]) || []); -} - export type AlgoliaObject = ReturnType; /** @@ -123,13 +121,15 @@ export function productAndVariantToAlgolia({ productName: product.name, variantName: variant.name, attributes, - description: product.description, + description: safeParseJson(product.description), slug: product.slug, thumbnail: product.thumbnail?.url, grossPrice: listing?.price?.amount, categories: categoryHierarchicalFacets(variant), collections: product.collections?.map((collection) => collection.name) || [], - metadata: formatMetadata(variant), + metadata: metadataToAlgoliaAttribute(variant.product.metadata), + variantMetadata: metadataToAlgoliaAttribute(variant.metadata), + otherVariants: variant.product.variants?.map((v) => v.id).filter((v) => v !== variant.id) || [], }; return document; diff --git a/apps/search/src/lib/algolia/metadata-to-algolia-attribute.test.ts b/apps/search/src/lib/algolia/metadata-to-algolia-attribute.test.ts new file mode 100644 index 0000000..d369213 --- /dev/null +++ b/apps/search/src/lib/algolia/metadata-to-algolia-attribute.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { metadataToAlgoliaAttribute } from "./metadata-to-algolia-attribute"; +import { map } from "zod"; + +describe("metadataToAlgoliaAttribute", () => { + it("Maps string attribute", () => { + expect( + metadataToAlgoliaAttribute([ + { + key: "foo", + value: "bar", + }, + { + key: "foobar", + value: "baz", + }, + ]) + ).toEqual({ + foo: "bar", + foobar: "baz", + }); + }); + + it("Maps json attribute to nested json", () => { + expect( + metadataToAlgoliaAttribute([ + { + key: "foo", + value: `{"bar": "baz"}`, + }, + { + key: "foobar", + value: `["a", "b", "c"]`, + }, + ]) + ).toEqual({ + foo: { + bar: "baz", + }, + foobar: ["a", "b", "c"], + }); + }); + + it("Maps invalid json attribute to string", () => { + const invalidJson = `{"bar": "baz"`; + + expect(metadataToAlgoliaAttribute([{ key: "invalidJson", value: invalidJson }])).toEqual({ + invalidJson: `{"bar": "baz"`, + }); + }); + + it("Maps empty value", () => { + expect( + metadataToAlgoliaAttribute([ + { + key: "foo", + value: "", + }, + ]) + ).toEqual({ + foo: null, + }); + }); +}); diff --git a/apps/search/src/lib/algolia/metadata-to-algolia-attribute.ts b/apps/search/src/lib/algolia/metadata-to-algolia-attribute.ts new file mode 100644 index 0000000..128d952 --- /dev/null +++ b/apps/search/src/lib/algolia/metadata-to-algolia-attribute.ts @@ -0,0 +1,6 @@ +import { MetadataItem } from "../../../generated/graphql"; +import { safeParseJson } from "../safe-parse-json"; + +export function metadataToAlgoliaAttribute(metadata: MetadataItem[]) { + return Object.fromEntries(metadata?.map(({ key, value }) => [key, safeParseJson(value)]) || []); +} diff --git a/apps/search/src/lib/safe-parse-json.ts b/apps/search/src/lib/safe-parse-json.ts new file mode 100644 index 0000000..c355db1 --- /dev/null +++ b/apps/search/src/lib/safe-parse-json.ts @@ -0,0 +1,11 @@ +export const safeParseJson = (jsonString: string) => { + if (!jsonString) { + return null; + } + + try { + return JSON.parse(jsonString); + } catch (e) { + return jsonString; + } +}; diff --git a/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts b/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts index 24a6d01..c87556e 100644 --- a/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts +++ b/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts @@ -1,15 +1,14 @@ import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { createGraphQLClient } from "@saleor/apps-shared"; import { ProductVariantUpdated, ProductVariantUpdatedDocument, } from "../../../../../generated/graphql"; import { saleorApp } from "../../../../../saleor-app"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; -import { createDebug } from "../../../../lib/debug"; import { createLogger } from "../../../../lib/logger"; -import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; -import { createGraphQLClient } from "@saleor/apps-shared"; export const config = { api: {